Compare commits

..

172 commits

Author SHA1 Message Date
Erik
3c3293aebb divergence register -> docs/architecture (living doc) + CLAUDE.md rules: same-commit row discipline, symptom-scan trigger, phase-checklist hook 2026-06-12 12:25:47 +02:00
Erik
ebf61f9eeb retail divergence register: 108 audited rows (14 IA / 27 AD / 31 DA / 30 TS / 6 UN) - deviations found by audit, not playtesting 2026-06-12 12:11:29 +02:00
Erik
0664cba925 #112 CLOSED: threshold tick-skip absorbing state fixed by the retail growing-walk port (user-gated 2026-06-12) 2026-06-12 11:45:41 +02:00
Erik
be03146e30 #112 ROOT CAUSE: outdoor-seed pick lacked retail's growing-array walk - threshold tick-skip became absorbing
The instrumented capture (cottage-112-capture1.log) + dat replay pinned
the transparent-cottage mechanism end to end:

1. The A9B3 cottage's entry cell 0x104 is a 0.22 m-wide THRESHOLD band
   (x 184.68->184.46 at y~82). A running player (~13-16 cm/tick at
   30 Hz) can cross it BETWEEN two physics ticks - the tick where the
   centre is inside 0x104 never happens.
2. Our outdoor-seed branch ran CheckBuildingTransit over a landcell
   snapshot and STOPPED - building-admitted entry cells were never
   expanded. The tick after the skip (centre in 0x100, a deep room not
   building-portal-adjacent) found no containing candidate -> the pick
   kept the outdoor landcell FOREVER (absorbing): the user walked the
   whole interior classified outdoor (render faithfully drew an outdoor
   frame = transparent walls), promoting only on touching
   portal-adjacent 0x102's own volume minutes later (captured:
   0xA9B3003C -> 0xA9B30102 with no transitions in between).
3. Retail cannot strand: CObjCell::find_cell_list (0x0052b4e0) runs ONE
   growing-array walk for EVERY seed (0052b576-0052b5ab,
   cells[i]->find_transit_cells vtable dispatch over the GROWING array)
   - the landcell's building bridge admits 0x104 (the foot sphere still
   overlaps the band one tick after the skip) and the walk expands
   0x104's portals to 0x100 where containment wins. Recovery fires one
   tick after any skip.

Fix: BuildCellSetAndPickContaining now runs retail's single growing
walk for both seeds with per-cell-type dispatch (landcells ->
CLandCell::find_transit_cells 0x00533800 -> CSortCell 0x00534060 ->
check_building_transit 0x0052c5d0; envcells -> FindTransitCellsSphere
with the straddle gate + once-per-walk outside add). The old indoor
branch behavior is preserved (seed at index 0, hysteresis, straddle-
gated outdoor pick); the outdoor branch gains the expansion + the
indoor branch gains the retail landcell bridge dispatch for
straddle-admitted landcells.

Pins (dat-backed, Issue112MembershipTests): tick-skip recovery one tick
past the threshold (RED pre-fix); run-speed entry replay across tick
phases never strands outdoor; threshold-gap outdoor-seed keeps outdoor
(over-fix guard); entry-walk replay diagnostic prints the full
promotion chain (0x3C -> 0x104 -> 0x100 -> 0x103 -> 0x100 -> 0x102).

Suites: App 246+1skip / Core 1438+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:35:52 +02:00
Erik
756ea61e30 file #129 (door/doorway leak through terrain at distance) + #130 (background strip at doorway top edge) 2026-06-12 09:06:02 +02:00
Erik
0b214d673a #119 + #128 CLOSED: tower stairs/barrel resolution chain recorded (user-gated 2026-06-12) 2026-06-12 09:01:27 +02:00
Erik
6a9b529113 #119: entity bounds from dat vertex data - works for every case, not just multi-part
The 1ca412d part-offset expansion fixed the staircase but still rested
on the 5 m promise one level down: a SINGLE part whose mesh extends
more than 5 m from its own origin (offset 0 -> box +-5 m) keeps the
gaze-dependent vanish. Per the user's mandate ("it must work for every
case"), the bound now derives from the dat VERTEX data - the same
vertices that get drawn - so no synthetic containment promise remains.

Oracle context (read this session): retail has NO whole-entity
visibility volume - CPhysicsPart::Draw (0x0050d7a0) viewcone-checks
each part's dat-authored CGfxObj.drawing_sphere at the part's own
world position (RenderDeviceD3D::DrawMesh 0x005a0860). Retail's bound
IS data; ours was a promise. Our per-ENTITY granularity stays (a
deliberate batching-era choice, WB-owned per the inventory) but the
volume is now data-derived and conservative: visually identical by
construction, never culls what retail would draw.

- GfxObjBounds: per-GfxObj vertex AABB, cached by id (parts repeat
  heavily); LocalBoundsAccumulator: union of part-transformed AABB
  corners (conservative-correct under any affine transform).
- WorldEntity.SetLocalBounds + RefreshAabb preferred path: rotate the
  root-local bounds' 8 corners into world axes + DefaultAabbRadius
  margin (absorbs animated-pose drift vs the rest-pose bounds; keeps
  small objects at their historical box size). Offset heuristic stays
  as the fallback for boundless fixtures.
- All four hydration sites wired (outdoor stabs, scenery incl. baked
  scale, interior cell statics, server live spawns).

Tests: tall-single-part coverage (the case 1ca412d could not see),
rotation-following, accumulator union. Suites: App 246+1skip / Core
1434+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:39:05 +02:00
Erik
1ca412d07b #119: entity bounds must cover the parts - the gaze-dependent staircase vanish
User re-gate after 2163308/987313a: run-from-town stairs FIXED, barrel
GONE - but the stairs still vanish by VIEWING ANGLE (visible climbing
down, gone climbing up; same at the tower top). The gate3 probe data
exonerates everything downstream: the entity always draws with correct
batches when it reaches the dispatcher (cache hit:119, restZ correct,
zero WALK-REJECTs, never clip-culled) - so the vanish lives in the one
gaze-dependent gate the probe cannot see: the bounds-based cullers.

WorldEntity.RefreshAabb was a fixed +-5 m box around the entity ANCHOR.
The staircase's 43 parts spiral 15 m ABOVE the anchor, and BOTH
visibility gates derive from the box: the dispatcher's per-entity
frustum cull AND RetailPViewRenderer.EntitySphere (the viewcone sphere
= this box's bounding sphere). Looking up the spiral put the anchor's
neighborhood out of view -> the whole entity culled while 15 m of it
stood in front of the camera; looking down kept the anchor in view ->
visible. Exactly the reported asymmetry.

Fix: expand the box by the largest MeshRef part-translation magnitude
(rotation-invariant, so entity.Rotation needs no handling; identity-
part entities get offset 0 - behavior unchanged; scenery scale is
already baked into the part transforms).

Suites: App 246+1skip / Core 1431+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:58:17 +02:00
Erik
987313aa54 knife-edge port: polyClipFinish W=0 eye-plane clip + degenerate-view propagation; EyeInsidePortalOpening rescue DELETED
Ports retail ACRender::polyClipFinish (0x006b6d00, pc:702749) near-eye
semantics into PortalProjection.ProjectToClip - the fundamental fix for
the in-plane portal clip family (climb strobes, tower-top roof/floor
flap while turning; live-corroborated this session: [viewer-diff]
0xAAB30108 strobing 27x mid-climb, whole interior dropping at the top).
Pseudocode: docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md.

Three legs, all decomp-driven:

1. ProjectToClip clips at w >= 0 EXACTLY (was EyePlaneW=1e-4), with
   retail's any-negative-w gate. Boundary intersections land at w == 0
   (homogeneous directions), so a portal the eye is CROSSING yields the
   correct unbounded half-region that the bounded view-region clip cuts
   to the screen. A w=0 vertex cannot survive a bounded region clip
   into the divide (direction fails some edge of any bounded convex
   region); the measure-zero corner case is guarded non-finite->empty.

2. CellView.CanonicalKey keys ALL-COLLINEAR (zero-area) views as their
   snapped segment ("L:" + extremes) instead of rejecting them - retail
   PROPAGATES degenerate views (ClipPortals decomp:433651-433711
   forwards any count!=0 GetClip output, no area gate anywhere), keeping
   the cell behind an exactly-in-plane portal in the draw list (cells
   draw whole; onward floods die naturally). Rejection dropped the
   whole chain for the frame - the parked-eye knife-edge band. Finite
   key space unchanged -> dedup + strict-growth convergence intact.

3. The EyeInsidePortalOpening rescue is DELETED (the T2-documented
   compensation for the 1e-4 divergence) along with EyeStandingPerpDist
   + PointInPoly2D. Empty clip = no flood, period (retail's rule).
   CornerFloodReplay - the gate that REFUTED the previous deletion
   attempt - passes WITHOUT the rescue under the W=0 port.

Harness criterion corrected to retail's rules (it codified the rescue):
cells fully BEHIND the camera are not required (all-behind portals clip
empty in retail); monotone area holds per root regime; the two
manufactured exact-on-plane steps assert root-only (boundary root pick
is ambiguous; the in-plane portal there is ~perpendicular to the gaze =
genuinely off-screen). Build_CollapsedInteriorPortalNearEye test
inverted to pin the retail empty-clip rule (it pinned the rescue).

New pins: eye-crossing portal -> w==0 boundary verts + half-region (not
sliver); gaze-along-plane degenerate view accepted + segment-key dedup;
non-finite guard. Replay harnesses (CornerFloodReplay, Issue120,
TowerAscent, HouseExit, Issue127) all green.

Suites: App 246+1skip / Core 1430+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:44:23 +02:00
Erik
2163308032 #119 ROOT CAUSE: interior-id X-byte collision + player-landblock cache hints = cross-entity batch serving
The decisive probe (3cf6bcc) caught it live in ONE session: a 43-part
staircase entity (src=0x020003F2, healthy MeshRefs tZ=[0.35..15.15])
drew with cache=hit:3 restZero=3 - THREE batches belonging to a 1-part
entity - then under a different hint the correct hit:119. Two
compounding bugs:

1. interiorIdBase = 0x40000000 | (landblockId & 0x00FFFF00) resolved to
   0x40YYFF00 for landblock keys 0xXXYYFFFF - the landblock X byte
   DISCARDED. Every landblock in a map Y-row shared one id space:
   Holtburg town A9B3's 9th interior stab == the AAB3 tower's spiral
   staircase, both 0x40B3FF09. Fixed to 0x40000000|(lbX<<16)|(lbY<<8)
   (the scenery 0x80XXYY## scheme).

2. The Tier-1 classification cache's #53 tuple key (EntityId,
   LandblockHint) was fed the PLAYER's landblock at bucket-draw time
   (RetailPViewRenderer.DrawEntityBucket fabricates its tuple with
   ctx.PlayerLandblockId), so colliding ids from different landblocks
   shared a key: whichever entity classified first under a hint won,
   and the loser wore its batches all session (static fast path never
   re-classifies). Also: bucket-hinted entries were never swept by
   InvalidateLandblock(owner) - stale entries survived owner unload.
   Fixed: ResolveCacheLandblockHint derives the hint from the entity's
   owning cell (ParentCellId landblock, canonical 0xXXYYFFFF), falling
   back to the tuple id for ownerless paths (outdoor stabs/scenery,
   where the tuple IS the owner).

Explains the session-shaped repro exactly: town-login + run to the
tower hydrates/classifies town interiors first -> the tower staircase
cache-hits the town twin's batches (stairs missing/partial + a wrong
object near the floor - the "water barrel"); login-inside classifies
the tower first -> usually clean. meshMissing=0 / entSeen==entDrawn
both ways (everything draws, wrong batches). Likely also feeds #113's
distance-dependent phantom staircase (the town twin wearing the
tower's staircase batches).

3 new cache tests pin the collision contract + hint derivation.
Suites: App green / Core 1430+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:43:45 +02:00
Erik
3cf6bcc219 #119 decisive probe: ACDREAM_DUMP_ENTITY one-shot entity dump (H-A/H-B/H-C discriminator)
The broken-state log (user-session-capture2.log) shows meshMissing=0 /
entSeen==entDrawn WHILE broken stairs are on screen - the staircase is
DRAWN WRONG, not missing. This probe discriminates the three live
hypotheses in ONE launch (handoff 2026-06-11 s4):

- HYDRATE dump (GameWindow.BuildInteriorEntitiesForStreaming): per-part
  placement-frame translations + dropped-part accounting at the MOMENT
  MeshRefs are constructed. H-A (SetupMesh.Flatten identity fallback /
  silent gfx-null part drops under degraded dat reads) shows here as
  zero translations or built<43.
- DRAW dump (WbDrawDispatcher, first tuple per entity): live MeshRefs
  translation summary + per-part loaded flags + Tier-1 classification
  cache state (batch count + RestPose translation summary), re-emitted
  compactly on signature change. H-B (partial/stale cached batch set)
  shows as correct translations + odd batch count.
- WALK-REJECT lines (rate-limited): attributes 'entity never reaches
  the draw loop' to the specific gate (visibleCellIds/frustum).
- Correct everything -> H-C (draw-side compose), instrument next.

Targets: ACDREAM_DUMP_ENTITY=0x020003F2,0x020005D8 (the 43-part spiral
staircase Setup + the wall barrels; H-A predicts the user's 'barrel' IS
the collapsed staircase). Probe is inert when the env var is unset.
Parser in RenderingDiagnostics (diagnostic-owner pattern) + 5 unit tests.

Suites: App 242+1skip / Core 1427+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:01:08 +02:00
Erik
d82f070b88 docs: tower-stairs fundamental handoff - the broken-state log kills all mesh-absence theories
The users final broken-state session (user-session-capture2.log,
standing in front of broken stairs) reports meshMissing=0 and
entSeen==entDrawn: the staircase is DRAWN WRONG, not missing. The
handoff records the 8 verified fixes shipped today (none was this bug),
the ranked hypothesis space (H-A hydration-time MeshRef corruption via
SetupMesh.Flatten identity fallback - predicts the barrel IS the
collapsed staircase; H-B Tier-1 partial-batch cache; H-C draw compose),
the decisive one-launch probe design, the polyClipFinish/cdstW port
spec for the climb strobes + top flap (read done, constant pinned), the
apparatus inventory, and the paste-ready pickup prompt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:46:35 +02:00
Erik
7bbb169c6c #128 STRUCTURAL: missing meshes re-request their load at the POINT OF USE - permanent invisibility becomes impossible
The registration-time re-arm was insufficient and the user proved it
(ran back from the lifestone -> broken stairs + exposed barrel again):
a preparation cancelled by landblock churn AFTER the last registration
event has no later event to re-fire it - crossing blocks loads/unloads
them repeatedly behind the player, so the cancel-after-last-register
window is routinely hit on any cross-country run.

The structural fix: the draw dispatcher touches every
missing-but-referenced mesh every frame (the meshMissing slow path) -
THAT is the one site a retry can never be missed from. Both miss paths
(per-MeshRef and per-Setup-part) now call WbMeshAdapter.EnsureLoaded
(idempotent passthrough to PrepareMeshDataAsync, which early-outs on
existing data and dedups pending tasks), deduped per Draw pass.
Retail-equivalence: retail loads synchronously - geometry is never
permanently absent; this converges the async pipeline to the same
guarantee regardless of cancellation/eviction timing.

Also fixes the #53-one-level-deeper hole found en route: a missing
SETUP PART did not mark the entity incomplete, so a partial batch set
could cache permanently for Setup-shaped render data.

New apparatus: [mesh-miss] once-per-id line under ACDREAM_WB_DIAG=1 -
any future missing mesh names itself instead of needing a live repro.

Suites: App 242+1skip, Core 1422+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:30:56 +02:00
Erik
120aeff720 #126 RETAIL-CORRECTED: restores commit the server Z - retail never re-derives position from surfaces
The user caught the process failure: two snap fixes were written without
reading retails restore code. The named decomp settles it -
CPhysicsObj::SetPositionInternal (0x00515bd0, pc:283892-283945) treats
the supplied Position as INPUT: AdjustPosition resolves which cell
CONTAINS it, CheckPositionInternal/find_valid_position VALIDATES it
through the collision transition, and the no-cell case goes
store_position + GotoLostCell. There is NO terrain or surface
re-grounding anywhere in the restore path. Trust + validate.

Both prior shapes diverged: grounding outdoor claims to terrainZ warped
a roof-deck logout (ACEs authoritative z=127.2 on the AAB3 tower)
through the roof into the building volume -> the transparent-interior
spawn on every login; the cell-walkable scan that replaced it missed
shell-geometry decks entirely (no EnvCell owns the deck surface) and
failed silently - the user logged in transparent at the tower bottom
again.

Fix: a zero-delta outdoor restore above terrain commits the claims Z
verbatim ([snap] line says so); the first physics tick validates and
settles against the REAL collision world (the BR-7 building channel
covers the deck). max(terrain, z) stays as the under-terrain sanity
bound - our recoverable stand-in for retails lost-cell machinery
(documented divergence, same class as the #107 demote).

Suites: App 242+1skip, Core 1422+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:22:17 +02:00
Erik
b94a7e8017 #126: outdoor restore grounds onto elevated walkables, not through them
A zero-delta RESTORE of an outdoor claim standing far above terrain
(logged out on a building roof deck - the AAB3 tower 0x010A slab at
z=127.2 over terrain 112) was grounded to TERRAIN unconditionally,
warping the player through the roof into the building interior,
outdoor-classified -> the transparent-interior spawn the user hit on
every login while the save sat on the roof. Retail restores settle via
AdjustPosition onto real surfaces, not the heightmap.

Fix: the snap outdoor branch, zero-delta shape only, when the claim z
exceeds terrain by more than step height: ground to the nearest CELL
WALKABLE at/below the claim z (the #111 WalkableFloorZNearest query -
real floors only, never the ceiling soup), keeping the outdoor cell id
(honest: a deck-stander center sits above the slab cell BSP - the same
state the user played in all afternoon). GfxObj-shell roofs without
cells not covered - file if a real case shows.

Suites: App 242+1skip, Core 1422+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:02:48 +02:00
Erik
2eca7f5033 docs: #119-residual root cause (render lift in the visibility graph) recorded in ISSUES 2026-06-11 19:27:14 +02:00
Erik
f35cb8b164 #119-residual ROOT CAUSE: the +0.02 m render lift leaked into the portal-visibility graph - horizontal portals side-culled anyone standing on them
The live capture pinned it end to end. BuildInteriorEntitiesForStreaming
lifts the render-side cell transform +0.02 m Z (shell z-fighting vs
terrain - a DRAW concern) and passed that LIFTED transform to
BuildLoadedCell, so every plane in the visibility graph sat 2 cm high.
The portal side test's in-plane window is +-10 mm: an eye standing ON a
floor containing a HORIZONTAL portal (the tower's deck lip 010A->0107,
stair landings, cellar mouths) sits 0-10 mm above the TRUE plane = 10-20
mm BELOW the lifted plane -> outside the window -> the cell behind the
portal side-culled out of the flood. Captured live at the stair top:
root=0xAAB3010A eye z=126.803 vs the portal plane at 126.80, flood=1,
0x0107 (the whole tower interior incl. the staircase) dropped WHILE THE
GAZE LOOKED STRAIGHT AT IT - "stairs disappear and you can walk on
them", and the roof/edge flap as the gaze swung the marginal admissions.
Vertical doorways were immune (the lift slides their planes along
themselves) - exactly why this hit stairs/decks/floors and not doors.

Chase chain (the apparatus did all the work): [viewer] print-on-change
probe with eye@mm -> the user's climb capture -> [viewer-diff] naming
the dropped cells per flip -> headless replay of the exact captured
(eye,fwd) frame: healthy UNLIFTED, reproduces ONLY with the production
lift -> gate-by-gate diagnostic (side test dot=+0.003 unlifted vs
-0.017 lifted; clip + rescue exonerated; knife-edge z-sweep all-stable,
killing the float-chaos theory).

Fix: BuildLoadedCell receives the PHYSICS (unlifted) transform; the
drawn shells keep their lift. The seal/punch fans (which read the
visibility LoadedCell's WorldTransform) now stamp TRUE depth - MORE
consistent with the unlifted terrain they protect.

Pins: CapturedTopOfStairs_MainCellStaysInFlood - arm 1 (unlifted =
post-fix production) asserts the main cell admitted at the captured
frame; arm 2 (lifted) is the mechanism canary asserting the drop, with
instructions if it ever starts passing. Plus the gate-by-gate
diagnostic + knife-edge sweep as the investigation record.

Also this session: Issue127FloodFlipReplayTests (the captured 4 cm
outdoor flip pair replays STABLE across fovs/pre-gate arms - the
outdoor churn is NOT the flood math; remaining #127 = distant-building
admission churn, lower priority now that the tower-cell drops are
explained by the lift), and the [viewer-diff] probe (per-flip added/
removed cell naming - keep, it found this).

Suites: App 242+1skip, Core 1422+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:26:06 +02:00
Erik
cd12d3dbbc capture run decoded: #126 spawn-through-roof + #127 bistable flood admissions + #128 session-sticky invisible staircase filed; [viewer] probe gains fwd=
The users tower capture (tower-viewer-capture.log, 551 [viewer] lines)
decodes into three distinct issues:

- #126 (HIGH, #107/#111 family): an OUTDOOR spawn claim on the tower
  roof (z=127.2) is grounded to TERRAIN z=112 - the player is warped
  through the roof into the tower interior, outdoor-classified ->
  the transparent-interior spawn. The snap outdoor branch must ground
  to the nearest WALKABLE surface (roofs/GfxObj floors), not terrain.
- #127 (HIGH, the flap mechanism): per-building flood admissions are
  BISTABLE per frame under the outdoor root - flood size oscillates
  +-1-3 cells at millimetre eye deltas (45<->52 standing on the roof,
  including a byte-static eye flip). Every oscillation = building
  interiors dropping in/out -> the roof/edge flap; running past a
  building = #123. Interior side shows the same family (flood 1<->3,
  outPolys 0<->1 during the climb).
- #128: the staircase was invisible the WHOLE climb under a HEALTHY
  interior root (0xAAB30107 FullScreen views - the cone cannot cull a
  root-cell static), while the SAME build rendered it perfectly in a
  different session (diag spawn + screenshot, meshMissing=0).
  Session-sticky nondeterminism; the barrel tracks this bug (a
  partial subset of staircase parts), NOT dat content (user axiom:
  no barrel in retail). Needs a diag-instrumented repro of the users
  session shape.

The [viewer] probe now logs the camera forward (fwd=) so the next
capture can be replayed headlessly - Build clip results depend on the
view-projection, not just the eye.

Suites: App 238+1skip, Core 1422+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:53:17 +02:00
Erik
a974504e6e #119-residual: [viewer] capture probe - the capture half of the tower capture-replay loop
One line per change of (root cell, flood size, OutsideView polys, player
cell), with the projection eye at mm precision on every line
(ACDREAM_PROBE_VIEWER=1, print-on-change, silent while stable). The
tower-ascent harness replays the captured production (eye, root) pairs
deterministically - replacing the synthetic helix that proved unphysical
in the roof-lip band (the real collided camera may never reach it).

Suites: App 238+1skip, Core 1422+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:41:05 +02:00
Erik
899145e1d7 #119-residual: tower-ascent harness pins the roof-lip flood gap; barrel claim RETRACTED (user axiom: not in retail)
User verdict on the post-#120 build: "Barrel is gone and more stairs
exist" - the #120 fix partially cured the tower, and the earlier
"legit dat barrels on the landings" claim is RETRACTED (USER AXIOM: the
barrel is NOT in the tower in retail; what the user saw was itself a
render artifact of the corrupted floods, and what the 0x020005D8 cell
statics actually render as is unverified - do not assume barrel).

Remaining tower bugs, both PINNED by TowerAscentReplayTests (the #118
exit-walk pattern, vertical - a helix ascent with the gaze locked ON
the staircase, so a cull has no gaze excuse):
- steps 195-201 (eye z 126.9-127.3, the roof-lip band between the main
  cell's ceiling at 126.8 and the roof aperture plane at ~127.2) resolve
  OUTDOOR and the per-building exterior flood admits NOTHING (flood=1 =
  the outdoor node alone): the eye is above every side aperture's useful
  view and ON/INSIDE the roof aperture's plane, so BuildFromExterior's
  seed side-test / in-plane reject refuses every exit portal. The tower
  interior never floods -> the staircase (a 0x0107 static) cone-culls
  while staying walkable (user symptom 1), and the roof-lip cell
  geometry flaps as the live eye bobs across the band's edges (user
  symptom 2). One mechanism, both symptoms.
- The pin is committed as a SKIPPED red test
  (TowerAscent_StaircaseStaysConeVisible_EveryStep; the skip reason
  carries the defect) so the suite stays green - un-skip with the fix.
- TowerAscent_RootDoesNotPingPong + the per-step diagnostic stay active.

Fix direction (oracle-first, next): determine which side diverges from
retail - (a) viewer-cell resolution (retail curr_cell may keep the eye
INTERIOR through the band: keep-curr above open-top cells / cell BSP
classifying the parapet bowl as inside 0x010A, where our resolution
demotes to outdoor), or (b) exterior seed admission (retail
ConstructView(CBldPortal) Sidedness with an in-plane eye). Grep the
named decomp for both before touching either layer.

Suites: App 238 + 1 skip (236+3 new, 1 pinned), Core 1419+2skip,
UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:34:45 +02:00
Erik
0c55b473dd docs: #125 root-cause-fixed status + #119 decoded/likely-fixed-by-#120 ledger update 2026-06-11 18:20:36 +02:00
Erik
fcade06c46 #125: gpu_us query ring reads never-begun query objects - root cause of the WB_DIAG GL error cascade; fixed + live-verified
Root cause (by read, verified live): a glGenQueries name does not become
a QUERY OBJECT until its first glBeginQuery - GetQueryObject on a
never-begun name is GL_INVALID_OPERATION. The N.6 gpu_us ring assumed
ONE dispatcher Draw per frame with both passes always non-empty; the
pview pipeline issues MANY small Draws per frame (landscape slices,
per-cell static buckets, dynamics), where zero-draw passes routinely
skip BeginQuery. Under ACDREAM_WB_DIAG=1 the slot read queued an
InvalidOperation EVERY frame - silently, until WB's diligent
texture-path glGetError checks ate the stale errors and treated their
own successful uploads as failures ([wb-error] + the sticky drop) and
ProcessDirtyUpdates' check threw (process death, tower-wbdiag3.log).
The GL-error-attribution trap, textbook form.

Fix: begun-flags per ring slot per target; the read path only queries
slots that were actually begun (a skipped pass contributes 0 ns).
Live verification (tower-wbdiag4.log, in-tower spawn): zero [wb-error]
(was 7), no crash, gpu_us reads real values (9-11 us) for the first
time under the pview pipeline, meshMissing=0 / entSeen==entDrawn.

Consequences: (1) the #119 missing-stairs mechanism theory via sticky
GL upload failures is RETIRED for normal runs (WB_DIAG off = no query
calls = no errors; clean runs confirmed zero wb-error) - and the
in-tower screenshot on the current build shows the spiral staircase
RENDERING, so the stairs were most plausibly a #120 flood-corruption
casualty (the tower threshold cells portal back to 0x0107 exactly in
the ping-pong window); user verdict pending. (2) The sticky-drop
defect (upload failure never retried) stays filed under #125 as
defense-in-depth debt - the trigger is gone but the design flaw isn't.

Suites: App 236, Core 1419+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:19:55 +02:00
Erik
63d14c3d6b #125 filed + WB-DIAG median crash fixed - the #119 stairs mechanism is a sticky GL upload failure
The in-tower ACDREAM_WB_DIAG launch (the saved character spawns inside
the #119 tower - a free deterministic repro lever) produced the
mechanism evidence in one run (tower-wbdiag3.log):

1. [wb-error] upload of 0x0100321D died on a GL InvalidOperation in
   ManagedGLTextureArray..ctor (new TextureAtlasManager) - caught,
   returns null, and the drop is STICKY: _preparationTasks.TryRemove
   runs BEFORE the upload, so a failed upload is never re-prepared.
   Permanently invisible mesh, one log line. This failure class is the
   likely #119 missing-stairs mechanism (dat + extraction +
   registration + dispatcher all exonerated by read/test this session).
2. The SAME GL error then fired UNCAUGHT in Tick -> GenerateMipmaps ->
   ProcessDirtyUpdatesInternal and killed the process. Both render-
   thread - not thread affinity. Filed as #125 (HIGH) with the open
   question of GL error attribution (a stale error queued by an earlier
   unchecked call lands on WB's diligent glGetError checks).

Also fixed here: WbDrawDispatcher.MedianMicros crashed with
IndexOutOfRange on the first diag flush when exactly 1 sample was
recorded (copy[copy.Length - nz/2] with nz==1) - the same off-by-one
GameWindow's TerrainDiagMedianMicros twin fixed; same fix applied.
ACDREAM_WB_DIAG=1 is usable again.

Suites: App 236, Core 1419+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:08:46 +02:00
Erik
1b8c9f1f50 #119: tower decoded - the "missing stairs" are Setup 0x020003F2 (43-part spiral staircase); the "barrel" is a legit dat static orphaned by it
The user stood in the tower and logged out; the next login [snap] pinned
it: cell 0xAAB30107, AAB3 building[1] (model 0x01001117, NOT the #113
meeting hall 0x010014C3). Issue119TowerDumpTests decodes the dat truth:

- The tower interior cell 0x0107 has ZERO ramp polys - the stairs are
  NOT cell geometry. They are cell STATICS: Setup 0x020003F2 at the
  exact tower center = a 43-part spiral staircase (5 corner platforms
  0x01000E2A + 38 steps 0x01000E2B/2C/2D/2F/31/32, placement frames
  spiraling z 0.35..15.15, all parts fully drawable - 0 NoPos polys).
- The four 0x020005D8 statics (1 part, 0x01001774, 24 polys - barrel-
  shaped) sit along the wall at ascending heights: legitimate dat
  barrels on the stair landings. With the staircase missing, the
  bottom one reads as "a barrel in the middle where it's not supposed
  to be" - the barrel is NOT extraneous, the stairs around it are gone.

Pipeline reads (all correct by read, no errors logged):
BuildInteriorEntitiesForStreaming flattens Setup statics per part
(SetupMesh.Flatten -> 43 MeshRefs with placement transforms);
LandblockSpawnAdapter registers per MeshRef GfxObj id; the dispatcher
walks per MeshRef and composes PartTransform * entityWorld; the
ConsoleErrorLogger (wb-error) is wired and silent. Remaining suspects
are runtime-state: the #55 meshMissing population (parts never
finishing PrepareMeshDataAsync) or a draw-level drop - the saved
character now spawns INSIDE the tower, so a WB_DIAG launch at spawn
reads the dispatcher counters directly on the exact content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:59:52 +02:00
Erik
c4464739d2 #121: dynamics-owner particle pass - world portals visible again; re-gate ledger in ISSUES
Fix: dynamics' ATTACHED emitters (portal swirls on server-spawned portal
entities, creature effects) fell through EVERY particle filter under the
unified pview path - the landscape slice filter carries outdoor statics
(+ the #118 outside-stage dynamics), the per-cell callback carries cell
statics, and T4 deleted the clipRoot==null global pass from normal
frames. T5 never checked portals; the user's re-gate caught it ("all
portals that were previously showing are now gone"). DrawDynamicsLast
now hands its cone-surviving dynamics (minus outside-stage entities,
whose emitters already drew in the landscape slice - alpha particles
must not double-draw) to a new DrawDynamicsParticles callback;
GameWindow draws Scene-pass emitters filtered to those owner ids,
mirroring DrawRetailPViewCellParticles. Retail shape: emitters draw
with their owner object.

Re-gate ledger (user verdicts are axioms):
- #117 CLOSED ("Yes solved"), #118 CLOSED ("Yes solved" + NPC-through-
  door "Yes fixed").
- #108 REOPENED narrowed: cellar-ascent eye-below-grade window only
  (grass covers the exit door until the head pops over ground level);
  fix belongs on the membership/viewer side - the depth-gated punch
  stays (DO-NOT-RETRY).
- #119 user split: phantom walkable stairs at the hill cottage (#113
  family), tower missing stairs + barrel (#119 proper), hill-house
  transparent-on-entry (#112 - re-check after the #120 fix; the
  ping-pong fired at exactly A9B3 0103/010F).
- #120 FIXED pending re-gate (dede7e4).
- NEW #122 window oscillation on entry (re-check after #120 first),
  NEW #123 buildings transiently disappear running close past,
  NEW #124 far-building back walls missing through openings (lead:
  per-building look-in floods run only for outdoor roots -
  NearbyBuildingCells is null for interior roots; retail runs the
  look-in inside LScape::draw for ANY root).

Suites: App 236, Core 1419+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:36:58 +02:00
Erik
dede7e491c #120: CellView containment rejection - the reciprocal ping-pong converges; tripwire firings reproduced + killed
The armed tripwire self-attributed on the re-gate launch
(regate-118-119-launch.log): a pure TWO-CELL reciprocal ping-pong, 64 laps
each - chain root=0xA9B4015C eye=(109.995,37.158,96.249) cells 0162x64
015Cx64, and root=0xA9B3010F eye=(175.771,-107.310,118.814) cells 0103x64
010Fx64 (A9B3 = the hill cottage the user reports going all-transparent on
entry - likely the same mechanism, verify at the next gate).

Mechanism: with the eye within PortalSideEpsilon (+-1 cm; the T2
refuted-to-tighten root-lag tolerance - retail's is 0.0002) of a portal
plane, the in-plane case counts as interior for BOTH cells, so views lap
A->B->A...; each lap re-clips through two near-edge-on apertures whose
intersection numerics wobble by more than CellView's 1e-3 dedup grid, so
every lap keys as NEW and the in-place growth recurses to the depth-128
failsafe. The prior convergence sweeps could not reproduce because they
only load the corner building 0x016F-0x0175 - both firing pairs are
outside that set. Issue120ReciprocalPingPongTests loads the full
landblock's interior cells and drives the +-epsilon window directly:
deterministic firings + 65-polygon CellView piles pre-fix.

Fix (the handoff's own predicted class - dedup admitting near-duplicates
per lap, NOT a limit tune): CellView.Add rejects a polygon CONTAINED in
one already stored (convex edge test, DedupGridNdc slack). A round-trip
re-emission is, in exact arithmetic, a SUBSET of the polygon that
originated it - containment rejection makes union growth strictly
area-increasing, so no new visible area means no propagation. Bonus:
back-emission into a full-screen view (the root cell) now always rejects.
The corner-flood completeness pins stay green - no real region is dropped.

Suites: App 236 (232+4), Core 1419+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:32:21 +02:00
Erik
8d93665053 #119: the [up-null] lead is EXONERATED (dat-proven) - both GfxObjs are legitimately no-draw models
Issue119UpNullGfxObjDumpTests pins the dat truth: 0x010002B4 = 9 polys,
ALL NoPos, all surfaces Base1Solid; 0x010008A8 = 1 poly, NoPos,
Base1Solid|Translucent. Retail's skipNoTexture never draws either model
(the BR-1 build-time-skip <=> draw-time-skip equivalence), so
ObjectMeshManager's empty render-data cache is the CORRECT terminal state
- the only defect was the alarming "permanently invisible" log line,
reworded into an honest tripwire pointing at the dump test.

Second fact, same test (ShellModel_NoTexturedPolyIsDropped): on the
hall/tower shell 0x010014C3, ZERO textured polys are dropped by the
extraction gates (137/149 draw; the 12 dropped are the known #113
no-draw orphans) - the per-poly GfxObj extraction is exonerated for
building shells, kept green as a regression pin.

Net for #119: the missing tower-stair parts are NOT the up-null pair and
NOT a per-poly extraction drop. Remaining hypothesis space (interior
stair-cell flood admission, or a different model than assumed) needs the
re-gate to identify the exact tower; then the cell set + flood replay
headlessly like #118. ISSUES.md updated.

Suites: App 232, Core 1419+2skip (1416+3 new), UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:55:45 +02:00
Erik
5a80a2ee24 #118: outdoor dynamics draw in the outside stage under interior roots - the house-exit clip+vanish was the SEAL z-killing the player
Root cause (pinned by the new deterministic exit-walk harness, NOT guessed):
under an interior render root, the exit-portal SEAL stamps the door fan at
TRUE depth after the gated full depth clear, and T1's "ALL dynamics last"
pass then drew the outdoor-classified player depth-tested - every fragment
beyond the door plane z-failed against the seal across the whole aperture.
Harness measured the full window: from the moment the sphere center crosses
the plane until the eye follows (~2.6 m of camera lag, ~2.2 s at walk speed)
the player is invisible; while straddling, the beyond-plane body half clips
at the plane. The handoff's three cone-level candidates are all EXONERATED:
the cone walk passes every step; (eye, ViewerCellId) come from the same
SweepEye call with camera-update-before-visibility-read in the same frame;
the side-test window is sub-epsilon under healthy resolution.

Retail oracle (grep-named-first): PView::DrawCells 0x005a4840 runs
LScape::draw FIRST (pc:432719), then the gated depth clear (pc:432731-32)
and the exit-portal seals (pc:432785-86); outdoor cell objects draw inside
the landscape stage (DrawBlock 0x005a17c0 -> DrawSortCell pc:430124), and
an object draws once per overlapped shadow cell (pc:430056-64) - the
straddling body composes from both stages, neither half clips.

Fix: RetailPViewRenderer assigns dynamics to the OUTSIDE stage under an
interior root when outdoor-classified OR sphere-straddling an exit-portal
plane of their flood-visible cell (DynamicDrawsInOutsideStage - pure, the
harness drives it as the ordering contract); they ride the landscape slice
draw (pre-clear, seal-protected) with the same per-slice cone test as
outdoor statics. Indoor dynamics keep the last pass (retail loop C);
straddlers draw in both (retail shadow dual-draw). Outdoor roots keep
all-dynamics-last - the BR-2 punch-after-dynamics lesson (88be519) stands.

Apparatus: HouseExitWalkReplayTests - dat-backed corner-building exit walk
driving the production stack headlessly (RetailChaseCamera damping ->
healthy-sweep viewer resolution -> PortalVisibilityBuilder.Build ->
ClipFrameAssembler -> ViewconeCuller -> the DrawDynamicsLast predicate +
a CPU seal-depth model). 5 tests: cone pin, seal-depth pin, straddle
dual-draw pin, per-step table, stale-root window quantifier (#118 cand 2).

Suites: App 232 (227+5), Core 1416+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:49:29 +02:00
Erik
acaaeae434 docs: session handoff - T6/BR-7 shipped + T5 verdict + post-T5 state, with the next-session prompt
docs/research/2026-06-11-t6-br7-shipped-t5-gate-post-t5-handoff.md:
the 9-commit ledger with decomp anchors, the per-cell shadow
architecture summary, the T5 gate verdict (collision half 100% passed;
#117-#120 filed), the #117 fix detail (depth-gated punch - re-gate
pending), the #118 narrowing + exit-walk harness design, the #119
up-null lead, the #120 armed tripwire, watchouts/DO-NOT-RETRY
additions, new apparatus inventory, the next-session work order, and
the paste-ready prompt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:17:41 +02:00
Erik
0e6e24faf6 docs: #118 narrowed - partition + per-tick ParentCellId exonerated; decision-stack candidates + exit-walk harness design recorded
Read-level exonerations: the local player routes to Dynamics correctly
(ServerGuid set), and its entity ParentCellId syncs per tick from the
controller - neither is the vanish mechanism. Live candidates are the
doorway-crossing decision stack: (a) eye/cell incoherence under camera
damping (the verified #115/BR-8a divergence - we damp from our own
damped eye while the root comes from the swept cell), (b) the
exit-portal side test culling the OutsideView when the eye is
epsilon-outside while the root is still interior (retail's
AdjustPosition demotes the viewer cell the same moment), (c) the
aperture-cone tightness for an outdoor player with an indoor viewer.
Next step is the deterministic exit-walk harness (all-CPU drive of the
production decision stack over the corner-building cells); designed in
the issue entry, queued for a focused session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:11:54 +02:00
Erik
478c549b9e #117: depth-gate the aperture punch - stencil mark+punch (z-buffered equivalent of retail's painter's order)
T5 reported doors/interiors visible through terrain hills and through
nearer buildings, always in aperture-shaped regions. Root cause, decomp-
settled: retail's DrawPortalPolyInternal (Ghidra 0x0059bc90) draws the
punch with DEPTHTEST_ALWAYS + per-vertex far-Z (0.99999899, maxZ1 bit0)
- it UNCONDITIONALLY stomps any occluder depth at aperture pixels.
Retail is safe only because its outdoor pass is painter's-ordered
far->near: anything nearer (hills, closer houses) draws AFTER the punch
and re-covers it. Our z-buffered MDI frame has no such global order
(one terrain pass + one shells pass), so the faithful GL-state port of
the punch was unsafe by construction - the far house's aperture punch
erased the near house's wall depth / the hill's depth, and the interior
+ door entities (dynamics drawn last) painted through.

Fix - the z-buffer-correct equivalent of the painter's-order guarantee:
punch only where the aperture polygon itself is VISIBLE.
PortalDepthMaskRenderer's punch path is now two passes:
  A) stencil-mark: aperture fan at its (slightly biased) true depth,
     depth LEQUAL, no depth write -> stencil=1 where the aperture wins
     against everything drawn so far (terrain + all shells precede
     DrawExitPortalMasks in the frame, so the buffer holds the real
     occluders);
  B) far-Z punch with depth ALWAYS, stencil-gated EQUAL 1, zeroing the
     stencil as it goes (self-cleaning; no frame-level stencil state).
The mark bias (0.0005 NDC ~ 6 cm at 5 m) keeps #108's case covered:
terrain hugging the door plane still punches; a hill or another house
meters nearer no longer does. The SEAL path (interior roots) stays
retail-verbatim single-pass - it runs right after the gated full depth
clear, so there is nothing nearer to stomp.

Also: WindowOptions now requests 8 stencil bits explicitly (was the
GLFW platform default), and PortalDepthMaskRenderer's stale "RESERVED -
not wired" banner is corrected (T1 wired it via
DrawRetailPViewPortalDepthWrite).

Acceptance rides the focused post-T5 re-gate (downhill door check +
behind-house openings check + #108 cellar stays clean).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:05:48 +02:00
Erik
2d15084243 #120: arm the propagation tripwire for self-attribution + two convergence regression pins
Investigation: retail's growth propagation RECURSES natively too
(AddViewToPortals -> FixCellList -> AdjustCellView -> AddViewToPortals,
Ghidra 0x005a52d0/0x005a5250/0x005a5770, no depth guard) - the in-place
recursion shape is faithful; retail's safety is fast convergence. Our
depth-128 firing means slow/non-saturating growth (each lap of a portal
cycle nests one recursion level), not necessarily a true infinite loop.

Two dat-backed sweeps over the corner-building cell set could NOT
reproduce the T5 firing:
- PortalPlaneCrossings_InPlacePropagationConverges: +/-6cm eye sweep
  across every portal plane, seeded from both sides.
- InCellDirectionSweep_InPlacePropagationConverges: 3024 builds, in-cell
  eye grid x 8 yaw x 3 pitch (the walking-and-turning regime).
Both pass with 0 firings -> production-only ingredients suspected (full
lookup graph - one T5 firing was 0x0162, another building - and/or the
real camera path).

Armed: PortalVisibilityBuilder.ConvergenceTripwireCount (test
observable, both Build + look-in sites) + DumpPropagationChain - on the
next firing the log carries root cell, eye, per-cell frequency summary,
and the 24-entry chain tail, so the cycle's structure (A<->B ping-pong
vs 3-cycle laps) reads directly off the output. Both sweeps stay as
regression pins.

App tests: 227 green (was 225; +2 pins).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:57:25 +02:00
Erik
af5d424df0 docs: T5 comprehensive gate verdict - PARTIAL PASS; #108/#109/#97 closed, #117-#120 filed
The single comprehensive visual gate (2026-06-11, user-driven):

CONFIRMED (user axioms): doors block both ways incl. off-center (#99
visual pass), cellar descent/ascent clean + #108 grass-sweep GONE, inn
2nd floor clean (#97 CLOSED), interiors stable through doorways incl.
edge-on, #109 far-door oscillation GONE, formerly-popping stairs now
stable at all ranges. The entire T6/BR-7 collision port passed 100%.

REMAINING (render, filed at mechanism level - no live whack-a-mole):
- #117 aperture-shaped see-through: doors/interiors visible through
  terrain hills and through nearer buildings (the far-Z punch erases
  occluder depth at aperture pixels). Decomp direction:
  DrawPortalPolyInternal depth state vs draw order.
- #118 character clipped + vanishes momentarily on house exit
  (viewer-indoor/player-outdoor transition frames; local-player
  partition / aperture-clip suspects).
- #119 old-tower stairs partially invisible + extraneous water barrel
  (pre-existing); T5-log lead: [up-null] 0x010002B4 + 0x010008A8
  cached EMPTY render data, permanently invisible.
- #120 [pv-ERROR] in-place propagation tripwire at depth 128 on the
  cottage interior cells (T2 convergence invariant break, self-detected
  during the gate) - investigate FIRST.

Rain-indoors not verifiable (clear weather).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:46:30 +02:00
Erik
60c10707a3 docs: T6 (BR-7) ship closeout - #99/#90 closed, #97 likely-closed note, #116 filed, plan stamped
- ISSUES.md: #99 DONE (per-cell shadow architecture, dbfbf85+ca4b482;
  visual rides T5); #90 DONE (stickiness workaround removed, retail
  ordered-pick owns doorway hysteresis); #97 likely-closed note (the +5m
  pad producer deleted - verify at T5); #116 filed (slide-response
  family: tick-22760 lateral-slide loss + BSPStepUp D4 first-frame
  behavior, both pre-dating BR-7, oracle-first fix shape).
- Port plan: execution stamp updated to BR-2..BR-7 ALL CODE-COMPLETE
  with the per-commit map; BR-7 section marked code-complete; remaining
  = T5, the single comprehensive user visual gate.
- Memory digests (physics + render + MEMORY.md index) updated in the
  same session per the digest protocol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:49:18 +02:00
Erik
ca4b482f8b T6 (BR-7) C4: straddle-only outside-add (A6.P5 widening DELETED) + #90 stickiness removed
The two remaining flagged workarounds retired, per the BR-7 plan +
the WF1 [MEDIUM] correction (re-gate, do NOT delete the outside-add):

1. A6.P5 hasExitPortal topology widening DELETED. Outdoor cells enter the
   collision cell array ONLY on the retail straddle gate - |dist| <
   radius + F_EPSILON against an exterior portal plane
   (CEnvCell::find_transit_cells Ghidra 0x0052c820, gate 0052c9d6,
   live-binary verified) - the same flag that already gated the
   membership pick (#112 rider). The widening existed so outdoor-
   registered doors stayed findable from indoor cells under the old flat
   registry query; with per-cell shadow lists the door is found in the
   straddle-admitted outdoor cell's own list (tick-13558 pin holds).
   The hasExitPortal out-param + plumbing deleted from
   FindTransitCellsSphere; the AddAllOutsideCells call in
   BuildCellSetAndPickContaining re-gated on exitOutsideStraddle
   (once-per-walk = retail CELLARRAY.added_outside).

2. #90 ResolveCellId sphere-overlap stickiness REMOVED (the 4ca3596
   workaround, deferred-to-A6.P4 in the physics digest). It was dead
   code: the method's only caller is FindEnvCollisions' cache-null TEST
   fallback, and the indoor branch (where the stickiness lived) required
   a non-null DataCache. Production membership flows exclusively through
   the collide-then-pick advance whose ordered-array hysteresis (current
   cell at index 0, interior-wins-break) is the retail mechanism the
   workaround approximated. ResolveCellId reduced to the bare
   prefix-preserving outdoor re-derive, documented test-only.

Test updates (pins of the deleted behaviors inverted to retail):
- A6P5_BuildCellSetFromIndoorStart_ReachesDoorOutdoorCell (asserted the
  topology widening verbatim) -> DeepInteriorSphere_NoStraddle_
  AddsNoOutdoorCells: a deep-interior sphere admits NO outdoor cells.
- A6P5_BuildCellSetFromAlcove... -> AlcoveSphere_StraddlesExitPortal_
  ReachesDoorOutdoorCell (the captured alcove position genuinely
  straddles - the retail-positive half).
- Issue112MembershipTests straddle pin + the second-sphere straddle test
  updated to the single-flag signature.

Suites: Core 1416/0/2, App 225, UI 420, Net 294 - green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:44:49 +02:00
Erik
dbfbf8506c T6 (BR-7) C3: per-cell shadow architecture - flood registration, building channel, per-cell query; b3ce505 stopgap DELETED (closes #99)
The A6.P4 port, fused into one installment per the BR-2 half-port lesson
(registration and query are co-dependent: flood-registering shells under
the old radial query would re-open #98 through the vestibule).

REGISTRATION (ShadowObjectRegistry rewritten):
- Register/RegisterMultiPart/UpdatePosition compute the cell set via
  CellTransit.BuildShadowCellSet (the C2 find_cell_list flood) seeded by
  the entity's m_position cell id; the private 24m XY-grid rectangle and
  its single-landblock clamp are deleted. Flood spheres follow retail's
  CylSphere rule (base point + cyl radius, cap 10; BSP bounding-sphere
  fallback - Ghidra 0x0052b9f0). Statics flood with the do_not_load
  prune; dynamics (server spawns, isStatic:false) without.
- Keep-when-empty (SetPositionInternal num_cells gate, pc:283540): a
  failed flood leaves the previous registration in place.
- RefloodLandblock: streaming-race hook re-runs the flood when a
  landblock's cells hydrate (retail init_objects -> recalc_cross_cells,
  Ghidra 0x0052b420/0x00515a30); wired at GameWindow's hydration tail.
- GameWindow sites pass the server position's full cell id as the seed
  (spawn + UpdatePosition); the five static sites pass ParentCellId.

BUILDING CHANNEL (CSortCell.building shape):
- Building SHELLS are not shadow objects in retail (only caller of
  find_building_collisions is CSortCell::find_collisions 0x005340aa;
  one building per origin landcell, init_buildings 0x0052fd80 verified
  verbatim + ACE cross-ref). IsBuildingShell entities skip the registry;
  Transition.FindBuildingCollisions runs the shell part-0 BSP off
  cache.GetBuilding(cellId) with bldg_check set around it
  (find_building_collisions 0x006b5300), CollidedWithEnvironment on
  non-Contact non-OK. BuildingPhysics.ModelId = pre-resolved part-0
  GfxObj (0x02 Setups resolved at the CacheBuilding site).
- Placement/ethereal weakening: BSPQuery Path 1 passes center_solid=0
  when BldgCheck && HitsInteriorCell (BSPTREE::find_collisions 0x0053a82e
  + placement_insert 0x005399d8) so doorway crossings don't hard-fail
  against shell solids. SpherePath gains both retail fields;
  HitsInteriorCell is rebuilt at every cell-array build
  (build_cell_array reset 0x00509ef2 + find_cell_list/check_building_
  transit set sites).

QUERY (retail per-cell order, transitional_insert 0x0050b6f0):
- TransitionalInsert per attempt: env -> building (LandCell only) ->
  objects on the PRIMARY cell, then on OK the check_other_cells pass
  (env -> building -> objects per OTHER overlapped cell) + the
  carried-cell advance - the advance now happens AFTER all per-cell
  object passes (the WF1 ordering divergence), with Adjusted/Slid
  feeding the retry exactly like retail's OK_TS case.
- FindObjCollisionsInCell = CObjCell::find_obj_collisions (0x0052b750):
  iterate ONLY the asked cell's list. DELETED: the radial 9-landblock
  sweep, the +5m query pad, the b3ce505 indoor-primary gate, and the
  isViewer exemption (the camera is bounded by interior cell-BSP env
  collision - retail's own channel; CameraCornerSealReplayTests pins it
  against real dat, and the new building-channel camera test pins the
  outdoor stop).

TESTS: Core 1416/0/2 (was 1398 + 4 pre-existing #99-era fails + 1 skip),
App 225, UI 420, Net 294 - all green.
- 3 of the 4 #99-era reds flipped green as designed: the door apparatus
  (Apparatus_Grounded_50cmOffCenter_FrontApproach_Blocks) and tick-13558
  (indoor walkthrough) now assert the door BLOCKS; tick-22760 pins the
  outdoor blocking invariant.
- The 4th (BSPStepUp D4) + 22760's lateral-slide delta are NOT cell-set
  problems (probes prove the door is found + BSP-only dispatched;
  BR-7 left both byte-identical) - filed as issue #116 (slide-response
  family), D4 skipped with the issue reference.
- FindEnvCollisionsMultiCellTests migrated to the public entry (the A4
  multi-cell halt now lives at the retail call site).
- New registry pins: per-cell query surface, outdoor-footprint-never-
  indoor (#98 architectural), door-outdoor-cell membership, reflood.
- CameraCollisionIndoorTests rewritten against the building channel
  (the isViewer-exemption pins died with the exemption).

Closes #99 (doors block both ways via registration-time cell membership
+ the straddle-spanning player cell array). #97 likely closed (the +5m
radial pad that produced phantom-collision candidates is gone) - verify
at T5. #98 stays closed ARCHITECTURALLY (outdoor footprints structurally
cannot reach interior cells; the cellar harness stays green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:37:50 +02:00
Erik
abf36e2743 T6 (BR-7) C2: BuildShadowCellSet - the registration-side portal flood
Verbatim port of CObjCell::find_cell_list (Ghidra 0x0052b4e0, pc:308742)
as invoked by calc_cross_cells / calc_cross_cells_static (0x00515230 /
0x00515160) - the flood retail runs at SHADOW REGISTRATION time, minus
the containing-cell pick (registration passes a null out-cell):

- Seed: indoor -> exactly that one cell (added even when unloaded, like
  retail's null-pointer add_cell; the walk is then skipped per the
  0052b576 seed-pointer gate); outdoor -> block-crossing
  AddAllOutsideCells.
- Growing-array walk: indoor cells via FindTransitCellsSphere
  (sphere-vs-neighbor-BSP admission; exterior straddle -> outside cells
  once per walk, retail CELLARRAY.added_outside); outdoor cells via the
  CLandCell leg (0x00533800) = add_all_outside_cells (same once-guard)
  + the building bridge CSortCell -> CBuildingObj ->
  check_building_transit (0x00534060/0x006b5230/0x0052c5d0) - how an
  outdoor-positioned door reaches the vestibule's shadow list at
  registration. No XY grid, no visibility lists (the spec's
  VisibleCellIds rule was REFUTED by the WF1 verification).
- Static prune (do_not_load_cells, 0052b66e): indoor-seeded statics keep
  only {seed} + seed.stab_list (VisibleCellIds) - also strips outdoor
  cells, matching retail (interior statics never shadow into landcells;
  outdoor spheres reach them through their own array's building bridge).

11 unit tests: seeds (indoor/outdoor/unloaded), neighbor-BSP admission,
building bridge incl. the C1 negative-portal-id gate, exterior straddle
on/off, static prune drop/keep, zero-sphere no-op.

Consumed by C3 (ShadowObjectRegistry rewrite).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:02:08 +02:00
Erik
6ec4cde9a4 T6 (BR-7) C1: signed OtherPortalId + the >=0 building-transit gate
Retail CEnvCell::check_building_transit (Ghidra 0x0052c5d0) opens with
`if (other_portal_id >= 0)` on the SIGNED sign-extended portal id
(CBldPortal.other_portal_id is int, acclient.h:32098). Our BldPortalInfo
carried the dat reader's raw ushort and CheckBuildingTransit had no gate
at all, so a portal whose dat value is 0xFFFF (-1, "no reciprocal
portal") could admit its interior cell. BN's pseudo-C renders the
comparison unsigned - the sign-extension is Ghidra-proven (BR-7 verified
corrections, wf1-interior-collision.md).

- BldPortalInfo.OtherPortalId: ushort -> short; GameWindow construction
  reinterprets the dat ushort via unchecked((short)).
- CheckBuildingTransit: negative-id portals rejected before any sphere
  test; new multi-sphere overload matching retail's per-sphere loop
  (0052c5fe, first-hit admits) with the hits_interior_cell output
  (0052c650) the BR-7 building channel consumes next.
- Tests: negative-id skip vs positive-id admit on a leaf-root CellBSP;
  multi-sphere plumbing + zero-sphere no-op.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:56:16 +02:00
Erik
85fe20f51d docs: stamp the port plan with end-of-day execution status (BR-2..6 code-complete, BR-7 + T5 remaining)
Doc-and-reality sync before the session handoff: the plan's phase
sections read future-tense while T1-T4 landed them. The render digest
carries the authoritative ledger; this stamp points there and names the
4 pre-existing #99-era Core failures as BR-7's built-in acceptance
signal.
2026-06-11 13:23:28 +02:00
Erik
4a307d33b5 T4 (BR-6): ONE visibility gate - ACME BFS deleted from the frame, legacy second render path deleted
The one-gate rule (feedback_render_one_gate) is now structural:

- The per-frame ACME BFS (CellVisibility.ComputeVisibilityFromRoot) is
  GONE from the frame. Its only production consumer was the
  cameraInsideCell boolean - which is exactly 'viewerRoot is not null'
  (the TryGetCell that produced viewerRoot already proves cells are
  loaded; ComputeVisibilityFromRoot returned null iff root was null).
  A full second visibility computation ran every frame to derive a
  boolean we already had. The method + its tests remain as quarantined
  non-production code (dual-live-visibility-computations, confirmed).

- The clipRoot==null mini-pipeline is DELETED (legacy-outdoor-branch-
  remnant, adjusted-confirmed): the outdoor partition draw, the
  Chebyshev look-in gather, the DrawPortal invocation and the dynamics
  fallback. clipRoot is null only when NO viewer cell exists (pre-login,
  fly/debug cameras, transient gaps) - those frames draw flat through
  the dispatcher; every normal outdoor frame is the outdoor node.

- DELETED with it: InteriorRenderer (class file - its only caller was
  the legacy branch), RetailPViewRenderer.DrawPortal +
  RetailPViewPortalDrawContext (the look-in product; outdoor-root frames
  flood buildings via MergeNearbyBuildingFloods inside DrawInside),
  the _exteriorPortal*/_outdoorRootNoCells fields.

Per frame there is now exactly ONE visibility computation
(PortalVisibilityBuilder) and ONE render path (DrawInside).

Suites: build green, App 226 green, Core baseline (1398 + 4 pre-existing
#99-era).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:03:06 +02:00
Erik
a6aec8c32f T3 (BR-5): viewconeCheck port - per-view sphere culling for statics/dynamics/particles, weather player-gate, unattached outdoor emitters
Ports Render::viewconeCheck (Ghidra 0x0054c250): meshes are sphere-CULLED
per portal view, never hard-clipped. NEW ViewconeCuller lifts each
slice's <=8 clip-space half-planes to world-space eye-edge planes (the
view_vertex.plane analog, acclient.h:32483 - one matrix fold: L = VP
rows . P) and tests bounding spheres from the entity's cached AABB (the
dispatcher's own cull bounds source).

Gating now matches retail's shape end to end:
- Per-cell STATICS: sphere vs THEIR CELL's views - the statics-through-
  walls fix (the cottage phantom staircase's actual draw path: a static
  outside every view of its cell no longer paints through the wall).
- DYNAMICS (last pass): sphere vs their cell's views; outdoor/unresolved
  vs the outside views (pass-all under the outdoor root). A dynamic in a
  non-flooded room culls HERE - retail never reaches an object whose cell
  is not in the draw list; the partition still routes it so the CULL is
  what drops it, retail's shape exactly.
- OutdoorStatic (landscape pass): pre-filtered per outside slice; the
  per-slice entity gl_ClipDistance routing is DELETED (entities draw
  outside the clip bracket; terrain/sky keep their plane clip).
- PARTICLES: the scissor-AABB gate is DELETED; emitters gate through
  their cone-surviving owners (candle-flames-through-walls fix).
- WEATHER: gated on the PLAYER being outside (retail is_player_outside -
  an indoor player gets no rain even looking out a doorway). Closes
  weather-gate-player-vs-viewer.
- UNATTACHED emitters (campfires) get their missing outdoor-root pass
  (closes unattached-particles-dropped-outdoors).

Suites: App 226 green (flood gates included), Core baseline unchanged
(1398 + 4 pre-existing #99-era).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:56:48 +02:00
Erik
88f3ce1fa0 T2 slice 3 (BR-4): draw-driven flood gating - per-building frustum pre-gate, 48m seed cap DELETED
Retail floods a building's interior exactly when its shell DRAWS and an
aperture survives the view: DrawBuilding (Ghidra 0x0059f2a0) -> per-view
viewconeCheck on the shell -> portal-BSP walk -> ConstructView(CBldPortal)
side test + GetClip-vs-view + GetVisible. There is NO distance constant
anywhere on that chain (verifier-confirmed, flood-gate-shape adjusted).

Port:
- GameWindow's outdoor-node gather: per-BUILDING frustum pre-gate on the
  aperture bounds (Building.PortalBounds - the tight flood-purposes
  equivalent of the shell viewconeCheck), iterating the per-landblock
  BuildingRegistries. Replaces the Chebyshev<=1 landblock cell-sweep.
  Also the proper fix for the 2026-06-07 'FPS drops when I look out'
  problem the Chebyshev hack approximated: dozens of AABB tests instead
  of an O(all loaded cells) portal sweep.
- OutdoorBuildingSeedDistance 48f -> infinity (the binary visibility pop
  at ~48 m - the confirmed #109 mechanism candidate - is gone; admission
  is now the screen clip per portal, retail's GetClip gate).
- The legacy clipRoot==null look-in path keeps its 48 m: it is T4
  deletion scope; improving doomed code wastes effort.

Closes the building-flood-seeding-48m-cutoff divergence (culling area,
adjusted-confirmed). Suites: App 226 green (flood gates included).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:46:46 +02:00
Erik
529dfcfee9 T2 slice 2 (BR-4): in-place growth propagation, strict reciprocal cull, retail seed in-plane reject - two retail constants REFUTED by the gate and documented
IN (retail-faithful):
- Growth propagation is IN PLACE, never by re-enqueue: retail
  AddViewToPortals (Ghidra 0x005a52d0) enqueues only on first discovery
  (InsCellTodoList); growth into a popped cell runs AdjustCellView -
  re-clip ONLY the new views through that cell's portals immediately.
  ProcessCellPortals + the processedViewCounts watermark port exactly
  that; the MaxReprocessPerCell=16 drift cap is DELETED (the 1-px merge
  is the physical fixpoint floor). Depth-128 tripwire logs loudly if the
  convergence invariant ever breaks (failsafe, not control flow). Same
  restructure in BuildFromExterior.
- Reciprocal-empty culls strictly (retail OtherPortalClip returning
  nothing = the opening is invisible from the neighbour's side); the
  eye-in-opening pre-clip restore is gone.
- Look-in seeds: retail ConstructView(CBldPortal) IN_PLANE reject at the
  TRUE F_EPSILON (SeedInPlaneEpsilon=0.0002, const @0x007c8c70) + the
  full-screen substitute rescue DELETED (the verifier-flagged non-retail
  bypass that admitted floods retail strictly rejects).

REFUTED BY THE CONFORMANCE GATE (attempted, reverted, documented inline):
- PortalSideEpsilon 0.0002: retail's tight epsilon assumes the viewer
  cell transits the instant the eye crosses a plane; our root can lag
  ~1 cm at pressed corners (CornerFloodReplay failed at every step -
  0x0171/0x0173 chain dead). 0.01 KEPT as the documented root-lag
  tolerance; tighten only with eye-exact viewer tracking + cdstW.
- Deleting the clip-empty eye-in-opening rescue: same gate, same total
  failure - our ProjectToClip near-eye behavior (EyePlaneW=1e-4) diverges
  from retail polyClipFinish's UNPINNED cdstW constant. Rescue KEPT as
  the documented cdstW-gap compensation; re-attempt only after pinning
  cdstW from the binary.

Gates: App 226/226 green (CornerFloodReplay + MeetingHallFlood + the
collapsed-portal pin all pass); Core baseline unchanged (1398 + 4
pre-existing #99-era). CloneViewPolygons orphan removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:42:41 +02:00
Erik
cf8a2c379b T2 slice 1 (BR-4): multi-view UNION merge + retail 1-px vertex merge (the fixpoint floor)
(a) MergeBuildingFrame now UNIONS a building flood's views into cells
already present in the frame (retail Render::copy_view APPENDS every
clipped portal polygon as a new view_poly, Ghidra 0x0054dfc0 - a cell
visible through two apertures holds two views). The old first-wins
'ContainsKey -> continue' dropped the second aperture's views: the
multiview-loss-first-wins divergence, a named #109 suspect.

(b) ClipToRegion output now runs retail's post-divide vertex merge:
consecutive vertices closer than ~1 pixel collapse (copy_view's
|dx|<=1 && |dy|<=1 screen-unit merge), polygons that collapse below 3
distinct verts return empty (retail's '<3 survivors -> count 0'). This
is the flood's PHYSICAL fixpoint floor - re-clipping can only insert
sub-pixel slivers, which the merge removes, so accumulated views
converge instead of drifting. Unit note: builder has no viewport, so
1 px is expressed as NDC at reference 1080p (0.00185); coarser at higher
res, which only strengthens convergence. This is the prerequisite for
removing the MaxReprocessPerCell=16 drift cap (T2 slice 2) and the
EyeInsidePortalOpening rescue.

Gates: all 10 flood conformance tests green (CornerSweep monotone pin
included); App 226 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:20:12 +02:00
Erik
579c8b06bc T1 (fused BR-2/3): retail frame order - dynamics last, punch+seal, shell chop deleted
The complete retail drawing order in one installment (per the amended plan:
every installment is a COMPLETE retail behavior - the half-ported punch of
88be519 is re-landed here WITH the ordering that makes it correct):

  static world (sky/terrain/weather/shells/scenery)
    -> aperture depth writes (interior SEAL at true depth / outdoor+look-in
       PUNCH to far-Z; PortalDepthMaskRenderer, DrawPortalPolyInternal
       Ghidra 0x0059bc90)
    -> interior cells WHOLE, far-to-near, drawn once (DrawCells Loop 2,
       Ghidra 0x005a4840; use_built_mesh pc:427905)
    -> per-cell STATIC object lists
    -> ALL dynamics LAST (DrawDynamicsLast), depth-tested, never hard-clipped

InteriorEntityPartition: new contract - every ServerGuid != 0 entity goes
to Dynamics regardless of cell (indoor/outdoor/unresolved/hidden); ByCell
carries only dat-baked indoor statics of visible cells; Outdoor renamed
OutdoorStatic. Fixes the audit's livedynamic-invisible-under-interior-roots
divergence as a side effect (live entities are never dropped by the
visibility set; culling is T3's viewcone).

DELETED (retail has no counterpart): the gl_ClipDistance shell chop
(927fd8f enable + 9ce335e outdoor scoping + UseShellClipRouting + the
per-slice shell loop + clipShells param) - retail never clips cell
geometry; aperture exactness = punch/seal + z-buffer + this order. The
old per-slice scissored AABB depth clear is replaced by retail's single
gated full clear (ClearDepthForInterior). The interior-root LiveDynamic
top-up draw and the look-in's dynamics involvement are gone (one last
pass, no double-draws).

Closes at the T5 gate (expected): #114 (chop deleted), the char-eaten-by-
doorway regression (ordering), outdoor interiors-through-doorways (punch);
#108's render half (seal) - its membership half stays re-attributed.

Suites: build green, App 226 green (partition tests rewritten to the T1
contract), Core 1398 + 4 pre-existing #99-era + 1 skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:16:27 +02:00
Erik
1e5db94f0e docs: plan amendment - user directive: port everything, test once at the end
'I don't care if it is non-playable... I want everything ported, then we
test.' Per-phase playability + per-phase visual gates DROPPED. BR-2..BR-6
execute as ONE continuous port with build+tests green per commit and a
single comprehensive visual pass at the end (T5). Replaces the
playability rule with: every installment must be a COMPLETE retail
behavior, never half of one (the BR-2 punch-without-ordering lesson,
88be519).
2026-06-11 11:03:37 +02:00
Erik
9abbf58cb0 docs: #108 re-attributed render -> membership (BR-2 gate finding)
The BR-2 punch/seal gate proved #108 (cellar grass-sweep) is a membership
flip (player classified outdoor mid-cellar), not a render depth bug. The
punch only masked it on outdoor-root frames. Move #108 to the membership
track; the interior depth seal is a separate mechanism that does not fix it.
2026-06-11 10:37:40 +02:00
Erik
88be519ec0 revert(render): BR-2 depth discipline - the gate proved #108 is MEMBERSHIP, not depth
Visual gate (2026-06-11) on the seal+punch build, then on the punch-reverted
build, isolated the truth:
- With the punch wired: #108 (cellar grass-sweep) gone BUT the player/NPCs
  go transparent by exactly their overlap with any doorway viewed from
  outside (the far-Z punch erases the depth of dynamic objects standing in
  the aperture, so the interior paints over them).
- With ONLY the punch reverted (seal+full-clear kept): characters render
  correctly AND #108 is BACK.

The punch is wired for OUTDOOR roots + the look-in path ONLY; it never runs
on a clean interior (cellar) frame. For it to have suppressed #108, the
cellar-transition frames must render through the OUTDOOR root -> the player
is being classified OUTDOOR mid-cellar (the known #112/#106 cellar
membership ping-pong). So:
- #108 is a MEMBERSHIP bug (render is downstream of membership); the punch
  was MASKING it, harmfully. Re-attributed to the membership track.
- The interior-root SEAL addresses a case that is NOT #108 (confirmed: #108
  isn't an interior-root frame), so it has no verified visible effect yet.

Per no-workarounds + verify-before-layering: reverted ALL of BR-2's depth
machinery (seal, punch, the per-slice->full-clear swap) to the pre-BR-2
baseline (restored from 6cba950). The phantom-site probe (6cba950) is kept.
PortalDepthMaskRenderer.cs is KEPT as a RESERVED, unwired primitive (it is
verified-correct; the depth discipline will be rebuilt during BR-3 with
dynamics-after-interior ordering, where it can be verified against the
shell-chop deletion).

What survives from this session's execution: BR-1 (already-equivalent,
695eca2) stands. #108 moves to membership. BR-2 to be re-approached under
BR-3 with correct ordering. No net production behavior change vs 6cba950.

Suites: build green, App 226 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:35:57 +02:00
Erik
4ac547f6eb BR-2 commit 2: far-Z PUNCH on building entry apertures (outdoor + look-in)
Mirror of commit 1's seal: the punch half of retail's invisible portal
depth writes (DrawPortalPolyInternal maxZ1, Ghidra 0x0059bc90;
ConstructView(CBldPortal) mode-1, pc:433827).

Generalized DrawRetailPViewExitPortalSeal -> DrawRetailPViewPortalDepthWrite
with retail's maxZ1/maxZ2 selector (forceFarZ):
- INTERIOR root: forceFarZ=false (SEAL, true depth) - unchanged from c1.
- OUTDOOR root + DrawPortal look-in: forceFarZ=true (PUNCH, far depth) -
  erase the terrain depth inside a flooded building's entry aperture so
  the interior shows THROUGH the doorway against the nearer front-ground.

Architectural note (divergence from retail ORDER, same RESULT): retail
draws the shell LAST (DrawBuilding Draw(part,1) punch -> draw interior ->
Draw(part,0) shell) so the shell closes everything outside the punch. Our
pipeline draws the building shell FIRST (it is an outdoor WorldEntity in
the landscape pass), so the outside-the-aperture wall occlusion is already
in the depth buffer when interiors draw - we need ONLY the punch for
in-aperture visibility, no shell reorder. The punch is confined to each
door polygon clipped to its slice (NOT a full clear), so it does not
reintroduce the 'cellar paints over everything' hazard that gated the old
outdoor ClearDepthSlice to null.

DrawExitPortalMasks is now wired on the outdoor-root DrawInside context and
the DrawPortal look-in context (both previously null -> no-op).

Suites: build green, App 226 green, Core 1398 + 4 pre-existing #99-era.
NEEDS VISUAL GATE (batched with BR-3): outdoor interiors-through-doorways
must not bleed; cellar grass-sweep (#108) gone; tower stairs near+far.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:15:52 +02:00
Erik
6d4cac2418 BR-2 commit 1: exit-portal depth SEALS + retail full depth clear (the #108 machinery)
Ports the seal half of retail's invisible portal depth writes
(D3DPolyRender::DrawPortalPolyInternal, Ghidra 0x0059bc90; dispatched by
PView::DrawCells loop 1, Ghidra 0x005a4840 pc:432783-432786):

- NEW PortalDepthMaskRenderer: draws a portal polygon as a color-masked
  triangle fan, depth-test ALWAYS + depth-write ON, at the polygon's TRUE
  projected depth (retail maxZ2 seal) or forced to far-z 0.99999988
  (retail maxZ1 punch - the constant from 0x0059bc90's tail; punch wiring
  lands in BR-2 commit 2). Where retail software-clips the fan against
  the installed view (polyClipFinish), we apply the SAME slice region via
  gl_ClipDistance from the slice's <=8 clip-space half-planes. GL state
  fully self-contained (set -> draw -> restore, no early-outs).

- DrawExitPortalMasks is now WIRED in production (was a null-callback
  no-op since birth): for interior roots, every visible cell's portals
  with OtherCellId==0xFFFF get their world-space polygon sealed per view
  slice, far-to-near, after the landscape slices.

- ClearDepthSlice (per-slice scissored AABB clear - wrong shape, wrong
  scope, no seal after it) is REPLACED by ClearDepthForInterior: ONE
  full-buffer depth clear between the outside stage and the interior
  stage, gated on any outside slice having drawn (retail's
  portalsDrawnCount gate semantics staged as an open question, marked
  inline). DepthMask(true) asserted at the clear site (c4df241 lesson).
  Outdoor roots: no clear, no seals (interiors must depth-test against
  terrain until the commit-2 punch).

Closes the mechanism behind #108 (outdoor grass sweeping across the
upstairs door opening - terrain depth seen through the doorway is now
re-stamped at the door plane so farther interior geometry z-fails inside
the aperture). Visual gate: BR-2/BR-3 batched checklist (cellar doorway
+ cottage wall + tower stairs near/far).

Suites: build green, App 226 green, Core 1398 + 4 pre-existing #99-era
failures + 1 skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:03:10 +02:00
Erik
6cba95047c BR-2 task 1: phantom-site probe (ACDREAM_PROBE_PHANTOM)
The BR-1 pre-check left the #113 phantom residual with two surviving
suspects, both cell-side: (a) flood-admitted cells whose shell draws with
a pass-all slice (NoClipSlice fallback when the assembler handed no slot,
or an assembler slot-0 scissor-fallback slice), and (b) cell entity
buckets drawn unclipped + un-viewcone'd by design.

[phantom-shell]: per shell-pass cell, print-on-change - clip-enable
state, slot presence, every drawn slice's slot + plane count with
PASS-ALL flagged. [phantom-objs]: per object-list cell, print-on-change
- entity bucket size. Env-gated ACDREAM_PROBE_PHANTOM=1, zero cost off,
throwaway (strip when the phantom closes).

Repro protocol: launch with the probe on, stand at the hall bisect spot
(world ~216,-108 looking at the AAB3 meeting hall west face) where the
phantom is visible, read which mechanism fires for stair cells
0xAAB30100..0x106. Shells pass-all -> BR-2/BR-3 close it; statics ->
BR-5 closes it.

Build green; App suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 06:27:44 +02:00
Erik
695eca2c1f BR-1: RESOLVED as already-equivalent - premise falsified by pre-check, equivalence pinned, #113 attribution corrected
The plan's BR-1 ('implement the skipNoTexture draw-time surface gate')
died on its pre-check: acdream ALREADY suppresses every portal fill.
ReplicateProductionEmission_OnPortalFills replicates the exact emission
conditions of the production extractors on the hall/cottage fills:
pos=False neg=False for every one (Stippling.NoPos skips the positive
side at ObjectMeshManager.PrepareGfxObjMeshData:1046,
PrepareCellStructMeshData:1394, CellMesh.Build:44, GfxObjMesh.Build:71;
the fills have no negative surface). There is nothing to gate.

What ships instead: StipplingSurfaceEquivalenceTests - 2,607 polys across
13 building models + 13 environments, ZERO violations both directions:
NoPos <=> untextured-surface. Our build-time skip is proven equivalent to
retail's draw-time skipNoTexture rule (Ghidra 0x0059d4a4, default on
@0x00820e30) on this content. The pin fails loudly if future content
breaks the invariant - the cue to implement the draw-time gate then.

Corrections folded into the plan + comparison docs:
- The #113 phantom residual CANNOT be GfxObj fills (they never reach a
  vertex buffer). Plausible true sites are cell-side: flood-admitted
  cells drawn with the pass-all NoClipSlice when slot-less
  (RetailPViewRenderer.cs:71), and/or cell statics drawn unclipped +
  un-viewcone'd (object-lists-skip-portal-view-gate, confirmed).
  BR-2 opens with the probe that pins which.
- The e46d3d9 user-gate observations (filter removed phantom/doors) were
  confounded - the filter was a provable mesh no-op on shells AND doors.
- Ledger rows solid-surface-skip-missing + the acdream half of
  portal-polys-baked-unconditional re-marked REFUTED-for-fills; the
  retail mechanism descriptions and the un-consumed PortalIndex->
  CBldPortal pairing (BR-4) stand.

Suites: Core 1398 green (1392 baseline + 6 new facts) + the 4 pre-existing
#99-era failures + 1 skip. No production code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 06:25:31 +02:00
Erik
eb689ae73f docs: add "out of scope / tracked follow-ups" section to the port plan ($4)
Names the boundary of what BR-1..BR-8 delivers, so the gaps are written
down rather than silently assumed (the very thing that breeds whack-a-mole):
FU-1 transparency/sorting (BR-9 candidate, area unmapped), FU-2 dungeon
visibility scaling #95 (plausibly helped by BR-4/BR-6 but NOT guaranteed -
re-measure after), FU-3 LOD/degrades, FU-4 picking, FU-5 the ~30 open
questions (in the comparison doc $6), FU-6 verification top-up before
BR-8b lighting. None blocks BR-1..BR-8; each becomes its own item.

The #95 dungeon-scaling follow-up was previously raised only verbally -
now tracked in the plan. Sections 4/5 renumbered to 5/6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 06:15:03 +02:00
Erik
5e2f99d08e docs: Phase A comparison + Phase B port plan (holistic building-render investigation)
Deliverable 1: docs/research/2026-06-11-building-render-acdream-vs-retail-
comparison.md - the acdream-vs-retail architecture comparison synthesized
from two ultracode mapping fan-outs (11/12 areas, ~90 agents, every retail
claim Ghidra/pc-cited, every acdream claim file:line, 40/76 divergences
adversarially verified so far; raw per-area evidence committed under
docs/research/2026-06-11-holistic-map/).

Headline findings: (1) retail flattens GfxObjs/cells at load exactly like
us (ConstructMesh + RemoveNonPortalNodes) - the MDI pipeline survives;
(2) the phantom/door mechanism is the skipNoTexture draw-time surface gate
(dat-confirmed); (3) retail never geometrically clips world geometry -
aperture exactness is a DEPTH discipline (punch maxZ1 / seal maxZ2 / gated
clear + far-to-near whole-mesh draws) - reframes #114; (4) flood admission
is already faithful, the trigger/depth/multi-view/cone-culling layers are
missing; (5) #115 root cause verified (boom damping severed from the
published collided viewer); collision A6.P4 design verified with
corrections (signed other_portal_id >= 0 gate).

Deliverable 2: docs/plans/2026-06-11-building-render-port-plan.md - the
phased port plan (BR-1 surface gate, BR-2 depth punch/seal, BR-3 delete
the shell chop, BR-4 draw-driven floods, BR-5 viewconeCheck, BR-6 one
gate, BR-7 collision A6.P4, BR-8 camera/lighting/LOD) with per-phase
acceptance criteria, bug closures, keep-list, and a playable-after-every-
phase migration order. AWAITING USER APPROVAL - no implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 05:54:12 +02:00
Erik
31ea849277 test(conformance): skipNoTexture confirmation - ALL Holtburg portal-fill quads are Base1Solid (untextured)
Phase A confirmation fact (DumpPortalFillSurfaceTypes): every portal-fill
polygon on the audited building models (hall 0x010014C3, cottages
0x01000827/0x0100082E/0x01000C17) carries an untextured surface
(Base1Solid, mostly +Translucent) with Stippling=NoPos and no negative
surface. Retail's skipNoTexture rule (D3DPolyRender inner draw 0x0059d4a0,
default on @0x00820e30) therefore skips ALL of them on the building/cell
pass - door fills, window fills, AND the phantom stair-ramp. Retail never
draws any baked fill; visible doors are door ENTITIES. acdream draws the
solid batches as colored geometry, which is both the phantom staircase AND
why dropping them read as 'doors disappeared'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 05:54:12 +02:00
Erik
e223325410 test(conformance): door-vanish mystery SOLVED - every 'orphan' is a DrawingBSPNode.Portals PortalRef (no static filter can be right)
Charter open mystery #1 (docs/research/2026-06-11-building-render-holistic-
port-handoff.md "4.1): the e46d3d9 DrawingBSP poly filter made doors vanish
because the PosNode/NegNode walk only collected node.Polygons and never
node.Portals (List<PortalRef> {PolyId, PortalIndex}).

Dat-proven across all 13 Holtburg-area building models (A9B4/A9B3/AAB3/
A9B5/AAB4): TRUE-orphans = ZERO everywhere. Every dictionary poly the
filter dropped is a PORTAL POLYGON - the baked door-filling (1.9x2.5 m)
and window-filling quads at doorway/window apertures, AND the meeting
hall's phantom stair polys {0,1} (ramp-shaped portal apertures into the
interior stair cells).

Consequences for the holistic port:
- The door entities (setup 0x020019FF) were never affected: base parts +
  every degrade variant have full BSP coverage, and doors don't take the
  IsIssue47HumanoidSetup degrade swap. The vanished 'doors' were shell
  portal polys.
- Retail draws portal polys CONDITIONALLY during portal-view traversal
  (closed doors/windows draw a surface; open apertures and the hall's
  stair apertures don't). The phantom staircase and the door rendering
  are the SAME mechanism with opposite signs - there is NO correct
  static filter; this is the dat-side proof the one-drawing-discipline
  port is required.
- The exact retail conditional (BSP portal-node draw gate in
  CPhysicsPart::Draw / BSPPORTAL) is a named Phase A question.

Diagnostic-only commit: new dump facts in
Issue113DoorVanishDiagnosticTests (door setup + degrade chains, control
models, Holtburg orphan sweep with portal discrimination). No production
code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:10:20 +02:00
Erik
9c45144047 docs: holistic building-render port charter + next-session prompt (the 2026-06-11 mandate)
User mandate: stop bug-by-bug; map acdream-vs-retail for building draw,
interiors, interior collision, dynamics, clipping, culling; plan the port
of retails drawing discipline once and for all. The handoff carries the
branch state (124c6cb, nothing on main), the full evidence inventory from
this session (orphan no-draw polys, door-vanish mystery, draw-side clip
status, straddle gate), the gap map, tooling (Ghidra MCP 8081 correct
PDB, live cdb protocol, dat dump + flood harnesses), the investigation
charter (workflow fan-out per subsystem, adversarial verification), and
the paste-ready new-session prompt. #113 marked REOPENED and folded in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:50:23 +02:00
Erik
124c6cb2af revert(render): #113 - un-apply the DrawingBSP poly filter (door regression); keep helper + dat pins
User gate 2026-06-11: the filter (e46d3d9) removed the phantom staircase
everywhere (verified) but DOORS disappeared across Holtburg - the naive
PosNode/NegNode walk evidently misses polygons some models reference
another way. Doors > phantom stairs: filter application removed; the
CollectDrawingBspPolygonIds helper and the dat-fact tests (hall orphans
0+1, cottage 0..7) stay as apparatus for the holistic building-render
port. First diagnostic for re-landing: run the DrawingBSP histogram on a
door GfxObj. See
docs/research/2026-06-11-building-render-holistic-port-handoff.md.

Branch state after this commit: outdoor-scoped shell clip (927fd8f +
9ce335e) + retail straddle gate (414c3de) + all diagnostics; phantom
staircase VISIBLE again (known, documented); doors functional.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:47:44 +02:00
Erik
e46d3d9273 fix(render): #113 root cause #2 - GfxObj meshes draw only DrawingBSP-referenced polys (the REAL phantom staircase)
The user gate + bisect overturned the coincident-cell attribution: the
phantom staircase persists in the PRE-session build (bisect screenshot at
the hall wall) and is drawn by the ENTITY pipeline, untouched by any clip.

Root cause (dat-proven, DumpHallModel_PolyFlagHistogram): retail renders a
GfxObj by TRAVERSING its drawing BSP (D3DPolyRender); polygons present in
the Polygons dictionary but referenced by NO DrawingBSP node are never
drawn - they are physics/no-draw geometry. The Holtburg meeting hall
(0x010014C3) keeps its exterior stair-ramp as dictionary polys 0+1: in
the PhysicsBSP (ACE walks The Sentry on it at z 117-118; invisible-but-
walkable in retail) but orphaned from the draw tree (true at ALL degrade
levels - the LOD theory is dead, Degrades[0] IS the base model). The hill
cottage (0x01000827) carries 8 such orphans. Our extraction iterated the
dictionary -> drew the collision skeleton: the wall staircase up close,
the flying stairs over the cottage roofline from afar (orphan ramp spans
world 221-232 at z 116-124.5; visible over the cottage roof from the west).

Fix: PrepareGfxObjMeshData filters to CollectDrawingBspPolygonIds(gfxObj)
when a drawing BSP exists; models without one draw everything (unchanged).
Physics untouched (collision keeps the full physics set - retail parity).
CellStruct extraction not touched (different conventions; no orphan
evidence there yet).

Dat-backed pins: Issue113DrawingBspFilterTests (hall orphans == 0+1,
cottage orphans == 0..7). Suites: App 226 / Core 1392 + the 4
pre-existing #99-era failures / UI 420 / Net 294.

Note: the earlier shell-clip enable (927fd8f, scoped 9ce335e) remains
correct and orthogonal - it crops interior CELL geometry to apertures
outdoors; this commit removes the phantom SHELL geometry at its source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:52:52 +02:00
Erik
6c9bbce433 docs: file #114 (indoor shell-clip region quality) + #115 (camera feel) from the first user gate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:24:09 +02:00
Erik
9ce335eb17 fix(render): #114 - scope the PView shell clip to outdoor-eye roots (first user gate findings)
The 2026-06-11 user gate on 927fd8f: the OUTDOOR half works (phantom
meeting-hall staircase GONE at the original spot) but enabling the clip
for INDOOR roots exposed that our indoor clip regions are admission-
quality, not draw-quality - chopped interior stairs, a neighbour rooms
barrel visible through a clipped-away wall, missing candle-holder
geometry, inner walls vanishing while passing building exits. Retail
crops indoors too, but with pixel-exact recursively-clipped regions;
ours have knife-edge cases indoors that were invisible until the GL
enable made them cut real geometry.

Scope: DrawInside enables the shell clip only when RootCell.IsOutdoorNode
(the regime Issue113MeetingHallFloodTests validates); DrawPortal (from-
outside look-in) keeps it on; indoor roots draw unclipped - yesterdays
user-accepted state. Filed #114 for bringing indoor regions to retail
crop quality (also the home of the remaining gate findings to re-test:
entry-transparency at the hilltop cottage, particles visible through
buildings, camera feel in cramped interiors).

App 224 green; no Core/UI/Net surface touched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:22:29 +02:00
Erik
8259598970 docs: #113 closed (attribution + fix) + #112 residual resolved in ISSUES.md
#113 moved to Recently closed: the phantom staircase was the Holtburg
meeting hall (AAB3, not A9B3) interior stair cells drawn unclipped from
outside - the PView shell clip was routed but never GL-enabled (927fd8f).
Misplaced-cell hypothesis refuted with dat evidence. #112 residual
paragraph updated: retail straddle gate live-binary verified + ported
(414c3de); at-doorway demote is retail-faithful, deep gaps now keep-curr.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:56:27 +02:00
Erik
414c3deaf4 fix(phys): #112 residual - retail straddle gate for outdoor-cell admission (live-binary verified)
The oracle read the #112 residual was waiting on, settled against the
LIVE 2013 client (cdb attach, CEnvCell::find_transit_cells @ 0052c820;
BN pseudo-C was ambiguous and partly wrong per
feedback_bn_decomp_field_names - it invented portal_side tests in this
branch): retail admits outdoor transit cells from an indoor cell IFF a
path sphere STRADDLES an exterior portal polygon plane,
|dist| < radius + F_EPSILON(0.000199999995, @ 007c8c70). The flag at
[esp+18h] (set 0052c925, x87 decode fcompp/test ah,41h +
fcomp/test ah,5/jp) gates the add_all_outside_cells call (0052c9d6 je).
Graph reachability alone NEVER admits outdoor cells in retail.

Port (CellTransit):
- FindTransitCellsSphere: exitOutside now carries the retail straddle
  semantics; new hasExitPortal out carries the old topology-only flag.
- BuildCellSetAndPickContaining: the collision cell SET keeps the A6.P5
  topology widening on hasExitPortal (outdoor-registered doors must stay
  findable from indoor cells until #99/A6.P4 ships per-cell shadow
  lists - the 2026-05-25 door capture scenario), but the membership
  PICK's outdoor branch is gated on the retail flag. Membership is now
  retail-identical in both regimes: straddle -> outdoor candidates valid;
  no straddle -> outdoor ignored -> retail keep-curr. This is what stops
  deep-interior containment gaps in ANY house from demoting to outdoor
  (the #112 transparent-interior shape) - the systemic protection the
  user asked for, without house-by-house verification.

The at-doorway A9B3 gap demote is RETAIL-FAITHFUL (gap point is 0.23m
from 0x104s door plane < 0.48 foot radius -> retail straddles + demotes
+ self-heals inward): DocumentsResidual renamed to
...DemotesRetailFaithfully, expectation unchanged. New conformance pins:
deep-gap keep-curr (A9B3Cottage_GapBeyondStraddleDistance_KeepsCurrCell)
+ function-level gate semantics on real dat geometry
(FindTransitCellsSphere_ExitPortalStraddleGate_MatchesRetail).

Tests: Core 1391 green (+2) / App 224 / UI 420 / Net 294; pre-existing
4 #99-era failures unchanged; P1 membership goldens + A6.P5 door-set
tests explicitly green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:52:24 +02:00
Erik
927fd8fde2 fix(render): #113 - enable GL clip distances for the PView shell pass (phantom exterior staircase)
Attribution (dat-evidenced, supersedes the misplaced-cell hypothesis):
the phantom staircase is the Holtburg MEETING HALL (AAB3 building[0],
model 0x010014C3 at AAB3-local (36,84,116)), NOT an A9B3 building - the
user stood at the A9B3/AAB3 boundary (cell-transit trail in
issue112-gate1.log) and clicked through the hall to the NPC behind it.
The hall's interior stair cells (0x100..0x106, ring climbing z 116->124.5
to the deck hatch) have geometry coincident with the shell's west wall
(both at local x=29.0). Our outdoor per-building flood admits them with
CORRECT tight clip regions (4-6 planes, door-aperture NDC boxes -
Issue113MeetingHallFloodTests proves it), but DrawEnvCellShells drew them
WHOLE: mesh_modern.vert writes gl_ClipDistance from the routed CellClip
slot, and gl_ClipDistance is ignored unless GL_CLIP_DISTANCEi is enabled -
which no caller ever did for the shell pass (born inert in 1405dd8).
Interior staircase painted across the exterior wall; unpickable because
it is cell geometry, not an entity.

Retail oracle: cell geometry IS clipped to the accumulated portal view -
Render::set_view (:343750) installs the view polygon edge planes,
DrawEnvCell submits every cell polygon with planeMask=0xffffffff (:427922)
through ACRender::polyClipFinish. Characters/meshes are NOT poly-clipped
(viewconeCheck path) - entity routing stays cleared, comment scoped.

Fix: enable GL_CLIP_DISTANCE0..7 around exactly the shell pass
(self-contained per feedback_render_self_contained_gl_state; no early-outs
between set and restore). Slot-0 fallback slices (>8-plane regions) still
draw pass-all - the assembler's scissor fallback remains unimplemented and
documented; the new flood test pins 0 such slices at the hall.

Refuted along the way (full evidence in Issue113PhantomStairsDumpTests):
- ONE misplaced interior EnvCell unifying #113+#112+collision gaps: all 17
  A9B3 cottage cells share an identical dat Position (nothing to misplace);
  the #112 gap is a real 20cm doorway micro-gap 0.23m outside threshold
  cell 0x104 (straddles its exterior portal plane at foot radius 0.48);
  missing object collision remains #99/A6.P4.
- A9B3 dat content near the spot: no stair geometry in shell (balcony at
  z119 + turret roof only), cells (flat 116/118.8), statics, or stabs.

Tests: Core 1389 green (+6 dump facts) / App 224 (+1 flood replay) /
UI 420 / Net 294; pre-existing 4 #99-era failures unchanged.
Visual gate pending: user re-check of the hall west face vs retail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:26:55 +02:00
Erik
6d2218cac3 docs: #113 pickup handoff - phantom-stairs/misplaced-cell attribution plan + #112 residual rider + tonight's shipped-state table
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:28:44 +02:00
Erik
77d7ea1530 docs: file #113 - phantom exterior staircase on A9B3 building (unclickable => shell/cell geometry); suspect misplaced interior cell unifying #112-gap + collision symptoms
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:24:53 +02:00
Erik
6509a28926 docs: #112 primary fix shipped - hatch removed, lateral recovery in; residual = at-doorway demote via outdoor candidates (oracle read pending)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:04:25 +02:00
Erik
2d6954ee44 fix(phys): #112 - remove the non-retail escape-hatch demote from the pick; lateral stab-graph recovery + retail keep-curr
Root cause (oracle: CLandCell::point_in_cell :316941 = terrain-poly only;
find_cell_list null-result keep-curr pc:308788-308825; CEnvCell::
check_building_transit :309827 = sphere_intersects_cell per portal-adjacent
cell): retail KEEPS curr_cell when nothing contains the centre — including
inside a house's containment gaps. Our 6dbbf95 escape hatch instead demoted
any hydrated indoor claim the sphere no longer overlaps to the outdoor
column; at the A9B3 hill cottage's real interior gap this stranded the
player outdoor-classified deep indoors, where re-promotion is portal-
adjacent-only (retail-identical) -> the outdoor flood rendered the interior
transparent (the user's "sometimes transparent" walk).

The hatch's actual target - poisoned (cell, position) SAVES - has been
handled at the SNAP by PhysicsEngine.Resolve's AdjustPosition validation
since #107/#111, so the per-tick pick reverts to retail semantics:
1. lateral recovery first - when the sphere no longer overlaps the claim,
   search the claim's stab list for a containing cell (retail
   find_visible_child_cell :311444, the same recovery AdjustPosition uses);
   the #111 adjacent-claim shape now self-heals laterally (dat-backed test:
   pick(seed 0x172) at a 0x171-interior point -> 0x171);
2. else KEEP curr_cell (retail null-result).

Two old tests asserting the hatch demote rewritten to the retail semantics
(tests-can-codify-bugs); P1 retail-golden conformance gates explicitly green
(FindCellListConformance + ThresholdPortalCrossing + CottageDoorway +
CameraCornerSeal = 11/11). New Issue112MembershipTests: the lateral-recovery
fact + a DocumentsResidual fact pinning the remaining at-doorway gap demote
(via the NORMAL outdoor-candidate path; open oracle read = retail's
add_all_outside_cells gate in CEnvCell::find_transit_cells pc:317499 -
sphere-proximity vs graph-reachability). Core 1383 + 4 pre-existing #99
failures + 1 skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:03:49 +02:00
Erik
e9c8a925d2 docs: file #112 - house containment gap demotes to outdoor with no containment-based re-promotion (A9B3 cottage, dat-scan evidenced)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:46:22 +02:00
Erik
33662b35b6 docs: #111 closed - three-layer fix chain (bestCell clobber, triangle-soup grounding, entity snap parity), user-gated at two buildings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:34:53 +02:00
Erik
2735695a6b fix(phys): #111 - snap the player ENTITY at login entry, parity with the teleport-arrival path
The entry snap set only the controller (physics stood on the grounded floor)
while the renderer kept drawing the entity at the server-restored height -
the user's "spawned 2 meters in the air" screenshot over a fully-correct
interior. The teleport-arrival path already does entity.SetPosition +
ParentCellId (GameWindow.cs:4914); the login path now matches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:28:29 +02:00
Erik
5706e0e10a fix(phys): #111 - ground the validated claim via PHYSICS walkable polygons, not the CellSurface triangle soup
Two grounding selection rules failed against live ACE restores before the
right one: (1) first-hit SampleFloorZ returned 0x171's 99.475 ceiling TOP face
over its 94.0 floor (issue111-verify2.log) - the player committed onto the
roof level, and the session's heartbeats poisoned ACE's save with z=99.475;
(2) nearest-to-reference self-confirmed that poison (the reference SAT on the
ceiling face, issue111-verify3.log). Root insight: ceiling/roof top faces are
upward-facing and XY-projectable - geometrically indistinguishable from
floors in the render-ish CellSurface soup. The PHYSICS walkable set (plane
normal.Z >= PhysicsGlobals.FloorZ over the claim's Resolved cell-local
polygons - retail BSPTREE::find_walkable's filter) contains only real floors:
PhysicsEngine.WalkableFloorZNearest transforms into the cell frame, drops on
each walkable plane under the XY, picks nearest the reference.

Verified live (issue111-verify4.log): ACE restored the roof-poisoned
(0xA9B40171, z=99.475); the snap validated the claim and grounded to
z=94.000 - the first fully clean indoor login of the arc:
[snap] claim=0xA9B40171 VALIDATED -> grounded to its walkable floor z=94.000
[cell-transit] 0x00000000 -> 0xA9B40171 pos=(155.525,12.416,94.000)

Baseline: Core 1381 + 4 pre-existing #99 failures + 1 skip; App/UI/Net green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:21:59 +02:00
Erik
5f1eb7c4b1 fix(phys): #111 - a validated indoor claim is authoritative at the snap; stop the whole-landblock bestCell floor-pick from clobbering it
The [snap] apparatus (issue111-snap1.log) caught the mechanism live: ACE
restored a CLEAN pair (0xA9B40171, on-floor) which AdjustPosition validated -
and the legacy Resolve then committed 0xA9B4013F instead: its bestCell floor-
pick scans EVERY CellSurface in the landblock (123 at Holtburg) for "any floor
under this XY nearest currentZ" and breaks same-height ties by iteration
order. The wrong cell then fails containment on the first movement -> outdoor
demote inside the building -> the #111 transparent interior. This free-pick
also explains the earlier "committed verbatim" mystery (the winning tie
happened to echo the input pair) AND seeded the ACE poison loop: our outbound
heartbeats reported the clobbered cell, ACE persisted it, and the NEXT login
inherited it (this run's [spawn-adjust] rejecting 0xA9B4013F is exactly that
echo coming back).

Fix (retail SetPositionInternal shape): when AdjustPosition VALIDATED an
indoor claim, the cell choice is settled - the snap grounds Z onto the
validated cell's own floor (find_valid_position's settle role, :283426) and
returns; it never re-picks the cell from floor geometry. Claims whose cell has
no own floor surface under the XY (thresholds, stair lips) fall through to
the legacy path unchanged; mover-shaped calls (delta != 0, tests) untouched.

[snap] diagnostic kept (snaps only - one line per login/teleport).
Baseline: Core 1381+4 pre-existing #99 failures+1 skip; App/UI/Net green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:12:21 +02:00
Erik
383af0ab5f docs: file #111 - ACE-mutated indoor restores start outdoor-classified (transparent until door-press); evidence + retail fix direction
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:54:37 +02:00
Erik
e4f6750e09 fix(phys): #107 gate-run regression — auto-entry hold opened on a mid-population cache read; wait for the claimed cell itself
Gate-run finding (wedge-107-gate-indoor-login.log + dat scan): ACE saved the
cell one room off (claim 0xA9B40172, position inside 0xA9B40171 — adjacent
rooms). FindVisibleChildCell corrects this fine when hydrated (proven by the
new AdjacentRoomClaim regression test), but the live entry committed the raw
claim: IsSpawnCellReady's "any cell struct in the landblock => claim is bogus,
proceed" disambiguator observed the MID-POPULATION state (interiors hydrate in
id order on the background worker; the render-thread predicate read the cache
mid-loop) and opened the gate before the claim and its stab neighbors were
cached. AdjustPosition then saw a null cell struct and silently passed the
claim through; the first movement demoted the player to outdoor inside the
house — the user-visible "transparent interior, see straight through walls"
(render is downstream of membership: an outdoor-classified viewer only sees
the interior through the doorway flood).

Fix: the hold now waits for THE CLAIMED CELL's struct, full stop
(IsSpawnCellReady simplification; HasAnyCellStructInLandblock removed).
Claims that can never hydrate are filtered by GameWindow against the dat's
LandBlockInfo.NumCells range (memoized IsSpawnClaimUnhydratable), and
PhysicsEngine.Resolve carries a loud lost-cell-equivalent safety net: an
indoor claim with NO cell struct AND NO CellSurface floor data demotes to the
outdoor landcell with a [spawn-adjust] line instead of committing raw
(retail GotoLostCell :283418; documented divergence). Partial hydration
(CellSurface present, struct pending) keeps the legacy floor-snap behavior —
HasCellSurface uses the file's masked-low-word norm so bare-id fixtures and
full-id production both resolve.

Baseline restored: Core 1381 (+4 new #107 conformance tests) + 4 pre-existing
#99-era failures + 1 skip; App 223 / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:10:01 +02:00
Erik
34fcbc3806 docs: #107 closed - root cause + four-leg fix + live verification; ledger updated
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:53:42 +02:00
Erik
1090189d39 fix(phys): #107 indoor-login spawn wedge — validate the server (cell,pos) pair at the player snap (retail AdjustPosition) + unfreeze same-landblock teleport arrivals + self-consistent wire pairs
Root cause (capture resolve-107-login1.jsonl + dat conformance scan): ACE
restored the player with a POISONED (cell, position) pair — cell 0xA9B40162
(one building) with a position inside 0xA9B40171 (a different building 55 m
away). Our entry snap trusted the claim verbatim: the player stood
fake-grounded for minutes (isOnGround passthrough, no contact plane, no
walkable polygon — zero-move resolves short-circuit), the FIRST movement input
ran a real transition, the pick demoted the indoor claim to outdoor
mid-building, and the player fell 2.4 m through the cottage floor onto the
terrain underneath — wedged inside the building shell. The second wedge shape
(flood-fix-gate2.log) was the PortalSpace freeze: the teleport-arrival
detection gated on `differentLandblock || farAway>100m`, an invented
heuristic — ACE's same-landblock short-hop corrections matched neither, so
PortalSpace never exited and movement input stayed frozen all session.

Four legs, all retail-anchored:

1. PhysicsEngine.Resolve (the player snap path: login entry + teleport
   arrival) now runs AdjustPosition first — retail SetPositionInternal step 1
   (acclient :283892, AdjustPosition :280009): validate/correct the claimed
   cell from the foot-sphere center BEFORE any physics. Corrections log one
   [spawn-adjust] line.
2. AdjustPosition's previously-deferred indoor seen_outside →
   adjust_to_outside sub-fallback (:280037-280046) is completed; CellPhysics
   gains the SeenOutside flag (dat EnvCellFlags.SeenOutside) cached in
   CacheCellStruct. The camera path does not reach this sub-branch in the
   gated scenarios (CameraCornerSealReplayTests green).
3. PortalSpace arrival = ANY player position update (holtburger PlayerTeleport
   handler conformant; recenter still only on landblock change). Verified
   live: ACE sent a same-lb dist=69.8 correction that the old gate would have
   frozen on — it now completes.
4. Outbound wire (cell, position) pairs are now SELF-CONSISTENT: derive the
   landblock frame from the resolver's full cell id instead of welding a
   position-derived landblock onto its low word — the old composition could
   write exactly the poisoned pair shape into ACE's character save. Plus the
   #106-gate-2 hold extension: an indoor spawn claim waits for the claimed
   cell's hydration (IsSpawnCellReady) so the validation can act — the async
   equivalent of retail's synchronous cell load.

Live verification (wedge-107-verify1.log): entry clean; ACE's same-lb teleport
correction completed (old code: permanent freeze); the teleport destination
itself carried ANOTHER poisoned claim (0xA9B40150) which [spawn-adjust]
corrected to 0xA9B40019; player fully controllable, walking across landcells.
3 new dat-backed conformance tests pin the poisoned-pair facts
(Issue107SpawnDiagnosticTests). Baseline: 1380+4 pre-existing #99-era
failures+1 skip / 223 / 420 / 294.

Pending user gate: park indoors, log out gracefully, relaunch — expect a clean
indoor spawn standing on the interior floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:52:47 +02:00
Erik
fb360ab3cc docs: #110 corner press USER-GATED - camera no longer clips into the wall pressed into a corner
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:18:43 +02:00
Erik
096b81657b docs: #105 x #110 CLOSED - staged-texture-flush drop close-out (evidence chain + lesson); handoff marked historical
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:17:09 +02:00
Erik
d4b5c71e66 fix(render): re-land near plane 0.1m (retail Render::znear) — #110 resolved, closes the §4 corner see-through; close #105/#110
The 137b4f2 payload, re-landed now that #110 is resolved: the missing-indoor-
textures correlation was the pre-existing #105 staged-texture-flush drop
(fixed in c787201), not a near-plane mechanism. znear=0.1 merely raised #105's
trigger probability — a closer near plane makes close-up geometry newly
visible, inflating per-frame prepare/upload pressure indoors and growing the
never-flushed tail. Exactly the handoff's only-credible-link hypothesis,
verified instead of assumed.

Retail: Render::SetFOVRad sets znear=0.1 flat (decomp :342173, initializer
:1101867). 0.1 < the 0.3m camera-collision sphere, so a wall the collided eye
presses against no longer falls inside the near plane — the §4 corner
see-through-wall closes.

Verification on the 0.1 arm (the arm that struck 2-of-3 on 2026-06-10):
nearplane-reland-1.log — [tex-flush] after=0 on all 45 lines, 68,291 [shell]
lines with zero zh>0 batches, all four dat tripwires silent, no [wb-error].
ISSUES.md: #105 + #110 moved to Recently closed with root cause + evidence.
Pending user re-gate: corner press (wall stays solid) + distance scan for
z-shimmer (none expected; retail ships 0.1 with D24).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:14:00 +02:00
Erik
c78720127a fix(render): #105 white indoor walls — restore WB's per-frame staged-texture flush dropped in the N.4/O-T4 extraction
Root cause: TextureAtlasManager.AddTexture only STAGES texture content (PBO
write + ManagedGLTextureArray._pendingUpdates); the actual TexSubImage3D
copies + mipmap regeneration happen in ProcessDirtyUpdates, which WB drives
once per frame via ObjectMeshManager.GenerateMipmaps() from its render loop
(WB GameScene.cs:975, just before the opaque pass). GameScene is the file we
replaced with GameWindow, so the call site was silently dropped — staged
updates only reached the GPU as a side effect of PBO growth (UpdateLayerInternal
flushes pending updates before orphaning the PBO). Every layer staged after an
array's LAST growth kept undefined TexStorage3D content behind a valid,
resident bindless sampler handle: white/garbage walls, zh==0, dat tripwires
silent — exactly the #105 signature. Only ObjectRenderBatch.BindlessTextureHandle
consumers are affected (EnvCellRenderer cell shells = indoor walls); entities
resolve via TextureCache (immediate TexImage2D) and terrain via TerrainAtlas
(immediate GenerateMipmap), which is why only indoor walls ever struck.

Fix: WbMeshAdapter.Tick() now calls _meshManager.GenerateMipmaps() after the
staged-upload drain — Tick runs before all draw passes (GameWindow OnRender),
the exact WB-equivalent position.

Evidence (ACDREAM_PROBE_TEXFLUSH=1 apparatus, kept env-gated):
- pre-fix (texflush-prefix.log): pending updates climb 0->48->...->142 and
  park at 126 across 34/34 atlas arrays at standstill, forever (19 heartbeats);
  brief dips only at PBO-growth crossings — the broken contract live.
- post-fix (texflush-postfix.log): every line after=0 — staged updates drain
  the same frame, all 34 arrays clean.

Intermittency explained: background decode-completion order shuffles which
textures land in the never-flushed tail; whether a visible wall samples one is
per-run luck. Also explains the #110 correlation: znear=0.1 makes close-up
geometry newly visible -> more prepare/upload pressure indoors -> bigger tail
-> higher strike probability. The near plane is mechanism-innocent (re-land
follows as its own commit).

Baseline maintained: App 223 / UI 420 / Net 294 / Core 1377 green + 4
pre-existing #99-era failures + 1 skip; CornerFloodReplayTests (5) and
CameraCornerSealReplayTests (2) gates green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:10:00 +02:00
Erik
5d63038b61 docs: #105 x #110 handoff - white-texture GL-side investigation plan + near-plane re-land path
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:17:36 +02:00
Erik
8bd3492612 revert(render): near plane back to 1.0m pending #110 - 0.1 correlated with missing indoor textures
Bisect (user-gated): two consecutive runs on 0.1 lost indoor textures; the 1.0 bisect run rendered clean. #105 tripwires silent on the bad runs (GL-side). No known mechanism links the near plane to texturing - #110 filed to investigate (RenderDoc / flip-testing) before re-landing retail's znear=0.1, which the corner see-through fix depends on. Comments on all four cameras point at #110 so the retail value is not re-landed blind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:02:55 +02:00
Erik
137b4f2d25 fix(render): near plane 1.0m -> 0.1m (retail Render::znear) - corner see-through-wall; file #107-#109
The collided camera eye sits 0.3m from walls (viewer_sphere radius); a 1.0m near plane clipped the wall face away, so pressing the camera into a corner showed the clear color through the wall (gate result: unchanged by the flood fix - it was never a flood bug). Retail sets Render::znear = 0.1 flat in SetFOVRad (decomp :342173, initializer :1101867). All four cameras aligned. Also files #107 (indoor spawn wedge, 3-for-3), #108 (cellar-up terrain sweep across door opening), #109 (exit-door texture/background oscillation) from the 2026-06-10 visual gate; gate confirms the dac8f6a flood fix: room-room + indoor-outdoor transitions clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:48:53 +02:00
Erik
dac8f6ad1f fix(render): §4 flood strobe — homogeneous reciprocal clip + collinear-aware region dedup
THE BUG (pinned deterministically by the new CornerFloodReplayTests harness — real
Holtburg cells, captured corner-press scenario): a smooth 2 cm/step monotonic eye
sweep across the 0172↔0173↔0171 doorway produced a NON-monotonic flood — on ~10 of
61 steps the player's room (0172) vanished from the flood entirely or collapsed to
a sub-pixel sliver, taking its downstream chain (016F, the outside view) with it.
Live, those isolated frames are the §4 background strobe: openings/passages flash
the clear color during transitions, and the corner press shows background at the
angles that park the eye near the doorway plane.

TWO root causes, both fixed:

1. ApplyReciprocalClip ran the reciprocal portal polygon through the legacy
   divide-first ProjectToNdc + 2D Intersect path, justified by "the reciprocal is
   never near the eye." That assumption is exactly false at doorways/corners: the
   reciprocal IS the same opening whose plane the eye presses against (2-60 cm).
   ProjectToNdc's MinW=0.05 eye-clip + side-plane clip + divide is knife-edge
   there — 2 cm eye moves flipped its output between a no-op and a duplicated-
   vertex hairline that ground the healthy region down to <3 distinct vertices.
   FIX: route the reciprocal through the SAME homogeneous pipeline as the forward
   clip (ProjectToClip + ClipToRegion) — which is what retail does:
   PView::OtherPortalClip (decomp:433524-433563) runs the reciprocal through the
   very same GetClip(finish=1) → ACRender::polyClipFinish homogeneous clipper.
   Also ported retail's skip: exact_match portals (CCellPortal.exact_match,
   acclient.h:32300; PView::ClipPortals :433689) bypass the reciprocal clip —
   both sides share the same polygon, so re-clipping is redundant.

2. CellView.CanonicalKey missed COLLINEAR re-emissions: the homogeneous region
   clipper legitimately inserts intersection vertices ON a subject edge when a
   region edge grazes it, so BFS re-clip rounds re-emit the SAME geometric region
   with 1-2 extra collinear edge vertices — keyed as distinct, defeating the
   dedup and accumulating duplicate polygons (this was the real mechanism behind
   the historical "float drift defeats the dedup" rationale that had parked the
   reciprocal on the unstable path). FIX: canonicalize away collinear snapped
   points (exact integer cross-products on the 1e-3 NDC grid) so the key is
   purely a function of the region's corners.

Conformance: CornerSweep_FloodIsCompleteAndMonotone pins the fixed behavior —
61-step monotonic eye sweep ⇒ full flood every step, outside view always reached,
player-room region monotone (was: clean shrink 4.000→2.879 with zero drops, vs
~10 glitch steps before). Diagnostic facts (trace diff, hop microscope, primitive
scratch) retained as the apparatus.

Suites: App 223 green (incl. Build_AppliesReciprocalOtherPortalClip, now passing
with proper tightening AND dedup), Core 1377 green + the 4 pre-existing #99-era
failures + 1 skip, UI 420, Net 294. Visual gate pending: corner press, room↔room,
cellar↔floor, indoor↔outdoor transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:26:01 +02:00
Erik
482b0dea1b docs: SS2b corner-seal refuted (openings, not walls) - SS4 converges on edge-on clip collapse; next = retail clip oracle
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:01:04 +02:00
Erik
b21bb28918 test(phys): §2b corner-seal replay — camera-penetration hypothesis REFUTED (openings, not walls)
Replays the captured corner/exit "escape" sweeps (corner-seal-capture.log) through
the real ResolveWithTransition with the camera probe call shape. Geometry-map
diagnostic proves every zero-contact traversal runs through a REAL opening: the
0170 exit-door portal for the viewer-outdoor frames; the 0171↔0173↔0172 doorway
chain (0173 = 20 cm threshold cell) for the corner-press frames. Captured eyes land
inside door-opening rectangles; the containment walk shows no path point in solid
cell volume; 8,703/14,230 indoor sweeps in the same capture collided correctly
(pull-in up to 2.77 m) — camera collision works, nothing penetrates.

The user-visible "background at the corner" is therefore NOT collision — it is the
§2a edge-on portal-clip collapse with the eye hovering at the doorway plane. Both
§4 siblings converge on one render-side mechanism: edge-on clip collapse near
opening planes. Next per the 2026-06-08 handoff §5.3: read retail's edge-on clip
oracle (PView::GetClip :432344, PView::ClipPortals :433572, polyClipFinish :702749)
before designing the fix.

Assertion fact = characterization pinning the verified behavior (openings pass
clean); Diagnostic fact = dispatch trace + room map + containment apparatus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:59:48 +02:00
Erik
df2ef7c598 docs: §4 outdoor full-world flap CLOSED — depth-mask leak close-out (evidence chain + fix + verification)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:22:02 +02:00
Erik
c4df241690 fix(render): §4 outdoor full-world flap — empty Transparent pass leaked DepthMask(false), no-oping the frame depth clear
ROOT CAUSE (pinned by the [clip-route] probe run + [gl-state] tripwire, one
capture): EnvCellRenderer.RenderModernMDIInternal established the Transparent
pass state (Enable(Blend) + DepthMask(false)) BEFORE the batch pass-filter, so
a cell whose batches are ALL opaque (a plain cottage interior) hit the
totalDraws==0 early-out and returned without ever reaching the end-of-pass
restore. The frame ended with dmask=0 + blend=1; the NEXT frame's
glClear(GL_DEPTH_BUFFER_BIT) silently no-oped (depth clears honor glDepthMask),
and every world fragment — terrain, entities, player, sky — failed GL_LESS
against its own previous-frame depth ghost. Screen = the fog-tinted clear
color. Onset locks to the building-flood merge because that is the first frame
the flooded building shell draws; holds while merged (the leak re-arms every
frame); camera rotation recovers because the cell drops from the flood and the
restore-skipping path stops running.

Capture evidence (flap-cliproute-capture.log): all three draw-input suspects
exonerated — landscape scissor full-screen all run, terrain-UBO/region-SSBO
planes full-screen on both sides of every merge, all 41,373 instances on the
correct repacked slot with cullEnt=0 — while [gl-state] showed frames entering
with dmask=0 blend=1 for exactly the merged stretches (145,238 consecutive
frames in the held window, flipping with each merge boundary at the end-of-run
strobe cycles).

Fix (all paths root-cause, no suppression):
- EnvCellRenderer: move the pass-state establish BELOW the totalDraws==0
  early-out so state is only set on a path that always reaches the restore;
  hoist the globalVao==0 check (the second leak-shaped early-out) above the
  state set.
- GameWindow frame clear: assert DepthMask(true) before glClear — the clear
  DEPENDS on the depth write mask, so it sets the state it depends on
  (feedback_render_self_contained_gl_state; this is the 4th instance of the
  class, in the same function as the 1st).

Very likely the same family as the "parts of the screen flash while running
past cottages" and cottage enter/exit artifacts (every brief merge = a
1-frame no-op depth clear). Visual gate pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:13:14 +02:00
Erik
682cba36f1 diag(render): §4 flap [clip-route] probe — slot routing + clip-buffer content + landscape scissor
The decisive probe between the two surviving suspects from the 2026-06-09
building-flood-merge handoff (docs/research/2026-06-09-flap-outdoor-fullworld-
building-flood-merge-handoff.md section 1), gated by ACDREAM_PROBE_CLIPROUTE=1,
all print-on-change:

- [clip-route] (RetailPViewRenderer.DrawLandscapeThroughOutsideView): the
  outside slice slot + NDC AABB + planes, the CellIdToSlot routing table, the
  region-SSBO bytes DECODED at the routed slot, and the terrain-UBO head —
  captured after SetTerrainClip + UploadClipFrame + SetClipRouting, i.e.
  exactly what the landscape draws consume. Pins/refutes suspect (b) and the
  slot-repack half of suspect (a).
- [clip-route-disp] (WbDrawDispatcher.Draw, routed draws only): per-slot
  instance histogram exactly as staged for binding=3 plus the count of
  entities dropped by ResolveSlotForFrame CULL. Pins/refutes the
  instance-routing half of suspect (a).
- [clip-route-scis] (GameWindow.DrawRetailPViewLandscapeSlice): the ACTUAL GL
  scissor enable + box read back right after BeginDoorwayScissor — the whole
  landscape pass (sky + terrain + outdoor entities + player) draws inside this
  box, so a doorway-sized box here IS the full-world kill by construction.

Code-reading findings recorded while building the probe: the landscape pass is
scissored to slice.NdcAabb end-to-end (GameWindow.cs DrawRetailPViewLandscapeSlice),
and ResolveEntitySlot CULLs server entities with null ParentCellId while routing
is active — both now directly observable under the probe.

Throwaway apparatus — strip once §4 ships.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:37:09 +02:00
Erik
d877e4329a docs: §4 outdoor full-world flap — onset pinned to building-flood merge (handoff)
Evidence-chain handoff for the outdoor flap investigation: frame-exact
onset (pv-input flood 1->5 + the gl-state doorway-box fingerprint, same
frame), the full probe exoneration chain (camera matrix NaN-free at
6 dp, eye above terrain, cross-frame GL leak refuted, full-screen quad
planes can't cull, MergeNearbyBuildingFloods doesn't touch OutsideView,
ClipFrame capacity clean), the two surviving kill-mechanism suspects
(per-instance clip-slot routing under outdoor roots / terrain UBO
content at draw time), the decisive [clip-route] probe spec, the
user-validated repro protocol, and the probe-semantics gotchas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:19:54 +02:00
Erik
fafe5d66e8 diag(render): §4 flap apparatus — eye-vs-terrain field + GL-state tripwire
Two probe additions from the §4 outdoor full-world flap investigation
(world turns to fog-tinted clear color at specific outdoor spots;
strobes then holds; camera rotation recovers; forward-walk triggers):

- [pv-input] gains terrZ=/eyeAbove= (terrain height under the camera
  eye) — used to REFUTE the buried-eye hypothesis (eyeAbove stayed
  +1.6..2.1 m through every held-flap block).
- [gl-state] (ACDREAM_PROBE_GLSTATE=1): snapshots the GL fixed-function
  state entering the world passes (depth, blend, cull, scissor + box,
  viewport, FBO, color/clip masks, glGetError), printing on change.
  Delivered the frame-exact flap-onset marker: the leftover scissor box
  flips from full-screen to a drifting 9x21 px doorway footprint at the
  exact frame the nearby building flood merges in (pv-input flood 1->5).

Both probes are gated and zero-cost when off. Strip with the rest of
the §4 apparatus when the flap ships.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:18:26 +02:00
Erik
41fa3cbbc4 docs: #106 CLOSED — gate-4 verification + running-artifact attribution correction
Move #106 to Recently closed (user-verified collision + solid walls;
probe-verified 49 clean transitions incl. south A9B4->A9B3 at y=-0.19,
east A9B3->AAB3 at x=192.2, and room-by-room tracking through the
originally-failing A9B3 cottage). Records the three adjacent
pre-existing bugs the gate runs surfaced and fixed (legacy Resolve bare
ids, bogus-indoor-claim recovery, entry-hold streaming deadlock).

Correct the capture doc's attribution: the outdoor running distortion
was NOT fully the stale anchor — gate 4 shows residual background-color
screen artifacts persist with a correctly-following anchor. The
residual is the render §4 flap family (render digest), not membership.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:59:24 +02:00
Erik
e6913ac2f9 fix(app): #106 gate-3 — unblock in-world streaming before chase entry (entry-hold deadlock)
Gate-3 never entered player mode: the new spawn-ground entry hold
(6dbbf95) waits for terrain under the spawn, but the K-fix1 streaming
gate skipped streaming entirely until the chase camera first engaged —
which requires player-mode entry. Circular wait; the user sat in the
fly camera with an empty world (the probe log has zero [cell-transit]
lines and no "auto-entered player mode").

K-fix1's intent was narrower than its implementation: suppress the
hardcoded-Holtburg center flash BEFORE LOGIN. The streaming tick even
has a dedicated in-world fly-camera branch (observer from
_lastLivePlayerLandblockId) that the outer gate made unreachable. The
fix gates streaming on pre-login only: once the live session reaches
InWorld, streaming runs (centered on the server-known player landblock
or the fly camera), terrain hydrates, the spawn-ground predicate flips,
and auto-entry fires. The world-geometry RENDER gate is untouched — the
pre-entry screen still shows sky only, exactly K-fix1's visual.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:30:40 +02:00
Erik
6dbbf953c6 fix(phys): #106 gate-2 — bogus-indoor-claim recovery + spawn-ground entry hold
Gate-2 fallout chain: session 1's bare-id wedge poisoned ACE's save (the
client reported garbage cells while walking through walls), so session 2
logged in with (cell=0xA9B4013F inn interior, pos=(91.4,32.7)) — a
position that cell does not contain. Probe evidence: exactly one
[cell-transit] line all session; the player free-fell into an empty
world. Two holes, both fixed at the root:

1. CellTransit pick escape hatch — restores the #83/A1.7 + #90
   verification that lived in PhysicsEngine.ResolveCellId before the
   collide-then-pick rewrite moved membership into
   BuildCellSetAndPickContaining: an indoor current cell that IS
   hydrated but whose CellBSP no longer overlaps ANY part of the foot
   sphere is a bogus claim (corrupt save, or walked out through an
   unblocked gap). The portal BFS can never reach an exit portal from a
   cell the sphere isn't in, so no candidates exist and the claim held
   forever — wedging collision (ShadowObjectRegistry's #98 gate reads
   "indoor primary" -> outdoor object sweep skipped), wall BSP, terrain,
   and the render root. The pick now demotes to the outdoor column under
   the sphere centre (the LandDefs.AdjustToOutside result already
   computed for the pick — cross-block safe). Sphere-overlap
   (BSPQuery.SphereIntersectsCellBsp, pseudo_c:317666 -> :323267), NOT
   point-in: doorway push-back leaves the centre a few cm outside while
   the sphere still overlaps — no demotion, #90's ping-pong stays dead.
   An unhydrated cell cannot be verified — stale beats null while
   streaming hydrates (retail-equivalent: stale curr_cell kept when the
   pick finds nothing).

2. PlayerModeAutoEntry spawn-ground hold — player-mode entry now waits
   for the terrain under the spawn position to stream in
   (isSpawnGroundReady predicate, K.2 pattern). Entering earlier
   integrates gravity against an empty world: indoor-claimed spawns got
   no floor from any source and free-fell into the void; outdoor spawns
   raced hydration by ~1s every login. Retail never has this state (it
   loads cells synchronously) — the hold is the async-streaming
   equivalent of that invariant. With the hold, the entry snap
   (Resolve, stepUp=100) runs against hydrated cell floors + terrain
   and re-seats a corrupt save's claim immediately.

Tests: IndoorSeed_SphereFullyOutsideHydratedCell_DemotesToOutdoorColumn
(the gate-2 wedge shape, red pre-fix), straddle + no-BSP guards (the #90
hysteresis and stale-beats-null), TryEnter_Armed_SpawnGroundNotReady_
DoesNotFire. Full suite: 294+218+420 green; Core 1375 green + the same
4 pre-existing door/#99-era failures + 1 skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:47:11 +02:00
Erik
23adc9c9df fix(phys): #106 follow-up — legacy Resolve returns full prefixed cell ids (teleport bare-id wedge)
The #106 live gate run was sabotaged by a pre-existing bug the corrective
ACE teleport exposed: PhysicsEngine.Resolve (the legacy Phase-D resolve,
still used by the teleport-arrival snap at GameWindow.cs:4869 and the
player-mode-entry snap at :11295) returned BARE low-word cell ids on
every computed exit (ComputeOutdoorCellId, bestCell.CellId & 0xFFFF,
nextCellIndex, enterCellIndex). The teleport committed 0x0000013F into
PlayerMovementController.CellId, and a bare indoor id wedges the entire
membership chain:

- GetCellStruct(0x0000013F) misses (cells are keyed full-prefix) -> no
  indoor wall BSP -> walk through walls;
- the b3ce505 #98 gate reads "indoor primary" -> outdoor object radial
  sweep skipped -> NO object collision anywhere in the world;
- BuildCellSetAndPickContaining early-returns an unresolvable id forever
  (block 0x0000 is a real far-NW map block) -> membership frozen;
- render root never resolves -> interiors draw empty when stepping in.

Probe evidence: probe-cell-106-gate.log has exactly 2 [cell-transit]
lines for the whole session, both reason=teleport — the second one
(0xA9B3003C -> 0x0000013F) is the wedge. This is the L.2e "player CellId
tracked as bare low byte" finding (2026-05-12) finally biting; prefix
survival until now was a race artifact — Resolve only preserved the full
id when the landblock had not streamed in yet (passthrough exit), which
is why login snaps usually came out prefixed.

Fix: capture the matched landblock's key in Resolve's containment loop
and return lbPrefix | (targetCellId & 0xFFFF) on the computed exit —
the same full-32-bit convention Resolve's own doc comment states for
its inputs, and what both production callers (player snaps) require.
The passthrough exits (no landblock / step-reject) still return the
caller's id unchanged.

Tests: Resolve_IndoorStay_ReturnsFullPrefixedCellId (the teleport
shape, red pre-fix) + Resolve_OutdoorStay_ReturnsFullPrefixedCellId;
Resolve_LeaveIndoorCell_TransitionsToOutdoor's unmasked
`CellId < 0x100` assertion codified the bare behavior — now masked +
asserts the prefix. Full suite: 294+218+420 green; Core 1371 green +
the same 4 pre-existing door/#99-era failures + 1 skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:34:30 +02:00
Erik
7078264291 fix(phys): #106 — outdoor membership crosses landblock boundaries (LandDefs global-lcoord port)
The player's outdoor cell froze at the last in-block cell the moment they
walked over a landblock boundary (10,449-frame playerCell freeze in the
2026-06-09 capture; whole neighbouring-block interiors unenterable, plus
the running-distortion from the stale render anchor). Root cause: the
add_all_outside_cells port clamped BOTH the candidate proposal and the
find_cell_list containing-cell pick to the current landblock's 8x8 grid,
in a frame that silently assumed the current block sits at world origin.
One step over the line -> zero candidates -> FindCellSet returns
currentCellId forever.

Retail has no such clamp. Its cell math runs in a GLOBAL landcell grid
(lcoord 0..2039 spanning the map): get_outside_lcoord = blockid_to_lcoord
+ floor(blockLocalPos/24) with no bounds besides the map edge, and
lcoord_to_gid re-derives the landblock id from the lcoord's upper bits —
crossings are inherent, never special-cased.

The fix, decomp-cited throughout:
- New AcDream.Core.Physics.LandDefs: in_bounds (pc:68509),
  blockid_to_lcoord (pc:68520), inbound_valid_cellid (pc:163438),
  gid_to_lcoord (pc:163500), lcoord_to_gid (pc:171859),
  get_outside_lcoord (pc:438690), adjust_to_outside (pc:438719).
  Cross-checked against ACE LandDefs.cs; three artifacts documented and
  avoided: BN's int8_t mis-render of block_y, BN's dropped 192f
  BlockLength constant, and ACE add_cell_block's "FIXME!" same-block
  guard (an ACE divergence, not retail).
- CellTransit.AddAllOutsideCells rewritten as the faithful sphere
  variant (pc:317499 @0x00533630): adjust_to_outside re-seats the
  (cell, position) pair cross-block, check_add_cell_boundary (pc:317229)
  adds up to 3 neighbours by global lcoord, add_outside_cell (pc:317056)
  has no same-block filter. adjust_to_outside failure breaks the sphere
  loop (pc:533699 verbatim).
- BuildCellSetAndPickContaining: the outdoor containing-cell pick is now
  the global XY-column under the sphere centre (AdjustToOutside), not
  the [0,8)-clamped current-prefix reconstruction. Interior-wins order
  and current-cell-first hysteresis unchanged.
- World->block-local frame conversion via the landblock origin already
  registered in CellGraph (new TryGetTerrainOrigin); Zero fallback
  preserves the legacy anchor-block assumption for unregistered terrain.
- Cross-landblock building entry comes free: the candidate snapshot now
  contains neighbour-block landcells, so GetBuilding/CheckBuildingTransit
  fire for cottages across the line (the capture's one failing entry).

Investigated FIRST per the pickup brief: the b3ce505 #98 stopgap gate is
definitively exonerated — it is a collision-object query gate that fires
only for indoor primary cells; no membership path touches
ShadowObjectRegistry.

Tests: 31 new (25 LandDefs conformance incl. capture-geometry goldens
0xA9B40031 -> 0xA9B30038/0xA9B30034 and the northbound return; 4
AddAllOutsideCells cross-block; 3 FindCellSet membership goldens incl.
the non-anchor-frame origin conversion). Full suite: 294+218+420 green;
Core 1369 green + the 4 pre-existing door/#99-era failures + 1 skip
(unchanged from baseline).

Pseudocode + artifact notes:
docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md.
Remaining acceptance: live boundary walk with ACDREAM_PROBE_CELL=1
(ISSUES.md #106).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:10:59 +02:00
Erik
12fb408972 docs: #106 pinned — outdoor membership freezes at landblock boundaries
The 53MB flap-probe capture (user live-reproduced the broken-house entry)
plus 3-agent analysis settles the day: playerCell froze at 0xA9B40031 for
10,449 frames spanning ~130m of outdoor walking into landblock A9B3 and a
stand INSIDE an A9B3 cottage. Within-landblock outdoor flips are 96/96
clean; all 10 successful indoor entries were same-landblock buildings; the
single cross-landblock entry failed. The render flood independently drew the
A9B3 interior cells the whole time — rendering is downstream and healthy;
membership is the broken layer (feedback_render_downstream_of_membership,
proven again). The stale render anchor also explains the outdoor running
distortion; the capture refutes flood-level causes outdoors (26,960/26,960
outdoor frames rigid at outPolys=1 vis=1).

Files #106 (HIGH, physics/membership) with fix pointers: ResolveCellId /
AddAllOutsideCells cross-landblock proposal, the b3ce505 outdoor-sweep gate
(possible stopgap fallout, like #99), retail find_cell_list :308742 +
LandDefs.get_outside_lcoord. Reframes #105: largely superseded by #106;
residual (single wall missing while membership indoor-correct) stays open
with all tripwires armed. Handoff:
docs/research/2026-06-09-105-capture-analysis-membership-landblock-pin.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 22:38:19 +02:00
Erik
0a38d934fd diag(render): #105 round 3 — finalize-replace + late-register tripwires on EnvCellRenderer
Live evidence narrowed #105 to the pending->committed instance hand-off:
walls missing from BOTH inside and outside views while the same cells'' props
draw and collision works = the wall-shell INSTANCES never reach the committed
draw set. FinalizeLandblock uses REPLACE semantics (lb.Instances =
lb.PendingInstances), and with two-tier streaming a landblock can finalize
while a promote job is still registering its cells on the worker thread —
the partial pending set commits, the remainder lands in a fresh pending list,
and the next finalize REPLACES the committed set with only the remainder.
Whoever registered first is silently lost: per-session-random (drain timing),
per-building-persistent, from session start.

- [finalize-replace] a finalize that DISCARDS already-committed instances
- [late-register]    a RegisterCell landing after its landblock finalized

Both print only on the suspect interleavings. Next occurrence proves or
kills the theory; the fix (merge semantics + registration/finalize atomicity)
follows the evidence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 22:07:43 +02:00
Erik
cbba71f8a9 diag(render): #105 round 2 — tripwires on the upload/registration loss paths
Live session evidence narrowed #105 decisively: a wall section rendered as the
sky/clear color from session start, HAD collision (cell + physics fully
loaded), zero round-1 tripwires fired (all dat reads succeeded), and the hole
showed terrain or clear color depending on camera angle. So the wall mesh was
built and then lost between mesh-build and draw. New tripwires cover the
loss candidates in that window (all print ONLY on anomaly):

- [geom-null]     ProcessQueueAsync EnvCell branch resolved null, with the
                  failing sub-step (prepare-null / cellstruct-missing /
                  env-read-failed) — a null here means the DEDUPLICATED cell
                  geometry never renders for ANY cell that shares it, and
                  nothing retries (RegisterCell fire-and-forgets the task).
- [geom-misroute] an EnvCell geom id (bit 33) whose pending request vanished
                  fell through to the generic path, where its hash-derived
                  low bits resolve to nothing -> silent null.
- [up-null]       UploadGfxObjMeshData returned null and the EMPTY substitute
                  was cached in _renderData forever (permanently invisible).

Pair with the existing one-shot draw-side audit (ACDREAM_A8_AUDIT=1, light:
one line per unique cell/geom pair, prints renderData=null + bindless-handle
status) for full attribution on the next occurrence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 21:52:01 +02:00
Erik
8dc707d43b docs: dat-reader investigation handoff + file #105 (white walls, tripwired)
Records the 2026-06-09 dat-reader thread-safety investigation: concurrent
READS on Chorizite.DatReaderWriter 2.1.7 exonerated (source audit + 1.1M-read
hammer, b3920d8); the real crash was dispose-during-read at teardown (fixed,
8fadf77); the white-walls mechanism remains open as #105 with every silent
dat-miss exit tripwired (7433b70) so the next occurrence self-attributes.

Also corrects project lore: the A.1-era rule that all dat reads must stay on
one thread does not hold for the 2.1.7 read path, and both investigation
subagents'' claimed ReadBlock instance-field race does not exist in the
shipped source — verify agent claims against source before acting on them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 21:29:06 +02:00
Erik
b3920d83f6 test(conformance): dat-reader concurrency hammer — concurrent READS exonerated
Settles the long-standing lore that DatCollection is not thread-safe for reads,
for Chorizite.DatReaderWriter 2.1.7: replays acdream''s real four-population
access pattern (render / streamer / decode-pool / raw) against the live dats —
golden FNV-1a fingerprints taken single-threaded, then 8 threads x 25 shuffled
passes over ~2900 files spanning the cell heightmap/LBI/EnvCell set and the
portal texture chain (Environment -> Surface -> SurfaceTexture -> RenderSurface
incl. highres probes). Two layers: raw TryGetFileBytes (BTree + ReadBlock, no
caching) and typed TryGet with FileCachingStrategy.Never (full production
unpack path: ArrayPool + DatBinReader + ObjectFactory).

Result: ~1.1M concurrent reads, ZERO anomalies — byte-identical to golden.
Matches the line-level audit of the release/2.1.7 source (ReadBlock keeps all
cursor state in locals over a stable read-only mmap view; locked LRU BTree
node cache; ConcurrentDictionary file cache; fresh DatBinReader per call).
The real crash bug was dispose-during-read at teardown (fixed in 8fadf77).
Keep this as the regression guard for any future dat-reader version bump.

Skips cleanly when the dats are absent (CI), matching suite convention.
Full evidence: docs/research/2026-06-09-dat-reader-thread-safety-investigation.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 21:28:32 +02:00
Erik
7433b704fb diag(render): tripwires on every silent dat-miss path (white-walls attribution, #105)
The intermittent white-cottage-walls failure has NEVER produced a log line:
every dat-read failure on the walls-relevant paths exits silently, and the
failed result is cached for the session (mesh batches build once). Today it
reproduced on a probe-free launch with a 35-line, zero-error log — so the
prior heavy-probes-starve-the-dat-reader framing is not the whole story.

Tripwires (print ONLY on anomaly; zero cost healthy; keep until #105 closes):
- [dat-miss]  DatDatabaseWrapper.TryGet — a miss for an id whose BTree entry
  EXISTS (re-probed under the same lock); legit not-found fallbacks stay quiet.
- [tex-miss]  TextureCache.DecodeFromDats x3 — render-thread decode fell back
  to magenta (Surface / SurfaceTexture / RenderSurface miss).
- [cell-miss] GameWindow interior hydration x2 — EnvCell or Environment read
  returned null, so a cell''s WALLS are silently never registered while its
  statics still draw (the exact observed geometry signature).

Color discriminates the layer on the next occurrence: magenta = TextureCache;
see-through + [tex-skip]/[dat-miss] = mesh build; see-through + [cell-miss] =
hydration; broken with NO tripwire output = GL-side upload/residency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 21:28:32 +02:00
Erik
8fadf770fe fix(render): quiesce dat readers before teardown — kill the shutdown AccessViolation
ObjectMeshManager.Dispose never stopped its Task.Run(ProcessQueueAsync) decode
workers, and LandblockStreamer.Dispose abandoned its worker after a 2s join.
GameWindow.OnClosing then disposed the DatCollection, which unmaps the dats''
memory-mapped views (MemoryMappedBlockAllocator.DestroyMappedFile nulls
_viewPtr) — a worker still inside ReadBlock dereferences the dead view pointer:
an uncatchable AccessViolationException with ReadBlock on the stack, firing on
close/relaunch during decode storms. This is the recorded crash signature from
the 2026-06-09 white-walls session.

- ObjectMeshManager.Dispose: set IsDisposed under the queue lock, cancel+drain
  pending requests, then wait (<=10s) for _activeWorkers==0; loud LogError if
  workers outlive the wait. ProcessQueueAsync re-checks IsDisposed per dequeue;
  Prepare*Async entries + enqueue blocks early-out when disposed.
- LandblockStreamer.Dispose: join 2s -> 15s with a loud [streamer] line on
  timeout (cancellation honored between jobs; one landblock load bounds it).
- Also includes the [tex-skip] tripwire lines on ObjectMeshManager''s five
  silent dat-miss exits (GfxObj + CellStruct texture chains) — part of the
  white-walls attribution net (#105), zero output when healthy.

Verified: 3x close-mid-decode-storm smoke (in-world at ~8s, WM_CLOSE at ~11s),
clean exits, no crash signatures, no quiesce timeouts. Full suite: 294+218+420
green; Core 1338 green + 4 pre-existing physics failures (reproduced at bare
HEAD, unrelated). Investigation:
docs/research/2026-06-09-dat-reader-thread-safety-investigation.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 21:27:22 +02:00
Erik
d0bd28543b docs(memory): distill CLAUDE.md render+physics banners into claude-memory digests
Collapse ~285 lines of dated render-flap + A6/#98/door/#100/#101 physics saga banners into two pointers to new claude-memory digests (project_render_pipeline_digest + project_physics_collision_digest), each carrying current-truth + a DO-NOT-RETRY table. Add an always-loaded memory pointer to the How-to-operate section: read the digests before domain work; update the digest, don't add new dated banners here. ~380 fewer lines in the always-loaded instruction file; no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:20:17 +02:00
Erik
a1b12dff40 docs(render): R-A2b shipped + flap residual (sec 4) + texture red-herring handoff
R-A2b (485e44d) killed the 0171<->0173 churn (maxPop 16->1, measured). Visible flap residual is sec 4 (edge-on openings render-side + corner camera-seal). Camera-damping tried+failed+reverted. The white-walls scare was a RED HERRING: heavy per-frame probes (ACDREAM_PROBE_FLAP) starve the thread-unsafe dat-reader so texture-decode loses the race -> white; a clean launch (no probes) fixes it. The dat-reader thread-safety bug is the real underlying issue (filed). Repo clean at HEAD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:44:33 +02:00
Erik
485e44d163 fix(render): R-A2b — cull back portal like retail (InitCell side test), kill the indoor flap cycle
Pinned (flap-sidechk.log): the indoor doorway flap is a 0171<->0173 flood cycle. Back portals show camInterior=False (our side test already agrees with retail) but were traversed when eyeIn=True because the side-cull had an  bypass (added 2026-06-05 for the void). Within 1.75m of a doorway that bypass kept the BACK portal alive -> mutual re-contribution -> re-enqueue churn (maxPop=16) -> eye-sensitive flood depth -> grey flap + dropped floor.

Fix (Option B1): drop the bypass from the side-cull in Build + BuildFromExterior so back portals cull by the side test alone, exactly like retail PView::InitCell (:432962, no eye-in-opening bypass). The forward-portal clip-empty void rescue is a SEPARATE branch and is untouched (Build_EyeStandingInInteriorPortal_FloodsNeighbour stays green). New RED->GREEN test Build_BackFacingPortal_EyeStandingInOpening_StillCulled; full App suite 218 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:06:57 +02:00
Erik
89a2032c8e diag(render): [pv-trace] sidechk — pin back-portal traversal (B1 bypass vs B2 side-test) for R-A2b
Logs camInterior/eyeIn/D per portal under the existing PortalBuildTrace so the 0173->0171 back-portal traversal can be attributed to B1 (EyeInsidePortalOpening bypass) or B2 (CameraOnInteriorSide convention). Throwaway; stripped in Phase 4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:46:14 +02:00
Erik
7b8a490da9 docs(render): R-A2b plan — back-portal side-cull (Option B), verify-first B1/B2 pin
Reading retail InitCell (:432896) side test during writing-plans showed retail's flood is acyclic (the back portal fails the side test, so 0171<->0173 can't cycle). Our flood traverses the back portal -> the cycle -> the churn. Option B (user-chosen): cull the back portal like retail, keep the forward-portal void rescue, remove the dead cap. Phase 1 pins WHY the back portal is traversed (B1 eyeInsideOpening bypass vs B2 CameraOnInteriorSide convention) before the fix; spec REVISION updated A->B.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:25:28 +02:00
Erik
3fd71a123c docs(render): R-A2b spec — revive bounded-propagation, churn confirmed at flap-time
The indoor doorway flap is the portal flood's re-enqueue churn (0171<->0173 mutual re-contribution; drifted near-duplicate regions AddRegion won't dedup -> grew -> re-enqueue, capped at MaxReprocessPerCell=16 -> eye-sensitive flood depth -> grey flash). Confirmed live: launch-churn-confirm.log shows maxPop=16 on 44% of frames during a doorway walk-through. The 2026-06-08 'maxPop=1, churn refuted' verdict was a camera-turn-at-rest capture (wrong reproduction); its DO-NOT is overturned.

Fix (Option A, user-approved): contributions already covered by the neighbour's accumulated view don't grow it (no re-enqueue); only the uncovered remainder propagates -- retail's 'redundant -> empty before copy_view' (copy_view confirmed to just append). Remove MaxReprocessPerCell; keep re-processing of genuinely-new slices. Scope: PortalVisibilityBuilder only. Revives 2026-06-08 spec+plan (banners redirected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:48:53 +02:00
Erik
8f879bd7d9 docs(render): calibrated indoor-flap handoff — MEASURED vs HYPOTHESIS vs OPEN
Separates what is measured (eye smooth + 1um at rest -> not jitter) from the leading-but-unproven hypothesis (clip edge-on) and the NOT-ruled-out alternative (camera position: retail eye collided/head-on 93%, ours floats edge-on). The one 'clean' pass had ratio 4.2x back-and-forth, so the flood claim is indicated not proven. Lists the verify-first steps before any R-A2b fix. Counters this session's pattern of overclaiming then refuting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 08:16:51 +02:00
Erik
8c78f1f07a docs(render): plan status — R-A4 ruled out by measurement; remaining work is R-A2b (indoor-flood edge-on robustness)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 08:10:06 +02:00
Erik
c7069cf0b6 diag(camera): add F6 in/out eye to [flap-sweep] probe
Logs SweepEye input (desiredEye) vs output (eye) at micrometre precision. Used to prove the indoor flap is NOT the camera: the eye is smooth (clean one-way pass = 3/18 direction-changes over 25.7k frames) and ~1um stable at rest, yet the visible-cell count oscillates 414x with 648 clip=0 near edge-on doorways. The flap is the flood/clip's edge-on behaviour, not the eye.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 08:09:10 +02:00
Erik
3c178b28e6 diag(cdb): flap-cam-measure.cdb — retail eye-hover + CameraManager smoothing capture
Attaches to live retail, dumps CameraManager/SmartBox type offsets, captures viewer eye origin per frame (pub+sought) to measure boom jitter vs acdream. Used to pin the indoor flicker to the camera's published/swept eye (retail settled ~tens of um vs acdream ~1.3mm).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 23:14:54 +02:00
Erik
2ec189c106 fix(render): R-A2 seam fix — flood null-BuildingId cells instead of dropping them
MergeNearbyBuildingFloods skipped cells whose BuildingId is null; the pre-R-A2 outdoor-node reverse-portal flood reached them, so dropping left holes at building/terrain seams. Key by (BuildingId ?? CellId) so unstamped/outdoor-adjacent exit-portal cells still seed a per-entrance flood; cells without an exit portal contribute nothing as before. App Rendering 207/207.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:08:14 +02:00
Erik
c62663d7cb feat(render): R-A2 — per-building floods (the flap fix)
Replace the outdoor root's single unified reverse-portal flood (whose root-level
portal-side test oscillated as the chase eye grazed a doorway — the measured
flood 2<->6) with retail's per-building floods.

- OutdoorCellNode.Build(uint): portal-less land root; floods only itself ->
  full-screen OutsideView -> terrain (PortalVisibilityBuilder IsOutdoorNode seed).
- PortalVisibilityBuilder.ConstructViewBuilding: per-building flood seeded at a
  building's own finite entrance (retail ConstructView(CBldPortal) 0x5a59a0 via
  DrawPortal 0x5a5ab0 / portal_draw_portals_only 0x53d870). Entrance-bounded ->
  consistent ~2-cell depth (measured retail cell_draw_num, handoff OPTION-A 3.4).
- RetailPViewRenderer.DrawInside: when the root is the outdoor node, group nearby
  cells by BuildingId and merge each per-building flood into the frame before
  assembly; existing shells/object-list draw path unchanged. 48 m seed cutoff.
- GameWindow: pass flat NearbyBuildingCells only on outdoor-node frames.

Tests: +3 PortalVisibilityRobustnessTests (per-building touches ~2 cells, membership
stable under the measured 36 um eye jitter). UnifiedFloodTests retired (its subject,
the unified flood from the outdoor node, is removed); surviving full-screen-OutsideView
coverage moved to OutdoorCellNodeTests. App Rendering 207/207, Core movement 14/14.

Conformance-verified sound; the grazing-doorway flap is the visual acceptance test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:44:43 +02:00
Erik
7fe98098f5 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 (build + targeted suites green:
App PortalVisibilityBuilderTests 24/24, Core PlayerMovementControllerTests 14/14).

Right-sized from the planned 'collapse to one root': reading the live dispatch,
the viewerRoot ?? outdoorRoot split is already correct (viewerRoot feeds
cameraInsideCell/lighting via the older CellVisibility BFS; clipRoot is the render
root), and the 2026-06-07 cutover flip already made in-world frames single-path
DrawInside. The real flap fix is R-A2 (per-building floods). Dead exterior
DrawPortal look-in deletion deferred to R-A3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:25:58 +02:00
Erik
6996e5645c docs(render): mark bounded-propagation plan + spec SUPERSEDED (churn refuted by measurement)
Point both at the Option-A full-retail-port handoff so a fresh session can't follow the dead plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:20:24 +02:00
Erik
fe87e9794a docs(render): FLAP settled by live-retail measurement — full retail port DECIDED (Option A) + exhaustive handoff
Attached cdb to the live 2013 retail client at the Holtburg doorway + read the decomp.
The indoor flap is a STRUCTURAL divergence, settled by measurement (not inference):

- Retail has ONE render path: DrawInside(viewer_cell) every frame. NO inside/outside
  branch (RenderNormalMode's outside branch is dead code; is_player_outside only gates
  sky/lighting). "Entering a building" is not a render event — only the camera sweep
  resolving a different viewer_cell. Same path before/after threshold -> no seam.
- Retail's eye JITTERS ~36um at rest yet membership is stable -> robustness is
  STRUCTURAL: many small per-building floods (~7/frame, ~2 cells each, via terrain BSP
  -> DrawPortal -> ConstructView(CBldPortal)), not one giant knife-edge flood.
- Our 3 divergences: (D1) invented inside/outside branch (GameWindow.cs:7498,
  clipRoot = viewerRoot ?? _outdoorNode :7396); (D2) synthetic _outdoorNode; (D3) one
  unified flood.

DECISION (user-approved): Option A — rip out branch + outdoor node, root always at the
real viewer_cell, one DrawInside, per-building rendering. Phased, conformance-tested,
visual-gated.

REFUTED by measurement (do not retry): bounded-propagation/churn (maxPop=1, 0/63k
reciprocals empty); byte-stable eye (retail's jitters ~36um — rest-snap cd974b2 failed +
regressed, reverted 9b1857a).

Lands the canonical exhaustive handoff for a FRESH session
(docs/research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md), the CLAUDE.md
READ-THIS-FIRST banner, and reusable cdb apparatus. No project code changed; working tree
at the known-good baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:19:34 +02:00
Erik
9b1857ac52 Revert "fix(camera): rest-snap render position — kills the indoor doorway standing-still flicker"
This reverts commit cd974b29bc.
2026-06-08 15:08:25 +02:00
Erik
cd974b29bc fix(camera): rest-snap render position — kills the indoor doorway standing-still flicker
Root cause (pinned live, flap-churn.log at the Holtburg cottage doorway): the physics
body is byte-stable at rest (rawPlayer = 1 distinct value), but
PlayerMovementController.ComputeRenderPosition's Lerp(prev, curr, alpha) dithers the
render position by microns — the two physics-tick snapshots lag the settled body
(per-frame resolve edge-settles the resting sphere against the doorframe after the last
tick wrote curr) while the leftover-accumulator alpha varies every frame. The grazing-
doorframe camera-collision sweep (PhysicsCameraCollisionProbe.SweepEye) amplifies that
~1000x into a ~1.3 mm eye jitter (eye 17 distinct, RenderPosition 15 distinct) that trips
the PortalVisibilityBuilder clip -> the standing-still flicker (blue void / grass over the
cellar entrance) the user reported.

Fix: at rest (body velocity below RestVelocityEpsilonSq) render AT the authoritative
byte-stable body position instead of interpolating between two stale tick snapshots, so the
camera's pivot input is byte-stable and the sweep output stops jittering. Mirrors retail (a
resting object renders bit-stable) + the boom convergence snap
(RetailChaseCamera.ApplyConvergenceSnap, d2212cf), one layer earlier. Sub-tick interpolation
is preserved during motion (velocity above epsilon).

This SUPERSEDES the committed bounded-propagation plan: the live pin proved ZERO portal
re-enqueue churn during the flap (maxPop=1 across 13k oscillating frames; 0/63k reciprocals
ever clipped empty), so the flap was never the churn the spec hypothesized. The
ACDREAM_PROBE_PORTAL_CHURN apparatus did its job (refuted the hypothesis before the wrong
fix was built); plan/spec/memory updates to follow.

TDD: extracted the rest-snap into an internal-static pure ComputeRenderPosition; RED rest-
snap test (stale prev!=curr + varying alpha dithers) -> GREEN after the gate; motion test
guards interpolation; precondition test confirms a settled body's velocity is below the
gate threshold. 29 controller+cellar + 62 camera+portal tests green, no regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:59:59 +02:00
Erik
b3a9884dff diag(render): launch-flap-churn.ps1 — Phase 1 portal-churn pin capture script
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:56:44 +02:00
Erik
a866c510e3 test(render): deterministic re-pop anchor for the bounded-propagation pin
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:52:46 +02:00
Erik
e6fe4c611a diag(render): [portal-churn] probe — per-Build re-enqueue + reciprocal pre/post
Step 4 summary-emit adapted from the plan: the plan's Invariant($"a" + $"b" + sb) form

passes a string to FormattableString.Invariant (which requires a FormattableString) and

does not compile; merged the two interpolated fragments into one literal and appended the

already-invariant-formatted reciprocal detail outside the Invariant call. Same output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:51:48 +02:00
Erik
687040ba52 feat(render-diag): add ACDREAM_PROBE_PORTAL_CHURN flag for the bounded-propagation pin
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:49:17 +02:00
Erik
a3dadbf664 docs(render): implementation plan — portal-flood bounded-propagation (instrument+pin, then fix)
Phase 1 (fully specified): add the [portal-churn] probe (per-Build re-enqueue +
reciprocal pre/post), a deterministic re-pop anchor test, and a live doorway
capture to PIN the exact divergence (where acdream's redundant reciprocal
back-contribution stays non-empty where retail clips to empty) — a float-drift
runtime fact, not derivable from decomp.

Phase 2 (evidence-gated outline): port the bound (redundant contributions don't
add propagatable slices; remove MaxReprocessPerCell), keeping re-processing +
Build_ViewGrowthAfterDoneCell green. Gets its own no-placeholder plan after the
Phase 1 pin — apparatus-first, not a deferred placeholder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:38:02 +02:00
Erik
ab6ed905f1 docs(render): correct flap spec — enqueue-once REFUTED, bounded-propagation port
The writing-plans decomp pass read FixCellList (433407) -> AdjustCellView (433741)
-> ClipPortals(update_count) + AddViewToPortals, proving retail RE-PROCESSES a
grown-after-drawn cell. So the approved "enqueue-once / no re-process" approach is
wrong (it would break Build_ViewGrowthAfterDoneCell for the right reason — that test
is actually retail-faithful).

Corrected approach (user chose the faithful moderate port over an epsilon-dedup
band-aid): KEEP re-processing on growth, but BOUND it the way retail does — each
view slice processed once (monotonic update_count watermark) and redundant
reciprocal back-contributions clip to EMPTY (OtherPortalClip -> no copy_view -> no
new slice), so the reciprocal/drift loop can't churn. acdream churns because its
reciprocal yields a drifted non-empty sliver, bounded only by the
MaxReprocessPerCell=16 hack. Remove the cap; bound structurally.

Scope unchanged: PortalVisibilityBuilder only; no rooting/camera/clip-math-rewrite/
seal change. One open precision (exact line where acdream's sliver becomes
non-empty — float-drift-dependent on real geometry) deferred to the plan's first
task: instrument PortalVisibilityBuilder (per-pop re-pop count + reciprocal-clip
in/out + grew), capture at the doorway, pin it, THEN fix.

Spec updated in place with a REVISION banner; superseded enqueue-once body retained
for the audit trail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:33:06 +02:00
Erik
6c3a96b26e diag(render): flap re-diagnosed as portal-flood re-clip DRIFT; physics + camera REFUTED
The 2026-06-08 AM "physics rest micro-jitter" diagnosis is refuted with primary
evidence (door-recheck 216K standstill records: 0 position re-snaps; player
byte-stable during the flap). Two adversarial verification sub-agents confirmed:

- Retail roots the render at the camera viewer_cell (swept from the player via
  SmartBox::update_viewer 0x453ce0; DrawInside(viewer_cell) 0x453aa0) and toggles
  DrawInside / LScape::draw -- so acdream's eye-cell rooting + inside/outside
  toggle are RETAIL-FAITHFUL. The locked-design "root at player cell" is wrong.
- The flap is render membership instability, eye-motion-driven: the visible-cell
  set oscillates (8<->3) as the eye sweeps monotonically. Root = the
  re-enqueue-on-growth DRIFT (PortalVisibilityBuilder.cs:322, MaxReprocessPerCell
  =16) re-clipping each grown cell every round -> sub-cm eye jitter flips membership.

Fix (spec, not yet implemented): verbatim port of retail's enqueue-once flood
(ConstructView + AddViewToPortals): enqueue once on first discovery, clip each
cell's portals once, union late growth in place (AddToCell) + draw-reorder
(FixCellList), never re-enqueue. Kills the drift; rooting/camera/seal untouched.

This commit lands VERIFIED GROUNDWORK + design only:
- spec: docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md
- findings: docs/research/2026-06-08-flap-physics-diagnosis-REFUTED-its-render-membership.md
- [pv-input] probe gains rawPlayer + yaw (disambiguates the varying input)
- 4 GREEN physics rest-stability tests (prove rest is bit-stable -> flap not physics)
- apparatus: launch-flap-capture.ps1, analyze_flap_live.py, find_burst.py
- captured fixtures: tests/.../Fixtures/flap-doorway/0xA9B4017{0..5}.json

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:21:46 +02:00
Erik
d6aa526dd3 diag(render/physics): flap root-caused to physics rest µm-jitter; refute prior diagnoses
Apparatus + handoff for the indoor flap. Confirmed (primary evidence): the flap is the
portal-flood clip being µm-sensitive at the threshold, driven by a ~1-8µm jitter in the
player RenderPosition (physics resting position not bit-stable; Lerp surfaces it). REFUTES
the 2026-06-07 see-through/EnvCell/outdoor-node diagnosis (ModelId GfxObj 0x01000A2B IS the
solid exterior) AND an enqueue-once attempt (retail propagates late slices via AddToCell;
the existing PropagatesNewSlicesToExit test caught it; reverted). Adds: Build determinism
test, A8CellAudit gfxobj dump, [pv-input] 6dp probe + [render-sig] outRoot/bshell fields.
No functional fix shipped. Next: higher-precision physics rest trace -> port retail
kill_velocity/contact rest-stability. Canonical: docs/research/2026-06-08-flap-rootcause-physics-rest-handoff.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:16:12 +02:00
Erik
d0b65c4170 docs(render): re-scope flap fix to retail enqueue-once traversal port (not an overlap band-aid)
Per senior-eng direction: the retail-faithful fix is to stop diverging from PView::
AddViewToPortals (first-discovery enqueue + AddToCell/FixCellList in-place growth, no
re-enqueue/re-clip), removing acdream's MaxReprocessPerCell re-enqueue fixpoint and its
documented per-round ProjectToClip drift. Drops the overlap-predicate approach. Viewpoint
bit-stability (the ~1-8um player RenderPosition jitter) is the contingency next step only
if a residual flap survives the visual gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:51:51 +02:00
Erik
d9d69394bb docs(render): spec — portal-flood membership stability (indoor flap root-cause fix)
Confirmed root cause via primary evidence (determinism test + 6dp jitter probe + retail
grounding): the flap is portal-flood set-membership flipping because the drift-prone
ClipToRegion vertex count gates membership while the player RenderPosition micro-jitters
(~1-8um) into a grazing portal's knife-edge clip. Design: gate membership on a stable
side-test + view-region overlap, not the vertex count. Refutes the 2026-06-07 see-through/
EnvCell/outdoor-node handoff (ModelId GfxObj 0x01000A2B is the solid exterior; outside is
stable; root is stable 0170).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:23:34 +02:00
Erik
5c6e53b0a4 docs: add verify-first kickoff prompt to the render-residuals handoff (treat the diagnosis as a suspect statement)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:12:38 +02:00
Erik
ef2186147d docs: cutover flip shipped — see-through + oscillation DIAGNOSED (evidence-based handoff)
The flip killed the branch-toggle flap (one path, zero OutdoorRoot frames). It exposed two residuals now PROVEN via a live [bshell] probe, not guessed: (1) oscillation = the outdoor-node flood membership swings 1<->~13 building cells frame-to-frame, so the walls (EnvCell shells) blink; (2) see-through = EnvCell wall polys are single-sided for SidesType==CounterClockwise, so from outside you see their culled back. The ModelId building shells DO render (6/6 with mesh) but are a partial frame, not the walls — the skip-all-interiors experiment proved the walls are the EnvCell shells. Fixes identified (stabilise flood + build back faces) but not implemented; full do-not-retry list + open pre-flip-reconciliation question in the doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:49:00 +02:00
Erik
774cb22713 Revert "fix(render): outdoor look-in draws interior cells only through real doorway apertures (no see-through walls)"
This reverts commit 0030dacaaa.
2026-06-07 21:16:11 +02:00
Erik
0030dacaaa fix(render): outdoor look-in draws interior cells only through real doorway apertures (no see-through walls)
Cutover-flip follow-up: see-through buildings from outside. When the outdoor-node flood reaches a building, each interior cell is meant to draw clipped to its doorway aperture. But DrawEnvCellShells falls back to the no-clip slot 0 (full-screen) when a cell's aperture degenerates — screen-covering when you get close, or edge-on. Indoors that fallback is load-bearing (it seals the room the camera stands in; near walls hide the over-draw). From OUTSIDE it paints the building interior across the whole screen, depth-tested, so it shows wherever the solid exterior does not cover — the see-through walls, appearing 'past a threshold' exactly where the aperture degenerates.

Fix: for the outdoor-node root only, skip a flooded interior cell with no real plane-clip slot (HasRealClipSlot). From outside, 'no real aperture' means 'do not paint this interior', not 'paint it everywhere'. Interior roots keep the seal-everything slot-0 fallback unchanged. Applied to DrawEnvCellShells AND DrawCellObjectLists so a skipped cell shows neither walls nor furniture; the dead DrawPortal exterior look-in gets the same gate.

Root cause traced over the WB EnvCell render path: CellMesh.cs is physics-only; ObjectMeshManager.PrepareCellStructMeshData builds double-sided walls, so this was never a culling bug. App 216/0, build green. Visual gate pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:08:43 +02:00
Erik
88caa0dc8b fix(render): outdoor-root skips the full-screen depth clear (cellar/buildings no longer draw over the world)
Cutover-flip follow-up (issues 1+2 from the visual gate). After the flip an outdoor-node frame ran DrawInside with a full-screen OutsideView slice (Step A) whose ClearDepthSlice wiped the ENTIRE depth buffer AFTER terrain/exteriors/player drew — so the flooded building interior shells (cellars) drew over everything: the cellar painted in front of the player from outside, and distant building interiors showed through the ground in the open field.

The depth clear is a doorway look-in trick (clear a small door region so the cell seen through it draws over the terrain drawn through it). It is wrong for the full-screen base terrain of the outdoor root. Skip it there (ClearDepthSlice=null when clipRoot is the outdoor node); interiors now depth-test against terrain + exteriors and appear only through real door openings. Interior roots keep the doorway clear unchanged.

App 216/0, build green. Visual gate pending (player must be outdoors to exercise the outdoor root).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:18:33 +02:00
Erik
445e861163 feat(render): Phase 3 (Step B) — single render path rooted at the viewer cell (cutover flip)
The CUTOVER FLIP that kills the indoor FLAP. Replace the two-branch gate (clipRoot = playerIndoorGate && viewerRoot ? viewerRoot : null) with clipRoot = viewerRoot ?? _outdoorNode, so EVERY frame routes through the one RetailPViewRenderer.DrawInside path rooted at the viewer cell — interior EnvCell when the eye is indoors, the synthetic outdoor node when outdoors. There is no inside/outside branch to toggle as the 3rd-person eye crosses the doorway, so the flap (textures battling at every transition) dies by construction. Matches retail SmartBox::RenderNormalMode -> DrawInside(viewer_cell) (0x453aa0 -> 0x5a5860).

clipRoot is null only pre-spawn/login (viewerCellId==0 -> _outdoorNode null), so the outdoor LScape block still runs as the safety path and login keeps its live sky. playerIndoorGate stays computed for the [render-sig] probe.

Preserve the LiveDynamic entity draw (server entities with no resolved ParentCellId — the transient unpositioned case) for the outdoor-node root: the old outdoor branch drew it; DrawInside does not, so re-issue it after DrawInside to keep the spec section 10 byte-identical-outdoor guarantee (no live entity blinks out).

The old outdoor else block + DrawPortal/BuildFromExterior are now dead when clipRoot is non-null but are LEFT IN PLACE for the user visual gate (handoff section 4 Step D deletes them only after the user confirms). App 216/0, build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:06:27 +02:00
Erik
5379f6ecd3 feat(render): Phase 3 (Step A) — outdoor-root seeds full-screen OutsideView
Render unification cutover, Step A (additive, behavior-neutral until Step B). When PortalVisibilityBuilder.Build roots at the synthetic outdoor node, seed OutsideView with the full-screen NDC quad so ClipFrameAssembler yields a full-screen OutsideView slice and DrawInside's DrawLandscapeThroughOutsideView draws terrain/sky/scenery/weather as the node's shell — the same callback that already draws the doorway slice for an interior root looking out.

Keyed on a new explicit LoadedCell.IsOutdoorNode flag (set by OutdoorCellNode.Build), NOT a cell-id heuristic: production EnvCell ids are >= 0x100 but test fixtures use low interior ids, so an id test misfired on 4 existing PortalVisibilityBuilderTests.

Nothing roots at the node until Step B, so this is behavior-neutral. Tests: App 216/0 (2 new UnifiedFloodTests incl. the spec section 10 pure-outdoor regression guard + 2 OutdoorCellNode flag assertions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:06:13 +02:00
Erik
9bc0db9351 docs: handoff — render unification CUTOVER FLIP (canonical pickup)
Super-detailed pickup for the one remaining step that fixes the flap: the cutover
flip (terrain via OutsideView for the outdoor root + clipRoot=viewerRoot??_outdoorNode
+ launch + visual gate + delete old paths). Exact steps, current line numbers, the
de-risking already done (shell no-op, flood validated, OutsideView mechanism), the
4 render cases, the Step-B integration checklist, do/don't, and a kickoff prompt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:42:04 +02:00
Erik
7b3091c44d docs: plan progress — Task 2 done; cutover flip de-risked + precisely specified
Shell pass is a safe no-op for the node id (no exclusion needed); indoor->outdoor
terrain already works via OutsideView; the only new piece is feeding the outdoor
ROOT node's full-screen region to OutsideView. Remaining = OutsideView integration
(read ClipFrameAssembler) + clipRoot flip + launch + visual gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:36:20 +02:00
Erik
d01fe30ac0 feat(render): Phase 3 (Task 2) — build the outdoor node each frame (additive, unconsumed)
Builds the synthetic outdoor cell node (OutdoorCellNode.Build) every outdoor frame
from the nearby building-entrance portals (Chebyshev <=1 landblocks), stored in
_outdoorNode. NOT yet rooted — clipRoot/viewerRoot unchanged, so behaviour is
identical this commit. [outdoor-node] probe (ACDREAM_PROBE_FLAP) reports the live
portal count so the next (cutover) step can confirm real building entrances were
found before flipping the render root. App.Tests 214/214, build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:35:25 +02:00
Erik
1e9485532f docs: plan progress — Tasks 1+3 done (outdoor node + outdoor-root flood validated)
Foundation shipped + validated: the flood roots at the outdoor node and reaches
buildings with ZERO production changes (the design's central risk is resolved).
Next = Task 2 + Phase 3 cutover together, inline (contextual GameWindow surgery
ending at the visual gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:26:09 +02:00
Erik
c5b4f77fe4 test(render): Phase 2 — Build floods from the outdoor node into buildings (+cycle guard)
TDD characterisation test proving PortalVisibilityBuilder.Build correctly roots at the
outdoor cell node (OutdoorCellNode) and floods into adjacent buildings through their
entrance portals. No changes to Build or OutdoorCellNode were needed.

Key finding: the task spec's building fixture used InsideSide=0 for an exit portal whose
building interior is at Y>=5 (Normal=(0,-1,0), D=5). The correct InsideSide is 1
(interior where dot<=0 -> Y>=5); with InsideSide=0 the outdoor camera (Y=-3, dot=8)
incorrectly passes as "interior" of the building so OutdoorCellNode.Build's InsideSide
flip (0->1) puts the outdoor camera on the wrong side of the gate.
Corrected fixture uses InsideSide=1 matching OutdoorCellNodeTests geometry convention
(building interior = POSITIVE dot side, outdoor = negative dot side; flip makes outdoor
negative-dot side the traversable direction). Both tests pass; full suite 214/214.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:23:28 +02:00
Erik
2a2cc97d28 feat(render): Phase 1 — OutdoorCellNode.Build (outdoor world as a flood node)
Purely additive: creates the synthetic outdoor cell node that will serve as the
flood-graph root for the unified render pipeline. Each nearby building's exit
portal (OtherCellId==0xFFFF) is reversed into a portal pointing back into the
building, with its polygon transformed to world space and InsideSide flipped so
the outdoor half-space is "inside" the node. WorldTransform=Identity (portals in
world space). Mirrors retail's outdoor landcell that DrawInside(viewer_cell)
roots at (SmartBox::RenderNormalMode, decomp pc:92635). Nothing consumes this
yet — consumer wiring is Task 2.

2 new tests, 212 total passing, 0 regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:16:55 +02:00
Erik
06666b75a1 docs: plan — render unification (outdoor-as-a-cell), 4-phase TDD
Bite-sized TDD plan for the unification spec. Phase 1 (outdoor node) + Phase 2
(outdoor-root flood) are additive + unit-tested (full code/tests in the plan).
Phase 3 is the single visual-gated cutover (wire one path, repoint exit portals,
delete the branch/BuildFromExterior/DrawPortal/OutsideView). Phase 4 cleanup.
Pure-outdoor regression guard keeps open-world rendering byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:12:58 +02:00
Erik
bb64a674fc docs: spec — render unification (outdoor-as-a-cell, single DrawInside path)
Brainstormed design to collapse acdream's two render paths (OutdoorRoot vs
RetailPViewInside) into one, matching retail SmartBox::RenderNormalMode ->
DrawInside(viewer_cell). Roots the FLAP as the two-branch split toggling on the
viewer cell crossing the indoor/outdoor boundary (pinned 2026-06-07 via live
render-sig); the 2026-06-05 viewer-cell-stability plan (boom + dead-zone + w-clip)
is exhausted. Models the outdoor world as a flood-graph cell node whose shell is
the landscape, so one flood + one draw handle indoor and outdoor uniformly.
Clean cutover, 4-phase plan (phases 1-2 additive, phase 3 the visual-gated cutover).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:07:33 +02:00
Erik
1405dd8e90 feat(render): indoor render WORKS — terminating portal flood + every-cell seal + look-in FPS
Checkpoint of the unified retail-faithful indoor render. The two-week HANG/grey is fixed and the
interior seals (live-verified by the user). Commits the session render-rewrite foundation together
with the fixes that made it functional.

- HANG fix: PortalVisibilityBuilder.Build portal flood did not terminate (the faithful ProjectToClip
  near-side clip drifts per round, defeating the CellView dedup; the BFS had no bound after U.2a removed
  MaxReprocessPerCell). Fix = drift-tolerant snapped/canonical CellView.Add dedup (PortalView.cs) plus
  restored MaxReprocessPerCell=16 bounded re-enqueue (PortalVisibilityBuilder.cs). Re-enqueue is kept
  (load-bearing for late-slice propagation, Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit);
  only its count is capped. CellViewDedupTests added.
- Seal (DrawCells Task 2): RetailPViewRenderer.DrawEnvCellShells draws EVERY visible cell via
  IndoorDrawPlan.ShellPass (was gated on the ClipFrameAssembler slot filter, leaving slot-less cells grey).
- Look-in FPS: GameWindow exterior look-in candidates limited to the player landblock +-1 (was all ~81
  loaded LBs iterated every outdoor frame). No behaviour change (far cells were >48m, already culled).

Remaining dominant issue = the FLAP at transitions: viewer-cell metastability (render roots at the
camera-eye cell, which oscillates outdoor-indoor as the 3rd-person boom drifts across the doorway,
confirmed in render-sig). SEPARATE fix, NOT the DrawCells port. Full handoff + flap fix plan + tracked
follow-ups (#78 terrain, look-in-from-inside, look-in FPS, L-spotlight):
docs/research/2026-06-07-indoor-render-session-handoff.md.

Baselines: build 0 err; App.Tests 210/210; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:14:43 +02:00
Erik
bff1955066 feat(render): IndoorDrawPlan.ShellPass — every visible cell, no drawable filter (R1)
Pure port of retail DrawCells membership: reverse cell_draw_list, per-slice. Pins the
grey regression — a cell in OrderedVisibleCells is never dropped from the shell pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:59:26 +02:00
Erik
2ec8f41200 docs: implementation plan + pickup handoff — verbatim retail DrawCells port
Task-by-task plan (TDD pin for the grey regression + per-task visual gates) to replace the
indoor-render approximation with a verbatim PView::DrawCells port, sequenced so Task 2 alone
should kill the grey. Pickup handoff for a fresh session: state, baselines, rules, do-not-relitigate.
Local commit only (not pushed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:44:19 +02:00
Erik
eb7b1fa67c docs: spec — verbatim retail indoor render port (DrawInside/DrawCells)
Design for replacing the indoor render approximation layer with a verbatim
port of retail PView::DrawCells (0x5a4840). Locates the grey/bleed in the
ClipFrameAssembler slot-pool + drawableCells filter (RetailPViewRenderer.cs:52/237):
visible cells without a clip-slot are dropped (grey) and the per-cell trim was
globally disabled (bleed). Plan: draw EVERY OrderedVisibleCells cell, trim shells
per-slice via ClipPlaneSet gl_ClipDistance, draw objects membership+depth gated
(no hard clip → no half-character). Scope A+B (DrawInside + look-in DrawPortal);
keeps the faithful PortalVisibilityBuilder + ProjectToClip/ClipToRegion ported
this session. Local commit only (not pushed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:28:27 +02:00
Erik
8116d101bc docs: SHELL-SEALING / wrong-flood-root handoff (cellar floor + interior walls grey)
The user's primary symptom (interior walls + cellar floor render as grey clear-color
with dynamic objects / outdoor slices showing through; flicker at room/cellar
transitions) is the KNOWN R1-completion problem: the PView flood roots at the CAMERA
cell (viewer cell), and when the camera is in a different interior cell than the player
(room 0171 vs cellar 0174), the flood does not seal the player's cell. Decisive
evidence: flap-cam root=0xA9B40171 playerCell=0xA9B40174. This handoff separates the
two problems I conflated, lists the disproven causes, gives the next diagnostic step
(shell/flap/vis probes in the cellar), and a kickoff prompt. HEAD 2b7f5a1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:04:52 +02:00
Erik
2b7f5a16c6 fix(render): branch inside/outside on is_player_outside, not the camera cell (PARTIAL)
Retail SmartBox::RenderNormalMode (0x453aa0:92665) branches DrawInside vs the
outdoor LScape::draw on is_player_outside (the PLAYER's cell, 0x451e80), then
roots DrawInside at the VIEWER cell. acdream keyed the whole branch off the
camera cell (clipRoot = visibility.CameraCell), so a 3rd-person chase camera
lagging in a doorway AFTER the player stepped outside took the DrawInside path
rooted at the threshold cell, where the exit-portal flood degenerates: grey
world + entities-through-walls. Now ShouldRenderIndoor(playerCellId,
viewerCellResolved) gates the branch on the player; the DrawInside root stays
the viewer cell (handoff invariant preserved).

SCOPE / HONESTY: this REDUCES the player-OUTSIDE doorway grey (visual-confirmed
reduced a lot) but does NOT fix the deeper symptom: when the player is in one
interior cell (cellar 0174) and the camera is in another (room 0171), the flood
roots at the camera cell and does NOT seal the player's cell, so the cellar
floor / interior walls drop to grey. That is the KNOWN R1-completion problem
(2026-06-05 Residual A handoff + 2026-06-02 design doc section 3: a
SHELL-SEALING / wrong-flood-root bug), not this branch.

Tests: Core 1331p / 4f (documented) / 1s, App 187p, build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:02:09 +02:00
Erik
d2212cfaea fix(render): Part 1 — camera boom convergence snap (kills the at-rest viewer-cell flicker trigger)
Port retail CameraManager::UpdateCamera's convergence snap (0x00456fcd):
once the per-frame lerp step is below 0.0004 m AND the rotation within
0.000199999995, freeze the damped eye at an exact fixed point instead of
Vector3.Lerp's endless sub-mm asymptote. The drift was walking the 3rd-person
eye across the vestibule/room portal plane at rest, flipping the per-frame
viewer-cell resolve 0170<->0171 -> the indoor grey/texture flicker. The
collided-eye firewall (separate publishedEye local) is already present.

Adds ApplyConvergenceSnap static (TDD: 3 unit tests + 1 integration freeze
test) + SnapEpsilon/RotCloseEpsilon. App suite 183 -> 187, all green.

Plan: docs/superpowers/plans/2026-06-05-indoor-viewer-cell-flicker-fix.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:56:04 +02:00
Erik
9601ef39c3 docs: indoor flicker/void root cause (decomp + live cdb) + 3-part fix plan handoff
Diagnosis session: the indoor bluish void + grey/texture flicker is visibility metastability at cell boundaries, not a missing flood (R1's per-cell DrawInside is built; the cellar seals). Confirmed by named-retail decomp AND a live cdb capture of retail (viewer_cell rock-stable: clean monotonic transitions, zero oscillation across 4916 samples). Retail stays stable via boom stability + a 0.2mm viewer-cell dead-zone + clip-space portal clipping; acdream diverges on all three. Handoff documents the root cause, the cdb evidence, and the prioritized 3-part retail-faithful fix (boom stability -> dead-zone -> w-space clip) with decomp anchors + a planning/implementation kickoff prompt. Adds the reusable retail viewer-cell cdb capture script and the superseding CLAUDE.md banner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:31:17 +02:00
Erik
9f95252d20 fix(render): flood the neighbour when the eye stands in an interior portal
When the chase camera roots in a thin doorway cell and the eye stands in an interior portal opening (live capture: vestibule->room portal D=0.16m, proj=0), the 2D projection degenerates and the neighbour was culled (cells=1) -> only the thin cell drew -> bluish void / transparent ceiling. Retail's 3D clip imposes no constraint for a portal the eye is inside, so the neighbour is fully visible. When the clipped region is empty but the eye stands in the opening (EyeInsidePortalOpening: within 0.5m of the portal plane AND point-in-opening), flood the neighbour with the current view. Guarded so an off-screen degenerate portal stays culled (no #95 blowup; over-include is mesh-frustum-culled at draw). Visual-verified: cellar ceiling now solid.

Band-aid for thin-cell-root coverage; likely superseded by the boom-stability + viewer-cell dead-zone + w=0 near-plane clip fix next session (reassess / maybe revert). 2 RED->GREEN tests; cyclic/hub termination guards unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:27:39 +02:00
Erik
5f596f2d25 fix(render): clip portal projection against frustum side planes (clip-space)
ProjectToNdc clipped only the eye half-space (w>MinW, a 2026-06-03 workaround) and left the 4 frustum side planes to the 2D ScreenPolygonClip. When the eye is within a portal's near plane, small-w verts explode under the perspective divide (probe saw NDC (10.2,-67.4)); the 2D clip then collapses to empty -> OutsideView empty -> terrain Skip -> the bluish doorway void. Clip the eye plane + 4 side planes (homogeneous Sutherland-Hodgman) before the divide so NDC is bounded to the screen by construction, matching retail GetClip -> polyClipFinish (clip in clip-space before the divide; pc:432344).

Partial: NOT the full flicker fix. The dominant cause (camera boom drift + viewer-cell flip at boundaries + missing w=0 near-plane clip) is identified and deferred to the next session per the handoff. 2 RED->GREEN tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:27:24 +02:00
Erik
02837ad5dc docs(A): wrap Render Residual A — handoff + roadmap for the core inside render
Residual A (camera collision = verbatim SmartBox::update_viewer) is SHIPPED +
user-kept (0ffc3f5/5177b54/9e70031). Wrap it and hand off to the render session:

- New canonical handoff (docs/research/2026-06-05-render-residual-a-shipped-core-
  inside-render-handoff.md): what A shipped, what A EXPOSED (the render roots at the
  viewer cell — clipRoot=CameraCell, GameWindow.cs:7322 — and A made that cell
  accurate, so the PVS flood from the viewer cell doesn't reach the player's cell →
  cellar floor drops), the reframing (the user's "step C" = the CORE inside render /
  R1 completion, NOT R2 outside-looking-in), the evidence-first job, KEEP/DON'T, the
  kickoff prompt.
- CLAUDE.md banner: A SHIPPED; next = core inside render (R1 completion).
- Render redesign spec: 2026-06-05 sync note (A shipped; R1 is actually incomplete —
  the bleed + cellar-floor drop are the unfinished flood/seal; next is R1, not R2).

The visible problems (bleed + the floor A exposed) are the same family: the inside
path still draws the whole outdoor world instead of retail's "inside → DrawInside
only". A faithful DrawInside seals them by construction (render spec 2026-06-02 §2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:49:31 +02:00
Erik
9e70031bc6 feat(A): wire SweepEye to the verbatim update_viewer (start-cell + fallbacks)
Complete Render Residual A's faithful port: PhysicsCameraCollisionProbe.SweepEye
now mirrors SmartBox::update_viewer (acclient_2013_pseudo_c.txt:92761) end-to-end:

- Start cell (pc:92824-92844): indoor (>=0x100) seats the sweep at the head-PIVOT
  via PhysicsEngine.AdjustPosition (the cellar-lip case — feet in the low connector,
  head up at floor level); outdoor keeps the player cell.
- Sweep pivot -> sought-eye from the seated start cell (unchanged 0x5c viewer flags).
- Success (pc:92870): set_viewer(curr_pos), viewer_cell = curr_cell.
- Fallback 1 (pc:92878): AdjustPosition(sought_eye).
- Fallback 2 / no-cell (pc:92775, 92886): snap to player, viewer_cell = null. This
  also makes cellId==0 faithful (was returning the desired eye; retail snaps to
  player_pos) and adds the playerPos arg to ICameraCollisionProbe.SweepEye.

Supporting: ResolveResult.Ok surfaces FindTransitionalPosition's return (retail
find_valid_position != 0, pc:273898) so SweepEye knows when to fall back.

TDD: 11 new tests (FindVisibleChildCell 4, AdjustPosition 3, ResolveResult.Ok 2,
SweepEye orchestration 2). The seating test's RED proved the sweep does NOT auto-
advance feet->room, so the pivot-seated start cell is genuinely decisive. Core
1326 pass / 4 documented-fail / 1 skip; App 179 pass / 0 fail. No regression.

Per the live-capture finding, the visible payoff is the cellar-corner (point 3);
the cottage-room bluish void stays for residual C. Spec:
docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:10:32 +02:00
Erik
5177b54bbe feat(A): port find_visible_child_cell + AdjustPosition (Render Residual A primitives)
The two Core physics primitives retail's SmartBox::update_viewer calls down into,
ported verbatim (TDD, 7 new tests):

- CellTransit.FindVisibleChildCell (CEnvCell::find_visible_child_cell, pc:311397):
  return the cell whose cell-BSP point_in_cell contains a world point — start cell
  first, then (stab-list mode) the start's VisibleCellIds or (portal mode) its
  direct portals. Sibling of FindCellList. Mirrors FindCellList's null-CellBSP skip
  (CellTransit.cs:518) so a cell lacking hydrated CellBSP doesn't spuriously claim
  every point via PointInsideCellBsp's null-node "inside" default.

- PhysicsEngine.AdjustPosition (CPhysicsObj::AdjustPosition, pc:280009): resolve a
  point's cell from a seed. Indoor (>=0x100) → FindVisibleChildCell(stab-list);
  outdoor → landcell snap (same grid lookup as ResolveCellId). The seen_outside
  sub-fallback is deferred (off the cottage/cellar path; spec §6).

Both are unwired into any production path — they land the machinery update_viewer's
start-cell + fallback 1 need (and that residual C also needs). The App SweepEye
orchestration that calls them lands next.

Decomp-faithful per the live-capture finding: A's V1 sweep already contains the eye
(eyeInRoot=Y 99.75%, never void); this completes A as a verbatim port. Spec:
docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:56:16 +02:00
Erik
0ffc3f5be9 docs(A): spec — verbatim SmartBox::update_viewer completion (Render Residual A)
Live ACDREAM_PROBE_FLAP capture (Holtburg cottage/cellar) proved the V1 camera
spring-arm already contains the eye (eyeInRoot=Y 99.75%, viewerCell never 0,
indoor collide 97.6% in 0174). The dominant inside-cottage bluish void is the
render-sealing residual C (DrawPortal), NOT the camera.

This spec scopes the FAITHFUL completion of Residual A: port the two missing
update_viewer pieces verbatim — the indoor start-cell seated at the pivot via
CPhysicsObj::AdjustPosition (pc:280009) → CEnvCell::find_visible_child_cell
(pc:311397), plus the two AdjustPosition/snap-to-player fallbacks — and land
FindVisibleChildCell (which residual C also needs).

Faithful layering (mirrors retail SmartBox→CPhysicsObj): primitives in Core
(PhysicsEngine.AdjustPosition + CellTransit.FindVisibleChildCell + ResolveResult.Ok),
orchestration in App PhysicsCameraCollisionProbe.SweepEye. Deterministic crux test
(start-cell resolution) in Core.Tests with the cottage fixtures; SweepEye glue in
App.Tests. Visible payoff is narrow (the cellar-corner, point 3); the cottage-room
void stays for residual C.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:44:04 +02:00
Erik
2c7948a9f1 docs: handoff + kickoff for Render Residual A (camera collision verbatim port)
Session wrap: cellar-lip wedge fixed + visual-verified (cc4590f/9fdf6a5/41db027).
Next task per the plan = Render Residual A: keep the chase camera eye inside the
player's cell by porting retail SmartBox::update_viewer verbatim (fixes interior
walls going grey/transparent from inside).

- New canonical handoff with copy-paste fresh-session kickoff prompt, the retail
  update_viewer decode, the V1 current-state map, the gap to pin (faithful
  start-cell + AdjustPosition fallbacks + the no-wall-hit cause), and the
  evidence-first plan ([flap-sweep] capture → deterministic SweepEye test → port).
- Key finding recorded: find_valid_position (pc:273890) just calls
  find_transitional_position — the sweep function is faithful, NOT the divergence.
- CLAUDE.md banner updated to point at the new state + handoff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:46:52 +02:00
Erik
41db027f34 docs(p2): record cellar-lip wedge visual-gate PASS (cellar smooth, door blocks, step-up climbs)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:24:54 +02:00
Erik
9fdf6a5d01 chore(p2): strip cellar-lip dispatch-trace probes after visual confirmation
The stale-footCenter fix (cc4590f) is visually confirmed: cellar ascent is
smooth, inn door still blocks, generic step-up still climbs. The residual
9/29 (0,-1,0)-sliding-normal records did NOT manifest in live play —
confirming they were buggy-trajectory artifacts.

Remove the temporary investigation scaffolding added for this trace:
- [fc-dispatch] probe in BSPQuery.FindCollisions
- [step-sphere-down] probe in BSPQuery.StepSphereDown
- CellarLipWedgeTests.Diagnostic_TraceRecordByIndex [Theory]

Kept: the fix, the Fix_StaleFootCenter_* regression guards, and the
DocumentsResidualWedge_* documents-the-bug test. Core suite 1317 pass /
4 fail (documented baseline) / 1 skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:24:20 +02:00
Erik
cc4590f9e5 fix(p2): cellar-lip wedge — check_other_cells must use the LIVE sphere position
Root cause of the "blocked at the last cellar step" wedge (the primary,
ramp-climb family — 20/29 captured records). The prior session's pinned
"find_walkable is never called during the step-down" was a probe artifact:
a fresh [fc-dispatch]/[step-sphere-down] trace proves Path-3 StepSphereDown
IS reached for both the carried cell and the iterated other-cell.

The real divergence is in Transition.CheckOtherCells. Retail's
check_other_cells (acclient_2013_pseudo_c.txt:272735 → (*cell+0x88)(this))
re-collides the OTHER cells against the LIVE sphere_path.global_sphere — the
position AFTER the primary insert_into_cell ran. The primary collide can MOVE
the sphere: a Path-5 full-hit dispatches step_sphere_up, and a successful
step-up climbs the foot onto the cottage floor yet still returns OK. acdream
instead reused a footCenter snapshot captured BEFORE the primary collide, so
once the lip-riser step-up climbed the foot onto the floor, check_other_cells
still queried 0171 at the pre-climb (sunk ~0.25 m below the floor) position →
the foot spuriously near-missed the very floor it had climbed onto →
neg_step_up → a doomed second step_up vs the floor normal (0,0,1) whose
step_up_slide unwound the climb → validate_transition reverted → 0% advance.

Fix: re-read footCenter = sp.GlobalSphere[0].Origin at the top of
RunCheckOtherCellsAndAdvance (one line). Pre-fix 0/29 wedge records advanced;
post-fix 20/29 climb onto Z≈94.

No regression: full Core suite 1321 pass / 4 fail (the documented baseline:
Apparatus_Grounded_50cmOffCenter, 2× DoorBugTrajectoryReplay LiveCompare_*,
BSPStepUpTests.D4) / 1 skip. The 2 door LiveCompare divergences are
byte-identical with/without the fix (the door's step_up FAILS → sphere
restored → position unchanged → footCenter == live).

Tests: CellarLipWedgeTests.Fix_StaleFootCenter_RampRecordClimbsCottageFloor +
Fix_StaleFootCenter_MajorityOfWedgeRecordsAdvance (new, GREEN).
DocumentsResidualWedge_LiveFloorCp_SlidingNormalKillsPlusY documents the
remaining 9/29 (0,-1,0)-sliding-normal +Y-kill family (slide territory,
deferred to the visual gate).

Apparatus retained (gated on ACDREAM_PROBE_INDOOR_BSP): [fc-dispatch] in
BSPQuery.FindCollisions + [step-sphere-down] in BSPQuery.StepSphereDown +
CellarLipWedgeTests.Diagnostic_TraceRecordByIndex — strip once the residual
is resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:15:19 +02:00
Erik
bc1be26907 test(p2): faithful cellar-lip wedge reproduction + investigation apparatus (no fix yet)
P2 / M1.5 "blocked at the last step" cellar-lip wedge. This session built a faithful
deterministic reproduction and peeled the cause through six evidence-disproven framings
to one bounded question. NO fix landed — the last layers were each disproven by evidence,
and guessing at the load-bearing collision code is the saga's failure mode.

Apparatus:
- CellarLipWedgeTests.cs + Fixtures/cellar-lip/ (3 real cell dumps + wedge-records.jsonl =
  29 captured ACDREAM_CAPTURE_RESOLVE wedge calls). Replays the exact calls + body-before
  through the lip-cell engine: all 29 reproduce at 0% advance in <200 ms. Tests are
  documents-the-bug / diagnostics (GREEN while the wedge exists).
- TEMP probes ([path5-wall]/[fw-enter]/[find-walkable] in BSPQuery; [neg-poly]/[stepsphereup]/
  [stepdown-decide]/CheckOtherCells cn/sn/negHit in TransitionTypes), gated on
  ACDREAM_PROBE_INDOOR_BSP, marked STRIP. TransitionTypes neg-poly shortcut has a reverted-fix
  comment (slide attempt didn't clear the wedge).
- tools/cdb/retail-*-trace.cdb (retail cdb traces).

Findings (handoff: docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md, see the
"NEXT-SESSION KICKOFF" at top):
- Flat-floor contact plane is retail-faithful (v1 trace, full-file correlation). NOT the bug.
- PosHitsSphere cull sign is retail-faithful (cdb -z verified; the Binary Ninja `test ah,N; jp`
  parity-jump reads inverted — caught + reverted a wrong fix from that mis-read).
- Sphere radius correct (0.48 player / 0.30 camera probe).
- Retail connector cell 0xA9B40175 never blocks (CEnvCell::find_collisions trace: 0 Collided/Slid).
- PINNED: during the step-up's step-down, BSPQuery.FindWalkableInternal is never called for cell
  0171, so the cottage floor (poly 0x0023, Z=94) is never tested as walkable -> no contact plane
  -> step-up fails -> StepUpSlide=Collided -> wedge.

Next: trace FindEnvCollisions -> FindCollisions path dispatch for 0171 during StepDown=true (why
StepSphereDown/find_walkable is skipped), port retail, validate via CellarLipWedgeTests, regress
DoorBugTrajectoryReplayTests + visual gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:30:36 +02:00
Erik
57435e912b docs(p2): fresh-session kickoff prompt — principled P1 membership fix (user-approved)
Appends the copy-paste kickoff prompt for the next session: pursue the principled
P1 fix for the cellar-lip cell-resolver ping-pong (demote ResolveCellId / make the
swept curr_cell the per-frame membership authority), NOT a stickiness band-aid.
Captures the evidence, apparatus, retail anchors, do-not list, and test baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:43:11 +02:00
Erik
664101f08f docs(p2): re-diagnose cellar wedge — cell-resolver ping-pong, not step-up
Instrumented acdream at the cellar lip (ACDREAM_DUMP_STEPUP=1): step-up WORKS
(518 attempts, 220 SUCCESS landing the candidate on the cottage floor Z=94.0,
matching retail's landings), but the committed CurPos never advances -- every
success is reverted, and [cell-transit] shows ResolveCellId ping-ponging every
tick at the 3-cell junction (0xA9B40175<->0174<->0171, reason=resolver). So the
wedge is a MEMBERSHIP cell-resolution instability reverting a working step-up --
NOT a collision/step-up bug, NOT edge-slide.

Notably this contradicts the master-plan P1 claim that ResolveCellId was demoted
out of the per-frame path: it is STILL driving per-frame cell changes here and is
unstable. Fix direction = the parked, approval-gated (a) ResolveCellId
demotion/stickiness (membership), now justified as a real bug by live evidence.
Collision-side fixes (abbd761 B1, 0935a31 slide_sphere) are correct + kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:39:21 +02:00
Erik
5ad897b0a5 docs(p2): cellar corner-wedge pinned to step-up-onto-floor (retail cdb) + trace apparatus
Live retail cdb trace (tools/cdb/cellar-corner-escape.cdb) of the Holtburg
cottage cellar-top corner decodes the ground truth: retail escapes by
step_sphere_up->step_up (196x vs 38 near-misses), transitioning the contact
plane from the ramp (N.z=0.78) onto the flat cottage floor (N.z=1.0, 76
landings). acdream slides at the lip and never makes that ramp->floor
transition -> the intermittent cellar wedge.

So the remaining cellar bug is the #98-core step-up-onto-cottage-floor
(DoStepDown / step_sphere_down / find_walkable), which the shipped B1 (abbd761)
+ slide_sphere (0935a31) fixes got close to but didn't finish. Door still
blocks; generic step-up climbs; cellar went always-stuck -> works-mostly.

Next (handoff doc): instrument acdream's OWN corner path (does step_up fire at
the lip and fail to land on the cottage floor?) before porting the lip-climb --
no guessing (#98 saga rule).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:27:44 +02:00
Erik
0935a315bf fix(p2): port retail slide_sphere for head near-miss (cellar wall-slide)
Replace the A6.P4 Collided shortcut in transitional_insert's neg_step_up==0
branch with the faithful CSphere::slide_sphere. Retail
(acclient_2013_pseudo_c.txt:273350-273351, CTransition::transitional_insert):
neg_step_up==0 (HEAD-sphere near-miss) -> slide_sphere -> continue the insert
loop; neg_step_up==1 (foot) -> step_up_slide. The acdream foot branch already
did that; only the head branch took the shortcut (SetCollisionNormal + return
Collided = dead hard-stop). The slide itself is the existing
SlideSphereInternal (Sphere.SlideSphere port): it strips the into-wall
component and keeps the tangential crease (collisionNormal x contactPlane.N).

Surfaced by the B1 near-miss-gate fix (abbd761): once the grounded mover climbs
onto the cottage floor, its head sphere brushes the cellar stairwell walls and
the old hard-stop wedged it (2026-06-04 live capture: 274 (0,-1,0) + 78
(1,0,0) hits, out==current, dead oscillation). Post-fix capture shows 96
hit-and-advanced frames (the body slides along the walls).

Visual-verified 2026-06-04: closed cottage door still BLOCKS (no walkthrough --
drifts sideways along it, retail-faithful); cellar ascent now works (was always
stuck). An intermittent corner-wedge (slide into the -Y/+X wall corner) remains
-- separate finer issue, under investigation.

Core 1310 pass / 4 fail (pre-existing: 3 door documents-the-bug + D4 airborne).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:28:59 +02:00
Erik
f984e92e37 docs(p2): correct the handoff — B1 was the Path 5 near-miss gate, not the climb
The prior localization (step-up CLIMB) was disproven by an ITestOutputHelper
capture. Records the real root cause (A6.P4 near-miss missing retail's
num_sphere>1 gate, fixed in abbd761), that the door blocks faithfully with a
real floor, and that the remaining red tests are separate (apparatus
synthetic-floor artifact, LiveCompare buggy-captures, D4 airborne) — not
simple "flip to green" targets. Next is the user visual gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:35:14 +02:00
Erik
abbd7615ee fix(p2): Path 5 near-miss = retail num_sphere>1 gate (fixes B1 step-up wedge)
The 2026-06-03 handoff localized the failing Core tests to the BSP Path 5
step-up CLIMB (find_walkable/step_sphere_down). An ITestOutputHelper capture
of B1 disproved that: the climb code is correct (matches ACE
Polygon.adjust_sphere_to_plane / BSPTree.step_sphere_down exactly). The real
bug is the A6.P4 near-miss dispatch in FindCollisions' Path 5 (Contact
branch), which diverged from retail three ways:

  1. Recorded a near-miss NegPolyHit UNCONDITIONALLY. Retail gates both
     set_neg_poly_hit calls behind `if (num_sphere > 1)`
     (acclient_2013_pseudo_c.txt:323852).
  2. Checked the foot sphere's near-miss before the head's. Retail checks
     the head (sphere1) first.
  3. Mapped foot->neg_step_up=false / head->true. Retail maps head(index 0)
     ->false (slide), foot(index 1)->true (step-up), per
     SPHEREPATH::set_neg_poly_hit (:323279, neg_step_up = arg2).

For B1's single foot sphere, the spurious near-miss -> outer loop
`!NegStepUp -> SetCollisionNormal + Collided` -> revert: the grounded mover
wedged at x=0.1 and never advanced to the wall to step up. With the verbatim
gate, a single-sphere near-miss records nothing, the sphere advances,
full-hits the wall, and step_sphere_up climbs the 0.25 m step (verified via
probe capture: foot ends at (0.6, 0, 0.25)).

The Holtburg cottage door still blocks faithfully (door slab (0,-1,0) normal,
stops in front of the door) when the scenario has a real floor — confirmed
this change does not regress the door.

The two BSPQueryTests Path5 near-miss tests used a single sphere (the very
non-retail assumption that caused this wedge); converted to the production
2-sphere shape where the head sphere records the near-miss, matching retail.

Core 1312 pass / 4 fail (the 4 pre-existing: 3 door documents-the-bug + D4
airborne, none regressed here); App 177 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:32:55 +02:00
Erik
82045805fd docs(p2): session wrap — P1 done, P2 (Path 5 step-up) localized; handoff + plan/CLAUDE.md update
P1 membership is DONE (proven to already match retail; the 0/11 was a cdb capture artifact;
merged + pushed). P2 root cause localized to BSP Path 5 grounded step-up: the Path 5 wrappers
(DoStepUp=retail step_up, DoStepDown=retail step_down) are verified faithful + reached; the
divergence is in the step-up CLIMB (find_walkable/step_sphere_down up-adjust when sp.StepUp=true).

- docs/research/2026-06-03-p2-door-stepup-handoff.md: canonical P2 pickup + fresh-session prompt +
  DO-NOT-RETRY (the wrappers) + the tooling note (xunit swallows Console.WriteLine).
- master-plan §3: P1 marked DONE + the (a)-(d) deletes/unifications re-scoped to approval-gated
  refactors of working code; P2 localization recorded.
- CLAUDE.md M1.5: dated 2026-06-03 pointer (P1 done, P2 active, render seam in P3/P4, pickup doc).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 08:52:56 +02:00
187 changed files with 42649 additions and 2579 deletions

350
CLAUDE.md
View file

@ -249,6 +249,19 @@ pursuing live in [`docs/architecture/code-structure.md`](docs/architecture/code-
## How to operate
**Memory — read the digests before domain work.** Durable project knowledge
lives in `claude-memory/` (the auto-loaded index is `MEMORY.md`). Before starting
work in a domain that has a **digest**, read it first: `project_render_pipeline_digest.md`
(indoor render / doorway FLAP) and `project_physics_collision_digest.md`
(physics / collision / #98 / membership). Each digest is current-truth-on-top
plus a DO-NOT-RETRY table — it supersedes the dated banners that used to sprawl
across this file. The memory-handling protocol (distill-don't-journal, the digest
pattern, tags, recall + capture) is in `reference_obsidian_vault.md`. **Do NOT add
new dated banners to this file — update the relevant digest instead.** Obsidian
(auto-started in the main repo via a SessionStart hook) is the live search / graph /
tag lens over `claude-memory/` through the `mcp__obsidian__*` tools when it's
running; the files read and write the same with it closed.
**You are the lead engineer AND architect on this project at all times.**
You own the architecture (`docs/architecture/acdream-architecture.md`),
the execution plan (milestones doc + strategic roadmap), the development
@ -444,6 +457,28 @@ isn't enough, attach cdb to a live retail client (Step -1).**
source was safe; replacing the entire transform composition broke
everything.
### The divergence register (mandatory bookkeeping)
[`docs/architecture/retail-divergence-register.md`](docs/architecture/retail-divergence-register.md)
is the single auditable list of every KNOWN place acdream's runtime
behavior can deviate from retail (108 rows at creation, 2026-06-12:
intentional architecture / adaptation / approximation / stopgap /
unclear). Two rules, both binding on subagents too:
1. **Any commit that introduces a deviation** (an adaptation, an
approximation, a stopgap, a "retail does X but we...") **adds its
register row IN THE SAME COMMIT.** Any commit that ports the retail
mechanism deletes the row in the same commit. A deviation found
without a row is a bug twice over.
2. **Any unexplained visual/physics symptom → scan the register BEFORE
instrumenting.** The "Risk if assumption breaks" column is written as
the symptom you'd observe (the #119 vanishing staircase, the #112
transparent cottage, and the knife-edge flap all lived in rows of
this register's scope before they had names).
The register holds one-line rows and pointers — detail lives at the
cited `file:line` and in the digests, never in the register itself.
### What NOT to do:
- **Do not guess** at AC-specific algorithms, formulas, constants, wire
@ -478,6 +513,9 @@ Before marking any phase as done:
- [ ] Every AC-specific algorithm has a decompiled reference cited in
comments (named symbol + address from `named-retail/symbols.json`,
OR function address + chunk file from older `decompiled/` chunks)
- [ ] Every retail deviation this phase introduced has a row in
`docs/architecture/retail-divergence-register.md` (and every
deviation it retired had its row deleted)
- [ ] Conformance tests exist for the critical paths
- [ ] The code was cross-referenced against at least 2 reference repos
- [ ] `dotnet build` green, `dotnet test` green
@ -725,308 +763,22 @@ Visual side-by-side passed: Holtburg town, inn interior, dungeon all
render identically to pre-O. Spec:
[`docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md`](docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md).
**2026-05-30 — RENDER PIPELINE PIVOT (read this first).** The two-pipe
(inside / outside) render approach is **ABANDONED**. acdream inherited a
WorldBuilder-style split — a normal outdoor draw plus a separate flat
`RenderInsideOut` stencil pass toggled on `cameraInsideBuilding` — and that
split is the root cause of every indoor seam bug (the flap, missing/transparent
walls, terrain bleeding into interiors). Retail has no such split; it renders
through one portal-visibility traversal (`PView`) and is seamless by
construction. We are building **Phase U — a single unified retail-faithful
render pipeline**. This supersedes the A8/A8.F two-pipe arc (issue #103). The
camera-collision work (retail `SmartBox::update_viewer` spring arm) + a
physics viewer-cap fix **SHIPPED this session and are kept** (they're real and
retail-faithful, just not the seam fix). Full decision + scope + next-session
pickup prompt:
[`docs/research/2026-05-30-unified-render-pipeline-decision-and-handoff.md`](docs/research/2026-05-30-unified-render-pipeline-decision-and-handoff.md).
The M1.5 narrative below is history retained for context.
**2026-05-31 — U.4c doorway FLAP FIXED** (`0ee328a`, visual-verified "flap gone").
Root cause (converged on a live `ACDREAM_PROBE_FLAP` capture, after disproving an
H2 `PortalSide` side-test fix and an H1 PVS-grounding hypothesis): indoor visibility
was rooted at the 3rd-person camera **eye**, which drifts out of the player's cell →
`FindCameraCell` returns a STALE cell for its grace frames → the doorway portal is
culled as behind-the-eye → exit cell + terrain + shells drop. Fix: root indoor
visibility (cell resolution + portal-side test) at the **player's cell**
(retail `CellManager::ChangePosition`; matches the existing lighting decision). Eye
still drives projection. **The flap is done; the indoor pipeline is NOT yet seamless**
the visual gate revealed three SEPARATE residuals: (1) **#78** outdoor terrain not gated
inside (now more visible since terrain draws again); (2) **camera collision** needed (the
chase eye is outside the player's cell ~79% of frames → the eye-projected clip
over-includes → transparent outer walls); (3) **U.5** outside-looking-in (deferred).
Camera collision (retail `SmartBox::update_viewer` keeping the eye in the cell) is the
highest-leverage next step. CANONICAL handoff (read first next session):
[`docs/research/2026-05-31-u4c-flap-fixed-and-residuals-handoff.md`](docs/research/2026-05-31-u4c-flap-fixed-and-residuals-handoff.md).
Apparatus `ACDREAM_PROBE_FLAP` + `tools/A8CellAudit` are committed + ready. Do NOT retry
H1 (PVS grounding) or H2 (`PortalSide` side-test) — both evidence-disproven.
**Currently working toward: M1.5 — Indoor world feels right** (resumed
from 2026-05-20 baseline after Phase O ship). **A6.P1 + A6.P2 + A6.P3
slice 1 SHIPPED 2026-05-21.** **A6.P3 slice 2 v2 SHIPPED 2026-05-22**
(commit `f8d669b`): tried removing the L622 per-tick CP seed
(`892019b` v1) but it broke BSP step_up at the last step of stairs;
reverted + added a benign no-op-if-unchanged guard inside
`CollisionInfo.SetContactPlane`. Slice 2 outcome: **#96 partially
addressed — accepted as documented retail divergence** (the per-tick
seed is load-bearing for `AdjustOffset` slope-projection on sub-step 1
which BSP step_up depends on; matching retail would require deeper
refactor of AdjustOffset). Slice 2 verification surfaced a NEW
M1.5-blocking bug: **user cannot walk UP out of cottage cellar — stuck
at last step due to cell-resolver ping-pong (filed as issue #98,
Finding 3 family).** **A6.P3 slice 3 SHIPPED 2026-05-22** (commits `8898166` v1 +
`3e140cf` v2): cell-resolver stickiness added in `ResolveCellId`'s
indoor branch (point-in check against `fallbackCellId`'s CellBSP
before falling through to FindCellList). Data confirms ping-pong is
FULLY CLOSED — scen4 cellar capture shows 1 cell-transit (login
teleport) vs 20+ pre-fix. **#90 workaround now redundant — deferred
to A6.P4 removal. #98 APPARATUS COMPLETE 2026-05-23 evening**
(commits `35b37df` triage → `f62a873` cell-dump probe → `3f56915`
fixtures → `856aa78` replay harness → `6f666c1` cdb script →
`28c282a` divergence comparison doc). Four sessions of speculative
fixes (10+ variants) shipped the wrong diagnosis each time; this
session shipped the APPARATUS that turns evidence-driven analysis
into a 200ms test loop. Real divergence: retail's sphere is at
world Z ≈ 94.48 (resting on cottage floor) when find_walkable
accepts; acdream's failing-frame sphere is at world Z ≈ 92.01
(2.47m lower). Retail's ContactPlane writes during cellar-up are
ONLY flat floors (cellar floor or cottage floor), never the ramp.
Retail's find_crossed_edge fires once in 35K BPs; ours uses it
heavily. **Fix targets (priority): (1) Transition.AdjustOffset
slope projection / DoStepUp WalkInterp handling — ramp climb
doesn't gain Z; (2) cottage-cell candidacy using wrong sphere
reference; (3) find_crossed_edge over-use; (4) ramp polygon normal
divergence (low confidence).** Full divergence reading +
fix-plan pickup prompt at
[`docs/research/2026-05-23-a6-p3-issue98-replay-comparison.md`](docs/research/2026-05-23-a6-p3-issue98-replay-comparison.md).
Current A6 phase:
**A6.P3 — PAUSED 2026-05-23 (full day). Trajectory replay harness shipped
but BLOCKED on a new bug surfaced during commissioning.** Read
[`docs/research/2026-05-23-a6-p3-issue98-harness-handoff.md`](docs/research/2026-05-23-a6-p3-issue98-harness-handoff.md)
as the canonical pickup document — it has the chronological commit list,
the apparatus inventory, the exclusion list (do-not-retry), and three
concrete next-session options ranked by recommendation.
from 2026-05-20 baseline after Phase O ship).
The session shipped further apparatus + first failed fix attempt + revert:
`8a232a3` (`[step-walk-adjust]` probe inside `Transition.AdjustOffset`
revealing branch tokens and per-call zGain), `8daf7e7` (findings note
at [`docs/research/2026-05-23-a6-stepwalkadjust-findings.md`](docs/research/2026-05-23-a6-stepwalkadjust-findings.md)
+ capture snapshot), `0cb4c59` (Shape 1 fix: gate `BSPQuery.AdjustSphereToPlane`'s
two `SetContactPlane` call sites by `Normal.Z >= 0.99`), `402ec10`
(revert — Shape 1 broke OnWalkable tracking, sphere went into falling
state on every sloped surface). **Refined diagnosis:** AdjustOffset is
CORRECT (145/146 calls take `into-plane` branch, +0.045 m mean zGain
per call when offset points into ramp); the climb CAPS at world Z ≈
92.80 because step-up's downward step-down probe finds no walkable
within 0.6 m below the proposed position (cottage floor is ABOVE).
Earlier "Fix targets 14" priority list is OBSOLETE — AdjustOffset
projection is not the problem. The actual bug is in the step-up
validation at the ramp top. **Honest next-session moves**: (1) build
deterministic trajectory replay harness so fix attempts iterate in
<500ms instead of 5-minute live-test cycles; (2) pivot to a less-
coupled M1.5 issue while #98 awaits the harness; (3) targeted decomp
research on `CEnvCell::find_env_collisions``BSPTREE::find_collisions`
indoor CP-setting chain (prior research worked on the outdoor
`CLandCell` path; indoor was never fully traced). Session-end ISSUES.md
entry has the full reading and pickup prompt. **NO further #98 fix
attempts until apparatus or research has converged — six+ failed
attempts in the saga is the signal.**
**Indoor render & the doorway "FLAP" — read the digest, not banners.**
The full current state, the root cause, the DO-NOT-RETRY list, the ⚠️
`ACDREAM_PROBE_FLAP`→white-textures landmine, and the detail-doc pointers are
distilled in **`claude-memory/project_render_pipeline_digest.md`** (auto-loaded
via MEMORY.md). As of 2026-06-09 (HEAD `a1b12df`): governing direction is
**Option A — one `DrawInside(viewer_cell)`, NO inside/outside branch**;
R-A1/R-A2/R-A2b shipped (outside-looking-in flap GONE, seams GONE, portal-flood
churn KILLED); remaining = the visible indoor flap narrowed to §4 (edge-on
doorway grey + corner camera-seal). Render roots at the **VIEWER** cell, not the
player cell. Read the digest before any render/flap work — it supersedes the
dated render banners that used to live here.
**Late-day extension (2026-05-23 PM):** trajectory replay harness shipped
(commits `4c9290c``5c6bdbe`). Mechanics work — runs 200 ticks in <100 ms.
Five tests pass. NEW finding: the cellar ramp polygon is in a GfxObj
(static building piece), not the cell's PhysicsPolygons. Harness now
includes `RegisterStairRampGfxObj` for synthetic stair construction
and `AttachSyntheticBsp` to wrap hydrated cells (which have BSP=null)
with a one-leaf BSP that exposes the indoor BSP collision path.
**NEW BLOCKER:** even with full apparatus, sphere goes airborne at
tick 1 with `hit=(0,1,0)` (a +Y wall normal matching no registered
geometry). 6 hypotheses tested via the harness, none isolated root cause.
Per systematic-debugging skill's "question architecture" rule, stop and
reflect. Next session: build a side-by-side comparison harness that
captures live PlayerMovementController state and diffs against the
test harness — evidence-first instead of speculation-first.
Findings doc:
[`docs/research/2026-05-21-a6-cdb-capture-findings.md`](docs/research/2026-05-21-a6-cdb-capture-findings.md).
**Evening extension v2 (2026-05-23 PM late) — apparatus shipped + root
cause identified.** Four commits (`fb5fba6``44614ab``0f2db62`
`f29c9d5`). The side-by-side comparison harness was built and exercised:
- `PhysicsResolveCapture` ships a JSON Lines writer for every player-side
`ResolveWithTransition` call. Off by default; turn on via
`ACDREAM_CAPTURE_RESOLVE=<path>`. Filtered to `IsPlayer` so NPC / remote
DR doesn't pollute.
- Two live captures from a cottage-cellar session (41K + 70K records).
- Three `LiveCompare_*` tests load 3 representative records (spawn,
on-ramp, first-cap). Spawn + on-ramp PASS bit-perfect; the first-cap
test originally FAILED with a clear divergence — and that divergence
pinpoints the root cause.
- **The cap is caused by `obj=0xA9B47900` — a landblock-baked cottage
GfxObj.** Cottage floor polygons live in this GfxObj's polygon table
(registered as a ShadowEntry), NOT in any cottage cell. The harness's
cell fixtures (0xA9B40143/146/147) don't include the cottage GfxObj,
so the harness fails to reproduce the live cn=(0,0,-1) cap.
- User's confirming observation: jumping in the cellar caps at the same
Z — purely vertical motion. This rules out every step-up / AdjustOffset
hypothesis from the prior 6-shape saga. The bug is the head sphere
hitting the cottage floor at Z=94.0 from below (math: foot Z=92.74
+ sphereHeight 1.20 = head center 93.94, head top 94.42, intersects
cottage floor Z=94.0).
- The first-cap test is now in documents-the-bug form (PASSES while
bug exists; FAILS when fix lands). Test baseline maintained at
1178 + 8 (serial run).
- 13 new cell fixtures cover the full 0xA9B4014X neighborhood (272 KB).
Findings doc (canonical pickup):
[`docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md`](docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md).
**Evening v2 follow-on — apparatus convergence SHIPPED 2026-05-23 PM.**
Two commits (`cc3afbc``97fec19`):
- `cc3afbc` adds the GfxObj dump infrastructure (`ACDREAM_DUMP_GFXOBJS`)
mirroring the existing `ACDREAM_DUMP_CELLS` pattern, with new
`GfxObjDump`/`GfxObjDumpSerializer` parallel to `CellDump`. The new
env var triggers `PhysicsDataCache.CacheGfxObj` to write the full
resolved polygon table as JSON when a listed id caches. Closes the
gap that the existing `[resolve-bldg]` probe couldn't fill (the BSP
wire site that populates `LastBspHitPoly` was never wired, so the
probe only emitted GfxObj-level metadata, not per-poly geometry).
- `97fec19` lands the cottage GfxObj fixture (`0x01000A2B`, 74 polygons,
BSP radius 13.989m matching live), the new `RegisterCottageGfxObj`
harness helper, and a minimum-stub landblock so
`TryGetLandblockContext` succeeds at the cellar XY. Harness now
reproduces the live `cn=(0,0,-1)` cap bit-perfect. The full per-field
round-trip uncovers ONE residual: live preserves +0.0266m of +X
motion through the cap (edge-slide along the cottage floor); harness
blocks all motion. Captured in
`LiveCompare_FirstCap_ResidualXMotionDivergence_DocumentsNextInvestigation`
in documents-the-bug form.
- All 21 issue-#98-relevant tests (12 harness + 4 GfxObjDumpRoundTrip +
1 new PhysicsDiagnosticsTests + 4 CellDumpRoundTripTests) pass
deterministically in isolation.
- Pre-existing test suite flakiness observed (819 failures across runs
of the same code, from PhysicsResolveCapture / PhysicsDiagnostics
statics leaking between test classes). INDEPENDENT of A6.P3 — verified
by stashing the cottage helper and reproducing the same flaky range.
Out of scope for this session; tracked as follow-up.
**Evening v3 finding (2026-05-23 PM, even later) — NEW root-cause
hypothesis identified:** the cottage-floor cap is a SYMPTOM. The actual
bug is **stale ramp contact plane causing per-tick Z drift** that makes
the cap reachable in the first place.
Evidence:
- Body's contact plane at cap = ramp's plane (n=(0, 0.7190, 0.6950),
d=-69.5035) from the live capture's `bodyBefore`
- Cellar ramp's actual world XY: X∈[129.7, 131.3], Y∈[10.19, 13.09]
(computed from the cellar cell fixture's vertex data + WorldTransform)
- Player position at cap: world (141.5, 7.22, 92.74) — **10 m away**
from the ramp in cell-local X
- `AdjustOffset` projects requested motion along the contact-plane
perpendicular. Math: dot((0.0266, -0.4022, 0), (0, 0.719, 0.695))
= -0.2892 → projected = (0.0266, -0.1943, +0.2010). **+0.201 m of
Z gain per tick**, applied because the engine believes the player
is on the slope.
- Head sphere top at cap = foot Z + 1.68 = 94.42. Cottage floor at
Z=94.00. **Head sphere exceeds cottage floor by 0.42 m** → cap fires
- If the contact plane refreshed to the flat cellar floor when the
player walked off the ramp, AdjustOffset would produce zero Z gain
(no Z component in requested motion + horizontal-plane perpendicular).
No drift, no cap.
How this question surfaced: user asked "we know how retail OPENs it
from above, how hard can it be to know how to open it from below?" —
that reframing made the question "what's different about our state
when walking up vs down?" The answer: **nothing, actually — the
cottage geometry is the same. But our contact plane is wrong.** The
six prior fix attempts were all investigating the cap-event mechanics
(step-up, slope projection at the cap, edge-slide, SidesType, +X
residual). None questioned why the contact plane was the ramp at all
when the player was 10 m from the ramp.
**Next-session move:** verify the stale-contact-plane hypothesis
chronologically against the live capture (walk the JSONL records, find
the last tick the player was on the actual ramp, quantify Z drift),
then locate the walkable-refresh code path in
`Transition.FindEnvCollisions` / `SpherePath.SetWalkable` that's
supposed to detect a new walkable polygon under the sphere and
overwrite the contact plane. Retail decomp anchor:
`CObjCell::find_env_collisions`. Full pickup prompt at the bottom of
[`docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md`](docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md).
**A6.P4 door bug — `pos_hits_sphere` near-miss recording shipped
2026-05-25 PM** (commit `3253d84`). Single-line ordering fix in
`BSPQuery.PosHitsSphere`: `if (hit) hitPoly = poly;` now precedes the
front-face cull, matching retail's `CPolygon::pos_hits_sphere` at
`acclient_2013_pseudo_c.txt:322974-322993` where `*arg5 = this` fires
on static-overlap BEFORE `dot(N, movement) >= 0 → return 0`. With this
ordering, Path 5's existing `if (hitPoly0 is not null)` near-miss
branch (`BSPQuery.cs:1869`) finally fires — `NegPolyHitDispatch`
sets `path.NegPolyHit`, the outer `transitional_insert` loop dispatches
via `slide_sphere`, and the sphere slides along walls it's touching
instead of squeezing through. The handoff hypothesized swept-sphere +
closest-considered-polygon tracking; reading retail showed both
`pos_hits_sphere` and `polygon_hits_sphere_slow_but_sure` are STATIC
tests using motion only for the cull — the fix is just the ordering.
3 new RED→GREEN unit tests in `BSPQueryTests.FindCollisions_Path5_*`
cover: overlap + parallel motion (RED→GREEN), overlap + away motion
(RED→GREEN), overlap + into motion (regression guard, already passed).
Zero regressions in full Core suite — with-fix failure set is a strict
subset of baseline (14 vs 17, the 14 are pre-existing static-leak
flakiness + 2 stale-capture document-the-bug tests). Issue #98
`LiveCompare_FirstCap_FixClosesCottageFloorCap` regression test
passes. **Needs visual verification at Holtburg cottage door inside-
out off-center ~50 cm scenario** before A6.P4 is marked complete —
sphere should block at the door surface with no squeeze-through. The
"runs a bit into the door" over-penetration symptom is hypothesized
to close together with the squeeze-through (continuous near-miss
recording while approaching a wall means the sphere slides along it
substep-by-substep rather than catastrophically penetrating then
recovering), but separate investigation if the symptom persists.
Original demo scenario (Holtburg Sewer end-to-end) is unreachable: sewer
doesn't exist on this server, and **issue #95** (portal-graph visibility
blowup) blocks any substitute dungeon. Revised M1.5 demo split into
building/cellar half (PARTIALLY ACHIEVABLE post-slice-1; cellar-ascent
blocked on #98) + dungeon half (blocked on #95). Issues in scope: #80,
#81, #83, #88, #90 (workaround removal after slice 3), **#95**
(visibility; not A6 scope), **#96** (L622 seed; retail divergence
accepted), **#97** (phantom collisions; may close as #98 side-effect),
**#98** (cellar-ascent stuck; A6.P3 slice 3 target), L-indoor,
L-spotlight, indoor sling-out (Finding 3 family with #98), and the
`TryFindIndoorWalkablePlane` definition deletion (A6.P4). **M2
("Kill a drudge") is deferred until M1.5 lands.** Full M1.5 writeup at
the corresponding block in `docs/plans/2026-05-12-milestones.md`.
**A6.P8 — Mesh-AABB-fallback phantom suppression for GfxObj-only stabs — SHIPPED 2026-05-25.**
Three commits: `f6305b1` (PhysicsDataCache.IsPhantomGfxObjSource + 3 unit tests),
`5240d65` (GameWindow.cs wire-in at line 6127), `6ca872f` (test-class doc
line-ref sync from code review). Issue #101 CLOSED — the 10 phantom stair
cyls on the Holtburg upper-floor cottage staircase are gone; collision
falls through to entity `0x40B50089` (GfxObj `0x01000C16`, `hasPhys=True`
BSP with walkable inclined polygon at `Normal.Z=0.717`, world ramp from
(111.10, 25.50, 94.00)→(107.50, 27.10, 97.50)). Visual-verified end-to-end
2026-05-25: holding W continuously climbs Z=94→97.5 over the full 45°
ramp; no phantom diagonal slides (`[cyl-test]` count on `obj=0x40B500*`
post-fix = 0 vs 7101 pre-fix). Spec:
[`docs/superpowers/plans/2026-05-25-issue-101-stairs-cyl-phantom.md`](docs/superpowers/plans/2026-05-25-issue-101-stairs-cyl-phantom.md).
**Issue #100 — Transparent ground around buildings — SHIPPED 2026-05-25 (primary acceptance);
visibility-culling follow-up handed off.** Three commits: `f48c74a` (terrain shader Z nudge,
retail `zFightTerrainAdjust = 0.00999999978` applied per-vertex in `terrain_modern.vert`),
`a64e6f2` (removed ~50 LOC of `hiddenTerrainCells` / `BuildingTerrainCells` plumbing across
LandblockMesh / LoadedLandblock / LandblockLoader / GameWindow / GpuWorldState /
LandblockStreamer + 2 dead tests), `84e3b72` (docs SHA stabilization follow-up).
Visual-verified 2026-05-25 PM at Holtburg: 24m × 24m transparent rectangles around
every cottage are GONE; ground reads as continuous cobblestone / grass. Plan:
[`docs/superpowers/plans/2026-05-25-issue-100-terrain-cutout.md`](docs/superpowers/plans/2026-05-25-issue-100-terrain-cutout.md);
predecessor research [`docs/research/2026-05-25-issue-100-terrain-cutout-handoff.md`](docs/research/2026-05-25-issue-100-terrain-cutout-handoff.md).
**Secondary finding from visual verification:** outdoor terrain mesh visible inside
cottage cellars at certain camera angles (clears when camera moves closer; gameplay
unaffected). High-confidence root cause: **indoor-cell visibility culling not gating
outdoor terrain** — same family as filed issue #78 (outdoor stabs visible through inn
floor) and #95 (dungeon portal-graph blowup). Per user direction, NOT filed as a new
issue; treated as additional evidence for #78. Next session investigates + ports
retail's `CEnvCell::find_visible_child_cell` (decomp anchor
`acclient_2013_pseudo_c.txt:311397`) and/or WB's `RenderInsideOut` stencil pipeline.
Full handoff with pickup prompt:
[`docs/research/2026-05-25-issue-100-shipped-and-culling-handoff.md`](docs/research/2026-05-25-issue-100-shipped-and-culling-handoff.md).
**Physics / collision / cell-membership — read the digest, not banners.** The #98 cellar-ascent saga, the A6.P* phase ledger, the door/step-up (P2) work, the phantom-collision fixes (#100/#101), P1 membership, the apparatus inventory, and the full 18-entry DO-NOT-RETRY list are distilled in **`claude-memory/project_physics_collision_digest.md`** (auto-loaded via MEMORY.md). Current state (2026-06-09): #98 CLOSED via the `b3ce505` stopgap (a WORKAROUND — it introduced #99 door run-through, OPEN HIGH); P2 cellar-lip FIXED (`cc4590f`, visual-gated); P1 membership matches retail (no port needed); #100/#101 CLOSED. The open debt is the per-cell shadow architecture (A6.P4) that closes #99. Read the digest before any collision/physics/membership work — it supersedes the dated A6 banners that used to live here.
**Today's pre-M1.5 baseline (2026-05-20).** Five surgical fixes
shipped to close the user-reported "logged in inside the inn, ran

44
analyze_flap_live.py Normal file
View file

@ -0,0 +1,44 @@
import sys, re, math
from collections import Counter
pat = re.compile(
r'outRoot=(\w) flood=(\d+) eye=\(([^)]+)\) player=\(([^)]+)\) '
r'rawPlayer=\(([^)]+)\) yaw=([-\d.]+)')
rows = []
for l in sys.stdin:
m = pat.search(l)
if not m:
continue
rows.append((
m.group(1), int(m.group(2)),
tuple(float(x) for x in m.group(3).split(',')), # eye
tuple(float(x) for x in m.group(4).split(',')), # player (RenderPosition)
tuple(float(x) for x in m.group(5).split(',')), # rawPlayer (physics body)
float(m.group(6)))) # yaw
print("parsed pv-input rows:", len(rows))
if not rows:
raise SystemExit
print("flood histogram (outRoot,flood)->count:", dict(Counter((r[0], r[1]) for r in rows)))
def rng(idx):
return [max(r[idx][k] for r in rows) - min(r[idx][k] for r in rows) for k in range(3)]
print(f"eye range over window (m): {[round(v,6) for v in rng(2)]}")
print(f"render-pos range over window (m): {[round(v,6) for v in rng(3)]}")
print(f"raw-phys range over window (m): {[round(v,6) for v in rng(4)]}")
print(f"yaw range over window (rad): {round(max(r[5] for r in rows)-min(r[5] for r in rows),6)}")
flips = 0
samples = []
for i in range(1, len(rows)):
a, b = rows[i-1], rows[i]
if a[1] == b[1]:
continue
flips += 1
ed = math.dist(a[2], b[2]); pd = math.dist(a[3], b[3])
rd = math.dist(a[4], b[4]); yd = abs(b[5]-a[5])
if len(samples) < 18:
samples.append(f"{b[0]} {a[1]}->{b[1]:<2} eye={ed*1000:7.3f}mm rend={pd*1e6:8.1f}um raw={rd*1e6:8.1f}um yaw={yd*1000:8.4f}mrad")
print(f"flood flips in window: {flips}")
for s in samples:
print(" ", s)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,224 @@
# Retail Divergence Register — 2026-06-12
**What this is.** The single auditable register of every known place acdream's
runtime behavior can deviate from the retail client (Sept 2013 EoR build,
`docs/research/named-retail/`). It was triggered by a week of "small things"
surfacing one at a time through playtesting — a ±5 m culling-box promise
(#119), an epsilon eye-clip + rescue (knife-edge port), a half-ported cell
walk — each of which was a *known* deviation that lived only in a code
comment until it produced a visible symptom.
**The rule.** Every intentional deviation from retail behavior gets a row in
this register. A deviation discovered without a row here is a bug twice over:
once for the behavior, once for the missing row. When you add a deviation
(new adaptation, new stopgap, new approximation), add the row in the same
commit. When you retire one (port the retail mechanism), delete the row in
the same commit.
**The review trigger.** Any unexplained visual or physics symptom → scan this
register FIRST, before instrumenting. Filter by the subsystem you're staring
at; each row's "Risk if assumption breaks" column is written as the symptom
you would observe. Most of the historical multi-session sagas (#119 vanishing
staircase, #98 cellar ascent, the doorway FLAP) began as a deviation in
exactly this register's scope.
**Kinds.**
- **Intentional architecture** — deliberate design choices we stand behind; retiring them would be a redesign, not a fix.
- **Adaptation** — required by a real structural difference (async streaming vs synchronous load, ACE vs retail server semantics, GL vs D3D). Correct *given the difference*; each carries an equivalence argument.
- **Documented approximation** — we know retail's mechanism and chose a cheaper/safer stand-in with a recorded justification.
- **Temporary stopgap** — known-incomplete; explicitly awaiting a port/phase. These are scheduled debt.
- **Unclear** — the recorded justification is missing, contradictory, or never argued. These are the most dangerous rows and head the retire list.
Dedup convention: one divergence = one row at its primary site; secondary
sites listed in parentheses. Issue numbers in **bold** are the symptom
history. Sources: 5-area code sweep 2026-06-12 +
`docs/architecture/worldbuilder-inventory.md` + `docs/ISSUES.md`
accepted-divergence entries (#96, #49, #50).
---
## 1. Intentional architecture (IA) — 14 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| IA-1 | Contact-plane pre-seed on grounded movers (**#96 ACCEPTED** per ISSUES.md) — retail's `CTransition::init` clears `contact_plane_valid`; we seed from the body's previous-frame plane | `src/AcDream.Core/Physics/PhysicsEngine.cs:919` | Removing it broke last-step stair `step_up` (`892019b`, reverted); seed propagates the body's *real current* plane, behavior matched retail in the A6.P3 gates | A stale pre-seeded plane lets `AdjustOffset` project sub-step 1 onto a plane retail wouldn't have yet — wrong slope motion / step-up acceptance right after leaving a surface | `CTransition::init`, pc:272547 family |
| IA-2 | Lateral self-heal beyond retail's keep-curr: when no candidate contains the sphere, try `FindVisibleChildCell` over the claim's stab-list before keeping the claim | `src/AcDream.Core/Physics/CellTransit.cs:912` | Reuses the recovery retail's own `AdjustPosition` performs (:280028 stab-list mode), applied at the `find_cell_list` site to heal near-miss claims without a doorway crossing | In containment-gap geometry, membership flips to a neighbouring room where retail keeps curr — wrong render root / collision cell at gap positions | `find_cell_list` keep-curr pc:308788-308825; `find_visible_child_cell` :311444 |
| IA-3 | `get_state_velocity` prefers dat cycle velocity (`MotionData.Velocity × speedMod`) over the decompiled constant; constant kept only as max-speed clamp | `src/AcDream.Core/Physics/MotionInterpreter.cs:315` | Retail's constant equals the Humanoid RunForward `MotionData.Velocity`, so both paths agree on retail dats; dat is ground truth for other MotionTables (r03 §1.3) | Where dat velocity ≠ constant, body speed differs from the retail binary — DR / observer drift on exotic creatures or modded dats | `FUN_00528960`; `_DAT_007c96e0` RunAnimSpeed |
| IA-4 | `MultiplyFramerate` omits retail's negative-factor StartFrame↔EndFrame swap (direction encoded in Framerate sign instead) | `src/AcDream.Core/Physics/AnimationSequencer.cs:129` | Our callers (ForwardSpeed updates) only pass positive factors; Advance loop handles negative framerates against StartFrame as lower bound | A future negative-factor caller (reverse playback) scales without swapping bounds — wrong frame range traversal instead of clean reversal | `FUN_005267E0`; ACE Sequence.cs L277-287 |
| IA-5 | Per-ENTITY vertex-derived AABB culling (+5 m animated-drift margin; animated entities bypass cull) vs retail per-PART dat drawing spheres | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:693` (bounds at `src/AcDream.Core/World/WorldEntity.cs:153`, `src/AcDream.Core/Meshing/GfxObjBounds.cs:14`; dead `PerEntityCullRadius=5.0f` at dispatcher :210) | Batched MDI rendering can't cheaply cull per part; bounds derive from the SAME dat vertex data that gets drawn (containment by construction — the **#119** fix, `6a9b529`; memory: feedback_culling_bounds_from_drawn_data) | Geometry escaping bounds+margin (pose drift >5 m, a hydration path skipping `SetLocalBounds`) makes the whole entity vanish on-screen — the #119 vanishing-staircase class | `CGfxObj.drawing_sphere` / viewconeCheck 0x005a09a4 |
| IA-6 | Chat scrollback 500 lines vs retail ~200 (configurable) | `src/AcDream.Core/Chat/ChatLog.cs:19` | Strictly more useful for a dev client + plugins; deliberate default | Negligible — only if a plugin/UI behavior is ever specified against retail's exact retention cap | retail chat scrollback (~200) |
| IA-7 | PhysicsScript replay keyed by (scriptId, entityId) replaces the prior instance; retail's ScriptManager linked list could hold duplicates | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs:51` | Prevents duplicate-stacking on server retriggers; flat keyed list simpler than retail's linked schedule; hedged to retail's common path | A server intentionally layering the same script on the same object shows ONE effect where retail shows several (overlapping casts/impacts) | `ScriptManager::Start` FUN_0051be40 / tick FUN_0051bfb0 |
| IA-8 | Synthetic outdoor cell node as render root (outdoor-as-cell, Option A): one unified `DrawInside` path; retail roots at a real CLandCell with a separate outdoor pipeline | `src/AcDream.App/Rendering/OutdoorCellNode.cs:23` | Eliminating the inside/outside render branch kills the indoor FLAP by construction (2026-06-07 cutover); R-A2 restored retail's per-building flood topology | Any consumer assuming the root is a real cell mis-handles the synthetic node — historically the 2↔6 flood-depth oscillation and doorway-flap class | `SmartBox::RenderNormalMode` → DrawInside, decomp:92635; `LScape::draw` 0x00506330; ConstructView(CBldPortal) decomp:433827 |
| IA-9 | One unified camera matrix for terrain — retail's separate `LScape::update_viewpoint` landscape viewpoint does not exist | `src/AcDream.App/Rendering/TerrainModernRenderer.cs:266` | Phase W T4.2: with one matrix everywhere, viewpoint-desync bugs are unrepresentable — the unification IS the correctness argument | Anything retail derives from the landcell-relative viewpoint (float precision at extreme coords, viewpoint-keyed state) has no analogue; a future port expecting it silently reads the camera | `LScape::update_viewpoint`; `LScape::draw` 0x00506330 |
| IA-10 | Transparent groups sorted back-to-front per GROUP by first-instance position (no within-group sort) vs retail per-poly BSP-order draw | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1364` (comparer :1662) | One MDI call per pass requires group-granularity ordering; per-poly sorting is incompatible with instanced multi-draw; works when group instances are spatially coherent | Spatially spread or interleaved transparent groups composite in the wrong order — popping / wrong see-through layering as the camera moves | retail per-poly BSP-order transparent draw (D3DPolyRender / PView::DrawCells) |
| IA-11 | Tier-1 cross-frame batch-classification cache for static entities (retail re-walks part arrays every frame) | `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs:12` | Issue #53 perf tier; invariants documented (keys = EntityId + OWNING-landblock hint post-**#119** fix `2163308`; invalidation at despawn/LB-unload; mutation audit 2026-05-10) | Key collision or missed invalidation serves one entity another's batches — session-sticky wrong meshes (the #119 broken-stairs/water-barrel symptom) | retail per-frame part-array classification (no cache) |
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md |
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
---
## 2. Adaptation (AD) — 27 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 |
| AD-2 | Async spawn gates replacing retail's synchronous cell load: terrain-ready hold (**#106**) + indoor cell-hydration hold (**#107**, `IsSpawnCellReady`); claims beyond NumCells skip the gate (demoted) | `src/AcDream.App/Rendering/GameWindow.cs:1008` (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode | retail synchronous cell load before SetPosition (no gate exists) |
| AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) |
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
| AD-6 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects``recalc_cross_cells`, 0x0052b420 / 0x00515a30 |
| AD-7 | Full collision exemption on ETHEREAL alone; retail requires ETHEREAL_PS **and** IGNORE_COLLISIONS_PS (ETHEREAL-alone takes the unported `obstruction_ethereal` path) | `src/AcDream.Core/Physics/CollisionExemption.cs:78` | ACE's `Door.Open()` broadcasts ETHEREAL only (0x0001000C); without the shortcut, opened doors stay solid on ACE | ETHEREAL-only targets generate NO contact where retail records contact-but-allows-passage; against a retail-semantics server the bit means something different than we implement | pc:276782 (combined gate), :276795 (obstruction_ethereal) |
| AD-8 | MoveTo arrival gate `max(minDistance, distanceToObject)`; retail tests `dist <= min_distance` only | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:161` | ACE ships the threshold in `distance_to_object` with `min_distance == 0`; without the max, monsters never "arrive" and oscillate at melee range (user-reported 2026-04-28) | A server using both wire fields with retail semantics + large `distance_to_object` makes remotes stop short of the retail arrival point | `MoveToManager::HandleMoveToPosition` chase-arrival |
| AD-9 | 1.5 s stale-destination give-up timer on remote MoveTo (retail's MoveToManager runs until cancelled) | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:136` | Liveness guard sized to ACE's ~1 Hz re-emit cadence; prevents steering toward a stale destination after a missed cancel (the run-in-place symptom) | A server emitting MoveTo slower than ~1.5 s makes remotes freeze mid-chase and snap later instead of steering continuously | MoveToManager (no equivalent timeout) |
| AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 |
| AD-11 | Useability fallback: retail blocks Use entirely on null/zero useability; we allow it (behavioral fallback in the `IsUseableTarget` caller; justification recorded here) | `src/AcDream.Core/Physics/PhysicsDiagnostics.cs:163` | ACE's seed DB ships many weenies with `_useability` unset; without the fallback doors/lifestones/creatures are un-Useable on ACE | Objects a retail-faithful server intentionally marks non-useable become useable in acdream — wrong interaction gating when the ACE-ships-null assumption stops holding | `ItemHolder::UseObject` pc:402923 |
| AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD |
| AD-13 | 1-second dedup window for identical system chat messages (retail has none) | `src/AcDream.Core/Chat/ChatLog.cs:29` | ACE dual-sends the same system text (0xF7E0 + 0x02EB) for back-compat; without dedup every line doubled (Phase J compromise) | Two genuinely distinct but textually identical system messages within 1 s collapse to one line where retail shows both | ACE dual-send 0xF7E0 + 0x02EB |
| AD-14 | Script anchor world position cached at `Play()` time; retail fires hooks via vtable dispatch on the live owning PhysicsObj | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs:55` | Core's runner is decoupled from the entity graph; documented contract pushes per-frame anchor refresh to the owning subsystem (done for AttachLocal) | Any caller that forgets the per-frame refresh strands long-running effects at the spawn position while the entity walks away | FUN_0051bfb0 per-frame hook dispatch on owner |
| AD-15 | `IsEnv` masks low-16 of the cell id (`(Id & 0xFFFF) >= 0x100`) where retail tests the full id | `src/AcDream.Core/World/Cells/ObjCell.cs:25` | Every real prefixed EnvCell id has low-16 ≥ 0x100 and every outdoor cell ≤ 0x40 — identical answers for all real dat ids, works for both bare and prefixed forms | None for real dat data; a hypothetical convention-violating id would route to the wrong (BSP vs terrain) point-in-cell logic | `CObjCell::GetVisible` pc:308215 |
| AD-16 | Building-flood gate is a CPU frustum test on each building's `PortalBounds` AABB; retail floods exactly when the shell draws and an aperture survives (no bounds constant anywhere) | `src/AcDream.App/Rendering/GameWindow.cs:7634` | Documented as the tight equivalent of the shell viewconeCheck for flood purposes (the FPS fix the Chebyshev≤1 hack approximated); per-portal admission still goes through BuildFromExterior's screen clip; missing-bounds buildings always flood (safe over-include) | A too-small/stale PortalBounds AABB means the interior never floods — doorway shows a hole/black aperture from outside (inverse of the vanishing-staircase class) | `DrawBuilding` 0x0059f2a0; `BSPPORTAL::portal_draw_portals_only` 0x53d870 |
| AD-17 | ≤8 GPU `gl_ClipDistance` half-planes per view region, degrading to a union-AABB scissor (over-include) on multi-polygon / >8-edge views; particles always scissor; scissor slices disable per-object viewcone culling. Retail CPU-clips against the exact portal polygon | `src/AcDream.App/Rendering/ClipPlaneSet.cs:23` | GL guarantees only 8 simultaneous clip planes; invariant documented: over-inclusion is safe, under-inclusion is the bug class | Fallback on complex multi-aperture views draws terrain/sky/particles/objects outside the true aperture but inside its AABB — background/interior bleed strips at doorways (the **#130** family) | `ACRender::polyClipFinish` decomp:702749; PView portal_view slices |
| AD-18 | Aperture far-Z punch is two-pass stencil-gated with invented `PunchMarkDepthBias = 0.0005` NDC; retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders (hills, closer buildings), painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Bias is depth-dependent: an occluder within ~bias in front of a distant aperture gets punched through; door-plane-hugging geometry just beyond it re-occludes the aperture (a **#108**-class regression) | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) |
| AD-19 | Under outdoor roots, ALL dynamics draw in one z-buffered final pass; retail draws objects painter-ordered per landcell inside the landscape pass (interior roots route per **#118**) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs:126` | The dynamics-drawn-LAST invariant is what makes the aperture depth punch safe (first BR-2 attempt punched after dynamics and erased the player, reverted `88be519`); z-buffer substitutes for painter's order on opaque geometry | Punch/seal correctness hinges on an ordering invariant — any pass added after DrawDynamicsLast, or alpha content needing painter order, gets erased inside apertures or composites wrong | `LScape::draw``DrawBlock` 0x005a17c0 → DrawSortCell pc:430124; `PView::DrawCells` 0x005a4840 |
| AD-20 | Camera sweep fallback seeds the eye's `AdjustPosition` from the PLAYER's cell; retail re-seats at the sought eye's own tracked cell (rest of function is a verbatim `update_viewer` port) | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:97` | acdream's camera doesn't track the sought-eye's cell separately; the eye is near the player so the player-cell stab list is assumed to cover it | An eye outside the player cell's stab-list coverage (boundary corners, cross-landblock pull-back) seats in the wrong cell — and the viewer cell roots the whole render: one-frame wrong root (flap-class flash) | `SmartBox::update_viewer` 0x00453ce0, pc:92878-92883 |
| AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/GameWindow.cs:7671` | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 |
| AD-22 | Async streamed mesh loading with point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's per-frame meshMissing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:211` | Documented convergence argument: the self-heal makes absence transient, converging the async pipeline to retail's never-absent guarantee | A missing mesh referenced OUTSIDE the dispatcher's walk (a future consumer not touching meshMissing) stays permanently invisible — the #119/#128 broken-stairs class; best case, late pop-in | retail synchronous content load (note at WbMeshAdapter.cs:211) |
| AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal |
| AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) |
| AD-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding | `src/AcDream.App/Input/PlayerMovementController.cs:1212` | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral); at elasticity 0.05 retail's landing bounce is imperceptible; sledding reverts to retail rule | Landing-reflection-dependent behavior (slope-landing momentum, high-elasticity surfaces) won't reproduce; the suppression masks the landing-snap gate fragility and could outlive its reason | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |
| AD-26 | Auto-walk arrival requires facing alignment (invented 5° arrive / 30° walk-while-turning bands); retail's check is `dist <= radius` exact | `src/AcDream.App/Input/PlayerMovementController.cs:575` | ACE does the final `Rotate(target)` server-side before the Use callback; without a local gate the body used items while facing away (user feedback 2026-05-15). Thresholds are NOT retail constants | Arrival delayed by the rotation phase; if heading convergence fights another yaw writer, `AutoWalkArrived` never fires and the queued Use/PickUp never completes | `MoveToManager::HandleMoveToPosition`; `apply_interpreted_movement` |
| AD-27 | Use/PickUp action re-sent on natural auto-walk arrival; retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Input/PlayerMovementController.cs:322` | ACE's server-side chain may have timed out by the time our body arrives; the close-range re-send hits ACE's WithinUseRadius fast-path | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius |
---
## 3. Documented approximation (AP) — 31 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| AP-1 | Snap-path Z settle: validated claims ground on their own walkable polys, but floor-less claims (thresholds, stair lips) fall through to a legacy nearest-in-Z scan over every CellSurface in the landblock; retail settles via `CheckPositionInternal``find_valid_position` | `src/AcDream.Core/Physics/PhysicsEngine.cs:614` | `find_valid_position` unported; the **#111** fix narrowed the legacy pick's blast radius (validated claims bypass it) rather than replacing it | A threshold/stair-lip snap can still pick a neighbouring cell's same-height floor by iteration order — wrong cell or Z at login/teleport arrival (the #111 clobber class) | `SetPositionInternal` :283426 → find_valid_position |
| AP-2 | Visual-AABB fallback collision shape for Setups with no retail physics data; retail emits NO shapes (phantom). **#101** fixed the GfxObj-only class; the Setup-without-shapes fallback remains | `src/AcDream.Core/Physics/PhysicsDataCache.cs:96` | Lets the player collide with decorative meshes shipping no CylSphere/part-BSP instead of walking through furniture-like props | Retail-phantom entities block movement (the **#100/#101** family), and the synthetic box gives non-retail push-out normals when it collides | `CPartArray::InitParts` (cited at PhysicsDataCache.cs:386-389) |
| AP-3 | Step-down chain triggered only when contact is invalid OR steeper than walkable; retail's `transitional_insert` OK-path ALWAYS runs it | `src/AcDream.Core/Physics/TransitionTypes.cs:1197` | Conditional preserves the observed-to-matter cases (edge departure, steep cliff-slide) without running the chain every step (per pc:273191 agent reports) | Steps where retail runs step-down despite a valid walkable contact (bump maintenance, edge-slide arming) are skipped — float-off or missed edge slides in untested geometry | `transitional_insert` OK-path pc:273191 |
| AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) |
| AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 |
| AP-6 | Analytic swept-sphere cylinder collision (XY overlap + step-over + wall-slide) instead of retail CylSphere functions via the 6-path dispatcher; A6.P6 step-over branch ports `step_sphere_up`'s clearance check | `src/AcDream.Core/Physics/TransitionTypes.cs:2601` | Claimed to match retail for the exercised cases (trunks, NPC bodies, door foot-colliders); step-over and step_up_slide fallback retro-fitted from retail when the door phantom surfaced | Unported branches (push direction, interpenetration resolution) differ from retail against cylinder entities — the phantom-collision / sticky-NPC family | `CCylSphere::step_sphere_up` pc:324516-324538 |
| AP-7 | `calc_friction` threshold 0.0 with retail's state gate missing; retail uses 0.25 gated by an undecoded state check | `src/AcDream.Core/Physics/PhysicsBody.cs:307` | Bumping the threshold without the gate hammered normal walking (3 → 0.16 m/s); as-read 0.0 kept; locomotion probably state-exempted in retail. Filed L.3c-followup | Friction engages under different conditions — post-landing slides, knockback decay, sledding speeds mismatch retail's deceleration | pc:276702-276705 (state gate + 0.25) |
| AP-8 | Remote MoveTo driver is a minimum viable subset: no target re-tracking, no sticky/StickTo, no fail-distance detector, no sphere-cylinder distance variant | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:44` | All server-side concerns the local body needn't model; ACE re-emits MoveTo ~1 Hz with refreshed origins, substituting for re-tracking | If the re-emit cadence assumption breaks (or sticky-follow packets appear), chase/flee motion visibly diverges — orbiting, overshoot, giving up where retail tracks | `MoveToManager::HandleMoveToPosition` 0x00529d80 |
| AP-9 | Fixed π/2 rad/s in-motion turn rate; per-creature TurnSpeed unwired | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:77` | Matches ACE's monster TurnSpeed default; field hook documented for the future port | Creatures with non-default turn speeds rotate at the wrong rate — facing-correction mismatch vs retail observers | run_turn_factor 0x007c8914; `apply_run_to_command` 0x00527be0 |
| AP-10 | Dry-corner water depth: retail's 0.1 m allowed sink-in collapsed to 0 | `src/AcDream.Core/Physics/TerrainSurface.cs:481` | The 0.1 offset destabilizes the feet-exactly-on-plane contact-touch check (dist > EPSILON → SetContactPlane never fires → float/fall); retail's ~10 cm sink-in is visually indistinguishable | Masks a contact-touch epsilon fragility — other water-depth values exercising the same instability could oscillate shoreline walkable validation; retail's wet/dry corner sink-in visual absent | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port) |
| AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 |
| AP-12 | Enchantment family-stacking tiebreak by largest SpellId; retail picks highest Generation, tie-broken by latest cast | `src/AcDream.Core/Spells/EnchantmentMath.cs:89` | `ActiveEnchantmentRecord` doesn't carry Generation; SpellId correlates with generation level in practice | Where spell ids don't track power within a family (or same-generation re-cast), the wrong buff wins — vital-max / stat values diverge from retail | `CEnchantmentRegistry::EnchantAttribute` 0x00594570 (pc:416110) |
| AP-13 | `ComputeDamage` is a simplified retail damage formula (no augmentations/ratings) — verified DEAD CODE as of 2026-06-04, M2 scaffolding | `src/AcDream.Core/Combat/CombatModel.cs:184` | Not on the critical path; stubbed from r02 §5 + ACE CombatManager for the future M2 predictive display | If wired into the M2 attack-bar estimate as-is, predicted numbers diverge whenever augs/ratings apply | r02 §5; ACE CombatManager |
| AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) |
| AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs |
| AP-16 | Global nearest-8 viewer-distance light selection with 10% range slack (own r13 design); retail bound D3D lights per object/cell | `src/AcDream.Core/Lighting/LightManager.cs:10` | Honors retail's 8-hardware-light constraint while fitting a global-uniform shader; 1.1 slack is anti-pop hysteresis | With >7 nearby lights, different objects are lit than retail would light (retail's per-object pick can light a far object by ITS nearest lights); pop thresholds differ | r13 §12.2 (acdream design); retail D3D 8-light constraint |
| AP-17 | Spell metadata from third-party CSV (3,956 rows, bad rows silently skipped), not the portal.dat SpellTable; Family feeds stacking decisions | `src/AcDream.Core/Spells/SpellTable.cs:10` | The dat spell-table port (obfuscated/encrypted aspects) wasn't done; CSV closed #11 fast and unblocked #6 stacking | Any CSV↔dat drift (wrong Family, missing rows) silently produces wrong buff-stacking winners and wrong panel info | portal.dat SpellTable 0x0E00000E |
| AP-18 | Radar/indicator RGBA hand-tuned from screenshots; dispatch order ports `GetBlipColor` exactly but the real `RGBAColor_Radar*` static data is unrecovered | `src/AcDream.Core/Ui/RadarBlipColors.cs:33` | Color constants live in retail static data not yet extracted; comment invites tightening when recovered | Blip/indicator hues differ subtly from retail color cues | `gmRadarUI::GetBlipColor` 0x004d76f0; RGBAColor_Radar* (unrecovered) |
| AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 |
| AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 12 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 |
| AP-21 | Entity translucency: two-pass alpha-test (N.5 Decision 2, invented 0.95/0.05 thresholds); AlphaBlend + Additive + InvAlpha all composite under (SrcAlpha, 1SrcAlpha) — retail applies per-surface D3D blend incl. true additive. EnvCellRenderer + ParticleBatcher DO switch to additive; divergence confined to GfxObj/Setup entities via WbDrawDispatcher | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1563` (+ `Shaders/mesh_modern.frag:10`; #52 amendment removed the α≥0.95 discard) | Matches original WB's model; keeps the bindless MDI pipeline at two indirect draws; spec §6 documents the falsifiable fallback — a third indirect call with `glBlendFunc(SrcAlpha, One)` (~30 min) on a magic-content regression | Additive glow/magic entity surfaces composite darker / occlude instead of brightening — the predicted regression once spell VFX density increases; α<0.05 discard drops faint fringes retail blends | SurfaceType.Additive D3DBLEND_ONE per-surface routing |
| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Rendering/GameWindow.cs:3250` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions``CPartArray::FindObjCollisions` pc:286236 |
| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + speculative turn-to-target | `src/AcDream.App/Rendering/GameWindow.cs:11120` | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required (B.6) | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for an auto-walk that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius |
| AP-24 | Jump charge fill rate guessed at 2.0 extent/s (full in 0.5 s); retail's divisor illegible (clobbered x87 in `GetPowerBarLevel`). Height→velocity formula is byte-faithful | `src/AcDream.App/Input/PlayerMovementController.cs:170` | Only time-to-fill diverges; 2.0/s matched retail muscle memory better than 1.0/s; targeted Ghidra decompile of 0x0056ADE0 already flagged (M2 research) | Every held-spacebar jump reaches a different extent than the same hold in retail — fence/gap jumps succeed/fail differently until the constant is recovered | FUN_0056ade0 (GetPowerBarLevel) |
| AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) |
| AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 |
| AP-27 | PlayerDescription trailer: GameplayOptions skipped by a 4-byte-aligned heuristic scan for a valid inventory parse; options blob captured opaque, never decoded (retail decodes + applies UI options) | `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:69` | Variable-length opaque blobs; mirrors holtburger's heuristics; follow-up issue extends when panels consume those sections | An options blob that coincidentally parses as a valid inventory (or inventory not landing at EOF) yields wrong/empty inventory+equipped at login; retail-persisted UI options silently ignored | ACE GameEventPlayerDescription.WriteEventBody; holtburger events.rs:195-218 |
| AP-28 | 3D audio falloff via OpenAL InverseDistanceClamped with picked constants (ref 2 m, max 1000 m, rolloff 1); voice pool/eviction IS cited to retail | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:146` | Stands in for retail's DirectSound-era attenuation; r05 §5.3 documents inverse-square behavior but the three AL params were picked, not ported | Sounds attenuate at a different rate — too loud/quiet at range side-by-side; gain-driven eviction comparisons inherit the skew | FUN_00550ad0 (voice pool only); r05 §5.3 |
| AP-29 | Target-indicator fallback for entities with no baked selection sphere: invented 1.5 m × scale box + 16/12 px screen floors (primary path is a faithful `GetObjectBoundingBox` port) | `src/AcDream.App/UI/TargetIndicatorPanel.cs:86` | Fallback only fires when the Setup didn't bake a selection sphere — rare in practice | Sphere-less entities get a non-retail indicator size/placement; the pixel floors prevent retail's far-distance collapse | `SmartBox::GetObjectBoundingBox` 0x00452e20; `GetSelectionSphere` |
| AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1541` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 |
| AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter |
---
## 4. Temporary stopgap (TS) — 30 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| TS-1 | PrecipiceSlide context missing — conservative stop-at-edge instead of retail's EdgeSlide → PrecipiceSlide / CliffSlide | `src/AcDream.Core/Physics/TransitionTypes.cs:1254` | Awaiting the next L.2c slice; a diagnostic records which ingredient (precipice context / steep plane / EdgeSlide flag) is missing | Player stops dead at precipice edges where retail slides along/over — visible mismatch at cliff and roof edges | retail EdgeSlide → PrecipiceSlide chain |
| TS-2 | `BspOnlyDispatch` reduces retail's `(HAS_PHYSICS_BSP_PS && !pvpTargetPlayer && !missileIgnore)` to the flag test alone (M1.5 scope: no PK, no missiles) | `src/AcDream.Core/Physics/TransitionTypes.cs:660` | Both omitted terms are genuinely false pre-M2; comment directs wiring them with PK (M2+) and missiles (F.3) | If PK or missiles land without the terms, flagged entities get BSP-only where retail tests cyl+sphere — pass-through / wrong blocking in PvP/missile interactions | `FindObjCollisions` pc:276861; HAS_PHYSICS_BSP_PS acclient.h:2833 |
| TS-3 | `FramesStationaryFall` accounting absent (`moved = true` unconditionally in the accepted-move branch) | `src/AcDream.Core/Physics/TransitionTypes.cs:3691` | Explicitly deferred to the full physics port | A body wedged falling-in-place never triggers retail's stuck-fall escalation — indefinite falling-animation wedges | CPhysicsObj frames_stationary_fall |
| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place | `src/AcDream.Core/Physics/BSPQuery.cs:2001` | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge | `BSPTREE::find_collisions` SetCollide pc:323783-323821 |
| TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2) | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry |
| TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) |
| TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 |
| TS-8 | `MagicUpdateEnchantment` (0x02C2) records carry no StatMod — mid-session buffs don't move vital max until relog (**#7/#12**) | `src/AcDream.Core/Spells/Spellbook.cs:150` | The wire parser hasn't been extended to the full ~60-64 byte Enchantment payload; PlayerDescription's block IS parsed | Vitals HUD percent reads differently from retail for the whole session after any buff cast | `EnchantAttribute` 0x00594570; holtburger magic/types.rs |
| TS-9 | MP3 (0x55) and MS-ADPCM (0x02) waves undecoded — affected sounds skipped; retail decoded both via winmm ACM | `src/AcDream.Core/Audio/WaveDecoder.cs:33` | Managed decoder (NAudio or similar) deferred; PCM covers the vast majority of ~3500 waves | Any MP3 (common for music-ish clips) or ADPCM cue plays as silence where retail plays it | winmm ACM path (r05 §2.1) |
| TS-10 | Setup lights anchored at entity root — per-light Frames not transformed through the animated part chain | `src/AcDream.Core/Lighting/LightInfoLoader.cs:31` | Per-part world transforms aren't exposed to the lighting layer; awaiting animation hook integration | A carried torch glows from the character origin, not the hand, and doesn't track swing/idle animations | LightInfo.ViewSpaceLocation per-part Frame (r13 §1) |
| TS-11 | `CreateBlockingParticleHook` consumed as a no-op; no sequencer implements the pause retail performs (consistent with the missing pending_motions chain, 2026-06-04 deep-dive) | `src/AcDream.Core/Vfx/ParticleHookSink.cs:112` | Responsibility assigned to the (future) sequencer layer when the sink was written | Animations retail pauses on a particle (cast/effect beats) run straight through — visual beat desynced from the effect | retail sequencer blocking-particle handling (r04 §6) |
| TS-12 | Animated entities' emitters use rest-pose part transforms anchored at entity root; retail attaches to the live animated part (per-tick refresh deferred; statics fixed by C.1.5b/#56) | `src/AcDream.Core/Vfx/ParticleHookSink.cs:80` (+ :20) | The renderer doesn't expose per-part world transforms to VFX; root + precomputed matrices reproduce retail placement for everything that doesn't animate | Effects hooked to animated parts (swinging hand, nodding head) emit from the rest pose / float at spawn offsets instead of tracking motion | `ParticleEmitter::UpdateParticles` 0x0051d2d4 |
| TS-13 | `DefaultScriptHook` / `DefaultScriptPartHook` / `CallPESHook` animation hooks dropped (no OnHook case); blocker comment predates PhysicsScriptRunner (C.1.5a) and may be STALE | `src/AcDream.Core/Vfx/ParticleHookSink.cs:130` | Originally blocked on PhysicsScript dat exposure; spawn-time DefaultScript firing landed via EntityScriptActivator, the animation-frame path never did | VFX retail triggers from specific animation frames (mid-animation script calls) never appear | CallPES / DefaultScript hook dispatch (r04 §6) |
| TS-14 | Setup `Flatten` ignores ParentIndex part hierarchy (treats every placement as root-local); still in production use (GameWindow hydration, SkyRenderer) | `src/AcDream.Core/Meshing/SetupMesh.cs:15` | Most Setups are flat single-level rigs where root-local equals composed; hierarchical composition deferred ("Phase 3") | Any Setup with genuinely nested parts renders them at wrong offsets — mis-assembled multi-part objects in the Flatten paths | retail Setup ParentIndex chain composition |
| TS-15 | No distance-driven degrade (LOD): always close-detail slot 0; plus the **#47** static `Degrades[0]` swap for 34-part humanoids only (structural sentinel detector) | `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs:57` (+ `src/AcDream.App/Rendering/GameWindow.cs:2608`) | LOD plumbing doesn't exist; slot 0 is correct for player + nearby NPCs; #47 closed the visible low-detail-arms bug without porting UpdateViewerDistance | Distant objects render max-detail (perf + wrong visuals where far meshes intentionally differ/hide parts); a future 34-part non-humanoid matching the sentinel gets the wrong mesh swap | `CPhysicsPart::UpdateViewerDistance` 0x0050E030; ::Draw 0x0050D7A0; ::LoadGfxObjArray 0x0050DCF0 |
| TS-16 | Click picking is Stage A only: ray-vs-fixed-radius spheres (0.71.0 m) + screen rect matched to the indicator; retail's per-polygon refine deferred (**#71**); rect-over-circle is a user-approved UX divergence | `src/AcDream.Core/Selection/WorldPicker.cs:199` | Stage B only needed if visual testing surfaces Stage-A over-picks; sphere/rect + cell-BSP occlusion adequate so far | Clicks near (not on) an entity still select it; fixed radii can mis-prioritize overlapping candidates vs retail's polygon-accurate test | `CPolygon::polygon_hits_ray` 0x0054c889 |
| TS-17 | AttackConditions suffix always empty in combat chat — formatting ported, wire bitflag not plumbed (Phase I.7 follow-up) | `src/AcDream.Core/Chat/CombatChatTranslator.cs:233` | Only the wire plumbing is missing; the holtburger-ported formatter is ready | Combat log omits "[Sneak Attack]"-style suffixes retail displays — hidden combat-mechanic feedback | holtburger chat.rs:588-595 |
| TS-18 | `LandCell.BuildingCellId` (CSortCell building bridge) declared but never populated — always null in Stage 1 | `src/AcDream.Core/World/Cells/LandCell.cs:19` | Cell graph shipped in stages; population is explicitly membership Stage 2 (the outdoor→indoor entry path the physics digest flags as unvalidated) | Cell-graph paths that should discover a building's EnvCells from the outdoor cell silently find nothing — the doorway-entry bug class | CSortCell (acclient.h:31880) |
| TS-19 | Legacy non-retail ChaseCamera (invented pitch/distance, K-fix12 airborne Z-pin) retained behind `ACDREAM_RETAIL_CHASE=0` / DebugPanel toggle; both update every frame | `src/AcDream.App/Rendering/ChaseCamera.cs:49` | Diagnostic before/after comparison path, "pending the follow-up deletion commit" | When toggled on, the eye diverges from retail's spring-arm — and the render roots at the VIEWER cell, so a non-retail eye changes the render root near doorways, masking or manufacturing flap symptoms during debugging | `CameraManager::UpdateCamera` (retail path in RetailChaseCamera.cs) |
| TS-20 | GfxObj polys drawn by dictionary iteration, not DrawingBSP traversal (**#113**): physics/no-draw polys referenced by no BSP node render as visible surfaces; the `CollectDrawingBspPolygonIds` filter exists (:1004) but is NOT applied (naive walk made doors disappear, `e46d3d9` un-applied, user-gated 2026-06-11) | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1027` | Correct fix is full BSP-traversal-order drawing per the holistic port handoff (docs/research/2026-06-11-building-render-holistic-port-handoff.md); the id filter must first be diagnosed on a door GfxObj (Issue113PhantomStairsDumpTests) | Phantom geometry visible NOW (Holtburg meeting-hall "staircase" wall ramp 0x010014C3; 8 orphan polys on hill cottage 0x01000827); draw order also diverges from retail's BSP order | D3DPolyRender drawing-BSP traversal; ConstructMesh 0x0059dfa0 |
| TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands; "we don't parse yet" comment is STALE (K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.App/Input/PlayerMovementController.cs:341` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 |
| TS-22 | `adjust_motion` not ported — backward (×0.65) / strafe (×1) translation hand-mirrored at controller call sites; `get_state_velocity` returns (0,0,0) for backward/strafe-left | `src/AcDream.App/Input/PlayerMovementController.cs:1021` | Duplication exists because LeaveGround through the unported path wiped strafe/backward jump velocity (straight-up backward jumps) | Any NEW `get_state_velocity` consumer during backward/strafe motion silently gets zero velocity (the exact prior bug class); hand-mirrored formulas can drift from the grounded block they copy | FUN_00528010 (adjust_motion); FUN_00528960 |
| TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer EdgeSlide` | `src/AcDream.App/Input/PlayerMovementController.cs:1128` | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults too | On a PK/PKLite character, local client lets players walk through where retail collides — prediction vs server disagree the moment PvP statuses enter play | PWD._bitfield acclient.h:6431-6463; pc:406898-406918 |
| TS-24 | RawMotionState command list always empty (bits 11-31 = 0) — discrete motion events (emotes, one-shots) never packed outbound | `src/AcDream.Core.Net/Messages/MoveToState.cs:34` | Discrete client-initiated motions aren't implemented yet; documented builder scope | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | RawMotionState pack (holtburger types.rs) |
| TS-25 | `FlagCurrentStyle` (stance, bit 0x2) never written to outbound MoveToState | `src/AcDream.Core.Net/Messages/MoveToState.cs:130` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | RawMotionFlags CurrentStyle 0x2 (holtburger) |
| TS-26 | UpdatePosition's four u16 sequence numbers parsed but never checked for freshness; retail rejects stale/out-of-order packets | `src/AcDream.Core.Net/Messages/UpdatePosition.cs:30` | Loopback ACE rarely reorders, so the gap is invisible in the dev loop | On a real network, a reordered/post-teleport straggler applies as-is — remotes snap backward / flicker; a teleport-vs-position race renders an entity in the wrong cell | PositionPack trailer (ACE PositionPack.cs::Write) |
| TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 |
| TS-28 | LoginComplete sent on PlayerCreate (0xF746) arrival; retail sends it after the portal-space transition animation finishes (no such animation exists yet) | `src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs:30` | acdream has no portal-space animation; "InWorld" phrasing in the file is slightly stale (trigger is PlayerCreate) | Server flips the character out of the loading state and pushes initial updates while the client may still be streaming — server logic assuming retail's load-screen duration fires against a half-initialized client | retail post-EnterWorld flow (holtburger messages.rs:391-422) |
| TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) |
| TS-30 | UI panels drawn as flat translucent rectangles + 1 px border; retail composes 9-slice dat sprite backgrounds via LayoutDesc trees | `src/AcDream.App/UI/UiPanel.cs:10` | Development visibility until the D.2b retail-look toolkit consumes the dat assets | Purely visual until D.2b — but pixel-position assumptions built against the placeholder (hit regions, layout constants) may not survive the swap to retail sprite metrics | RenderSurface 0x06xxxxxx 9-slice; LayoutDesc 0x21xxxxxx |
---
## 5. Unclear (UN) — 6 rows
These rows have a missing, contradictory, or never-argued justification.
They are the highest-priority audits: each needs either a recorded
equivalence argument (promote to AD/AP) or a fix.
| # | Divergence | Where (file:line) | Recorded justification (deficient) | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| UN-1 | `CheckOtherCells` iterates the overlap set SORTED by cell id; retail walks the CELLARRAY in build order — and the loop halts on the first non-OK result, so order is behavior-bearing | `src/AcDream.Core/Physics/CellTransit.cs:1718` | Justified only as "deterministic order for greppable probe logs" — no equivalence argument vs retail's array order recorded | A sphere straddling two cells that would each return a different non-OK result halts on a different cell than retail — different collision normal / slide direction at multi-cell straddles | `CTransition::check_other_cells` pc:272717-272798 |
| UN-2 | `GetMaxSpeed`: XML doc asserts the bare run rate is retail-correct (~5.9 m/s catch-up; the ×RunAnimSpeed multiply "a misread" → ~23.5 m/s), yet the implementation multiplies by RunAnimSpeed citing ACE as retail-verified. The two recorded justifications CONTRADICT — one describes the current code as known-wrong | `src/AcDream.Core/Physics/MotionInterpreter.cs:972` | None coherent — doc and code disagree about which behavior is retail | If the bare-rate reading is right, remote-entity catch-up runs ~4× retail speed — the multi-second 1-Hz blip / racing-remote symptom the doc itself records | `CMotionInterp::get_max_speed` pc:305127; catch-up :353122 |
| UN-3 | AdminEnvirons fog-override RGB tints hardcoded with no retail constant cited (RedFog 0.60/0.05/0.05 etc.); Snapshot replaces fog COLOR only, keeping keyframe distances on an unverified assumption | `src/AcDream.Core/World/WeatherState.cs:350` | Enum semantics cite ACE EnvironChangeType + r12 §5.2; no source for the RGB values or the color-only override scope | A server-forced fog event renders the wrong hue and/or wrong density vs what retail clients showed for the same packet | AdminEnvirons 0xEA60; ACE EnvironChangeType.cs |
| UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 |
| UN-5 | Run multiplier applied to backward (and strafe) speed while the wire reports speed 1.0; the 0.65 backward factor IS retail's, the runMul on top is justified only by feel ("~2.4× ratio felt wrong"); strafe cites holtburger, backward cites nothing | `src/AcDream.App/Input/PlayerMovementController.cs:909` | Feel fix (K-fix3); no retail citation for run-scaling backward movement | If retail does NOT run-scale backward, the local body moves up to ~2.4× faster backward than the wire declares — observers dead-reckon slower and see lag/teleport when backing up at run | adjust_motion FUN_00528010 (0.65 only); holtburger common.rs (sidestep) |
| UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) |
---
## 6. Retire-next shortlist
Temporary-stopgap + unclear rows, ordered by risk (symptom severity ×
likelihood the guarding assumption breaks). Items below the line are
phase-gated — they carry their trigger in their row and should land
WITH that phase, not before.
1. **TS-20 — GfxObj DrawingBSP traversal (#113)** — phantom geometry is visible in Holtburg RIGHT NOW; the holistic port handoff already specs the fix; first diagnose the id filter against a door GfxObj.
2. **UN-2 — GetMaxSpeed contradiction** — the file argues against its own implementation; if the bare-rate reading is right, remote catch-up runs ~4× retail. Settle with one decomp re-read + a cdb catch-up trace; cheap to resolve, expensive to leave.
3. **TS-27 — Retransmit handling** — sole hard blocker for any non-loopback play; failure mode is silent permanent stalls (entities never spawn). Also fix the stale class-doc gap list while there.
4. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis.
5. **UN-5 — Backward/strafe run multiplier** — potential ~2.4× local-vs-wire speed mismatch on a common input (S at run); one cdb session against retail answers it.
6. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output).
7. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing.
8. **TS-22 — adjust_motion port** — active bug-class generator: any new `get_state_velocity` consumer during backward/strafe silently gets zero velocity.
9. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass.
10. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check.
11. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it.
12. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger.
13. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now.
14. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
15. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
**Phase-gated (do WITH the phase, flagged here so they aren't forgotten):**
M2 combat must land TS-2 (BspOnlyDispatch terms), TS-5 (CanJump gating),
TS-23 (PK bits), TS-25 (stance in MoveToState), TS-17 (AttackConditions),
and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the
0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing).
Membership Stage 2 must land TS-18 (BuildingCellId). D.2b lands TS-30;
the audio phase lands TS-9/TS-29; the animation-hook layer lands
TS-10/TS-11/TS-12/TS-13/TS-14.
---
*Maintenance: this register is part of the definition of done for any
phase that adds or removes a divergence. Sources merged 2026-06-12:
5-area code sweep, `docs/architecture/worldbuilder-inventory.md`,
`docs/ISSUES.md` accepted-divergence entries (#96, #49, #50).*

View file

@ -0,0 +1,381 @@
# The holistic building-render port plan (Phase B) — one drawing discipline
**EXECUTION STATUS (2026-06-11, post-BR-7): BR-2…BR-7 are ALL CODE-COMPLETE
on the branch — the render arc as the fused tasks T1T4 (T1 `579c8b0` frame
order; T2 `cf8a2c3`/`529dfcf`/`88f3ce1` flood fidelity, two retail constants
refuted by the conformance gate and kept at documented tolerances; T3
`a6aec8c` viewconeCheck; T4 `4a307d3` one-gate deletions), and BR-7 (T6,
collision A6.P4) as `6ec4cde` (signed OtherPortalId gate) + `abf36e2`
(BuildShadowCellSet flood) + `dbfbf85` (per-cell architecture: flood
registration, building channel, per-cell query, b3ce505 DELETED — closes
#99) + `ca4b482` (straddle-only outside-add, A6.P5 widening + #90
stickiness removed). Of the 4 #99-era Core reds, 3 flipped green as
designed (door apparatus + tick-13558 + tick-22760's blocking invariant);
the 4th (BSPStepUp D4) + 22760's lateral-slide delta proved to be a
SEPARATE pre-existing slide-response family — filed #116, D4 skipped with
the reference (probes show the cell-set layer innocent). Suites: Core
1416/0/2skip, App 225, UI 420, Net 294.
**T5 EXECUTED 2026-06-11 (the single comprehensive user gate) — PARTIAL
PASS.** ✅ Confirmed by the user: doors block both ways incl. off-center
(#99 visual), cellar descent/ascent clean + #108 grass-sweep GONE, inn
2nd floor clean (#97 closed), interiors stable through doorways incl.
edge-on, #109 far-door oscillation GONE, formerly-popping stairs now
STABLE at all ranges (the distance-pop class is dead). ❌ Remaining —
four filed render artifacts: **#117** aperture-shaped see-through
(doors/interiors through terrain hills + through nearer buildings — the
punch erases occluder depth), **#118** character clipped+vanishes for a
moment on house exit, **#119** old-tower stairs partially invisible +
extraneous barrel (pre-existing; `[up-null]` permanently-invisible mesh
lead in the T5 log), **#120** `[pv-ERROR]` in-place-propagation
convergence tripwire at depth 128 on the cottage cells (self-detected
T2 invariant break — investigate first). Rain-indoors not verifiable
(clear weather). NEXT: fix #120#117#118#119 at the mechanism
level, then a focused re-gate on just those spots.**
**Status: APPROVED + AMENDED (2026-06-11). EXECUTION DIRECTIVE CHANGED BY THE
USER: "I don't care if it is non-playable… I want everything ported, then we
test."** The per-phase playability constraint and per-phase user visual gates
are DROPPED. BR-2 through BR-6 execute as ONE continuous port (the fused
render discipline), with build + unit/conformance tests green at every commit
(engineering hygiene, not gates), and **ONE comprehensive visual test pass at
the end**. Rationale: the first BR-2 attempt failed precisely because the
phase slicing cut retail's frame order in half (the punch shipped without
entities-drawn-last and erased characters in apertures — reverted `88be519`);
the installment-must-be-a-complete-retail-behavior rule replaces the
playability rule. BR-7 (collision) runs as an independent track; BR-8b
(lighting) still wants the verification resume first.
Companion to the Phase A comparison:
[`docs/research/2026-06-11-building-render-acdream-vs-retail-comparison.md`](../research/2026-06-11-building-render-acdream-vs-retail-comparison.md)
(evidence appendices in
[`docs/research/2026-06-11-holistic-map/`](../research/2026-06-11-holistic-map/)).
Mandate: *"one solution that works every time I walk to a new landblock and
walk into a dungeon"* (2026-06-11).
---
## 0. The invariant (what "one drawing discipline" means, retail-cited)
Every phase below moves us toward — and no phase may move us away from — this
frame shape, which is retail's (Ghidra-cited in the comparison doc §2):
1. **Geometry is flattened at load** into surface-batched meshes (we already
do this). World geometry is **never geometrically clipped at draw time**.
2. **Untextured (solid) surface batches never draw** on building shells and
cell meshes (`skipNoTexture`); they do draw on plain objects.
3. **Portal polygons are not wall geometry.** They exist per frame only as
(a) flood admission tests (`ConstructView`: eye-side ε=0.0002 → clip vs
current view → cell loaded) and (b) **invisible depth writes** — far-Z
*punch* before an interior draws through an aperture; true-depth *seal*
on portals to the outside after the landscape draws.
4. **Cells draw whole, far→near, once** (frame stamp); the z-buffer plus the
punches/seals produce pixel-exact apertures.
5. **Objects and particles are culled per portal view** (sphere vs the view's
edge planes — `viewconeCheck`), never clipped, never scissored.
6. **One visibility computation feeds everything** — the PView flood. No
second BFS, no parallel gate, no distance constants in admission.
## 1. Keep-list (the code worth saving — explicitly not touched/rewritten)
- **Mesh pipeline**: `ObjectMeshManager` flatten + global VAO + bindless MDI
(`WbDrawDispatcher`) — retail-faithful architecture, confirmed by the
`ConstructMesh`/`RemoveNonPortalNodes` finding.
- **The flood port**: `PortalVisibilityBuilder` (homogeneous clipper, side
tests, reciprocal clip, exact-match skip) + conformance gates
(`CornerFloodReplayTests`, `Issue113MeetingHallFloodTests`) — BR-4 adjusts
constants/heuristics, it does not rewrite the clipper.
- **Membership** (P1 9/9 golden) + **straddle gate** (`414c3de`) +
**camera collision sweep** (verbatim `update_viewer`) + **znear=0.1** +
**#105 texture flush** + **two-tier streaming** + spawn/snap validation
(#107/#111/#112).
- Diagnostics/probes and the dat dump harness.
The M0 freeze list is superseded *for rendering only* by the 2026-06-11
mandate; nothing outside building/interior render + interior collision is in
scope.
## 2. Phases
Ordering rule: each phase lands green (build + full suites + named visual
gate) and the client stays playable after every phase. Conformance pins come
from the dat harness + the flood replay harnesses; retail constants are cited
inline when ported.
### BR-1 — The surface gate — ✅ RESOLVED AS ALREADY-EQUIVALENT (2026-06-11, execution day 1)
**Premise falsified before implementation (the BR-1 pre-check,
`ReplicateProductionEmission_OnPortalFills`):** acdream **already suppresses
every portal fill** — all four extraction paths skip `Stippling.NoPos`
positive sides (`ObjectMeshManager.PrepareGfxObjMeshData:1046`,
`PrepareCellStructMeshData:1394`, `CellMesh.Build:44`, `GfxObjMesh.Build:71`),
and the Holtburg fills have no negative surface. The planned "draw-time
surface gate" has nothing to gate.
**What shipped instead — the equivalence pin**
(`StipplingSurfaceEquivalenceTests`): 2,607 polys across 13 building models +
13 environments, **zero violations both directions** — `NoPos ⇔ untextured
surface`. Our build-time skip is therefore *proven equivalent* to retail's
draw-time `skipNoTexture` rule on this content; the
`portal-poly-suppression-criterion` divergence closes as
equivalent-with-proof. The pin fails loudly if future content breaks the
invariant (the cue to implement the draw-time gate then).
**Consequences (the honest part):**
- The **#113 phantom residual is NOT GfxObj fills** — it cannot be, they
never reach a vertex buffer. The "root cause #2" attribution from the
e46d3d9 session is corrected; the e46d3d9 user-gate observations (filter
removed phantom/doors) were confounded — the filter was a provable mesh
no-op on both shells and door parts.
- The phantom's plausible true sites are cell-side: flood-admitted stair
CELLS drawn with a pass-all slice when the assembler hands them no slot
(`RetailPViewRenderer.cs:71` draws ALL visible cells; `NoClipSlice`
default), and/or stair-cell STATICS drawn unclipped + un-viewcone'd by
design (`object-lists-skip-portal-view-gate`, confirmed). **BR-2's first
task is a 10-minute probe at the hall bisect spot pinning which** —
the closure moves to BR-2/BR-3 (shells) and BR-5 (statics).
- **Closes:** the `portal-poly-suppression-criterion` divergence (as
proven-equivalent); #113's closure moves to BR-2/BR-3/BR-5.
- **Shipped:** the pre-check + equivalence pin tests; no production code
(none needed).
### BR-2 — Aperture depth machinery (punch / seal / clear)
**What:** port the invisible depth writes:
(a) wire `DrawExitPortalMasks` (today an unwired no-op) as a depth-only draw
of each outside-leading portal polygon, software-clipped to its view slice
(the `ClipToRegion` math already exists), at the portal's **true projected
depth** (retail `maxZ2`) — after the landscape slices, indoor roots;
(b) add the **far-Z punch** (retail `maxZ1`) on building-aperture flood
success on the outdoor + look-in paths, before the interior cells draw;
(c) replace the per-slice scissored `ClearDepthSlice` AABB clear with
retail's discipline: one full depth clear between the outside stage and the
interior stage, gated on whether any seal was drawn (`portalsDrawnCount`);
(d) on the look-in path, draw interior-through-aperture **before** the shell
mesh (retail `DrawBuilding` order) so the shell's depth closes everything
outside the punch.
- **First task (from BR-1's falsification):** the 10-minute probe at the
hall bisect spot — when the phantom is visible, log per stair cell
(0x100..0x106) whether it drew with a real clip slot or the pass-all
`NoClipSlice`, and whether its statics drew — pinning the phantom's true
draw site (shells → fixed here/BR-3; statics → BR-5).
- **Closes:** #108 (outdoor terrain sweeping across the upstairs door — the
missing true-depth seal is the confirmed `missing-portal-depth-fence`
divergence); the outdoor-root depth-discipline gap; part of #109; the
#113 phantom residual if the probe pins it on pass-all shell slices.
- **Acceptance:** cellar↔main-floor walk shows no grass sweep (user gate);
phantom-spot check at the hall (user gate, replaces the old BR-1
acceptance); new harness fact: seal depth = portal plane depth inside the
clipped aperture polygon (GL readback test or probe assertion); suites
green.
- **Size:** ~3 commits (~80 lines of GL + clipper reuse per the area
estimate, plus the clear re-shape and order swap).
### BR-3 — Retire the geometric shell chop; whole-shell far→near draws
**What:** remove `gl_ClipDistance` as the *enforcement* mechanism for cell
shells (both the outdoor-scoped enable from `927fd8f`/`9ce335e` and the
never-enabled indoor half — i.e. #114 closes by *deleting* the chop, not
perfecting it). Shells draw whole, far→near per `OrderedVisibleCells`
(already the order), drawn-once. Clip regions remain for admission, punch
shapes, and (BR-5) object culling. The landscape-through-aperture pass keeps
its per-slice plane clip for now (open Q: `LScape::draw` internals) — revisit
after BR-2 proves the seal protects terrain.
- **Closes:** #114 (chopped stairs / vanished candle area / barrel-through-
wall were artifacts of clipping geometry retail never clips) — jointly
with BR-2. Removes the 8-plane budget + slot-0 PASS-ALL as load-bearing
for shells.
- **Acceptance:** meeting-hall interior + multi-room cottages render
unchopped from indoor and outdoor eyes (user gate vs the #114 screenshot
set); phantom stays gone (BR-1 unaffected); flood replay gates green.
- **Order constraint:** must not land before BR-2 (the depth fence replaces
the chop's job at apertures).
- **Size:** ~2 commits (mostly deletions + the draw-order assertion).
### BR-4 — Shell-draw-driven floods + flood fidelity
**What:** make the building's own draw the flood trigger, retail-shaped:
pair the shell GfxObj's `PortalRef.PortalIndex` with its `BuildInfo.Portals`
entry (the `outdoor_portal_list` correspondence) and, when a shell survives
the cull for a view slice, run each aperture through the ported
`ConstructView(CBldPortal)` chain under that slice. Then remove the
non-retail machinery the trigger replaces: the 48 m seed constant, the
Chebyshev≤1 candidate gather, the `EyeInsidePortalOpening` full-view rescue;
adopt retail constants (ε=0.0002; in-plane rejects for building portals);
add the 1-px screen-space vertex dedup to `ClipToRegion` output (retail's
fixpoint floor) and switch late view growth to in-place propagation
(`AddToCell`/`FixCellList`/`AdjustCellView` shape), removing the
`MaxReprocessPerCell=16` cap; make `MergeBuildingFrame` union views instead
of first-wins and retire single-slot consumers (`CellIdToSlot[0]`); bind
nested floods to their originating slot (the `building_view` latch).
- **Closes:** #109 (binary 48 m pop + first-wins view loss + missing punch
are its named mechanisms); the flood-stability family (edge-on doorway
residuals); enables interior-visible-through-window parity.
- **Acceptance:** flood replay harnesses extended: (a) building flood
triggers with no distance constant — admission matches the
clip-survival rule across an eye sweep; (b) two-aperture cell holds two
views; (c) growth propagates without the cap on a portal-dense fixture;
#109 spot user gate; suites green.
- **Size:** ~45 commits (trigger + pairing; constants/dedup; growth
in-place; merge union; deletions).
### BR-5 — Per-view object + particle culling (viewconeCheck)
**What:** port `Render::viewconeCheck`: per view slice, lift the per-edge
eye planes (each NDC edge + the eye defines a plane — the `view_vertex.plane`
analog) and sphere-test every entity and emitter against the slice before
draw; route particles through the same gate and the same clip/punch
discipline (delete the `BeginDoorwayScissor` AABB path); fix the
outdoor-root unattached-emitter drop; gate the weather pass on
`is_player_outside` (player cell, not viewer root).
- **Closes:** particles-through-walls (candle flames in other buildings);
rain-indoors-through-doorways; the neighbour-room object over-inclusion
half of the old #114 report.
- **Acceptance:** flame-through-wall spot at Holtburg (user gate); a
conformance fact pinning sphere-vs-slice culling on a fixture; no
regression in entity draw counts outdoors (perf probe within noise).
- **Size:** ~3 commits.
### BR-6 — One gate: consolidate visibility + delete legacy paths
**What:** make the PView flood the only visibility computation:
remove the per-frame ACME BFS (`CellVisibility.ComputeVisibilityFromRoot`)
by folding its remaining consumers (lighting indoor flag etc.) onto PView/
membership outputs; delete or quarantine the confirmed legacy remnants
(`InteriorRenderer`, `IndoorDrawPlan` consumers of the old path, the
`clipRoot==null` second render branch, the dormant exit-mask wiring once
BR-2 rewires it, duplicate frustum implementation); one frustum, one
center/radius window.
- **Closes:** the `dual-live-visibility-computations` inconsistency class
(the one-gate rule, `feedback_render_one_gate`); removes the surface area
where two gates disagree (future flap-class bugs).
- **Acceptance:** gate-audit re-run shows ONE visibility computation per
frame; every deletion verified by a launch + the visual gate set; suites
green.
- **Size:** ~3 commits, mostly deletions (each independently revertable).
### BR-7 — Interior collision: per-cell shadow lists (A6.P4, verified) — ✅ CODE-COMPLETE 2026-06-11 (`6ec4cde`+`abf36e2`+`dbfbf85`+`ca4b482`; visual confirmation rides T5)
**What:** ship the A6.P4 architecture with the investigation's corrections:
registration builds the cell set by sphere-overlap portal flood (not an XY
grid; crosses landblocks), per-cell `shadow_object_list` iteration on the
query side (`CheckOtherCells` runs env AND shadow objects per other cell),
buildings dispatch through a per-LandCell building channel
(`CSortCell.building` shape), `OtherPortalId` widened to signed with the
`>= 0` gate (sign-extension Ghidra-proven). Then remove the `b3ce505`
stopgap, the A6.P5 `hasExitPortal` widening, and the #90 stickiness
workaround.
- **Closes:** #99 (doors block from both sides), very likely #97; retires
three flagged workarounds.
- **Acceptance:** A6.P4 spec acceptance (doors block both ways at Holtburg
inn + cottages; #98 cellar ascent stays fixed — `CellarUp` harness green);
capture/replay comparison on the door apparatus; suites green.
- **Size:** the A6.P4 spec's estimate stands (~5 commits); independent of
BR-2..BR-5 — may run in parallel with them.
### BR-8 — Feel tier: camera, lighting, LOD (post-discipline polish)
- **BR-8a Camera (#115, verified root cause; can land any time):** damp the
sought eye FROM the published collided viewer each frame (retail
`PlayerPhysicsUpdatedCallback` shape) and apply the computed player fade
over the 0.45→0.20 m band. Acceptance: cramped-interior turn feel (user
gate). ~12 commits.
- **BR-8b Lighting (pending verifier confirmation):** interior sun mask
(never sun-light interiors), static cell-light burn-in (all lights, not
8-nearest), viewer light, per-object light selection, surface
luminosity/diffuse. Acceptance: side-by-side interior look vs retail
screenshots. Phase-sized; spec before code.
- **BR-8c LOD + dedup (low):** per-part degrade selection beyond humanoids;
frame-stamp draw dedup. Optional per-cell interleave for draw-order parity
is explicitly NOT planned (z-buffer makes it unnecessary; revisit only on
evidence).
- **Picking refinements** (all-low area): defer; file as issues when the
port changes what is clickable.
## 3. What this plan deliberately does NOT do
- No per-frame BSP traversal of ordinary geometry (retail doesn't either).
- No rewrite of the mesh/MDI pipeline, the flood clipper, membership, or
streaming (keep-list).
- No `leaf_cells`/`CPartCell` port (path dormant in the 2013 binary — needs
runtime proof first).
- No transparency-sorting work yet — that area's map is still re-running;
fold its findings in as a BR-9 candidate after review (the AlphaList
deferral machinery is already decompiled in the Area 1 file).
## 4. Explicitly out of scope — tracked follow-ups (NOT covered by BR-1…BR-8)
Completing BR-1 through BR-8 lands the building/interior **drawing
discipline** and the collision rearchitecture. It does **not** cover the
items below. They are named here so the boundary of what the campaign
delivers is written down, not assumed — each becomes its own roadmap item or
issue, none blocks BR-1…BR-8.
- **FU-1 — Transparency / draw-sorting (→ BR-9 candidate).** Retail's
`DrawSortCell` + AlphaList deferral (decompiled in
`2026-06-11-holistic-map/wf1-gfxobj-draw.md`) governs water surfaces,
translucent windows, and alpha-blend ordering. The area's *map never
completed* (agent hit the token limit), so there are no divergences yet —
scope it before promoting to BR-9. **Severity: medium; user-visible as
wrong window/water compositing.**
- **FU-2 — Dungeon visibility scaling (#95).** The 8 phases are
Holtburg-building-shaped. Dungeons share the EnvCell/portal discipline so
they benefit *automatically*, and BR-4's tighter flood admission
(no-distance-constant + screen-clip rejection + cell-loaded gate)
**plausibly** shrinks #95's 135-cells/frame blowup — but #95 is a
disconnected-landblock *seeding* problem that BR-4 is not guaranteed to
fix. **Re-measure #95 after BR-4/BR-6 land; if still blown, it needs its
own phase.** Do not assume the building port closes it.
- **FU-3 — Distance LOD / degrades (= BR-8c, optional).** Per-part degrade
selection beyond humanoids; far models stay base-detail until picked up.
- **FU-4 — Picking refinements** (4 low-severity divergences,
`wf2-picking-selection.md`). Defer; file as issues if/when the port
changes what is clickable (e.g. building shells, baked fills).
- **FU-5 — The ~30 open questions** live in the comparison doc §6
(`2026-06-11-building-render-acdream-vs-retail-comparison.md`). The
load-bearing ones are referenced inline in the phases that consume them
(e.g. `LScape::draw` clip behavior for BR-2/BR-3, the near-W constant,
`DrawPortal` mode-3 seal-on-failure for unstreamed interiors); the rest
are pinned during implementation, not before.
- **FU-6 — Verification top-up.** ~36/76 divergences remain UNVERIFIED (the
overnight resume was stopped to preserve budget; both runs are resumable
by ID — see comparison §7). Run a cheap resume before **BR-8b lighting**
scoping (the one phase that leans on unverified rows) and before promoting
FU-1 to BR-9.
## 5. Sequencing summary
```
BR-1 (surface gate) — ✅ RESOLVED as already-equivalent (pin shipped,
no production code; #113 closure moved to
BR-2/3/5 — see BR-1 section)
BR-2 (depth punch/seal) — FIRST implementation phase; opens with the
phantom-site probe; enables BR-3
BR-3 (delete shell chop) — closes #114 with BR-2
BR-4 (draw-driven floods) — closes #109; flood fidelity
BR-5 (viewconeCheck) — particles/objects through the same gate;
closes the phantom if it is statics-side
BR-6 (one gate + deletions) — consolidation after the discipline is in
BR-7 (collision A6.P4) — independent track; may interleave with BR-2..5
BR-8 (camera/lighting/LOD) — feel tier; BR-8a may land early
```
Every phase: `dotnet build` + full suites green, conformance pins added with
retail citations, named user visual gate, roadmap/ISSUES updated in the same
session, and the render digest updated when a phase closes one of the named
bugs.
## 6. Approval asks
1. Approve the plan shape + ordering (BR-1 → BR-8, BR-7 parallel-capable).
2. Approve the deletions implied by BR-3/BR-6 (shell-chop enforcement,
ACME BFS visibility, legacy render branches) — all on the strength of the
cited evidence that retail has no counterpart.
3. Note the verification caveat: ~36/76 divergences still carry UNVERIFIED
(resume in flight); BR-1..BR-3's load-bearing claims are either verified
or dat-confirmed locally, so approval need not wait on the rest.

View file

@ -0,0 +1,203 @@
# P2 pickup handoff — door / building-shell collision = BSP Path 5 grounded step-up
> **🔴 CORRECTED 2026-06-04 — the localization below (the step-up CLIMB) was WRONG; B1 is FIXED.**
> An `ITestOutputHelper` capture of B1 (xunit swallows `Console.WriteLine`) proved the climb code
> (`find_walkable`/`AdjustSphereToPlane`/`step_sphere_down`/`DoStepUp`/`DoStepDown`) is **correct** and
> matches ACE exactly. The real B1 bug was the **A6.P4 near-miss dispatch in `BSPQuery.FindCollisions`
> Path 5 (Contact branch)**, which diverged from retail three ways: (1) recorded a near-miss
> `NegPolyHit` **unconditionally** — retail gates both `set_neg_poly_hit` calls behind
> `if (num_sphere > 1)` (`acclient_2013_pseudo_c.txt:323852`); (2) checked the foot near-miss before
> the head's (retail checks the head/sphere1 first); (3) reversed the `neg_step_up` mapping (retail:
> head index0→FALSE/slide, foot index1→TRUE/step-up, per `SPHEREPATH::set_neg_poly_hit` :323279).
> For B1's single foot sphere the spurious near-miss → outer `!NegStepUp → Collided` → revert → the
> mover wedged at x=0.1, never reached the wall to step up. **Verbatim fix committed (`abbd761`):**
> the gate+order+mapping now match retail; B1 climbs (foot→(0.6,0,0.25)); the Holtburg door blocks
> faithfully (slab `(0,-1,0)` normal) when the scenario has a real floor.
>
> **Remaining red (NOT simple flips — all separate from B1):**
> - `Apparatus_Grounded_50cmOffCenter` — its tick-0 `(0,0,1)` "block" is a **synthetic-test artifact**:
> the apparatus sets `terrain=-1000` so the only BSP is the door slab; the contact-maintenance
> step-down finds no floor underfoot → false Collided/revert, then the mover walks through. With a
> real floor (`terrain=0`) the door blocks faithfully at Y≈11.5 with `(0,-1,0)`. Fix = give the
> grounded test a real floor + assert the block normal is the door's ±Y (NOT the tick-0 `(0,0,1)`
> contact-maintenance hiccup, which is a separate cold-seed first-frame artifact). Do **NOT** just
> flip to `Assert.True(blocked)` — that blesses the artifact.
> - `LiveCompare_DoorOffCenterWalkthrough_Tick13558` / `_DoorBlocksFromOutside_Tick22760` — compare
> against captured **buggy-live** positions; a correct fix makes the harness diverge (blocks earlier).
> Re-baseline to the corrected behavior or retire as documents-the-bug.
> - `D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames`**airborne (Path 6)**, a separate
> sliding-normal-persistence issue, unrelated to the Path 5 grounded near-miss. Pre-existing.
>
> See `memory/project_p2_door_stepup_findings.md`. **Next: USER VISUAL GATE** (walk through a cottage
> door cleanly; step up a stair) — the authoritative P2 acceptance. The original (wrong) analysis is
> retained below for the record.
> **Canonical pickup for the next session.** Branch `claude/thirsty-goldberg-51bb9b`
> (do NOT branch/worktree; do NOT push without asking; NEVER `git stash`/`gc`). PowerShell on
> Windows; launch logs are UTF-16. Read this FIRST.
## State both altitudes
- **Milestone:** M1.5 — Indoor world feels right.
- **Effort:** the VERBATIM spatial-pipeline port (master plan:
`docs/superpowers/specs/2026-06-03-verbatim-spatial-pipeline-port-master-plan.md`).
- **P1 (membership) = DONE.** Proven to already match retail; the "0/11 lag" was a cdb capture
artifact. Merged to `main` + pushed to both remotes (HEAD `f0d37d8`). See
`docs/research/2026-06-03-p1-membership-swept-advance-handoff.md` (RESOLVED banner) +
`memory/project_retail_membership_criterion.md`.
- **P2 (door / building-shell collision) = IN PROGRESS, root cause LOCALIZED.** The fix is the next step.
- **Next concrete step:** read+compare acdream `find_walkable`/`step_sphere_down` vs retail
`BSPTREE::step_sphere_down` (pc:323665) + `find_walkable`, pin the step-up CLIMB divergence, and
drive `B1_GroundedMover_LowStep_StepsUp` + the door apparatus RED→GREEN.
## TL;DR (the P2 finding)
All **5 failing Core tests** localize to **BSP Path 5 (the grounded `Contact + StepSphereUp` branch)**:
| Test | Symptom |
|---|---|
| `BSPStepUpTests.B1_GroundedMover_LowStep_StepsUp` | grounded mover wall-slides a **walkable 0.25 m step** instead of stepping up (`CurPos.Z` stays 0). The cleanest isolation of the bug. |
| `BSPStepUpTests.D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames` | airborne tall-wall sliding-normal count (`Expected: 2`) — Path 4/5 sliding-normal family. |
| `DoorCollisionApparatusTests.Apparatus_Grounded_50cmOffCenter_FrontApproach_DocumentsBug` | synthetic door + grounded off-center: now blocks at tick 0 with an `(0,0,1)` up-normal + goes airborne (Path 5 step-up artifact, not a faithful door block). |
| `DoorBugTrajectoryReplayTests.LiveCompare_DoorOffCenterWalkthrough_Tick13558` | replay of captured live tick diverges from the engine (documents-the-bug). |
| `DoorBugTrajectoryReplayTests.LiveCompare_DoorBlocksFromOutside_Tick22760` | same, outdoor-block tick. |
These have been the documented baseline RED set for a while (they are P2's target). They did NOT
regress this session — P1's work only touched conformance tests + docs.
## Root cause — PRECISELY localized (the whole upstream chain is verified faithful)
For B1 (the cleanest case), the path is reached + dispatched correctly; the divergence is deep in the
climb. Verified faithful and ruled out this session (DO NOT re-investigate these — see DO-NOT-RETRY):
1. **Path 5 dispatch is reached.** `BSPStepUpFixtures.MakeGroundedTransition` sets `State = Contact |
OnWalkable` + `StepDown=true` — but that `StepDown` is `ObjectInfo`'s flag; the Path 3 dispatch
gates on the `SpherePath.StepDown` flag (only set during a step-down probe), so the **main sweep
correctly lands in Path 5's `Contact` branch** (`BSPQuery.cs:1814`).
2. **Recursion guard passes.** `if (engine is not null && !path.StepUp && !path.StepDown) return
StepSphereUp(...)` (`BSPQuery.cs:1848`) — on the main sweep both `SpherePath` flags are false →
`StepSphereUp``DoStepUp` IS called on the wall hit.
3. **`DoStepUp` (`TransitionTypes.cs:3254`) = faithful port of retail `CTransition::step_up`
(pc:273099).** Same structure: clear contact-plane, `stepDownHeight = OnWalkable ? StepUpHeight :
0.04`, `walkableAllowance = OnWalkable ? GetWalkableZ() : LandingZ`, call `DoStepDown(...)`, return
its result. (acdream adds a restore-contact-plane-on-failure block — benign.)
4. **`DoStepDown` (`TransitionTypes.cs:3074`) = faithful port of retail `CTransition::step_down`
(pc:272946).** Skips the down-offset when `StepUp` is set, runs `TransitionalInsert(5)`, accepts
iff `OK && ContactPlaneValid && ContactPlane.N.z >= walkableZ`, then placement-validates.
**So the divergence is INSIDE the step-up climb:** `DoStepDown``TransitionalInsert(5)` → Path 3
`step_sphere_down`**`find_walkable`'s upper-floor find + sphere-up-adjust when `sp.StepUp=true`**.
It fails to locate/lift onto the 0.25 floor within the 0.30 budget → `DoStepDown` returns false →
`StepUpSlide` → wall-slide → `Z=0`. The retail oracle for the climb is `BSPTREE::step_sphere_down`
(`@ 0x53a210` pc:323665) + `BSPNODE/BSPLEAF::find_walkable` + `adjust_sphere_to_plane`.
acdream code map: `BSPQuery.StepSphereUp` (`:1372`), `BSPQuery.step_sphere_down` (`:1206`),
`BSPQuery.find_walkable` (`:693`), `BSPQuery.AdjustSphereToPlane` (search it), `Transition.DoStepUp`
(`:3254`), `Transition.DoStepDown` (`:3074`).
## ⚠ TOOLING NOTE (cost me a probe cycle — don't repeat)
**xunit swallows `Console.WriteLine`.** The built-in `ACDREAM_DUMP_STEPUP=1` trace (in `DoStepUp`)
and the `[step-walk]` probe (`PhysicsDiagnostics.ProbeStepWalkEnabled`) both print via `Console`
they do NOT surface in `dotnet test ... -l "console;verbosity=detailed"`. The tests that DID show
output used `ITestOutputHelper` (`_out.WriteLine`). So to trace which climb condition fails
(`TransitionalInsert(5)` result / contact-plane / `N.z` / placement), **add an `ITestOutputHelper`-based
trace to B1 (or a focused harness) — don't rely on the `Console` probes inside the engine.**
## DO-NOT-RETRY (verified faithful this session)
1. Path 5 dispatch / the Contact-branch reachability — confirmed B1 reaches Path 5.
2. The recursion guard / `StepSphereUp` call — confirmed `DoStepUp` is called.
3. `DoStepUp` vs retail `step_up` — faithful, ruled out.
4. `DoStepDown` vs retail `step_down` — faithful, ruled out.
The bug is downstream in `find_walkable`/`step_sphere_down`'s **step-up adjust**. Start there.
## Next steps (evidence-first — the door saga burned many SPECULATIVE fixes; do NOT repeat)
1. **Read+compare** acdream `BSPQuery.find_walkable` (`:693`) + `step_sphere_down` (`:1206`) +
`AdjustSphereToPlane` against retail `BSPTREE::step_sphere_down` (pc:323665) + `BSPNODE/BSPLEAF::
find_walkable` + `adjust_sphere_to_plane`. Focus on the `step_up==1` path: how retail lifts the
sphere onto a step within `step_down_amt`, and where acdream fails to.
2. **Instrument B1 with `ITestOutputHelper`** (Console is swallowed — see tooling note) to pin which
condition fails: does `TransitionalInsert(5)` return OK? is `ContactPlaneValid` set? is the landing
`N.z >= walkableZ`? does placement (`TransitionalInsert(1)`) reject? `B1` is sub-second, headless.
3. **Fix the climb verbatim** (cite the decomp anchor), drive `B1` GREEN, then `B2` (must still
block the 5 m wall), then the door apparatus (`Apparatus_Grounded_50cmOffCenter…` flips to
block-not-walkthrough → rewrite its assertion to `Assert.True(blocked) && pos.Y < 12.0`), then the
2 `LiveCompare` ticks, then `D4`.
4. **Definitive cross-check if the decomp is ambiguous:** cdb-attach to retail at a Holtburg cottage
doorway, break on `CTransition::step_up`/`step_down`/`BSPTREE::step_sphere_down`, walk a low step +
the door, capture what retail does. PDB MATCHES; tooling in `tools/cdb/` (CLAUDE.md "Retail debugger
toolchain"). Needs the user's retail client up + walking.
5. **User visual gate:** at a doorway, walk through cleanly (foot Y stable, no oscillation), walls
block; step up a low step (cottage stair) climbs.
## Test baseline (going into the P2 fix)
Core **1309 pass / 5 fail / 1 skip** — the 5 are exactly this P2 target (`Apparatus_Grounded…`,
`LiveCompare_DoorOffCenterWalkthrough_Tick13558`, `LiveCompare_DoorBlocksFromOutside_Tick22760`,
`BSPStepUpTests.D4…`, `BSPStepUpTests.B1…`). Conformance 60 pass / 1 skip / 0 fail. App 177 green.
## Parked (do NOT touch without explicit user approval)
- **(a)(d) membership cleanups** — approval-gated refactors of WORKING code (CLAUDE.md "don't replace
working retail-faithful logic without approval"): (a) remove redundant `ResolveCellId` (already out
of the prod per-frame path; survives only in the `DataCache==null` test fallback); (b) unify the
forked `find_env_collisions`; (c) replace the `CheckBuildingTransit` bridge with intrinsic building
stabs in `find_transit_cells`; (d) make the per-cell ObjCell graph the collision authority (collision
still uses the landblock-wide `ShadowObjectRegistry`). The one soft spot: outdoor→indoor `0031↔0170`
building-entry is live-clean but NOT conformance-locked (rides on `CheckBuildingTransit`).
- **Render residuals (P3/P4)** — the VISIBLE doorway seam is now in the render path: the flap =
camera-collision residual (chase eye drifts out of the cell → viewer-cell flips; master-plan P3,
`SmartBox::update_viewer`); the void = unported PView seal (P4). Membership (physics) is correct.
See `docs/research/2026-06-03-p1-visual-gate-render-residuals.md`. Master-plan order: P2 → P3 → P4.
---
## FRESH-SESSION PROMPT (copy-paste)
```
Continue the VERBATIM retail spatial-pipeline port for acdream. Branch claude/thirsty-goldberg-51bb9b
(do NOT branch/worktree; do NOT push without asking; NEVER git stash/gc). PowerShell on Windows;
launch logs are UTF-16.
STATE: M1.5 (Indoor world feels right). P1 membership = DONE (proven to already match retail; the
"0/11 lag" was a cdb capture artifact; merged + pushed, HEAD f0d37d8). P2 (door/building-shell
collision) = IN PROGRESS, root cause LOCALIZED to BSP Path 5 grounded step-up. The fix is the job.
READ FIRST (canonical, in order):
1. docs/research/2026-06-03-p2-door-stepup-handoff.md (THIS handoff — the localization, the
DO-NOT-RETRY list, the tooling note, the next steps).
2. docs/superpowers/specs/2026-06-03-verbatim-spatial-pipeline-port-master-plan.md (§3 P2; §1/§2 B3/B4).
3. docs/research/2026-05-25-door-bug-partial-fix-shipped.md (the door saga state + its do-not list).
THE FINDING: all 5 failing Core tests localize to BSP Path 5 (grounded Contact + StepSphereUp). For
B1 (cleanest: a grounded mover wall-slides a walkable 0.25 m step with a 0.30 m budget, Z stays 0),
the whole upstream chain is VERIFIED FAITHFUL + correctly reached — Path 5 dispatch, the recursion
guard, DoStepUp (= retail CTransition::step_up pc:273099), DoStepDown (= retail step_down pc:272946).
The divergence is INSIDE the step-up CLIMB: DoStepDown → TransitionalInsert(5) → Path 3
step_sphere_down → find_walkable's upper-floor find + sphere-up-adjust when sp.StepUp=true. It fails
to lift onto the 0.25 floor → StepUpSlide → wall-slide.
DO NOT RE-INVESTIGATE (verified faithful): Path 5 dispatch, the recursion guard, DoStepUp, DoStepDown.
DO NOT speculate on the BSP fix without apparatus (the door saga burned many speculative fixes).
TOOLING: xunit swallows Console.WriteLine — the ACDREAM_DUMP_STEPUP / [step-walk] probes don't surface
in the runner; instrument B1 with ITestOutputHelper to trace the climb conditions.
THE JOB (P2 fix, evidence-first):
1. Read+compare acdream BSPQuery.find_walkable (:693) / step_sphere_down (:1206) / AdjustSphereToPlane
vs retail BSPTREE::step_sphere_down (pc:323665) + BSPNODE/BSPLEAF::find_walkable + adjust_sphere_to_plane,
focused on the step_up==1 climb. Pin the divergence (instrument B1 with ITestOutputHelper if needed).
2. Port the climb verbatim (cite the anchor). Drive RED→GREEN: B1 (steps up), then B2 (still blocks the
5 m wall), then DoorCollisionApparatusTests.Apparatus_Grounded_50cmOffCenter (flips to block — then
rewrite its documents-the-bug assertion to Assert.True(blocked) && pos.Y < 12.0), then the 2
LiveCompare ticks, then D4.
3. If the decomp is ambiguous: cdb-attach to retail at a cottage doorway (break on step_up/step_down/
step_sphere_down) — needs the user's retail client. PDB matches; tools/cdb/.
4. USER VISUAL GATE: walk through a doorway cleanly (foot Y stable, walls block); step up a cottage
stair (climbs).
TEST BASELINE: Core 1309 pass / 5 fail (the P2 target above) / 1 skip; Conformance 60 pass / 1 skip;
App 177 green. PARKED (need explicit approval): the (a)-(d) membership cleanups + the render residuals
(P3/P4 — the visible doorway flap/void). Do NOT speculate a Path-5 fix before the climb divergence is
pinned with evidence.
```

View file

@ -0,0 +1,190 @@
# P2 pickup — cellar-top corner wedge = cell-resolver ping-pong (re-diagnosed) reverting a WORKING step-up
> **🟢 SUPERSEDED 2026-06-04 PM — the wedge is NOT membership and NOT a reverted landing.**
> Canonical findings + full evidence chain are now in `memory/project_p2_door_stepup_findings.md`
> (the "RE-DIAGNOSIS 2" + "SLIDE LOCALIZED" + "FAILING CONDITION PINNED" entries). One-line summary:
> a live **retail cdb trace** proved retail's carried cell ALSO flips 0174/0175/0171 at the lip yet
> retail is smooth → membership ruled out. The wedge is a **step-up coin-flip**: the step-up's
> internal step-down FAILS to set a contact plane on the FLAT cottage floor (`cpValid=False`,
> `walkInterp=1.0`) while it works on the ramp slope. acdream's `StepSphereDown`/`AdjustSphereToPlane`
> are FAITHFUL to retail (verified vs `find_walkable` pc:326793 + `adjust_sphere_to_plane` pc:322032),
> so the obvious "set the CP anyway" fix DIVERGES from retail — do NOT ship it. **NEXT STEP (ready):**
> run `tools/cdb/retail-flatfloor-trace.cdb` on the live retail client at the cellar lip to see whether
> retail's `step_sphere_down` returns 3 (sets CP) or 1 (no CP) on the flat floor — that decides where
> retail establishes the flat-floor contact plane, then port it. 4 TEMP probes (gated on
> ACDREAM_PROBE_INDOOR_BSP, marked STRIP) are uncommitted in the worktree. The text below is HISTORY.
> **Canonical pickup, 2026-06-04.** Branch `claude/thirsty-goldberg-51bb9b` (do NOT
> branch/worktree; do NOT push without asking; NEVER `git stash`/`gc`). PowerShell on
> Windows; launch logs are UTF-16.
> **🔴 RE-DIAGNOSED 2026-06-04 (acdream corner trace) — the cellar wedge is a MEMBERSHIP
> bug, NOT collision.** The "## The cdb-pinned finding" below (retail steps up onto the
> floor) is correct for RETAIL, but instrumenting acdream (`ACDREAM_DUMP_STEPUP=1`) at the
> lip showed acdream's **step-up WORKS**: 518 attempts, **220 SUCCESS** landing the
> candidate on the cottage floor (`CheckPos Z=94.0`, normal `(0,0,1)`), 298 FAILED,
> alternating. But the **committed `CurPos` never advances** — it stays on the ramp at
> `(…,9.70,93.41)`; every success is REVERTED. `[cell-transit]` shows a **cell-resolver
> ping-pong every tick at the 3-cell junction: `0xA9B40175↔0174↔0171`, `reason=resolver`**.
> So `ResolveCellId` flips the cell each frame → the floor-landing is validated against the
> wrong cell + rejected → revert → oscillation → wedge. **NOT step-up (works), NOT
> edge-slide.** It's the #98/"Finding-3" cell-ping-pong family. **The fix is membership/
> cell-resolution stability at the junction — the PARKED, approval-gated (a) `ResolveCellId`
> demotion/stickiness from the master plan** (P1 claimed it was demoted out of the per-frame
> path, but this trace shows it's STILL driving per-frame cell changes here + unstable). The
> collision-side fixes (B1 `abbd761`, slide_sphere `0935a31`) are correct + KEEP. Apparatus:
> `acdream-corner-capture.jsonl` + the `stepup:`/`[cell-transit]` lines in
> `launch-acdream-corner.log`. **Next:** pin whether the commit-rejection is caused by the
> resolver flip (trace `ResolveWithTransition` validate/commit vs the cell change at the
> lip), then stabilize membership there (do NOT touch step-up/slide — they work).
## State both altitudes
- **Milestone:** M1.5 — Indoor world feels right.
- **Phase:** P2 (door / building-shell collision) of the verbatim spatial-pipeline port.
- **Shipped this session (committed, branch HEAD `0935a31`):**
- `abbd761`**B1 fix:** Path 5 (Contact) near-miss dispatch ported verbatim — gate
behind `num_sphere > 1`, head-first order, `neg_step_up` mapping (head→false/slide,
foot→true/step-up). Retail `transitional_insert`/`find_collisions` Contact branch
(`acclient_2013_pseudo_c.txt:323838-323881`, `set_neg_poly_hit` :323279). Fixed the
B1 grounded-step-up wedge (the handoff's "climb" localization was WRONG — proved via
`ITestOutputHelper` capture).
- `0935a31`**slide_sphere fix:** head near-miss (`neg_step_up==0`) now calls the
faithful `CSphere::slide_sphere` (existing `SlideSphereInternal`) + continues the
insert loop, replacing the A6.P4 `Collided` shortcut (`transitional_insert`
pc:273350-273351).
- `f984e92` — docs (corrected the prior P2 handoff).
- **Visual-verified 2026-06-04:** generic step-up climbs; **closed cottage door still
BLOCKS** (slides tangentially, no walkthrough — regression check passed); **cellar
ascent went from ALWAYS-stuck → WORKS-MOSTLY.**
- **Remaining:** an **intermittent corner-wedge** at the cellar-top lip. Retail is
always smooth there (user-confirmed). So it's a real bug.
## The cdb-pinned finding (retail ground truth)
`tools/cdb/cellar-corner-escape.cdb` traced live retail at the cellar-top corner
(decode: `parse_corner_log.py`; raw: `cellar-corner-retail.log`). Retail escapes the
corner by **STEP-UP, not slide:**
- `step_sphere_up``step_up` fired **196×** vs only **38 near-misses**. `step_up`
normals: +X wall ×78, **ceiling `(0,0,-1)` ×36**, +Y wall ×32, X wall ×18, ramp
slope `(0,0.62,0.78)` ×11, Y wall ×10, floor `(0,0,1)` ×10. So retail step-ups
against EVERY grounded full-hit at the corner.
- **Contact plane transitions ramp `N.z=0.78` (×63) → flat cottage floor `N.z=1.0`
(×76).** That's the escape: retail **climbs the lip off the ramp ONTO the cottage
floor.**
- The user's "run in place against the ceiling (not stuck)" = `step_up` failing on the
ceiling normal `(0,0,-1)``step_up_slide` (transient; steer out).
**Divergence pinned:** retail escapes by **stepping up onto the cottage floor**;
acdream **slides at the lip and never makes the ramp→floor transition**. The slide
itself (the `0935a31` fix) is correct + working; the gap is the **final lip-climb**.
This is the **original #98 core**`DoStepDown`/`step_sphere_down` finding + landing
on the cottage floor — which B1+slide got close to but didn't finish.
## Next step (evidence-first — #98 saga rule: do NOT guess)
1. **Instrument acdream's OWN corner path.** The captures so far
(`cellar-up-capture*.jsonl`, `door-recheck-capture.jsonl`) have positions/normals but
NOT the path. Need to answer: at the cellar-top lip, does acdream's `step_sphere_up`
`DoStepUp` FIRE and FAIL to land on the cottage floor (DoStepDown can't find
`N.z=1.0` within `StepUpHeight=0.6`), or does it not fire (the hit goes to the slide
path instead)? Relaunch acdream with `ProbeBuildingEnabled` (→ `[neg-poly-dispatch]`/
`[bsp-test]`) + `ACDREAM_DUMP_STEPUP=1` + `ProbeStepWalkEnabled` (→ `[step-walk]`),
reproduce the wedge, read the path. (xunit-swallow doesn't apply to the live app —
Console probes DO surface in the launch log.)
2. **Compare to retail's 196 step_up / ramp→floor transition** and port the missing
lip-climb verbatim. Likely in `DoStepDown` (`TransitionTypes.cs:3074`) /
`BSPQuery.step_sphere_down` (:1206) / `find_walkable` (:693) — the cottage-floor
find+land. Retail anchors: `CTransition::step_up` pc:273099, `step_down` pc:272946,
`BSPTREE::step_sphere_down` pc:323665, `CObjCell::find_env_collisions` (the
walkable-refresh that overwrites the contact plane ramp→floor).
3. **USER VISUAL GATE:** cellar ascent clean (no intermittent wedge); door still blocks;
generic step-up still climbs.
## Apparatus (committed / available)
- `tools/cdb/cellar-corner-escape.cdb` — retail corner trace (step_up/step_sphere_up/
neg_poly_hit/contact_plane counts + args; 30K threshold — TOO HIGH for these
lower-frequency BPs, lower to ~3000 next time so it auto-detaches in one wedge).
- `parse_corner_log.py` — decodes the cdb log (hex→float, histograms).
- Captures (UNCOMMITTED, in worktree root, ~32 MB each — do NOT commit):
`cellar-up-capture.jsonl` (v1, pre-slide-fix wedge), `cellar-up-capture-v2.jsonl`
(post-slide-fix: 96 hit-and-advanced slide frames), `door-recheck-capture.jsonl`,
`cellar-corner-retail.log` (the retail cdb trace).
- `analyze_cellar.py` / `analyze_v2.py` — ad-hoc capture analyzers (capture-specific).
## Test baseline
Core 1310 pass / 4 fail / 1 skip. The 4 fails are pre-existing documents-the-bug /
separate-issue: `DoorCollisionApparatusTests.Apparatus_Grounded_50cmOffCenter`
(synthetic-test artifact — terrain=-1000, no queryable floor; NOT a real door-block
failure — see `memory/project_p2_door_stepup_findings.md`), 2× `DoorBugTrajectoryReplay
LiveCompare_*` (compare against captured-BUGGY-live positions; need re-baseline), and
`BSPStepUpTests.D4` (airborne Path 6 sliding-normal persistence — separate). App 177 green.
## Do NOT
- Guess (the #98 saga burned 10+ speculative fixes) — pin the mechanism with the apparatus first.
- Add a `ResolveCellId` stickiness clamp / suppression flag — the user chose the **principled**
P1 demotion, not a band-aid (no-workarounds rule).
- Flip `Apparatus_Grounded_50cmOffCenter` to `Assert.True(blocked)` — it blocks via a
synthetic-floor artifact, not a faithful door block.
- Re-investigate B1 (`abbd761`) or slide_sphere (`0935a31`) — both shipped + verified + correct.
## FRESH-SESSION KICKOFF PROMPT (copy-paste) — user-approved 2026-06-04: principled P1 membership fix
```
Continue the VERBATIM retail spatial-pipeline port for acdream. Branch claude/thirsty-goldberg-51bb9b
(do NOT branch/worktree; do NOT push without asking; NEVER git stash/gc). PowerShell on Windows;
launch logs are UTF-16.
STATE: M1.5 (Indoor world feels right). P2 COLLISION = DONE + shipped: B1 near-miss gate (abbd761) +
slide_sphere head-near-miss (0935a31). Generic step-up climbs; the closed cottage door BLOCKS (no
walkthrough); step-up AT THE CELLAR LIP works (220 successful candidate-landings on the cottage floor).
The remaining intermittent CELLAR-ASCENT WEDGE is RE-DIAGNOSED (live acdream + retail cdb traces) to a
MEMBERSHIP cell-resolver ping-pong — NOT collision. The user APPROVED the PRINCIPLED P1 fix (demote
ResolveCellId / swept curr_cell as per-frame authority), NOT a stickiness band-aid.
READ FIRST (in order):
1. docs/research/2026-06-04-p2-cellar-corner-stepup-handoff.md — RE-DIAGNOSIS banner + full evidence.
2. memory/project_p2_door_stepup_findings.md — RE-DIAGNOSIS 2026-06-04 entry + shipped fixes + do-not.
3. memory/project_retail_membership_criterion.md — P1 membership context (swept curr_cell pick).
4. docs/superpowers/specs/2026-06-03-verbatim-spatial-pipeline-port-master-plan.md — §A membership
A1A9, §1 KEEP/REPLACE/DELETE (ResolveCellId -> spawn/teleport seed; per-frame from swept curr_cell),
parked (a)(d).
THE FINDING (evidence): at the Holtburg cottage cellar-top lip (3-cell junction), acdream step-up
SUCCEEDS — lands CheckPos on the cottage floor (Z=94.0, normal (0,0,1)) 220/518 times, matching retail.
But committed CurPos never advances (stays on the ramp ~(…,9.70,93.41)); every success is REVERTED
because the cell PING-PONGS every tick (0xA9B40175<->0174<->0171, [cell-transit] reason=resolver) -> the
floor-landing is validated against the wrong cell + rejected. Retail (cdb) is smooth: step_up + contact
plane transitions ramp N.z=0.78 -> flat floor N.z=1.0 (76 landings), no cell ping-pong. This CONTRADICTS
P1's claim that ResolveCellId was demoted out of the per-frame path.
THE JOB (evidence-first; do NOT guess):
1. PIN the exact code path producing the per-frame [cell-transit] reason=resolver ping-pong at the lip
(is it PhysicsEngine.ResolveCellId despite P1's demotion claim, the swept advance, or
PlayerMovementController.UpdateCellId/UpdatePlayerCurrCell?), and CONFIRM the resolver flip CAUSES the
step-up commit-rejection (re-validation against the flipped cell) vs being a symptom.
2. PORT THE PRINCIPLED P1 FIX: make the swept curr_cell (find_cell_list pick over the uniform candidate
set) the per-frame membership authority at this junction; demote ResolveCellId to spawn/teleport seed.
Retail anchors: A1 CObjCell::find_cell_list 0x52b4e0 pc:308742; A8 change_cell/SetPositionInternal
0x513390/0x515330; A7 transitional_insert/validate_transition/check_other_cells. The cell must NOT
flip out from under a committed step-up. NO stickiness band-aid.
3. RED->GREEN: deterministic test for the lip junction (cell stable after step-up) + keep B1/B2/B3/door
tests green. USER VISUAL GATE: cellar ascent clean (no wedge); door still blocks; generic step-up climbs.
APPARATUS (in the worktree):
- acdream captures: acdream-corner-capture.jsonl (lip wedge: step-up-works + cell ping-pong),
cellar-up-capture-v2.jsonl, cellar-up-capture.jsonl (JSON Lines, ACDREAM_CAPTURE_RESOLVE, IsPlayer).
- Retail cdb: cellar-corner-retail.log + tools/cdb/cellar-corner-escape.cdb. Decode: parse_corner_log.py
/ tools/cdb/decode_retail_hex.py.
- Probes: ACDREAM_PROBE_CELL=1 ([cell-transit]), ACDREAM_DUMP_STEPUP=1 (stepup:), ACDREAM_PROBE_RESOLVE=1
([resolve]), ACDREAM_CAPTURE_RESOLVE=<path>. Live launch per CLAUDE.md "Running the client".
- cdb on retail at the lip (break CObjCell::find_cell_list / change_cell / SetPositionInternal) if the
decomp is ambiguous. PDB matches; tools/cdb/. Lower the trace threshold (~3000) so it auto-detaches in
one wedge.
DO NOT: re-investigate B1/slide_sphere (shipped, correct); add a ResolveCellId stickiness/suppression
band-aid (user chose principled); flip Apparatus_Grounded_50cmOffCenter to Assert.True(blocked)
(synthetic-floor artifact); guess.
TEST BASELINE: Core 1310 pass / 4 fail / 1 skip (the 4: Apparatus_Grounded_50cmOffCenter [synthetic-floor
artifact], 2x DoorBugTrajectoryReplay LiveCompare_* [captured-buggy-live, re-baseline], BSPStepUpTests.D4
[airborne Path 6, separate]); App 177 green. Branch HEAD: 664101f (+ this commit).
```

View file

@ -0,0 +1,502 @@
# P2 cellar-lip wedge — CANONICAL handoff (flat-floor contact-plane coin-flip)
## ✅ FIXED + VISUAL-GATE PASSED 2026-06-05 (START HERE — supersedes UPDATE 2 below)
**VISUAL GATE PASSED (user, 2026-06-05): "Yes all works!"** — cellar ascent smooth (no last-step wedge),
inn door still BLOCKS, generic step-up still climbs. The residual 9/29 sliding-normal records did NOT
manifest in live play, confirming they were buggy-trajectory artifacts (not a live issue). Commits:
`cc4590f` (fix + validation tests + handoff/memory) and `9fdf6a5` (strip the dispatch-trace probes).
**The P2 cellar-lip wedge is DONE.** (Commits are local on the branch — not pushed.) The rest of this
banner is the root-cause writeup.
**The pinned "find_walkable is NEVER called during the step-down" (UPDATE 2) was a PROBE ARTIFACT.**
A clean `[fc-dispatch]`/`[step-sphere-down]` trace (TEMP probes, gated on `ACDREAM_PROBE_INDOOR_BSP`,
in `BSPQuery.FindCollisions` + `StepSphereDown`) proved `find_walkable` (Path 3 / `StepSphereDown`)
**IS** reached for both 0175 (primary) and 0171 (other-cell) during the step-down — UPDATE 2 mis-read
it (the `[fc-dispatch]` cell logs `path.CheckCellId` = the carried cell 0175 even while iterating
0171's BSP, because `CheckCellId` is the carried cell, not the iterated one).
**THE REAL ROOT CAUSE (ramp-climb family, 20/29 records):** `Transition.CheckOtherCells` collided the
OTHER cells against a **stale `footCenter`** snapshotted at `FindEnvCollisions` entry (TransitionTypes.cs
~L1959) — i.e. BEFORE the primary `insert_into_cell` ran. The primary collide can MOVE the sphere: a
Path-5 full-hit dispatches `step_sphere_up`, and a successful step-up **climbs the foot onto the cottage
floor yet still returns OK**. Retail's `check_other_cells` (`acclient_2013_pseudo_c.txt:272735`
`(*cell+0x88)(this)`) reads the **LIVE `sphere_path.global_sphere`** (post-insert). acdream used the
pre-climb snapshot, which is sunk ~0.25 m below the floor → the foot spuriously **near-misses the very
floor it just climbed onto** → `neg_step_up` → a doomed SECOND step_up against the floor normal (0,0,1)
whose `step_up_slide` unwinds the climb (it slides relative to `GlobalCurrCenter` = the step start, low Z)
`validate_transition` reverts the whole step → **0 % advance**.
**FIX (shipped):** in `Transition.RunCheckOtherCellsAndAdvance` re-read `footCenter =
sp.GlobalSphere[0].Origin` before iterating other cells. One line + comment. Pre-fix 0/29 records
advanced; post-fix **20/29 climb onto the cottage floor (Z≈94)**. **Zero regression** — full Core suite
1321 pass / 4 fail (the documented baseline 4: `Apparatus_Grounded_50cmOffCenter`,
2× `DoorBugTrajectoryReplay LiveCompare_*`, `BSPStepUpTests.D4`) / 1 skip. The 2 door `LiveCompare`
divergences are **byte-identical** with/without the fix (the door's step_up FAILS → sphere restored →
position unchanged → `footCenter` == live). Tests: `CellarLipWedgeTests.Fix_StaleFootCenter_*` (2 new,
GREEN).
**REMAINING RESIDUAL (9/29, OUT OF SCOPE this pass):** the `(0,-1,0)` sliding-normal **+Y-kill**
(`AdjustOffset` slide-crease projects the into-cottage +Y onto the floor×wall crease = world X and zeroes
it → only X survives → hits the slab X wall → step_up fails on the flat floor → revert). 7/29 records;
record #6 is the canonical one (`DocumentsResidualWedge_LiveFloorCp_SlidingNormalKillsPlusY`). This is
**slide-recovery territory** the kickoff said NOT to re-investigate, and is **suspected to be a
buggy-trajectory artifact** (the stale slide accumulated only because the player was already oscillating;
once the ramp-climb advances cleanly the player should not enter the south-wall-slide-into-doorway state).
**Let the VISUAL GATE decide** whether it needs a follow-up before touching the slide. (Record #21 moves
Y away from the cottage and is likely a legitimate non-advance.)
**VISUAL GATE (next):** run the client, walk up the Holtburg cottage cellar stairs — expect the last-step
wedge GONE (smooth ascent onto the floor). Also re-confirm the inn door BLOCKS and a generic step-up
climbs (the fix only changes `check_other_cells`'s position reference). If the ascent still intermittently
wedges, the `(0,-1,0)` +Y-kill is live → investigate `AdjustOffset` slide-crease / the sliding-normal seed
(with the visual evidence then justifying touching the slide). Apparatus: `[fc-dispatch]`/`[step-sphere-down]`
probes + `CellarLipWedgeTests.Diagnostic_TraceRecordByIndex` reproduce any record in <200 ms.
---
## ▶ NEXT-SESSION KICKOFF (historical — its "find_walkable never called" framing was disproven above)
**State:** M1.5 / P2 cellar-lip "blocked at the last step" wedge. A FAITHFUL deterministic reproduction now
exists. The cause has been peeled through SIX evidence-disproven framings to one bounded question. No fix
landed (intentionally — the last layers were each disproven; do NOT guess, the collision code is load-
bearing). Branch `claude/thirsty-goldberg-51bb9b` (do NOT branch/worktree; do NOT push w/o asking; NEVER
`git stash`/`gc`). PowerShell on Windows; launch logs UTF-16. Use `superpowers:systematic-debugging`.
**READ (in order):** (1) THIS file's `## SESSION-END` + `UPDATE 1` + `UPDATE 2` (the live captures + the
pinned root cause) and `CORRECTION 1`/`CORRECTION 2` (CP + cull are RETAIL-FAITHFUL, proven). (2)
`memory/project_p2_door_stepup_findings.md`.
**DONE — faithful apparatus (uncommitted in worktree; RECOMMEND committing first):**
`tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs` + `Fixtures/cellar-lip/0xA9B4017{1,4,5}.json` +
`Fixtures/cellar-lip/wedge-records.jsonl` (29 real `ACDREAM_CAPTURE_RESOLVE` wedge calls). Replays the EXACT
captured calls (seed body-before, real climb dir X,+Y) through the lip-cell engine — **all 29 reproduce
the wedge at 0% advance in <200 ms.** Tests (all GREEN as documents-the-bug/diagnostics):
`DocumentsWedge_LiveFloorCp_PlayerStuckAtCottageFloorEdge`, `Diagnostic_ReplayLiveWedgeRecords_Advance`,
`Diagnostic_ReplayFloorCpRecord_StepUpProbes` (→ `%TEMP%/lip-wedge-stepup.log`), + 2 synthetic.
**DISPROVEN — do NOT re-investigate:** flat-floor CP (retail also no-CP, smooth — Correction 1); the
`PosHitsSphere` cull sign (retail-faithful, `cdb -z`-verified — Correction 2); sphere radius (0.48=player
correct, 0.30=camera probe); the A6.P4 neg-poly `Collided``slide_sphere` shortcut (fix attempted + reverted,
didn't clear it — the slide returns offset=0 then degenerates to Collided on re-check).
**PINNED ROOT-CAUSE LAYER (UPDATE 2):** during the step-up's step-down (`DoStepUp``DoStepDown`
`TransitionalInsert(5)`), **`BSPQuery.FindWalkableInternal` is NEVER called for cell 0171** (confirmed after
a CLEAN rebuild — `[fw-enter]` TEMP probe fires 0×). So the cottage floor (0171 poly 0x0023, n=(0,0,1),
Z=94) is **never tested as walkable** → no contact plane → step-down rejects (`cpValid=False`) → step-up
fails → `StepUpSlide=Collided` → wedge. `[other-cells] iter=0171 result=OK` is returned WITHOUT reaching
`StepSphereDown``find_walkable`.
**THE JOB (bounded, evidence-first — NO speculative edits):**
1. Trace `Transition.FindEnvCollisions` (TransitionTypes.cs) → `BSPQuery.FindCollisions` PATH DISPATCH for
cell 0171 when `StepDown=true`. Find WHY `StepSphereDown`/`FindWalkableInternal` is skipped — candidates:
entry `NodeIntersects` early-OK; Path 1 (Placement) taken (DoStepDown's placement insert); the primary
0175 collision returning Collided (the X-wall Path-5 `StepSphereUp`, `stepUp=stepDown=False` = the OUTER
non-step pass) short-circuiting before `CheckOtherCells(0171)`; or StepDown not actually set on that call.
Use the `[fw-enter]`/`[find-walkable]` TEMP probes — **FORCE A CLEAN REBUILD (`Remove-Item obj,bin`) for
any Core probe edit; `dotnet test`/`dotnet build` incremental did NOT pick up new BSPQuery.cs probes
(cost two probe rounds).**
2. Port retail's behavior (oracle: `CEnvCell::find_collisions` pc:309560 → `BSPTREE::find_collisions`
pc:323725 Path-3 → `BSPTREE::step_sphere_down` pc:323665 → `BSPLEAF::find_walkable` pc:326793). Verify
how retail's step-down reaches `find_walkable` on the cottage floor where acdream's does not.
3. Fix → VALIDATE: flip `CellarLipWedgeTests.DocumentsWedge_LiveFloorCp_*` to `advance>0.25·requested`
GREEN; `Diagnostic_ReplayLiveWedgeRecords` advance% jumps off 0%. 4. REGRESSION: `DoorBugTrajectoryReplayTests`
+ full Core suite. **VISUAL GATE: cellar ascent clean (no last-step wedge) + inn door BLOCKS + generic
step-up climbs.**
**ENABLER:** `cdb -z "C:\Turbine\Asheron's Call\acclient.exe"` = offline static disasm + `uf` w/ PDB symbols
(no live attach); use it to verify any retail branch/offset (the cull-sign error was a BN parity-jump
mis-read — never trust BN `if(p)` for `test ah,N; jp`).
**Apparatus (uncommitted, worktree):** the test + fixtures above; TEMP probes in `BSPQuery.cs`
(`[path5-wall]`,`[fw-enter]`,`[find-walkable]`, STRIP) + `TransitionTypes.cs` (`[neg-poly]`,`[stepsphereup]`,
`[stepdown-decide]`, CheckOtherCells cn/sn/negHit, STRIP) all gated on `ACDREAM_PROBE_INDOOR_BSP`; captures
`lip-wedge-resolve.jsonl`/`lip-cells/`/`launch-*.log`; cdb scripts `tools/cdb/retail-connector-collide-trace.cdb`
(+ flatfloor/lip); analyzers `analyze_wedge_jsonl.py`/`extract_wedge_records.py`/`analyze_v1_corr.py`;
`cdbz-disasm.txt`/`cdbz-poshits.txt`. **Test baseline:** Core prior 1310p/4f/1s + 5 GREEN lip tests; App 177.
---
> **Canonical pickup, 2026-06-04 PM.** Branch `claude/thirsty-goldberg-51bb9b` (do NOT
> branch/worktree; do NOT push without asking; NEVER `git stash`/`gc`). PowerShell on
> Windows; launch logs are UTF-16. This SUPERSEDES the membership re-diagnosis in
> `docs/research/2026-06-04-p2-cellar-corner-stepup-handoff.md` (now history) and folds in
> the full chain from `memory/project_p2_door_stepup_findings.md`.
---
## 🧪 SESSION-END 2026-06-04 PM — deterministic repro BUILT + first fix attempt REVERTED
**Apparatus shipped (uncommitted in worktree):** `tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs`
+ fixtures `tests/AcDream.Core.Tests/Fixtures/cellar-lip/0xA9B4017{1,4,5}.json` (copied from `lip-cells/`).
Loads the 3 lip cells (synthetic single-leaf BSP, same as `CellarUpTrajectoryReplayTests`), seeds the
player (r=0.48, foot bottom Z=93.456 → foot-sphere center Z=93.936 = the live wedge) carried in slab
0175, drives forward. **Reproduces the wedge deterministically in <90 ms:** the player FREEZES, blocked
by the threshold slab's X side wall (poly normal world (1,0,0)). Two tests, both GREEN as documents-the-
bug: `Diagnostic_DriveOffThreshold_DumpTrajectory` (dumps trajectory+probes to `%TEMP%/lip-wedge-diag.log`
via Console redirect) + `DocumentsWedge_PlayerFrozenAtThreshold_BlockedByMinusXWall`.
**FIX ATTEMPT #1 — REVERTED.** Hypothesis: the A6.P4 neg-poly `NegStepUp==false` branch
(`TransitionTypes.cs` ~line 1083) returns `Collided` (a deliberate "simpler response" shortcut; the
comment says the slide was deferred), where retail dispatches `neg_step_up==0 → slide_sphere`. Replaced
it with `SlideSphereInternal(NegCollisionNormal, GlobalCurrCenter[0].Origin)` (mirroring the NegStepUp=
true branch). **Did NOT fix the wedge** → reverted. WHY: the slide returns `Slid` with **offset=0** (the
Y displacement is already along the crease `dir=cross((1,0,0),(0,0,1))=(0,1,0)`), so the sphere
doesn't move; the loop re-checks with `gDelta≈0``SlideSphere`'s `offset.LengthSquared<ε → Collided`
branch (`TransitionTypes.cs:2877`) → revert. So the bug is NOT the shortcut alone — it's the
**slide/loop-commit**: the parallel-graze slide produces no advance, and the re-check degenerates to
Collided.
**TWO GAPS for the next pass:**
1. **Faithful repro:** the synthetic drive direction (world Y) is a GUESS and is PARALLEL to the X
wall (keeps grazing). The real climb direction is unknown without the exact `targetPos`. **Get a
short `ACDREAM_CAPTURE_RESOLVE=<path>` JSONL of the live wedge** (one acdream run, wedge ~5 s) → wire
a `LiveCompare`-style test (the proven `CellarUpTrajectoryReplayTests` pattern) with the exact
currentPos/targetPos/body-before. That makes the RED test faithful + the fix validatable.
2. **The real fix is in the slide/loop:** why does retail's `slide_sphere` advance the sphere PAST the
parallel graze where acdream's returns offset=0 then degenerates to Collided on re-check? Trace
retail `CSphere::slide_sphere` (pc:321660) vs acdream `SlideSphere` (`TransitionTypes.cs:2826`) for
the parallel-wall + grounded case, AND why the loop re-check sees `gDelta≈0`. NOTE the live wedge
used the **NegStepUp=TRUE** path (StepSphereUp→StepUpSlide=Collided) while the synthetic repro used
**NegStepUp=FALSE** (neg-poly-dispatch→Collided) — BOTH end in `SlideSphere`/`SlideSphereInternal`
returning Collided, so the common fix point is `SlideSphere`'s degenerate-offset handling, not the
dispatch branch. **DOOR REGRESSION RISK:** any `SlideSphere`/neg-poly change touches the A6.P4 door
block — regression-test `DoorBugTrajectoryReplayTests` + visual-gate the inn door BLOCKS.
**Test baseline unchanged:** the 2 new lip tests are GREEN (documents-the-bug). Build green. The reverted
fix leaves `TransitionTypes.cs` functionally identical (only an explanatory comment added at the shortcut).
### UPDATE (same session, later) — FAITHFUL repro BUILT + ROOT CAUSE PINNED
Got the JSONL (`ACDREAM_CAPTURE_RESOLVE``lip-wedge-resolve.jsonl`, 17K player records). The real climb
direction is **X,+Y** (my synthetic Y guess was backwards). Extracted 29 representative wedge records to
`tests/AcDream.Core.Tests/Fixtures/cellar-lip/wedge-records.jsonl` (`extract_wedge_records.py`).
`CellarLipWedgeTests` now replays the EXACT captured calls (seed body-before, replay `ResolveWithTransition`
through the lip-cell engine): **all 29 reproduce the wedge bit-faithfully (0% advance).** New tests (all
GREEN as documents-the-bug / diagnostics): `Diagnostic_ReplayLiveWedgeRecords_Advance`,
`Diagnostic_ReplayFloorCpRecord_StepUpProbes`, `DocumentsWedge_LiveFloorCp_PlayerStuckAtCottageFloorEdge`.
**ROOT CAUSE PINNED** (via `Diagnostic_ReplayFloorCpRecord_StepUpProbes``%TEMP%/lip-wedge-stepup.log`):
the player is at the doorway EDGE of the cottage floor. The step-up (triggered by the X wall, normal
(1,0,0), STEEP) → step-down → multi-cell check reaches **0171 poly 0x0023 = the cottage floor** (n=(0,0,1),
world Z=94). The 0.48 sphere **OVERLAPS** it (`overlapsSphere=True`, `dist=0.085`) — BUT it's **REJECTED
because the sphere center projects outside the floor poly's edge** (`insideEdges=False`, `gap=0.395`). So
`[other-cells] iter=0171 result=OK` (NOT Adjusted), no contact plane is set → `[stepdown-decide] cpValid=
False accept=False` → step-up FAILS → `StepUpSlide=Collided` → wedge. Retail accepts the floor at its edge
and crosses (0175 never blocks). **This is a WALKABLE-EDGE acceptance divergence**, not a CP/cull/slide bug.
**THE FIX (next, narrow):** compare acdream's walkable-edge math vs retail for the sphere-overlaps-floor-
but-center-outside-edge case. Actual walkable test = `BSPQuery.WalkableHitsSphere` (254) →
`PolygonHitsSpherePrecise` (overlap) + `AdjustSphereToPlane` (351); `[other-cells] result=OK` means one of
them returned false for poly 0x0023. The `[walkable-nearest]` diagnostic uses `CheckWalkable` (287, the
edge/`insideEdges` test). Retail oracle: `CPolygon::walkable_hits_sphere` (pc:323006) +
`CPolygon::check_walkable` (pc:322811) + `CPolygon::adjust_sphere_to_plane` (pc:322032). Read which one
rejects the edge-overlap and why retail accepts it. **Validate with `CellarLipWedgeTests` (flip
`DocumentsWedge_LiveFloorCp_*` to assert advance>0.25·requested). DOOR REGRESSION RISK: walkable changes
are global — run `DoorBugTrajectoryReplayTests` + visual-gate the inn door BLOCKS + generic step-up climbs.**
Apparatus: `lip-wedge-resolve.jsonl`, `Fixtures/cellar-lip/*`, `analyze_wedge_jsonl.py`,
`extract_wedge_records.py`, `%TEMP%/lip-wedge-stepup.log`.
### UPDATE 2 — deeper: `find_walkable` is NEVER called during the step-down (cottage floor never tested)
Drilled one layer further with TEMP probes `[fw-enter]`/`[find-walkable]` in `BSPQuery.FindWalkableInternal`
(gated on `ACDREAM_PROBE_INDOOR_BSP`, marked STRIP, uncommitted). **Confirmed after a CLEAN rebuild (deleted
obj/bin) — `[fw-enter]` fires ZERO times** while the prior probes fire. So during the step-up's step-down
(`DoStepUp``DoStepDown``TransitionalInsert(5)`), `FindWalkableInternal` is **never reached** for 0171 (nor
0175): the cottage floor poly 0x0023 is never tested by the walkable finder. `[other-cells] iter=0171
result=OK` is returned WITHOUT `StepSphereDown`→`FindWalkableInternal`. So the "walkable-edge acceptance"
framing in UPDATE 1 is one level too shallow — the floor isn't *rejected* by the edge test, it's never
*tested* at all. Root: `FindEnvCollisions`/`BSPQuery.FindCollisions` for 0171 during the step-down returns OK
on a path BEFORE Path 3 (StepDown→StepSphereDown). **NEXT: trace `FindEnvCollisions` (TransitionTypes) → which
`FindCollisions` path 0171 takes during `StepDown=true` (entry NodeIntersects early-out? Path 1 Placement?
the primary-0175 result short-circuiting CheckOtherCells?) and why StepSphereDown/find_walkable is skipped.**
The `[stepsphereup] stepUpFlag=False stepDownFlag=False` means the X-wall StepSphereUp is the OUTER
(non-step) collision; the step-DOWN that should find the floor is a separate inner insert that never runs
find_walkable. NOTE: a clean `dotnet build`/`dotnet test` did NOT pick up new `BSPQuery.cs` probes until
`Remove-Item obj,bin`**force a clean rebuild when adding Core probes** (cost two probe rounds this session).
**HONEST STATUS: NO FIX. The collision/step path is deeper than a single-line fix** — 6+ framings this
session (CP→cull→slide→neg-poly→walkable-edge→find_walkable-not-called), each disproven by evidence and the
next layer exposed. This is the systematic-debugging "question the architecture" signal. The FAITHFUL repro
(`CellarLipWedgeTests`, 29 records @0% advance) makes the next attempt iterable; the next move is the
`FindEnvCollisions`/`FindCollisions`-path trace above, NOT another speculative edit. The collision code is
load-bearing (every floor/wall/step) — do not guess.
---
## ⚠️ CORRECTION 2026-06-04 (next session) — THE CP IS RETAIL-FAITHFUL; v2 IS MOOT
**The "decisive question" below is ANSWERED from the EXISTING v1 log — no new retail trace
needed.** The v1 `retail-flatfloor-trace.log` was wrongly dismissed as a `gu` artifact. It is
**real data.** Proof (full-file correlation over all 5,349 records, `analyze_v1_corr.py`):
- sphere **z ≤ 90.0 → pure ret=3** (CP set); **z = 94.01 (the flat cottage floor) → pure ret=1
(NO-CP), 877 records, zero ret=3**; ret mixes only in the **ramp transition zone (9093.7)**,
which is physical. A corrupted-`eax` artifact CANNOT produce two large pure populations at
opposite Z extremes with a physical transition between — the ret tracks the input Z exactly.
- **`walk_interp = 1.0 → ret=1 (no-CP) 770×`** — i.e. retail, with `walk_interp=1.0` on the flat
floor, gets NO contact plane and is **smooth**. That is the *exact* acdream condition
(`walkInterp=1.000 cpValid=False`).
- `cdb -z acclient.exe` (offline static disasm, symbols) confirms `BSPTREE::step_sphere_down`
`+0x218`=`mov eax,3;ret` (reached only after `[eax+18h]=1` contact_plane_valid + `set_walkable`)
and `+0x227`=`mov eax,1;ret` (early `je` when `find_walkable` found nothing). So ret3↔CP-set,
ret1↔no-CP is certain.
**ANSWER:** retail's `step_sphere_down` returns **NO-CP (ret 1)** on the flat cottage floor —
exactly like acdream — and retail crosses smoothly. **The contact plane is NOT the divergence.**
Both "if NO-CP → trace set_contact_plane callers" and "if SET-CP → divergence in StepSphereDown"
branches below rest on a FALSE premise (that retail establishes a flat-floor CP somewhere). It
does not. **Do NOT run the v2 trace; do NOT hunt a retail flat-floor CP path.**
**REDIRECTED diagnosis (back to the connector recovery — the `RE-DIAGNOSIS 2` / `SLIDE LOCALIZED`
line in memory, which the flat-floor-CP finding had wrongly sidelined):** the wedge is the
per-cell collide on **connector 0175** returning **Slid** during the recovery, which reverts the
good floor landing. Static comparison this session confirms the recovery structures ALL MATCH
retail: `find_collisions` Contact full-hit → `step_sphere_up`; `step_up` fail → `step_up_slide`
`slide_sphere` (retail `CSphere::step_sphere_up` pc:321611321638, `step_up_slide` pc:273930);
`check_other_cells` halts on Slid (4) clearing CP (pc:272717, `cdb -z` jump-table on `(result-1)`);
acdream `TransitionalInsert` *continues* (no revert) on Slid (TransitionTypes.cs:881). The SOLE
open question: **does retail's per-cell `CEnvCell::find_collisions` return Slid (recover & slide)
or OK (never hits) for connector 0175 at the lip?**
- OK in retail → acdream's connector Slid is SPURIOUS (over-detect / over-step-up 0175) → fix there.
- Slid in retail → retail slides+continues; acdream's wedge is the substep **REVERT** upstream
(`FindTransitionalPosition` / `ResolveWithTransition`), not the collide.
**NEW decisive trace (READY, robust, no `gu`):** `tools/cdb/retail-connector-collide-trace.cdb`
breaks `CEnvCell::find_collisions+0x1e` (the SINGLE exit; `esi`=`this`, `eax`=result; cell id
`poi(esi+0x28)`), logs ret per lip cell 0xA9B4017X. Built + offset-verified entirely offline via
`cdb -z` (no live attach). **ENABLER:** `cdb -z "C:\Turbine\Asheron's Call\acclient.exe"` does
offline static disassembly with full PDB symbols — verify any trace offset without a running client.
Everything below this banner is RETAINED FOR HISTORY (the flat-floor-CP hypothesis, now disproven).
---
## ⚠️ CORRECTION 2 — 2026-06-04 PM (live retail + acdream captures; the REAL mechanism)
Two live captures this session settled it. **Retail-connector trace** (`tools/cdb/retail-connector-collide-trace.cdb`
`retail-connector-collide-trace.log`, breaking `CEnvCell::find_collisions+0x1e`, single nesting-safe exit):
over ~85K samples the **connector cell 0175 returns 2692 OK + 94 Adjusted + 0 Collided + 0 Slid** — it
**never blocks**. (Floor 0171 and 0174 DO block — real cottage-room walls — so the trace is working.) So
the connector is a pure pass-through / successful-step-up in retail; acdream spuriously blocks it.
**acdream live capture at the wedge** (`launch-lip-capture.log`, ACDREAM_PROBE_INDOOR_BSP=1 + the 4 TEMP
probes + cell dumps `lip-cells/0xA9B4017{1,4,5}.json`) — the stuck state is:
```
[indoor-bsp] cell=0xA9B40175 lpos=(8.523,-2.251,-0.064) lprev=(8.520,-2.251,-0.064) r=0.480 result=OK
[stepdown-decide] cell=0xA9B40175 insert=OK cpValid=False cpNz=1.000 walkableZ=0.664 accept=False pos=(...,93.456)
[stepsphereup] cell=0xA9B40175 stepUpFlag=False stepDownFlag=False n=(1.00,0,0) stepped=False
[stepsphereup] cell=0xA9B40175 StepUpSlide=Collided
[indoor-bsp] cell=0xA9B40175 r=0.480 result=Collided
```
**Decoded mechanism (this is NOT the memory's "connector Slid" — that was a pre-B1-fix state):**
- **0175 is a 0.364 m-tall threshold SLAB** (dump: 4 solid side walls at local X=7/9, Y=2.85/1.15;
open floor/ceiling portals poly4→0171, poly5→0174; WorldTransform 180°-rot at (161.929,7.503,94)).
- The wedge uses the **r=0.48 body sphere** (Ø0.96 — *bigger than the slab is tall*), centered at world
Z=93.936 (local Z=0.064, i.e. SUNK into the threshold, ~0.5 m below resting-on-floor Z≈94.48).
- That oversized sphere genuinely **full-hits** the X wall (poly 3, X=9; sphere at X=8.523 reaches
X=9.003 — a **3 mm graze**; `moveDot<0` so retail would keep it too) → `BSPQuery.StepSphereUp`
(Path 5, BSPQuery.cs:1849/1380) → **`DoStepUp` fails** (its internal step-down finds no CP on the flat
floor — `[stepdown-decide] cpValid=False`, retail-faithful per Correction 1) → **`StepUpSlide`
`SlideSphereInternal` returns `Collided`** → `FindEnvCollisions` returns Collided → wedge. The 3 mm
graze is hair-trigger → explains the intermittency.
**CULL SIGN = RED HERRING (verified faithful).** Mid-session I believed acdream's `PosHitsSphere` cull
(`if moveDot>=0 return false`) was OPPOSITE retail. **WRONG**`cdb -z uf acclient!CPolygon::pos_hits_sphere`
shows `test ah,5; jp +0x46`. `jp` is a **parity** jump; the cull branch is taken on EVEN parity = `{dot>=0}`.
So retail **keeps the hit when `dot<0`, culls when `dot>=0`** — IDENTICAL to acdream + ACE (`if dist>=0
return false`). Movement convention also matches (both `checkcurr`: acdream BSPQuery.cs:1663, retail
find_collisions). **Do NOT touch the cull.** The Binary Ninja pseudo-C renders `test ah,5; jp` as
`if (p) return 0` which READS like "cull when dot<0" it is not; the parity decode is inverted. LESSON:
verify any cull/branch sign against `cdb -z`, never the BN `if(p)` rendering of a parity jump.
**ENABLER:** `cdb -z "C:\Turbine\Asheron's Call\acclient.exe"` does offline static disasm + `uf` with full
PDB symbols — used to build/verify the connector trace AND to catch the cull-sign error. Ghidra `patchmem`
addresses do NOT match the PDB/BN addresses (0x005394f0 → `CPolygon::UnPack` in Ghidra); use `cdb -z`.
**THE SHARP REMAINING QUESTION:** at the thin slab, **why does retail's step-up SUCCEED (climb onto the
cottage floor, find_collisions returns OK) where acdream's `DoStepUp` FAILS (no CP → StepUpSlide=Collided)?**
Sub-leads: (a) **RULED OUT — r=0.48 vs r=0.30 is two DIFFERENT movers, not a bug.** r=0.48 = the PLAYER
(`PlayerMovementController.cs:1116`, "human player radius from Setup"); r=0.30 = the CAMERA collision
probe (`PhysicsCameraCollisionProbe.cs:18` `ViewerSphereRadius=0.3`, single sphere). The smooth r=0.30
crossings are the camera spring-arm; the wedge is the player. Player radius 0.48 is correct. So the
question is purely the player step-up, NOT the sphere. Open: why is the player SUNK to Z=93.936 (0.5 m
below resting-on-floor Z≈94.48) at the threshold — is that retail-faithful (it's mid-climb from the
cellar) or a position error? (b) does retail even full-hit 0175's wall, or does its sphere clear it
(position)? (c) the
flat-floor step-up success path (Correction 1's open question — retail's step_up establishes the floor
CP via some path acdream lacks). NEXT: either build a deterministic harness test from `lip-cells/*.json`
(place the r=0.48 sphere at the captured wedge pos, assert FindCollisions returns OK not Collided — RED→
GREEN), or one targeted retail trace of `CSphere::step_sphere_up`/`CTransition::step_up` at 0175 (does it
return 1/OK or fall to step_up_slide?). Apparatus committed-in-worktree: `tools/cdb/retail-connector-collide-trace.cdb`,
`lip-cells/0xA9B4017{1,4,5}.json`, `launch-lip-capture.log`, `cdbz-disasm.txt`, `cdbz-poshits.txt`,
`analyze_v1_corr.py`. TEMP `[path5-wall]` probe added to BSPQuery.cs Path 5 (STRIP; was NOT in the stale
--no-build binary, so it didn't fire — rebuild to use it).
---
## State both altitudes
- **Milestone:** M1.5 — Indoor world feels right.
- **Effort:** P2 of the verbatim spatial-pipeline port
(`docs/superpowers/specs/2026-06-03-verbatim-spatial-pipeline-port-master-plan.md`).
- **Symptom (user words):** "run up the cellar stairs, get blocked at the last step;
sometimes through, sometimes not." Retail is always smooth there.
- **This session's outcome:** NO fix landed. The diagnosis was corrected three times with
evidence; the wedge is now precisely localized; the FINAL decisive question is still open
because the retail trace tooling (`gu` in a bp action) produced an artifact. One clean v2
retail trace pins it. Everything is saved; resume cold.
## The corrected diagnosis (evidence chain — all three prior theories DISPROVEN)
1. **NOT membership / cell-resolver ping-pong** (the prior handoff's claim). A live **retail
cdb trace** (`tools/cdb/retail-lip-trace.cdb``retail-lip-trace.log`, breaking
`CTransition::step_up`, logging `sphere_path.check_pos.objcell_id`) proved retail's carried
cell ALSO alternates `0xA9B40174/0175/0171` at the lip (181/40/17 over 238 step_ups), yet
retail crosses smoothly. So the carried-cell flip is retail-faithful — a "keep the cell
stable" / `ResolveCellId`-stickiness fix would DIVERGE from retail. Also: the production
`[cell-transit] reason=resolver` is the SWEPT `find_cell_list` pick (`RunCheckOtherCellsAndAdvance`
`CellTransit.FindCellSet`), NOT `PhysicsEngine.ResolveCellId` (which is only the cache-null
test fallback). `result.cellId` is STABLE in runs of 100s of ticks in
`acdream-corner-capture.jsonl` (154K recs).
2. **NOT a reverted landing (the M1 guess).** In `acdream-corner-capture.jsonl` the player
reaches the floor (Z=94.0, cpz 0.78→1.00) and WALKS ON into the cottage (idx 7215→7244);
the landing commits fine.
3. **IT IS: a step-up coin-flip on the FLAT cottage floor.** The `[stepdown-decide]` probe
(in `Transition.DoStepDown`) shows the trigger unambiguously:
- `accept=True (1845×): insert=OK cpValid=True` on the RAMP (cpNz=0.781, Z≈93.3, walkInterp≈0)
- `accept=False ( 849×): insert=OK cpValid=False cpNz=1.000 walkInterp=1.000` on the FLAT floor (Z=94.0)
The step-up's acceptance check (`insert==OK && ContactPlaneValid && cpNz>=walkableZ`,
`TransitionTypes.cs:3147`) rejects ONLY when `cpValid=False`, which happens ONLY on the FLAT
floor: the sphere is already settled → `adjust_sphere_to_plane` is a no-op → `FindWalkableInternal`
records no poly (its gate is `walkable && adjusted`, `BSPQuery.cs:736`) → `StepSphereDown`
(`BSPQuery.cs:1216`) sets no contact plane → reject. On the RAMP the sphere is always sliding
(adjusted=true) so the CP is set → accept. **Ramp = adjusting = works; flat floor = settled =
no CP = fails.** The `[stepsphereup]` probe corroborates: lip-riser step-up (cell 0171,
n=(0,-1,0)) = 443 success / 445 fail; connector +X corner wall (cell 0175, n=(1,0,0)) = 74 fail
→ recursive `StepSphereUp``StepUpSlide` = 401 Slid / 203 Collided overall.
## Why the OBVIOUS fix is WRONG (do not ship it)
"Just set the contact plane whenever a walkable poly is found, even without adjustment" — this
DIVERGES from retail. Verified against the decomp:
- `BSPLEAF::find_walkable` (`acclient_2013_pseudo_c.txt:326793`) gates BOTH the poly AND the
changed-flag on `walkable_hits_sphere && adjust_sphere_to_plane` — IDENTICAL to acdream's
`FindWalkableInternal`.
- `CPolygon::adjust_sphere_to_plane` (`:322032`) updates `walk_interp` and returns 1 only when
`new_interp = (1-t)*walk_interp < walk_interp`, i.e. `t>0` (the sphere must move toward the
plane). For a SETTLED sphere (`t≈0`) retail ALSO returns 0 → records no poly → sets no
step-down CP.
So acdream's `StepSphereDown` + `AdjustSphereToPlane` are FAITHFUL. Retail must establish the
flat-floor contact plane through a DIFFERENT path during the climb — that path is what's still
unknown.
## THE DECISIVE OPEN QUESTION + the v2 trace protocol
**Does retail's `BSPTREE::step_sphere_down` SET the contact plane (ret 3) or NOT (ret 1) on the
flat floor cell (0xA9B40171)?**
- v1 trace FAILED: `gu` inside a cdb bp action ("commands skipped … target execution inside an
event handler") corrupted eax → perfect `1,3,1,3` alternation artifact (run-length=1; the
4216/1133 histogram is meaningless). **NEVER use `gu` in a cdb bp action.**
- v2 trace READY: `tools/cdb/retail-flatfloor-trace.cdb` — stashes the cell in `$t3` at entry
via `@@c++`, counts at the two RETURN addresses (no `gu`). **STEP 0: verify the +0x218 (ret 3)
/ +0x227 (ret 1) offsets against the `u acclient!BSPTREE::step_sphere_down` disassembly the
script logs, fix if needed, re-attach, THEN have the user wedge ~10s.**
- Interpretation: if floor cell 0171 is mostly **NO-CP** → retail establishes the floor CP via a
DIFFERENT path → next trace breaks `COLLISIONINFO::set_contact_plane` and logs the CALLER
(`poi(@esp)`) for normal≈(0,0,1) to find that path, then port it. If mostly **SET-CP** → the
divergence is inside acdream's `StepSphereDown`/`AdjustSphereToPlane` after all (re-read the
`walk_interp`/`t` math vs ACE).
## Leading hypothesis (UNCONFIRMED, pending v2)
Retail's step-up ALSO "fails" on the settled flat floor (step_sphere_down no-CP) but **recovers
via `step_up_slide` smoothly**, where acdream wedges — so the divergence may be in the SLIDE
RECOVERY (`SpherePath.StepUpSlide``Transition.SlideSphereInternal`, the B1/`slide_sphere`
area, commits `abbd761`/`0935a31`) and/or the connector-cell-0175 `StepSphereUp` interference,
NOT the contact plane itself. The B1/slide fixes are correct FOR THE DOOR; re-investigation is
warranted FOR THE CELLAR recovery only.
## Retail decomp anchors (verified this session)
`CTransition::step_up` pc:273099 (clears CP @273103, calls step_down) · `CTransition::step_down`
pc:272946 (the `if (step_up==0)` lower-gate @272954; `transitional_insert(5)`; accept iff
`!cond:0 && contact_plane_valid` @272968) · `BSPTREE::step_sphere_down` pc:323665 (sets
`contact_plane_valid=1` UNCONDITIONALLY when a poly is found @323711; return 3) ·
`BSPLEAF::find_walkable` pc:326793 · `CPolygon::adjust_sphere_to_plane` pc:322032 ·
`CTransition::transitional_insert` pc:273137 (neg_poly_hit → slide_sphere @273350) ·
`CTransition::validate_transition` pc:272547 · `CTransition::check_other_cells` pc:272717.
## acdream code map (where the fix will likely go)
`BSPQuery.StepSphereDown` (:1216) · `FindWalkableInternal` gate (:736) · `AdjustSphereToPlane`
(:351) · `FindCollisions` StepDown dispatch (:1753) · `StepSphereUp` (:1372) · `StepUpSlide`
(TransitionTypes.cs:472) / `SlideSphereInternal` · `DoStepUp` (:3269) / `DoStepDown` (:3089) ·
step-up acceptance (:3147) · neg_poly dispatch gated `!StepDown && !StepUp` (:1040) ·
`CheckOtherCells` (:1632) · `RunCheckOtherCellsAndAdvance` (:2158).
## Apparatus inventory
**TEMP probes (UNCOMMITTED in worktree, gated on `ACDREAM_PROBE_INDOOR_BSP`, marked STRIP):**
`BSPQuery.NegPolyHitDispatch``[neg-poly]`; `BSPQuery.StepSphereUp``[stepsphereup]`;
`Transition.CheckOtherCells``cn`/`sn`/`negHit` added to `[other-cells]`; `Transition.DoStepDown`
`[stepdown-decide]`.
**Existing env probes:** `ACDREAM_PROBE_INDOOR_BSP=1` (→ `[indoor-bsp]`+`[other-cells]`+the 4 TEMP),
`ACDREAM_DUMP_STEPUP=1` (→ `stepup:`), `ACDREAM_PROBE_CELL=1` (→ `[cell-transit]`),
`ACDREAM_PROBE_STEP_WALK=1` (→ `[step-walk]`, very high volume), `ACDREAM_CAPTURE_RESOLVE=<path>`.
**cdb scripts:** `tools/cdb/retail-lip-trace.cdb` (carried cell — DONE), `tools/cdb/retail-flatfloor-trace.cdb`
(v2, READY). Binary `C:\Turbine\Asheron's Call\acclient.exe` MATCHES `refs/acclient.pdb`.
**Logs (worktree root, UTF-16/big — do NOT commit):** `acdream-corner-capture.jsonl` (321MB),
`launch-corner-{innerflow,slidepoly,negpoly,ssu,decide}.log`, `retail-lip-trace.log`,
`retail-flatfloor-trace.log` (artifact), `corner-cells-audit.txt`. **Analyzers:** `analyze_corner.py`.
## DO NOT
- Re-diagnose as membership / add `ResolveCellId` stickiness (RULED OUT by retail cdb).
- Ship "set the step-down CP without adjustment" (DIVERGES from retail — verified vs decomp).
- Use `gu` inside a cdb bp action (corrupts eax — v1 trace artifact).
- Re-investigate B1/`slide_sphere` AS THE DOOR FIX (correct); but the cellar SLIDE RECOVERY is a
legitimate new suspect.
- Flip `Apparatus_Grounded_50cmOffCenter` to `Assert.True` (synthetic-floor artifact).
- Guess the fix — the divergence is genuinely subtle (`walk_interp`/slide-recovery), pin it first.
## Test baseline
Core 1310 pass / 4 fail / 1 skip (the 4: `Apparatus_Grounded_50cmOffCenter` [synthetic-floor],
2× `DoorBugTrajectoryReplay LiveCompare_*` [captured-buggy-live], `BSPStepUpTests.D4` [airborne
Path 6, separate]); App 177 green. Branch HEAD `664101f` + this session's UNCOMMITTED probes/docs.
## FRESH-SESSION KICKOFF PROMPT (copy-paste)
```
Continue the P2 cellar-lip wedge fix for acdream. Branch claude/thirsty-goldberg-51bb9b (do NOT
branch/worktree; do NOT push without asking; NEVER git stash/gc). PowerShell on Windows; launch
logs are UTF-16. Use superpowers:systematic-debugging.
READ FIRST (in order):
1. docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md (THIS handoff — canonical).
2. memory/project_p2_door_stepup_findings.md (full chain: RE-DIAGNOSIS 2 + SLIDE LOCALIZED +
FAILING CONDITION PINNED + RETAIL trace ATTEMPT #1 entries).
STATE: M1.5. The cellar "blocked at the last step, sometimes through" wedge is RE-DIAGNOSED with
live retail cdb evidence: NOT membership (retail's carried cell flips the same way + is smooth),
NOT a reverted landing. It IS a step-up coin-flip on the FLAT cottage floor — the step-up's
internal step-down sets NO contact plane on the settled flat floor (cpValid=False, walkInterp=1.0)
so the acceptance check rejects, while it works on the ramp slope. acdream's StepSphereDown +
AdjustSphereToPlane are FAITHFUL to retail (verified vs find_walkable pc:326793 + adjust_sphere_to_plane
pc:322032), so the obvious "set the CP anyway" fix is WRONG — retail establishes the flat-floor CP
via a DIFFERENT path that is still unknown.
THE JOB (evidence-first; do NOT guess):
1. Run the READY v2 retail trace tools/cdb/retail-flatfloor-trace.cdb (user relaunches the retail
client + walks to the cellar lip; STEP 0 = verify the +0x218/+0x227 return offsets against the
`u` disassembly the script logs BEFORE driving; NO `gu` in bp actions). Answer: does retail's
step_sphere_down set the CP (ret 3) or not (ret 1) at floor cell 0xA9B40171?
2. If mostly NO-CP → trace COLLISIONINFO::set_contact_plane callers (poi(@esp)) for normal≈(0,0,1)
to find retail's flat-floor CP path; port it. If mostly SET-CP → the divergence is in acdream's
StepSphereDown/AdjustSphereToPlane walk_interp/t math vs ACE. Leading hypothesis: retail's
step-up also "fails" on the flat floor but RECOVERS via step_up_slide smoothly where acdream
wedges → the divergence may be the SLIDE RECOVERY (StepUpSlide/SlideSphereInternal) +
connector-0175 StepSphereUp interference, NOT the CP.
3. RED→GREEN deterministic test + STRIP the 4 TEMP probes once the fix lands. USER VISUAL GATE:
cellar ascent clean (no last-step wedge); inn door still BLOCKS; generic step-up climbs.
DO NOT: re-diagnose as membership / add ResolveCellId stickiness; ship "set the step-down CP
without adjust" (diverges from retail); use `gu` in a cdb bp action; guess.
TEST BASELINE: Core 1310 pass / 4 fail / 1 skip (documented); App 177 green. Branch HEAD 664101f +
UNCOMMITTED TEMP probes (BSPQuery.NegPolyHitDispatch [neg-poly], BSPQuery.StepSphereUp
[stepsphereup], CheckOtherCells cn/sn/negHit, DoStepDown [stepdown-decide]) gated on
ACDREAM_PROBE_INDOOR_BSP.
```

View file

@ -0,0 +1,180 @@
# Handoff — Render Residual A: camera collision (verbatim port of `SmartBox::update_viewer`) — 2026-06-05
## ▶ FRESH-SESSION KICKOFF PROMPT (copy-paste)
```
Continue acdream M1.5 render work: Render Residual A — CAMERA COLLISION (keep the 3rd-person camera
eye inside the player's cell so interior walls stop going grey/transparent from inside). This is a
VERBATIM port of retail SmartBox::update_viewer — no hybrids, no bandaids (master-plan mandate).
Branch claude/thirsty-goldberg-51bb9b (do NOT branch/worktree; do NOT push without asking; NEVER
git stash/gc). PowerShell on Windows; launch logs are UTF-16 (Select-String / rg --encoding utf-16le,
NOT GNU grep). Use superpowers:systematic-debugging; the user pre-approved the verbatim-port APPROACH
and the A→C→B order, so when you reach the design step use superpowers:brainstorming only to present
the concrete port design for sign-off before editing.
READ FIRST (in order):
1. docs/research/2026-06-05-camera-collision-residual-a-handoff.md (THIS file — canonical).
2. docs/research/2026-06-03-membership-and-bluehole-shipped-handoff.md (§3 residuals A/B/C; the
blue-hole DON'T-redo: never re-add a CurrCell write inside ResolveWithTransition/ResolveCellId).
3. docs/superpowers/specs/2026-06-03-verbatim-spatial-pipeline-port-master-plan.md (§C Camera: C1/C3).
4. memory/reference_render_pipeline_state.md + project_camera_visibility_coupling.md.
STATE: M1.5 "indoor world feels right." The cellar-lip step-up wedge is FIXED + visual-verified
(committed cc4590f/9fdf6a5/41db027 — check_other_cells now reads the LIVE sphere position). Per the
plan the next task is Render Residual A: camera collision. User-confirmed problem mapping: Residual A
= interior walls/seams go grey/transparent WHILE INSIDE (the chase eye drifts OUT of the player's
cell → near walls back-face/clip away); Residual C = outside-looking-in glass-box (separate, bigger
DrawPortal phase, do AFTER A); Residual B = particles (smallest, last).
GOAL: port retail SmartBox::update_viewer (0x453ce0, pc:92761) faithfully so [flap-cam] eyeInRoot=y
while inside and interior walls stay opaque. Retail behavior: pivot at player head → (indoor) pick the
PIVOT's cell via CPhysicsObj::AdjustPosition → SWEEP the 0.3 viewer_sphere pivot→sought-eye via a
CTransition, stop at first wall → viewer=curr_pos, viewer_cell=curr_cell → fallback AdjustPosition at
sought-eye → fallback snap-to-player.
KEY FINDINGS (do NOT re-derive):
- find_valid_position (pc:273890) is literally `return find_transitional_position(this)` (pc:273898).
So acdream's SweepEye→ResolveWithTransition→FindTransitionalPosition IS the faithful sweep. The
sweep FUNCTION is NOT the divergence — do not re-port it.
- The sweep + viewer_cell are ALREADY wired (V1): RetailChaseCamera.Update (damped eye → pivot →
CollisionProbe.SweepEye) + PhysicsCameraCollisionProbe.SweepEye (viewer sphere r=0.3, moverFlags
IsViewer|PathClipped|FreeRotate|PerfectClip, gated on CameraDiagnostics.CollideCamera).
- THE BUG (per handoff §3 + the [flap-sweep] probe comment): the sweep RUNS but finds NO wall
(pulledIn≈0, resolved=Y, bsp=ok) → the eye flies to full chase distance (eyeInRoot=n ~90%) in cells
like 0xA9B40174/0175. Root cause of the no-wall-hit is NOT yet pinned.
- GAPS per master-plan C1: (a) faithful START-CELL — retail uses AdjustPosition to find the PIVOT's
cell; acdream passes the player cellId straight in. (b) the two AdjustPosition FALLBACKS are missing.
(c) C3 find_visible_child_cell (pc:311397) is not ported (viewer cell uses the sweep curr_cell —
fine for now). Whether (a)/(b) actually cause the no-wall-hit is UNVERIFIED — pin it with evidence.
THE JOB (evidence-first; the saga lesson = do NOT guess):
1. Live capture: launch with ACDREAM_PROBE_FLAP=1 (+ CameraDiagnostics.CollideCamera on), stand inside
the Holtburg cottage, rotate the chase camera into a back wall. Capture [flap-sweep] (cell/resolved/
bsp/desiredBack/eyeBack/pulledIn/collNormValid) + [flap-cam] (root/eyeInRoot). Use the probe-comment
fork in PhysicsCameraCollisionProbe.cs to read WHY: pulledIn≈0 + bsp=ok ⇒ the sweep reaches no wall
geometry in the candidate set (clip/candidate-cell issue or wrong start cell); resolved=n/bsp=nobsp
⇒ collision can't run there (cell/BSP not loaded).
2. Diagnose the no-wall-hit from the capture (likely: the sweep's candidate-cell set doesn't include
the wall's cell, OR the start cell is wrong because AdjustPosition isn't seating the pivot). Confirm
against retail update_viewer before changing anything.
3. Port verbatim: the faithful start-cell (AdjustPosition for the pivot's cell, indoor branch) + the
two AdjustPosition fallbacks, plus whatever the capture proves is the no-wall-hit cause. Consider a
DETERMINISTIC SweepEye test (cell fixture + seed pivot/eye, assert the sweep stops at the wall) —
the CellarLipWedgeTests pattern made the stairs fix iterable in <200ms; do the same here.
4. VALIDATE: eyeInRoot=y inside; build + Core(1317p/4f/1s)/App green. VISUAL GATE: stand inside the
cottage + rotate — interior walls stay solid (no grey/transparent, no NPCs/particles through walls);
inside-looking-out still correct (don't regress the fixed flap); generic outdoor chase unaffected.
DO NOT: guess / speculative-edit (the saga's failure mode); re-add a CurrCell write inside
ResolveWithTransition/ResolveCellId (the blue-hole clobber — CurrCell is player-only via
UpdatePlayerCurrCell); conflate A (camera-eye containment, this task) with C (DrawPortal outside-
looking-in, next task); re-port find_valid_position/the sweep (it's faithful).
TEST BASELINE: Core 1317 pass / 4 fail (documented: Apparatus_Grounded_50cmOffCenter, 2×
DoorBugTrajectoryReplay LiveCompare_*, BSPStepUpTests.D4) / 1 skip. App green. Branch HEAD 41db027.
```
---
## 1. Session summary (2026-06-05)
**Shipped + visual-verified: the P2 cellar-lip step-up wedge.** Root cause = `Transition.CheckOtherCells`
collided the other cells against a STALE `footCenter` snapshotted before the primary collide; after a
step-up climbed the foot onto the cottage floor, the stale (pre-climb, penetrating) position spuriously
near-missed that floor → a doomed second step-up → revert → 0% advance. Fix: re-read
`footCenter = sp.GlobalSphere[0].Origin` in `RunCheckOtherCellsAndAdvance` (retail `check_other_cells`
reads the live `sphere_path.global_sphere`, pc:272735). 0/29 → 20/29 captured wedge frames climb; zero
regression. User visual-gate: **"Yes all works!"** (cellar smooth, door blocks, step-up climbs).
Commits `cc4590f` (fix + tests) / `9fdf6a5` (strip probes) / `41db027` (visual-gate note). Full writeup
+ the disproven prior framings: [`2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md`](2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md)
(top banner) + memory `project_p2_door_stepup_findings`.
**Then: picked + scoped the next task (this handoff).** Per the plan the next step after the collision
fix is Render Residual A — camera collision. Aligned with the user on the problem statement (the two
symptoms → residuals A/C) and the approach (verbatim port of `SmartBox::update_viewer`, A→C→B order).
Did the read-only investigation below; NO camera code changed (next session implements after the
evidence-first diagnosis).
## 2. The problem (user-confirmed)
| Symptom (user words) | Residual | Cause | Fix |
|---|---|---|---|
| Inside a building, walls/seams flicker grey/transparent; can see through walls | **A** (this task) | 3rd-person chase eye drifts OUTSIDE the player's cell → near walls seen from their back-faces → culled | camera collision: sweep the eye, stop at the wall, keep it in the cell |
| Outside looking in through a doorway, building is a see-through glass box; ground over the floor | **C** (next) | outdoor→interior portal render (retail `DrawPortal`) not built | build that render phase |
| Particles bleed through floor | **B** (last) | scene particles not cell-clipped (#104) | cell-link the emitters |
Order **A → C → B**: A is smaller + builds the shared "which cell is the viewpoint in" machinery that C
also needs (shrinks C). Mechanism for "transparent wall" = **back-face culling** (a wall is a one-sided
sheet facing into the room; from outside the room you see its culled back) + the renderer drawing from
the **viewer's cell** then flooding portals (so the viewer's cell must be right).
## 3. Retail target — `SmartBox::update_viewer` (0x453ce0, pc:92761)
Decoded this session (read the decomp directly for the verbatim port):
1. If `player->cell == 0``reenter_visibility`; still 0 → `set_viewer(player_pos, 1)`, `viewer_cell=null`, return.
2. Compute the desired eye (`viewer_sought_position`) from the pivot (head + `pivot_offset`).
3. **Start cell:** if player indoor (`objcell_id >= 0x100`), `CPhysicsObj::AdjustPosition(&var_90, &viewer_sphere, &cell_1, 0, 1)` to find the PIVOT's cell; success → `cell = cell_1`, else `cell = player->cell`. Outdoor → `cell = player->cell`.
4. **Sweep:** `makeTransition``init_object(player, 0x5c)``init_sphere(1, &viewer_sphere, 1.0)` (ONE sphere) → `init_path(cell_1, pivot, sought_eye)``find_valid_position`.
- success → `set_viewer(curr_pos, 0)`, `viewer_cell = sphere_path.curr_cell`, return.
- **fallback 1:** `AdjustPosition(sought_eye, &viewer_sphere, &var_170, 0, 1)``set_viewer(var_120, 0)`, `viewer_cell = var_170`, return.
- **fallback 2:** `set_viewer(player_pos, 1)`, `viewer_cell = null`.
- `0x5c` = `IsViewer | PathClipped | FreeRotate | PerfectClip` (PathClipped = hard-stop at first contact).
- **`find_valid_position` (pc:273890) = `return find_transitional_position(this)` (pc:273898)** — the
sweep is the ordinary transition; acdream's `ResolveWithTransition` is faithful to it. **The sweep
function is NOT the divergence.**
## 4. acdream current state (V1, partial)
- `RetailChaseCamera.Update` ([src/AcDream.App/Rendering/RetailChaseCamera.cs:102](../../src/AcDream.App/Rendering/RetailChaseCamera.cs)):
damps `_dampedEye`; `pivotWorld = playerPos + (0,0,1.5)`; if `CameraDiagnostics.CollideCamera &&
CollisionProbe != null` → `swept = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId)`;
`publishedEye = swept.Eye`, `ViewerCellId = swept.ViewerCellId`. (Collides into a LOCAL, leaves
`_dampedEye` clean to avoid wall-press oscillation — keep that.)
- `PhysicsCameraCollisionProbe.SweepEye` ([src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:24](../../src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs)):
shifts pivot/eye down by the radius (InitPath sphere-center convention), `ResolveWithTransition`
(viewer sphere r=0.3, height 0, isOnGround=false, body=null, moverFlags
`IsViewer|PathClipped|FreeRotate|PerfectClip`, `movingEntityId=selfEntityId`), returns swept eye +
`r.CellId`. **Passes the player `cellId` straight in — does NOT do retail's AdjustPosition pivot-cell;
has NO AdjustPosition fallbacks.**
- The `[flap-sweep]` probe (in SweepEye, gated `RenderingDiagnostics.ProbeFlapEnabled` = `ACDREAM_PROBE_FLAP`)
+ the builder's `[flap-cam]`/`[flap]`/`[shell]`/`[vis]` probes are the diagnosis apparatus — already
in the tree.
## 5. The gap to pin (next session, evidence-first)
The symptom is "sweep runs, finds no wall" (`pulledIn≈0`, `eyeInRoot=n ~90%`). Candidates, in order of
suspicion:
1. **Start cell** — acdream passes the player cell; retail seats the start cell at the PIVOT via
`AdjustPosition`. If the pivot/eye path's walls live in a cell that isn't the start cell and isn't
reached by the sweep's `check_other_cells` candidate set, the sweep misses them. (Most likely +
matches master-plan C1's "faithful start-cell" gap.)
2. **Candidate-cell tracking across the multi-step sweep** — the eye is ~2.6 m behind the player and the
sweep subdivides; if the carried cell doesn't advance into the wall's cell, the wall poly is never
in the per-cell BSP queried. (Related to the Stage-1 membership work; the player path now tracks the
carried cell correctly — verify the camera sweep does too.)
3. **AdjustPosition missing** — fallbacks aside, retail's start-cell AdjustPosition may be what seats
the sweep so it engages geometry; acdream has no AdjustPosition port at all (check
`CPhysicsObj::AdjustPosition`).
Pin with the live `[flap-sweep]` capture FIRST, then port. A deterministic `SweepEye` test (cottage
cell fixture, seed pivot inside + eye behind the back wall, assert the swept eye stops at the wall and
`ViewerCellId` stays the room) would make this iterable like the cellar-lip fix.
## 6. Apparatus + anchors
- **Probes:** `ACDREAM_PROBE_FLAP=1``[flap-sweep]` (PhysicsCameraCollisionProbe) + `[flap-cam]`/
`[flap]`/`[shell]`/`[vis]` (CellVisibility / the render builder). `CameraDiagnostics.CollideCamera`
toggles the spring-arm.
- **Decomp anchors:** `SmartBox::update_viewer` 0x453ce0 pc:92761 · `find_valid_position` pc:273890 →
`find_transitional_position` pc:273613 · `CPhysicsObj::AdjustPosition` (grep the decomp) ·
`CEnvCell::find_visible_child_cell` 0x52dc50 pc:311397 (C3, viewer child cell — not yet ported,
optional for A).
- **DON'T-redo:** the blue-hole fix (`UpdatePlayerCurrCell` player-only render-root write) — never
re-add a `CurrCell` write in `ResolveWithTransition`/`ResolveCellId`. Don't conflate A with C.
## 7. Brainstorming state (for the fresh session)
Approach + order are USER-APPROVED (verbatim port of `update_viewer`; A→C→B). The brainstorming design
step was NOT completed — resume by doing the evidence-first diagnosis (§5), then present the concrete
port design (start-cell + fallbacks + the no-wall-hit fix) for sign-off before editing (HARD-GATE:
no code until the design is approved).

View file

@ -0,0 +1,216 @@
# Handoff — Render Residual A SHIPPED; next = the CORE inside render (R1 completion) — 2026-06-05
> **Canonical pickup for the next (render) session. Read this FIRST.** Residual A (camera collision)
> is a faithful verbatim port of `SmartBox::update_viewer` — shipped, tested, user-kept. It made the
> render's viewpoint *accurate*, which **exposed** the real next problem with precision: the inside
> render does not flood/seal correctly from the (now-correct) viewer cell. The "step C" the user asked
> for is therefore **not** the handoff-era "C / outside-looking-in" — it is the **core inside render
> (R1 completion)**. Branch `claude/thirsty-goldberg-51bb9b`. PowerShell on Windows; launch logs are
> UTF-16 (`Select-String` / `rg --encoding utf-16-le`, NOT GNU grep).
---
## 0. TL;DR
- **SHIPPED (A):** verbatim `SmartBox::update_viewer` — indoor start-cell seated at the head-pivot
(`AdjustPosition``find_visible_child_cell`) + the two fallbacks + cellId==0 snap-to-player.
Commits `0ffc3f5` (spec) / `5177b54` (Core primitives) / `9e70031` (SweepEye orchestration). TDD,
11 new tests, no regression. User-kept (chose "Keep it" over revert).
- **The live-capture finding that scoped A:** A's V1 sweep *already* contained the eye (`eyeInRoot=Y`
99.75%, `viewerCell` never 0, indoor collide 97.6%). So A is a faithfulness completion, not a
visible-bug fix. The dominant inside-cottage **bluish void / see-through-to-other-buildings is NOT
the camera — it is the render seal.**
- **What A EXPOSED (the handoff's whole point):** the render roots at the **viewer cell**
(`clipRoot = visibility.CameraCell`, GameWindow.cs:7322; Phase W V1 "one viewpoint"). A made that
cell *accurate* (the eye's real, collided cell). So when you stand in the cellar but the eye is up
in the room, `clipRoot = the room`, and the PVS flood from the room **does not reach the cellar**
**the cellar floor drops.** Before A, `viewerCell ≈ playerCell` (the sweep started from the feet
cell), which *accidentally masked* this. The user accepted this interim floor-drop to keep the
faithful viewpoint.
- **NEXT = the core inside render (R1 completion):** make `DrawInside` flood + seal correctly from the
viewer cell. This fixes BOTH the **bleed** (point 1) AND the **floor** A exposed — they are the same
family. The locked design already exists:
[`docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md`](../superpowers/specs/2026-06-02-render-pipeline-redesign-design.md).
- **Test baseline:** Core **1326 pass / 4 fail (documented) / 1 skip**; App **179 pass / 0 fail**.
The 4 Core fails are pre-existing (2× `DoorBugTrajectoryReplay` LiveCompare, `BSPStepUpTests.D4`,
`DoorCollisionApparatus`).
---
## 1. What shipped — Residual A (verbatim `SmartBox::update_viewer`, pc:92761)
| Commit | What | Layer |
|---|---|---|
| `0ffc3f5` | design spec (`docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md`) | docs |
| `5177b54` | `CellTransit.FindVisibleChildCell` (CEnvCell::find_visible_child_cell pc:311397) + `PhysicsEngine.AdjustPosition` (CPhysicsObj::AdjustPosition pc:280009) | Core |
| `9e70031` | `ResolveResult.Ok` (surfaces `find_valid_position != 0`) + `PhysicsCameraCollisionProbe.SweepEye` orchestration (start-cell seating + fallback 1 + fallback 2 + cellId==0 snap-to-player) | Core + App |
The orchestration mirrors `update_viewer` end-to-end: indoor (`objcell_id >= 0x100`) seats the sweep's
start cell at the **head-pivot** via `AdjustPosition` (the cellar lip: feet in the low connector, head
up at floor level); sweep `pivot → sought-eye` from that start; on success `set_viewer(curr_pos)`,
`viewer_cell = curr_cell`; fallback 1 = `AdjustPosition(sought_eye)`; fallback 2 / no-cell = snap to
player, `viewer_cell = null`. `SweepEye` gained a `playerPos` arg (for the snap).
**Why A's visible payoff was nil this session (don't be surprised):** the seating only differs from the
feet cell when the feet are in a *thin connector* cell while the head is in a taller neighbour (the lip
— a transient). Two live captures: in the cottage room every frame had `start == cell` (0 seated of
80,605); in the cellar the seating *did* fire (1,687 frames, `start != cell`). No fallback ever fired
(`ok=False`: 0). So A is faithful + correct; its job was to make the viewpoint accurate, which it did.
## 2. The exposure (READ THIS — it is the bridge to the next phase)
The render roots + projects from **one viewpoint = the viewer cell** (Phase W V1, GameWindow.cs:7322,
7330-7333: *"the render root (clipRoot = the viewer cell). ONE viewpoint"*). `PortalVisibilityBuilder.Build(clipRoot, viewerEye, …)`
floods the PVS from `clipRoot`.
A changed `viewerCell` from `≈ playerCell` (V1, sweep from the feet cell) to **the eye's actual cell**
(seated at the pivot). Live proof, player in the cellar (`playerCell=0xA9B40174`):
```
[flap-cam] root=0xA9B40171 viewerCell=0xA9B40171 playerCell=0xA9B40174 eye=(155,12,96.5) player=(153,9,93) ×1466 frames
```
`clipRoot = viewerCell = 0171` (the room, where the eye is) while the player is in `0174` (the cellar).
The PVS flood from the room **does not reach the cellar** → the cellar floor (the player's cell) is not
drawn → **missing floor**. This is exactly the spec's predicted symptom: §1.3 + §8 call the grey/missing
cellar floor a **sealing bug** (the closed cell mesh not covering pixels, OR the flood not reaching the
cell). **Cause unconfirmed — confirm it first (evidence-first, §4 below).**
The **bleed** (point 1: walls bluish, other buildings/particles/NPCs visible through them from inside)
is the same family: the inside path still draws the **whole outdoor world** then layers cell shells on
top, instead of retail's "inside → `DrawInside` only" (spec §2). A faithful `DrawInside` makes the bleed
impossible *by construction*.
## 3. The reframing — "C" is the CORE inside render, not outside-looking-in
The handoff-era residual letters (A camera / B particles / **C outside-looking-in**) map onto the locked
render spec's phases as: A camera (DONE), B → **R1b** (#104 particles), **C → R2** (`DrawPortal`,
street→interior). **R2 is a LATER phase.** The visible problems the user is hitting — the bleed AND the
floor A exposed — are **R1 (the core per-cell `DrawInside` flood+seal)**, which shipped only *partially*
(2026-06-03: the flap fix + basic shells + inside-looking-out, but NOT the "inside → DrawInside only"
inversion, NOT the general-case flood from `viewerCell != playerCell`). **The next session finishes R1.**
## 4. The job (next session) — evidence-first, then verbatim PView
1. **Read the locked design + its 4 research docs (in the spec's "Read first" list):**
`docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md`
`docs/research/2026-06-02-render-pipeline-redesign-handoff.md` (root cause, the three-gate failure) →
`…-retail-render-pipeline-full-reference.md` (the PView + `DrawCells` seal) →
`…-acdream-render-pipeline-inventory-and-failures.md` (the `WbDrawDispatcher.cs:1756` bypass, the
parallel BFS, the terrain Skip model) → `…-render-reference-crosscheck.md` (why WB two-pipe is wrong).
2. **Confirm the floor cause FIRST (do NOT guess — spec §8):** launch with `ACDREAM_PROBE_FLAP=1`
`ACDREAM_PROBE_VIS=1` `ACDREAM_PROBE_SHELL=1`; stand in the cellar, get the eye up in the room
(`viewerCell=room`, `playerCell=cellar`). Read `[vis]` (does `OrderedVisibleCells` include the cellar
when rooted at the room?) + `[shell]` (does the cellar shell draw?) + dump the cellar EnvCell mesh
(is the floor polygon present + front-facing?). This decides: **PVS-flood-not-reaching** vs
**cell-mesh-not-sealing**. Get a screenshot EARLY (memory `render-one-gate`).
3. **Port the PView seal/flood verbatim (spec §2 + §4):** the binary top-level decision (inside →
`DrawInside` only — removes the global outdoor pass → kills the bleed by construction) + the per-cell
`DrawInside` loop (landscape-through-door → conditional Z-only clear → per-cell shells → per-cell
objects → per-cell particles). Retail anchors: `RenderNormalMode` 0x453aa0, `PView::DrawInside`
0x5a5860, `ConstructView` 0x5a57b0, `DrawCells` 0x5a4840 (spec §11 has the full index).
4. **VALIDATE — visual gate (spec R1 gate):** Holtburg cottage + cellar — sealed interior (opaque walls,
**solid floor**, ceiling), sky/terrain through the door only, **no bluish void, no bleed** (no other
buildings/particles/NPCs through walls), no terrain under the floor. Build + Core(1326p/4f/1s)/App(179p) green.
## 5. KEEP / DON'T-REDO
**KEEP (do not reopen):**
- **Residual A** (the 3 commits). The viewer cell is now accurate — that is the *input* the render needs.
Do NOT revert it to mask the floor (the user explicitly chose to keep the faithful viewpoint).
- The Phase W V1 "one viewpoint" (clipRoot = viewer cell, project from the eye) — GameWindow.cs:7322-7338.
- The Stage-1 membership port + the blue-hole fix (`UpdatePlayerCurrCell`, player-only render-root).
- `PortalVisibilityBuilder` / `ClipFrame` / `EnvCellRenderer` / `TerrainModernRenderer` / the WB mesh
pipeline (spec §3 KEEP).
**DON'T:**
- Don't re-add a `CurrCell` write inside `ResolveWithTransition` / `ResolveCellId` (the blue-hole clobber).
- Don't reintroduce the WB two-pipe stencil / `isInside` gate / AABB grace-frame for the root (spec §8).
- Don't relax the faithful terrain `Skip` to "fix" the grey floor (spec §1.3 — it's a sealing bug, not a
terrain-clip bug).
- Don't jump to R2 (outside-looking-in / `DrawPortal`) — R1 (the inside seal+flood) is first.
## 6. KEY FILES + ANCHORS
```
RENDER (the next phase)
src/AcDream.App/Rendering/GameWindow.cs (OnRender ~7300-7610) ← clipRoot=viewerCell (7322); binary decision lives here
src/AcDream.App/Rendering/PortalVisibilityBuilder.cs ← the PVS BFS (KEEP; the flood to harden/port)
src/AcDream.App/Rendering/InteriorRenderer.cs ← per-cell DrawInside loop (partial)
src/AcDream.App/Rendering/EnvCellRenderer.cs ← per-cell shell mesh (Render(pass,{cellId}))
src/AcDream.App/Rendering/WbDrawDispatcher.cs (~1756) ← the ParentCellId==null bypass to delete
src/AcDream.App/Rendering/ClipFrameAssembler.cs / CellVisibility.cs
docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md ← LOCKED design (§2 model, §4 seal, §7 phases, §11 anchors)
CAMERA (A — shipped, the input)
src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs ← SweepEye = verbatim update_viewer
src/AcDream.Core/Physics/CellTransit.cs FindVisibleChildCell ← pc:311397
src/AcDream.Core/Physics/PhysicsEngine.cs AdjustPosition ← pc:280009
PROBES
ACDREAM_PROBE_FLAP=1 [flap-cam] root/viewerCell/playerCell/eyeInRoot + [flap] PVS BFS + [flap-sweep] camera (start vs cell, ok)
ACDREAM_PROBE_VIS=1 [vis] OrderedVisibleCells + OutsideView
ACDREAM_PROBE_SHELL=1 [shell] per-cell shell draw
ACDREAM_PROBE_CELL=1 [cell-transit] player CellId changes
```
## 7. RUNNING THE CLIENT (PowerShell; `+Acdream` spawns in the Holtburg cottage)
```powershell
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"
$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"
$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword"
$env:ACDREAM_PROBE_FLAP="1"; $env:ACDREAM_PROBE_VIS="1"; $env:ACDREAM_PROBE_SHELL="1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug *>&1 | Tee-Object -FilePath launch.log
```
Build green BEFORE launching. Logs are UTF-16. Close gracefully (✕ / Alt+F4) so ACE clears the session in ~3-5s.
## 8. KICKOFF PROMPT (copy-paste for the next session)
```
Continue acdream M1.5 render work: the CORE INSIDE RENDER (R1 completion) — make DrawInside flood + seal
correctly from the viewer cell so the cottage/cellar interior is SEALED: no bluish void, no see-through-to-
other-buildings BLEED, and the CELLAR FLOOR draws. This is what the user means by "step C" — it is NOT the
handoff-era "C / outside-looking-in" (that is R2, a later phase). Branch claude/thirsty-goldberg-51bb9b
(do NOT branch/worktree; do NOT push without asking; NEVER git stash/gc). PowerShell on Windows; launch
logs are UTF-16 (Select-String / rg --encoding utf-16-le, NOT GNU grep). Use superpowers:systematic-
debugging; the render redesign DESIGN is already LOCKED (read the spec), so this is execution + an
evidence-first floor-cause confirmation, not a re-brainstorm.
READ FIRST (in order):
1. docs/research/2026-06-05-render-residual-a-shipped-core-inside-render-handoff.md (THIS handoff — what
A shipped, what A EXPOSED §2, the reframing §3, the job §4, KEEP/DON'T §5, files §6).
2. docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md (the LOCKED design — §2 the one
model, §4 the seal mechanics, §7 phases/gates, §11 decomp anchors) + its 4 "Read first" research docs.
3. memory: reference_render_pipeline_state.md, feedback_render_one_gate.md,
feedback_render_downstream_of_membership.md, feedback_verify_render_seal_before_layering.md.
STATE: Residual A (camera collision) SHIPPED + user-kept (commits 0ffc3f5/5177b54/9e70031) — verbatim
SmartBox::update_viewer; the viewer cell is now ACCURATE. That accuracy EXPOSED the next problem: the
render roots at the viewer cell (clipRoot=visibility.CameraCell, GameWindow.cs:7322), and the PVS flood
from the viewer cell does NOT reach the player's cell when they differ (eye in the room, player in the
cellar → clipRoot=room → cellar floor not drawn). Same family as the bleed (the inside path still draws
the whole outdoor world instead of "inside → DrawInside only").
THE JOB (evidence-first, then verbatim PView):
1. Confirm the floor cause FIRST (spec §8 flags it unconfirmed): launch with ACDREAM_PROBE_FLAP/_VIS/_SHELL,
stand in the cellar with the eye up in the room (viewerCell=room, playerCell=cellar), read [vis]
(is the cellar in OrderedVisibleCells when rooted at the room?) + [shell] + dump the cellar EnvCell
mesh (floor poly present + front-facing?). Decide PVS-flood-not-reaching vs cell-mesh-not-sealing.
Screenshot EARLY.
2. Port the PView seal/flood verbatim (spec §2 + §4): the binary top-level decision (inside → DrawInside
ONLY — removes the global outdoor pass → kills the bleed by construction) + the per-cell DrawInside loop
(landscape-through-door → conditional Z-only clear → per-cell shells → per-cell objects → per-cell
particles). Anchors: RenderNormalMode 0x453aa0, PView::DrawInside 0x5a5860, ConstructView 0x5a57b0,
DrawCells 0x5a4840.
3. VALIDATE — visual gate: Holtburg cottage + cellar sealed (opaque walls, SOLID FLOOR, ceiling), sky/
terrain through the door only, NO bluish void, NO bleed. Build + Core(1326p/4f/1s)/App(179p) green.
DON'T: revert A to mask the floor (user chose to keep the faithful viewpoint); re-add a CurrCell write in
ResolveWithTransition/ResolveCellId (blue-hole); reintroduce the WB two-pipe / isInside gate / AABB grace
(spec §8); relax the faithful terrain Skip (spec §1.3 — it's a sealing bug); jump to R2 (outside-looking-in)
before R1 (the inside seal+flood) is done.
TEST BASELINE: Core 1326 pass / 4 fail (documented: 2× DoorBugTrajectoryReplay LiveCompare, BSPStepUpTests.D4,
DoorCollisionApparatus) / 1 skip. App 179 pass / 0 fail. Branch HEAD 9e70031 (+ this handoff commit).
```

View file

@ -0,0 +1,135 @@
# Retail PView Indoor Render Pseudocode (2013 EoR)
This note pins the indoor render port to the named retail decomp. The goal is
behavioral fidelity: modern GL renderers may supply the draw calls, but the
frame ownership, visibility graph, and draw order follow these functions.
## SmartBox::RenderNormalMode @ 0x00453aa0
```text
if render device has open scene:
outside = SmartBox::is_player_outside(player position)
seenOutside = outside || viewer_cell.seen_outside
set FOV/view distance
if !outside:
if seenOutside:
LScape::update_viewpoint(lscape, Position::get_outside_cell_id(viewer))
Render::update_viewpoint(viewer)
RenderDeviceD3D::DrawInside(viewer_cell)
else:
LScape::update_viewpoint(lscape, viewer.objcell_id)
Render::update_viewpoint(viewer)
Render::set_default_view()
Render::useSunlightSet(1)
LScape::draw(lscape)
FlushAlphaList()
run targeting/render callbacks
```
Important split: the top-level branch follows `is_player_outside`, while indoor
render calls `DrawInside(viewer_cell)`.
## RenderDeviceD3D::DrawInside @ 0x0059f0d0
```text
PView::DrawInside(RenderDeviceD3D::indoor_pview, viewer_cell)
```
This is a thin forwarder. The PView owns the indoor frame.
## PView::DrawInside @ 0x005a5860
```text
reset object scale
CEnvCell::curr_view_push(root_cell)
PView::add_views(root_cell.num_stabs, root_cell.stab_list)
Frame::cache()
Render::positionPush(root identity frame)
Render::copy_view(root_cell.portal_view[last], null, 4) # full-screen root view
forceClear = PView::ConstructView(root_cell, 0xffff)
PView::DrawCells(forceClear)
Render::framePop()
PView::remove_views(root_cell.num_stabs, root_cell.stab_list)
root_cell.num_view--
```
## PView::ConstructView(CEnvCell*) @ 0x005a57b0
```text
clear outside_view and cell draw/todo state
insert root cell into distance-priority todo list
while todo is not empty:
cell = pop nearest
append cell to cell_draw_list
InitCell(cell, otherPortalId)
project/clip each portal against the current cell view
exit portals append clipped polygons to outside_view
interior portals append clipped polygons to neighbor portal_view
newly discovered neighbors enter the todo list once
return forceClear flag
```
`cell_draw_list` is the only indoor membership source. Later growth can add view
polygons to a discovered cell, but does not create a second draw-list entry.
## PView::DrawCells @ 0x005a4840
```text
if outside_view.view_count > 0:
Render::useSunlightSet(1)
Render::PortalList = this
LScape::draw(lscape) # landscape clipped by outside_view
D3DPolyRender::FlushAlphaList(0)
render_device.frameStamp++
if forceClear || portalsDrawnCount != 0:
render_device.Clear(DepthOnly)
# Loop 1: exit portal masks, reverse cell_draw_list
for cell in reverse(cell_draw_list):
if cell.structure.drawing_bsp:
push cell frame and surfaces
for each current portal_view slice:
CEnvCell::setup_view(cell, slice)
for each exit portal:
DrawPortalPolyInternal(portal polygon)
pop frame
Render::useSunlightSet(0)
Render::restore_all_lighting()
# Loop 2: closed cell shells, reverse cell_draw_list
for cell in reverse(cell_draw_list):
if cell.structure.drawing_bsp:
push cell frame and surfaces
for each current portal_view slice:
CEnvCell::setup_view(cell, slice)
DrawEnvCell(cell)
pop frame
# Loop 3: cell object lists, reverse cell_draw_list
for cell in reverse(cell_draw_list):
Render::PortalList = cell.portal_view[last]
DrawObjCellForDummies(cell)
restore object scale
Render::useSunlightSet(1)
```
There is no global indoor object, terrain, sky, weather, or particle pass. Every
visible indoor object comes from the cell draw list, and the landscape appears
only through `outside_view`.
## RenderDeviceD3D::DrawObjCellForDummies @ 0x005a0760
```text
for object in cell.object_list:
draw object under Render::PortalList
attached effects/particles follow the owning object visibility
```
acdream maps this to per-cell `WorldEntity.ParentCellId` buckets. Parentless
live objects must not bypass the indoor PView graph.

View file

@ -0,0 +1,239 @@
# Handoff — Indoor SHELL-SEALING / wrong-flood-root (cellar floor + interior walls drop to grey) — 2026-06-05 (PM/eve)
> **For the next session/model. Read this FIRST.** The previous model (me) spent this session on the
> "viewer-cell flicker" 3-part plan, shipped two real-but-partial fixes, and **conflated two distinct
> problems**. The user's PRIMARY pain — **interior walls + cellar floor render as grey background with
> dynamic objects / outdoor slices showing through, plus flicker when changing rooms / entering the
> cellar** — is the **KNOWN, already-documented "R1-completion" problem**, NOT the boom and NOT the
> branch I touched. Get a SCREENSHOT + `[shell]` evidence EARLY; do not re-litigate the disproven causes
> below.
>
> **Tree:** branch `claude/thirsty-goldberg-51bb9b`, worktree
> `C:/Users/erikn/source/repos/acdream/.claude/worktrees/thirsty-goldberg-51bb9b`. HEAD `2b7f5a1`.
> Do NOT branch/worktree. Do NOT push without asking. NEVER `git stash`/`gc`. PowerShell on Windows;
> launch logs are UTF-16 (use `Select-String`/`Get-Content`, they handle it).
---
## 0. The honest TL;DR
- The user reports (with a screenshot): standing **inside** the cottage, the **floor draws but the walls
are grey** (the time-of-day clear color), with NPCs/doors/chests and a slice of the **outdoor world**
floating in the grey. In the **cellar**, the **floor is missing** (just grey). It **flickers** when
changing rooms / entering the cellar. This persisted through both of this session's fixes.
- **Decisive evidence (live, this session):**
```
[flap-cam] root=0xA9B40171 viewerCell=0xA9B40171 playerCell=0xA9B40174 eyeInRoot=Y
eye=(153.46,6.66,94.92) player=(153.58,8.88,92.76) terrain=Planes outVisible=True
[flap] root=0xA9B40171 ... | p0->0170 proj=4 | p1->0173 proj=0 | p2->0175 proj=5 || outPolys=1 vis=4
```
The render **roots the visibility flood at the CAMERA's cell `0171`** (the room, z≈95), but the
**PLAYER is in `0174`** (the cellar, z≈93). The flood reaches `vis=4` cells **from the room**; the
player's actual cell `0174` is **not sealed** by it (the stair-chain portal `p1→0173` is `proj=0`).
So the cellar shell around the player isn't drawn → grey.
- **This is exactly what the 2026-06-05 Residual-A handoff already flagged as NEXT:** *"when the player
is in the cellar but the eye is up in the room, clipRoot = room → the PVS flood from the room does NOT
reach the cellar → the cellar floor drops."* And the 2026-06-02 design doc §3 calls the grey a
**SHELL-SEALING bug** ("the closed cell mesh is not covering those pixels"). **The answer was in the
docs the whole time.**
- **Two fixes shipped this session (both real, both PARTIAL, neither is the cellar/walls fix):**
- `d2212cf` — Part 1 camera boom convergence snap (retail `UpdateCamera` 0x00456fcd). Freezes the
at-rest boom drift. Test-covered. The user confirmed the at-rest room was **never** flickering, so
this fixed a real-but-invisible thing. KEEP (faithful) or reassess.
- `2b7f5a1` — branch inside/outside on `is_player_outside` (retail `RenderNormalMode` 0x453aa0) instead
of the camera cell. **Reduced** the player-OUTSIDE doorway grey ("reduced a lot" — user). Test-covered.
Does NOT touch the player-inside case. KEEP (no regression — see §4) or reassess.
---
## 1. The two problems (do NOT conflate them — I did, and it cost the session)
| | Problem A — doorway transition grey | Problem B — interior shell not sealed (THE user's pain) |
|---|---|---|
| **When** | Player **fully outside** (landcell `<0x100`), camera lags **inside** the doorway | Player **inside** cell X, camera **inside** a DIFFERENT cell Y (camera in room, player in cellar) |
| **Why** | Branch keyed on camera → wrongly ran `DrawInside` rooted at the threshold | Flood roots at the camera's cell Y; the player's cell X isn't in/sealed by the flood from Y |
| **Symptom** | Whole screen grey at the in↔out threshold | Walls/cellar-floor grey while standing inside; flicker at room/cellar transitions |
| **Status** | **Reduced** by `2b7f5a1` (retail-verified) | **UNSOLVED** — this is the real target |
| **Evidence** | `outPolys=0` while the exit portal projects full-screen (`proj=6 clip=8`) | `root=0171` vs `playerCell=0174`; `vis=4` from the room excludes the cellar |
**Problem B is the one to fix.** Problem A's fix is a genuine retail-faithful improvement that happens to
share a "grey" symptom, which is what made me conflate them.
---
## 2. Problem B — what we know, and the core open question
**The render roots the PView flood at `clipRoot = visibility.CameraCell` (the VIEWER/camera cell)** —
`GameWindow.cs:7322` (now wrapped by the `ShouldRenderIndoor` branch, but still the **viewer cell** when
indoor). Retail `RenderNormalMode` (0x453aa0:92675) literally calls `DrawInside(this->viewer_cell)`. So
rooting at the viewer cell is retail-faithful **on its face**.
**But** when the 3rd-person camera is in a different interior cell than the player (room vs cellar), the
flood from the camera's cell does **not** seal the player's cell → the geometry **around the player**
(cellar floor, near walls) isn't drawn → grey. The decisive `[flap-cam]` line (`root=0171`,
`playerCell=0174`) is the whole story.
**The core open question the next model MUST resolve against the decomp (don't guess — this is the
crux):** how does **retail** avoid this? Candidate mechanisms, each needs decomp verification:
- **(a) Retail's collided camera stays in (or floods to) the player's cell.** Residual A
(`SmartBox::update_viewer` 0x00453ce0, swept `viewer_sphere`) is shipped, but verify: does retail's
`viewer_cell` actually equal the player's cell when the player is in the cellar and the boom would put
the eye up in the room? The spring arm sweeps from the **head-pivot** — if the pivot is the player's
head in the cellar, the swept eye may be stopped by the cellar ceiling and stay in `0174`. **Check
whether acdream's `viewer_cell` SHOULD be `0174` here and isn't** (i.e. the camera-collision/cell-
resolution is putting the eye in `0171` when retail would keep it in `0174`).
- **(b) Retail's flood from the viewer cell DOES reach the player's cell**, because retail's portal clip
is robust and the stair-chain portals don't go `proj=0`. Here `p1→0173 proj=0` stops the flood. Is
`0174` reachable only through `0173` (which is culled)? If so, the flood-reach is the bug, not the root.
- **(c) The design doc §5 intent: visibility roots at the PLAYER's physics cell** (`CellGraph.CurrCell`),
**eye drives projection only.** The 2026-06-05 viewer-cell handoff said *don't* switch the root to the
player cell ("retail roots `DrawInside` at viewer_cell"). **This is a real contradiction in our own
docs** — design doc §5 says player-cell-roots-visibility; the later handoff says viewer-cell-roots.
The next model should settle it from the decomp: in `RenderNormalMode`, `viewer_cell` is the argument
to `DrawInside` — but WHAT sets `viewer_cell`, and is it ever != the player's cell in normal play? If
retail's `viewer_cell` is always the player's cell (because the camera collision keeps it there), then
(a) and (c) converge and acdream's bug is that its `viewer_cell` drifts to the camera's room cell.
**Strong hypothesis to test first (cheap):** acdream's `viewer_cell` (`visibility.CameraCell`) is wrong
here — it's the *room* because the eye is geometrically up in the room, but **retail's collided
`viewer_cell` would be the cellar** (the swept sphere from the head-pivot is stopped by the cellar
ceiling / the pivot is in the cellar). I.e. this is a **camera-cell-resolution** bug, not a flood/root
bug. Verify by reading how `visibility.CameraCell` / `viewerCellId` is computed (`CellVisibility.
FindCameraCell` + `PhysicsCameraCollisionProbe.SweepEye`) and whether the pivot/sweep should keep the
cell in `0174`.
---
## 3. The next diagnostic step (do this BEFORE any fix — evidence first)
The apparatus is committed and ready. Stand the player in the cellar with the camera up in the room
(the exact repro), and capture:
```powershell
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"
$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"
$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword"
$env:ACDREAM_PROBE_FLAP="1"; $env:ACDREAM_PROBE_SHELL="1"; $env:ACDREAM_PROBE_VIS="1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object launch.log
```
Pin these three things from the log while standing in the cellar:
1. **Is the player's cell (`0174`) in the visible set?** `[flap-cam]` shows `root=` vs `playerCell=`;
`[vis]`/`[flap]` shows `vis=` count + (turn on `ACDREAM_PROBE_VIS` for the cell-id list). If `0174`
is NOT in the set → **flood-reach / wrong-root** problem (§2 a/b/c).
2. **If `0174` IS in the set, does its shell DRAW?** `[shell]` probe (`ACDREAM_PROBE_SHELL`) reports
per-cell shell draw (geometry/texture/depth). If `0174` is visible but its shell is skipped / a
polygon is back-facing / depth-culled → **mesh-seal** problem (design doc §3: dat-dump the `0174`
EnvCell mesh, look for a missing/back-facing floor polygon).
3. **What `viewer_cell` SHOULD it be?** Compare the live `viewerCell=` to where the player is. If the
camera collision (Residual A) is failing to keep the eye's cell == the player's cell, that's §2(a).
This single capture discriminates root-vs-flood-vs-mesh. **Don't pick a fix until it does.**
---
## 4. Exactly what's committed this session (and why each is safe to keep)
| SHA | What | Keep? |
|---|---|---|
| `d2212cf` | Part 1 boom convergence snap — `RetailChaseCamera.ApplyConvergenceSnap` + wiring; retail `UpdateCamera` 0x00456fcd (`SnapEpsilon=2×0.000199999995`, `RotCloseEpsilon=0.000199999995`). 4 new App tests. | **Keep** — retail-faithful, fixes at-rest boom drift, no regression. Not the visible fix. |
| `2b7f5a1` | Branch inside/outside on `is_player_outside` (`RenderingDiagnostics.ShouldRenderIndoor` + `GameWindow.cs:7322`). 5 new Core tests. | **Keep** — retail-faithful, reduced Problem A. **Provably no Problem-B regression:** for the player-inside case it yields `clipRoot = CameraCell`, identical to the pre-fix `visibility?.CameraCell`. |
Plan doc (Part 1's TDD steps; Parts 2/3 there are SUPERSEDED by this handoff —
see §5): `docs/superpowers/plans/2026-06-05-indoor-viewer-cell-flicker-fix.md`.
**Working tree is clean** (the TEMP w-stat probe added to `PortalVisibilityBuilder.cs` was stripped;
`git status` shows nothing under src/tests). Test baseline: **App 187p/0f**, **Core 1331p / 4f / 1s**,
build green. The 4 Core fails are the documented set (2× `DoorBugTrajectoryReplay.LiveCompare_*`,
`BSPStepUpTests.D4`, `DoorCollisionApparatusTests`).
---
## 5. DO NOT RE-LITIGATE (evidence-disproven this session)
- **The flicker is NOT viewer-cell oscillation / cell-membership instability.** Captured the render gate
(`terrain=Skip/Planes`, `outVisible`) flapping with the **viewer cell STABLE at `0171`**. The planned
**Part 2 (point_inside_cell_bsp ±0.2 mm dead-zone) was NOT implemented and is NOT the fix.**
- **The doorway grey is NOT the portal PROJECTION degenerating.** At the grey frame the exit portal
`p0→0170` projects **full-screen** (`proj=6 clip=8`, ndc spans ±1) while `outPolys=0`. So
`ProjectToNdc` is fine; the **`OutsideView`/flood assembly** (and, per §2, the **root/flood-reach**)
is the issue. Do not "harden the w-clip" (5f596f2 already did the clip-space side-plane clip; the
9f95252 eye-in-portal flood band-aid is still in — reassess only after Problem B is understood).
- **The boom drift (Part 1) was real but is NOT the visible flicker.** Freezing the boom did not change
the user-visible symptom.
- The 3-part plan's framing (flicker = boom + cell dead-zone; void = clip) was the previous-session
hypothesis; this session's live evidence **reassigns** the dominant symptom to Problem B (shell
sealing / flood root). Treat the plan's Parts 2-3 as superseded.
---
## 6. Canonical prior art (already documents Problem B — read these)
- **`docs/research/2026-06-05-render-residual-a-shipped-core-inside-render-handoff.md`** — *"player in
cellar, eye in room → clipRoot=room → flood doesn't reach the cellar → cellar floor drops. NEXT = the
CORE inside render (R1 completion)."* THE pointer.
- **`docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md`** §2 (the binary
`RenderNormalMode` model), §3 (grey = SHELL-SEALING bug; `[shell]` probe + dat dump), §5 (the
two-camera invariant — and the doc/handoff contradiction to settle, §2 above).
- **`memory/reference_render_pipeline_state.md`** — Residual A made the viewer cell *accurate*, which
**exposed** that the flood doesn't reach the player's cell. (This session is more evidence for that.)
- **`memory/feedback_render_one_gate.md`** + **`memory/feedback_verify_render_seal_before_layering.md`**
— get a SCREENSHOT + `[shell]` evidence EARLY; one gate for all geometry.
- **`memory/feedback_render_downstream_of_membership.md`** — a transition flicker can be a membership/
flood-root bug, not a render bug.
---
## 7. Kickoff prompt (copy-paste)
```
Continue acdream M1.5 indoor render in worktree thirsty-goldberg-51bb9b (branch
claude/thirsty-goldberg-51bb9b, HEAD 2b7f5a1). Do NOT branch/worktree; do NOT push without asking;
NEVER git stash/gc. PowerShell on Windows; launch logs are UTF-16. Running the client: see CLAUDE.md;
+Acdream spawns at the Holtburg cottage.
TARGET BUG (the user's real pain, with a screenshot): standing INSIDE the cottage, the floor draws but
the WALLS are grey (the time-of-day clear color) with NPCs/doors/outdoor-slices showing through; in the
CELLAR the FLOOR is missing (grey); flicker when changing rooms / entering the cellar. This is the KNOWN
"R1-completion" SHELL-SEALING / wrong-flood-root problem, NOT the camera boom and NOT the inside/outside
branch (both partially fixed this session).
READ FIRST (in order):
1. docs/research/2026-06-05-shell-sealing-cellar-floor-handoff.md (THIS handoff — the two problems §1,
the decisive root=0171/playerCell=0174 evidence §2, the next diagnostic step §3, the DO-NOT-RETRY §5).
2. docs/research/2026-06-05-render-residual-a-shipped-core-inside-render-handoff.md (the R1-completion
pointer) + docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md (§2/§3/§5).
3. memory: reference_render_pipeline_state, feedback_render_one_gate,
feedback_verify_render_seal_before_layering, feedback_render_downstream_of_membership.
DO (evidence first — this bug has burned many speculative fixes; do NOT add a workaround):
- Reproduce + capture with ACDREAM_PROBE_FLAP=1 ACDREAM_PROBE_SHELL=1 ACDREAM_PROBE_VIS=1, player in the
CELLAR with the camera up in the room. Pin: (1) is the player's cell (0174) in the visible set? (2) if
so, does its shell DRAW ([shell] probe) or is a floor polygon missing/back-facing (dat-dump the 0174
EnvCell mesh, design doc §3)? (3) is acdream's viewer_cell wrong — should retail's collided viewer_cell
be the cellar (0174), not the room (0171)? (See §2 a/b/c — settle the design-doc-vs-handoff
viewer-cell-vs-player-cell contradiction against the decomp: RenderNormalMode 0x453aa0 / update_viewer
0x00453ce0 / what sets viewer_cell.)
- THEN fix the actual cause (camera-cell-resolution keeping the eye in the player's cell, OR the flood
reaching the player's cell, OR the mesh seal) — retail-faithful, TDD where the logic is pure,
visual-verify with the user.
DON'T (§5, evidence-disproven): the flicker is NOT viewer-cell oscillation (Part 2 dead-zone is NOT the
fix); the doorway grey is NOT the portal projection (it projects full-screen while OutsideView is empty);
the boom (Part 1) is not the visible bug. Get a SCREENSHOT/[shell] evidence EARLY; don't declare a fix
before the user's eyes confirm it.
TEST BASELINE: App 187p/0f. Core 1331p / 4f (documented) / 1s. Build green.
```
---
## 8. A note from the outgoing model (honest)
I repeatedly presented fixes with too much confidence and conflated Problem A (doorway grey, which I did
reduce) with Problem B (the cellar/walls shell-sealing, which I did not touch). The user called this out
correctly. The two committed changes are real and test-backed, but the **next model should treat the
user's screenshot symptom (walls + cellar floor grey, interior-transition flicker) as Problem B from the
start**, gather `[shell]`/`[flap]` evidence in the cellar BEFORE proposing anything, and **get the user's
eyes on a sealed result before claiming success.**

View file

@ -0,0 +1,251 @@
# Handoff — Indoor flicker/void ROOT CAUSE confirmed (decomp + live cdb); 3-part retail-faithful fix planned — 2026-06-05 (PM)
> **Canonical pickup for the next (fix) session. Read this FIRST.** This session did the diagnosis
> the previous "core inside render (R1)" handoff asked for, and it landed somewhere different than that
> handoff predicted. The indoor **bluish void + grey/texture flicker** is NOT a missing per-cell flood
> port — R1's per-cell `DrawInside` is built and the cellar/ceiling seal correctly. The residual is
> **camera/viewer-cell instability at cell boundaries**, confirmed by both the named-retail decomp AND a
> live cdb capture of retail. The fix is a **3-part retail-faithful port** (camera boom stability +
> viewer-cell dead-zone + w-space portal clip), de-risked and ready to plan + implement.
> Branch `claude/thirsty-goldberg-51bb9b`. PowerShell on Windows; launch logs are UTF-16.
---
## 0. TL;DR
- **The premise we started with was stale.** "Cellar floor drops / R1 incomplete" — actually R1's per-cell
`DrawInside` + binary inversion already exist (commit `c4fd711`, 2026-06-02) and the cellar **is sealed**
(user visual-verified T1 this session). The flood reaches the cellar; the shell draws.
- **The real bug is two faces of ONE root cause — visibility metastability at cell boundaries:**
1. **Flicker (grey↔texture) at a "stationary" position** = the **viewer (camera) cell flips per-frame**
`0170↔0171` because the 3rd-person camera **boom drifts** (`desiredBack 3.11→3.07` while the player
stands still), walking the eye across a portal plane, and acdream re-resolves the viewer cell fresh
each frame with **no hysteresis**. The render roots at the viewer cell, so it redraws two different
solves → flicker.
2. **Stable bluish void** = when the eye is firmly in/at a thin/transition cell, the portal flood
**degenerates** because acdream projects-to-NDC-then-2D-clips instead of clipping in clip-space, so a
close/grazing portal drops (`proj=0`) → no terrain / neighbour not flooded → grey.
- **CONFIRMED by live cdb on retail** (this is the payoff): retail's `viewer_cell` is **rock-stable** at
the same Holtburg-cottage boundary — clean single monotonic transitions, **zero oscillation** across
4,916 samples; retail rests the camera in the *substantial* cell, never lingering in the thin doorway
cell. acdream's `[flap-sweep]` flips `0170↔0171` at the same spot. Retail is stable, acdream is not —
exactly as the decomp predicted.
- **The fix (3 parts, prioritized) is retail-faithful and anchored to specific decomp functions.** §4.
- **Two partial fixes shipped this session** (`5f596f2` NDC frustum side-plane clip — keep;
`9f95252` eye-in-portal flood — band-aid, likely superseded → reassess/revert). §3.
- **Test baseline:** App **183 pass / 0 fail** (179 + 4 new). Core **1326 pass / 4 fail (documented) /
1 skip**. Branch HEAD after this handoff commit. Build green.
---
## 1. The session arc (so you don't repeat it)
1. Read the "core inside render (R1)" handoff. **Discovered R1's inversion + per-cell `DrawInside` already
exist and are live** (`c4fd711`/`4b75c68`/`cf85ea4`, 2026-06-02; not reverted). So this was a *debug*
task, not a *port* task.
2. **Visual gate (user):** the cellar (T1) is **sealed**. The real symptoms are at **transitions**: a
bluish **void flap** exiting the building (screenshot), a **grey flash** cellar→room, **outdoor
content through the ground** looking out, and a **stationary flicker** (textures alternate grey↔texture
while standing still).
3. **Evidence-first (probes `ACDREAM_PROBE_FLAP/_VIS/_SHELL/_CELL`):**
- Refuted the prior handoff's hypothesis ("flood doesn't reach the cellar") — rooted at the room, the
flood **does** reach the cellar (`[vis] root=0171 ids=[...,0174]`).
- Found the void is `terrain=Skip` / `proj=0` on exit/interior portals when the eye is close
(`[flap] p->0xFFFF D=-0.28 proj=4 clip=0`; later `p1->0x0171 D=0.16 proj=0`).
- Found the **flicker** is the **viewer cell flipping** at a knife-edge boundary: in a held pose the
`[flap-sweep] viewerCell` flips `0170↔0171` as `D` crosses `0.00`, while the **player stands still**
and the boom **drifts** (`desiredBack 3.11→3.07`).
4. **Decomp spike (3 parallel agents + 1 verified-myself crux):** mapped how retail stays stable. §2.
5. **Live cdb on retail** (matching v11.4186 binary, PDB-paired): captured retail's `viewer_cell` across
the same crossings → **clean, no oscillation**. §2.4. Confirms the fix direction.
---
## 2. ROOT CAUSE — retail stays stable via THREE mechanisms; acdream diverges on each
### 2.1 Camera boom is a stable spring (Q3) — `CameraManager::UpdateCamera`
- Retail's boom vector `CameraManager::viewer_offset` (default `y = -2.5 m`) is **fixed** (changes only on
zoom keys). `UpdateCamera` (≈`0x00456660`, lerp body `0x00456d0d`) lerps the camera *position* toward
`pivot + viewer_offset` with stiffness, and **snaps to the current position when within 0.0004 m** → holds
a constant fixed point at rest.
- **The collided eye is firewalled from the desired position:** `set_viewer(…, arg3=0)` (the normal success
path, `update_viewer` pc≈92870) writes `this->viewer` (rendered eye) but **NOT** `viewer_sought_position`.
So the collision result never feeds back into the desired boom.
- `PlayerPhysicsUpdatedCallback` (`0x00452d60`, pc:91836) computes `viewer_sought_position = UpdateCamera(current_viewer)`.
- **acdream diverges:** `RetailChaseCamera` desired boom **drifts at rest** (`desiredBack 3.11→3.07`).
Hypotheses (verify in code): the collided eye is fed back into the desired (no firewall), and/or no
convergence snap. **This drift is the flicker trigger** (walks the eye across the boundary).
### 2.2 Viewer cell is sticky via a 0.2 mm dead-zone (Q1) — VERIFIED MYSELF
- `SmartBox::update_viewer` (`0x00453ce0`, pc:92761): `viewer_cell = sphere_path.curr_cell` after the
collision sweep, which **starts from the stable player cell** each frame (`cell_1` = player cell or
`AdjustPosition`-seated).
- `SPHEREPATH::init_path` (`0x0050ce20`, pc:274370): `curr_cell = arg2` (the start cell).
- The sweep updates the cell only on a **definite** crossing: `check_other_cells` (`0x0050ae50`, pc:272717)
`find_cell_list``check_cell`; `validate_transition` (`0x0050aa70`) promotes `curr_cell = check_cell`
(pc:272608) only when the cell/pos actually changed, else **restores `curr_cell`**.
- **The dead-zone (VERIFIED at pc:325513/325522):** `BSPNODE::point_inside_cell_bsp` (`0x0053c1f0`) uses
`0.000199999995f` (≈0.2 mm) symmetrically — a point within ±0.2 mm of a splitting plane belongs to
**neither** cell, so at a boundary graze `check_cell` is null and `curr_cell` stays at the start cell.
- **acdream diverges:** `RetailChaseCamera.ViewerCellId = swept.ViewerCellId` is re-resolved fresh **every
frame** with **no dead-zone** ("graph-tracked, deterministic, NO grace frames" — the comment) → flips at
the boundary.
### 2.3 Portal clip is homogeneous (w-space), before the divide (Q2) — `GetClip`/`polyClipFinish`
- `PView::GetClip` (`0x005a4320`, pc:432344) projects the portal then calls `ACRender::polyClipFinish`
(`0x006b6d00`, pc:702749), which **clips against the near plane (w=0) in clip-space, generating synthetic
edge vertices, BEFORE the perspective divide** — so a close portal never blows up to garbage NDC.
- `PView::InitCell` (`0x005a4b70`, pc:432896) **side-test culls** in-plane/back-facing portals (same 0.2 mm
band, pc≈432936) before any projection.
- The flood is substantially **root-invariant** for adjacent cells (both seeded full-screen; side-test is
symmetric).
- **acdream diverges:** projects-to-NDC then 2D-clips → degenerates at grazing/close angles (`proj=0`
portal dropped → grey void / neighbour not flooded). This session's commit `5f596f2` added the eye-plane
+ side-plane clip (partial); the missing piece is the **w=0 near-plane clip with synthetic verts** + the
side-test dead-band.
### 2.4 LIVE CDB CONFIRMATION (retail v11.4186, PDB-paired) — the payoff
Captured `SmartBox::viewer_cell` (`viewer.objcell_id`) at the Holtburg cottage while passing inside↔outside
+ the stairs + standing still. Run-length-encoded camera-cell sequence (4,916 samples):
```
0xa9b40032 ×3360 → 0031 ×173 → 0170 ×8 → 0171 ×134 → 0170 ×14 → 0031 ×129
→ 0170 ×7 → 0171 ×139 → 0170 ×15 → 0031 ×110 → 0170 ×7 → 0171 ×167 → … (clean repeats)
```
- **Every crossing is a clean, single, monotonic pass** (outside `0031/0032` → vestibule `0170` brief
715-sample pass-through → room `0171`, and back). **ZERO `0170↔0171` oscillation** anywhere.
- Standing still, retail rests in the **substantial** cells (room `0171` ×134197, outside long runs),
**never lingering in the thin vestibule `0170`**.
- **Contrast acdream:** `[flap-sweep] viewerCell` flips `0170↔0171` per-frame at the same boundary.
- **Conclusion:** retail's viewer cell is stable (boom holds + 0.2 mm dead-zone + sweep-from-player-cell);
acdream's is not. No surprises — the fix is de-risked.
---
## 3. What shipped this session (committed; partial)
| SHA | What | Keep? |
|---|---|---|
| `5f596f2` | `PortalProjection.ProjectToNdc` clips eye + 4 frustum **side planes** in clip-space before the divide (replaces the 2026-06-03 `MinW`-only workaround). Bounds NDC to the screen. | **KEEP.** Real correctness, retail-consistent (partial of §2.3). |
| `9f95252` | `PortalVisibilityBuilder` floods the neighbour when the eye **stands in** an interior portal (`EyeInsidePortalOpening`). Fixed the cellar **ceiling** (visual-verified). | **REASSESS / likely REVERT.** A coverage band-aid for the thin-cell-root case; the §4 boom + dead-zone keep the camera out of thin cells, and the w=0 clip handles close portals — this may become unnecessary or over-include. Easy `git revert 9f95252`. |
Neither is the flicker fix. Both green (App 183), Core baseline held (1326/4/1).
---
## 4. THE FIX — 3-part retail-faithful port (prioritized). Plan, then implement TDD.
### Part 1 (HIGHEST leverage) — Camera boom stability → kills the flicker trigger
- **Goal:** acdream's desired boom settles and **holds** at rest (no drift). Match `UpdateCamera`: desired
position derived each frame from a **fixed** boom offset + the player pivot; **firewall the collided eye**
out of the desired chain; add the **convergence snap** (return current when within ~0.0004 m).
- **acdream targets:** `src/AcDream.App/Rendering/RetailChaseCamera.cs` (the `_dampedEye` / `desiredBack` /
the lerp + where the collided `swept.Eye` is consumed). Verify whether `swept.Eye` feeds the next-frame
desired (the drift hypothesis) and whether a snap exists.
- **Anchors:** `CameraManager::UpdateCamera` `0x00456660` (snap ~`0x00456d0d` region), `PlayerPhysicsUpdatedCallback`
`0x00452d60` (pc:91836), `set_viewer` arg3=0 firewall (`update_viewer` pc≈92870).
- **Verify:** with the boom stable, the `[flap-sweep] eyeBack/desiredBack` is flat at rest and the eye stops
grazing the boundary.
### Part 2 — Viewer-cell dead-zone hysteresis → belt-and-suspenders for the flicker
- **Goal:** acdream's camera-sweep viewer-cell resolution doesn't flip on a sub-mm/boundary graze. Port the
retail dead-zone: a point within ±0.2 mm of a portal plane belongs to neither cell → keep the prior/start
(player) cell.
- **acdream targets:** `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs` (`SweepEye`) and the
Core cell-resolution it calls (`CellTransit.FindVisibleChildCell` / the point-in-cell test). Ensure the
sweep starts from the player cell and only changes `viewer_cell` on a definite crossing.
- **Anchors:** `point_inside_cell_bsp` `0x0053c1f0` (0.000199999995f, pc:325513/325522), `init_path` pc:274370,
`validate_transition` pc:272608, `check_other_cells` pc:272717.
### Part 3 — w-space portal clip robustness → kills the *stable* grey void
- **Goal:** extend `5f596f2` to a true **near-plane (w=0) clip with synthetic edge vertices before the
divide** + the **InitCell side-test dead-band** that culls in-plane/back-facing portals before projection.
- **acdream targets:** `src/AcDream.App/Rendering/PortalProjection.cs` + `PortalVisibilityBuilder.cs`
(the side test `CameraOnInteriorSide`). After this, **reassess `9f95252`** (the eye-in-portal flood may be
redundant → revert).
- **Anchors:** `GetClip` `0x005a4320` (pc:432344), `polyClipFinish` `0x006b6d00` (pc:702749, the w=0 clip),
`InitCell` `0x005a4b70` (pc:432896 side-test).
**Order:** Part 1 → visual gate → Part 2 → Part 3 → reassess `9f95252`. Each is independently verifiable.
---
## 5. KEEP / DON'T
**KEEP:**
- R1 per-cell `DrawInside` + the binary inversion (`c4fd711`) — built and correct; the cellar seals.
- Residual A (camera collision, `update_viewer` port) — the viewer cell is *accurate*; we're stabilizing it,
not removing it.
- Commit `5f596f2` (NDC side-plane clip).
- The two-camera-ish reality: eye drives projection; the fix makes the *viewer cell* stable (boom + dead-zone),
matching retail (`is_player_outside` decides; `DrawInside(viewer_cell)` roots).
**DON'T:**
- Don't re-attempt "the flood doesn't reach the cellar" — refuted (`[vis]` shows it does).
- Don't add a render-side debounce/grace-period for the flicker — it's a **membership/visibility stability**
bug; fix the *input* (boom + dead-zone), not the render (memory: render-downstream-of-membership).
- Don't switch the render root to the *player* cell — retail roots `DrawInside` at the *viewer* cell; the
fix is to make the viewer cell *stable*, not to change which cell roots.
- Don't put a `;` inside a cdb `$$` comment (it splits into a command — bit me this session; use `*` comments).
---
## 6. APPARATUS (committed / ready)
- **Probes** (all live): `ACDREAM_PROBE_FLAP` (`[flap]`/`[flap-cam]`/`[flap-sweep]`), `ACDREAM_PROBE_VIS`
(`[vis]`), `ACDREAM_PROBE_SHELL` (`[shell]`), `ACDREAM_PROBE_CELL` (`[cell-transit]`).
- **cdb script** `tools/cdb/retail-viewer-cell.cdb` — samples retail `SmartBox::viewer_cell` ~6/sec,
auto-`.detach` after 6000 hits. Binary verified MATCH via `tools/pdb-extract/check_exe_pdb.py`.
Re-run pattern + RLE analysis are in this session's transcript (PowerShell `Select-String` on
`retail-viewer-cell.log`). **Lesson:** per-frame bp + `dt`/`.printf` is heavy but survived here (retail
intact); keep samples sparse. `qd` is ignored in bp actions — use `.detach`.
- **TTD** is available (`tools/ttd-record.ps1` / `tools/ttd-query.ps1`) if a lower-overhead capture is needed.
---
## 7. STATE
- Branch `claude/thirsty-goldberg-51bb9b`. HEAD: this handoff's docs commit (after `9f95252`). No push (ask first).
- Build green. App **183 / 0**. Core **1326 / 4 (documented: 2× DoorBugTrajectoryReplay LiveCompare,
BSPStepUpTests.D4, DoorCollisionApparatus) / 1 skip**.
- Running the client: see CLAUDE.md "Running the client"; `+Acdream` spawns in the Holtburg cottage.
---
## 8. KICKOFF PROMPT (copy-paste for the next session)
```
Continue acdream M1.5 indoor render: fix the boundary FLICKER + stable bluish VOID with the 3-part
retail-faithful port that the 2026-06-05 spike confirmed. ROOT CAUSE (decomp + live cdb, both done):
the indoor flicker/void is VISIBILITY METASTABILITY at cell boundaries, not a missing flood — R1's
per-cell DrawInside is built and the cellar seals. Retail stays stable via three mechanisms; acdream
diverges on each. Branch claude/thirsty-goldberg-51bb9b (do NOT branch/worktree; do NOT push without
asking; NEVER git stash/gc). PowerShell on Windows; launch logs are UTF-16.
READ FIRST (in order):
1. docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md (THIS handoff — root
cause §2, cdb confirmation §2.4, the 3-part fix §4, KEEP/DON'T §5).
2. memory: reference_render_pipeline_state.md, feedback_render_downstream_of_membership.md,
feedback_render_one_gate.md, project_camera_visibility_coupling.md.
3. The decomp anchors cited in §2/§4 (named-retail) for each part you implement.
THE FIX (plan first, then implement TDD; each part independently visual-verified):
Part 1 (highest leverage) — Camera boom stability (RetailChaseCamera): desired boom settles + HOLDS at
rest; firewall the collided eye out of the desired chain; add the convergence snap. Anchors:
CameraManager::UpdateCamera 0x00456660, PlayerPhysicsUpdatedCallback 0x00452d60, set_viewer arg3=0.
Part 2 — Viewer-cell dead-zone (PhysicsCameraCollisionProbe.SweepEye / Core cell resolution): ±0.2 mm
dead-zone so a graze keeps the player/start cell. Anchors: point_inside_cell_bsp 0x0053c1f0
(0.000199999995f), validate_transition pc:272608, init_path pc:274370.
Part 3 — w-space portal clip (PortalProjection/PortalVisibilityBuilder): near-plane (w=0) clip with
synthetic verts before the divide + InitCell side-test dead-band; then reassess/revert the
eye-in-portal flood band-aid (commit 9f95252). Anchors: GetClip 0x005a4320, polyClipFinish 0x006b6d00,
InitCell 0x005a4b70.
START by using superpowers:writing-plans (or brainstorming if a part's shape is unclear) to turn §4 into a
step plan with per-part acceptance + visual gates, THEN implement Part 1 first via TDD.
DON'T (§5): no render-side debounce for the flicker (fix the boom/cell input); don't switch the render
root to the player cell (retail roots DrawInside at the viewer cell — stabilize it instead); don't reopen
"flood doesn't reach the cellar" (refuted).
TEST BASELINE: App 183 pass / 0 fail. Core 1326 pass / 4 fail (documented) / 1 skip. Build green.
This session committed 5f596f2 (NDC side-plane clip — KEEP) + 9f95252 (eye-in-portal flood — reassess).
```

View file

@ -0,0 +1,163 @@
# Indoor render HANG — root cause: `PortalVisibilityBuilder.Build` non-termination — 2026-06-06
> Report-only investigation (user chose "investigate more first"). **No code changed.**
> Worktree `thirsty-goldberg-51bb9b`. This blocks the verbatim-DrawCells port's Task 2
> visual gate: every indoor frame can freeze here.
## Symptom
Three launches of the client all **froze** (`AppHangB1`, Windows Event Log) within
seconds-to-minutes of the camera being indoors at the Holtburg cottage. Not a crash —
no access violation, no managed exception. The captured managed stack of the frozen
render thread (`hang-stack.txt`, via `dotnet-stack`) shows it **CPU-spinning**:
```
CPU_TIME
CellView.Add(ViewPolygon)
PortalVisibilityBuilder.AddRegion(CellView, List<ViewPolygon>)
PortalVisibilityBuilder.Build(...)
RetailPViewRenderer.DrawInside(...)
GameWindow.OnRender(...)
```
App.Tests 207/207 and Core 1331/4/1 are green; the bug is invisible to the suite (see §Evidence).
## Verdict
**It is NOT Task 2 (the verbatim-DrawCells / grey fix).** `Build(...)` runs at the very
top of `DrawInside` ([RetailPViewRenderer.cs:43](../../src/AcDream.App/Rendering/RetailPViewRenderer.cs)),
**before** any line Task 2 touched, and the call is byte-identical pre/post-change. Task 2's
draw logic was independently confirmed correct in the run-1 log: `[render-sig] draw=[…]`
equalled `ids=[…]` with `miss=[]`, and `[shell]` showed every visible cell drawing textured
(`zh=0`). The grey fix works.
**Root cause:** `PortalVisibilityBuilder.Build`'s portal BFS does not terminate for real
cottage geometry. It **re-enqueues a popped cell every time that cell's `CellView` grows**:
`queued.Remove(cell.CellId)` on pop ([:122](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs))
+ `if (grew && queued.Add(neighbourId))` on grow ([:289](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs)).
Termination therefore depends entirely on growth stopping. Growth is gated only by
`CellView.Add`'s **exact-match dedup** (`SamePolygon`, eps `1e-4`,
[PortalView.cs:79](../../src/AcDream.App/Rendering/PortalView.cs)). The **near-side portal clip**
(`ClipPortalAgainstView``PortalProjection.ProjectToClip``ClipToRegion`,
[:474/:485](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs)) produces a polygon
that is a hair different on each `A↔B` reciprocal round (float drift through the homogeneous
project→clip round-trip with a non-identity cell transform). The dedup never matches the
drifted near-duplicate → the region grows without bound → the cell re-enqueues forever →
`CellView.Polygons` grows to N, and `CellView.Add`'s O(N) dedup scan makes the whole thing
O(N²) → frozen.
## Evidence
1. **Captured stack** pins the spin to `CellView.Add ← AddRegion ← Build`, pure managed
`CPU_TIME` (not a GL call, not blocked, not a fault).
2. **The code already documents this exact failure** at
[:694-697](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs): the *reciprocal*
clip deliberately stays on the float-stable `ProjectToNdc` path *because*
"per-round float drift defeated the CellView SamePolygon dedup, inflating a tight A<->B
reciprocal view to ~4x its area." The **near-side** clip ([:474](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs))
did not get the same treatment — it uses `ProjectToClip`.
3. **The only bound was removed this session.** [:74](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs):
"Fixpoint termination replacing the old `MaxReprocessPerCell` hard cap." The fixpoint never
converges under drift; with the cap gone there is no other bound (no iteration cap, no
max-polygon cap, no time bound).
4. **It's the dirty-tree rewire the handoff said to KEEP.** `git diff --stat`:
`PortalVisibilityBuilder.cs +426/45` and `PortalProjection.cs +111` are **uncommitted**.
`ProjectToClip` is part of the new `PortalProjection` lines. The handoff
(`2026-06-06-verbatim-drawcells-port-pickup-handoff.md`) lists this rewire as the faithful
foundation to preserve and says "the clip math is already faithful — do not harden the
w-clip." The clip is faithful in the *picture* it computes; it is the *non-termination*
that is broken.
5. **Why the suite is green:** `PortalVisibilityBuilderTests` build cells with
`WorldTransform = Matrix4x4.Identity` and axis-aligned quads in 2-cell **chains**
(`cam → ground → exit`). No `A↔B` cycle, no transform-induced drift → the project→clip
round-trip is exact → the dedup collapses duplicates → the BFS converges. The real cottage
is a **cyclic** cell cluster (`0x016F0x0175`, mutual portals) with **non-identity**
transforms → drift + cycle → non-termination. The suite cannot reach the failing case.
6. **Why run 1 survived 113 frames then froze:** `Build` converges at most camera poses; only
specific poses create the non-converging drift cycle. The freeze coincided with the
metastable doorway flip (`[render-sig] stable` went 39→0, visible-cell count 5→4) one frame
before the log ended.
## Hypotheses (ranked)
1. **(confirmed)** Non-terminating BFS: re-enqueue-on-grow + `ProjectToClip` drift defeats the
`SamePolygon` dedup → unbounded `CellView` growth. Falsify: a re-process cap, a
drift-tolerant dedup, or `ProjectToNdc` on the near-side clip all make `Build` terminate.
2. *(ruled out)* GPU/driver hang from a malformed draw — the stack is pure managed `CPU_TIME`
in `CellView.Add`, never a GL call; no fault.
3. *(ruled out)* Probe-output stdout saturation — disproven: the probe-free run also hung.
4. *(ruled out)* Task 2 — `Build` is upstream of every Task 2 line and unchanged by it.
## Fix options (all additive — none reverts the dirty tree)
| | Fix | Touches | Pro | Con |
|---|---|---|---|---|
| **A** *(rec.)* | **Drift-tolerant dedup**: round clipped polygon vertices to a small grid (≈`1e-3`) before `AddRegion`, or widen/snaps `SamePolygon`'s match, so near-duplicates collapse → growth converges. | `CellView`/`AddRegion` | Fixes the actual root cause ("drift defeats dedup"); keeps the faithful `ProjectToClip`; preserves growth-propagation. ~10 lines. | Tolerance is a tuning constant (pick conservatively; over-merge = minor over-tighten). |
| **B** | **Restore a re-process bound** (`MaxReprocessPerCell`-style cap on the BFS). | `Build` loop | Smallest; guarantees termination; doesn't touch clip. | A guard, not a root fix; may under-include a late-growing view. The user's "no workarounds" rule applies — this is the band-aid. |
| **C** | **Near-side clip on `ProjectToNdc`** (what the reciprocal clip already uses). | `ClipPortalAgainstView` | Removes the drift source directly; consistent with `:694`. | Steps on this session's homogeneous near-eye clip work; the handoff's "don't harden the w-clip" is closest to here. |
**Recommended next step:** approve **A** (drift-tolerant dedup) — it closes the precise
mechanism the code half-acknowledges at `:694`, terminates structurally, and leaves the
faithful clip path intact. Implement in a follow-up (not report-only) session, then re-run the
Task 2 visual gate (probe-free) at the cottage + cellar.
## What this is NOT
- **NOT** Task 2 / the grey fix — that is verified working (`draw==ids`, `miss=[]`, textured shells).
- **NOT** a wrong-pixels / unfaithful-projection bug — it's a **termination** bug. The handoff's
"the clip math is faithful, don't harden the w-clip" is about projection *correctness*; this is
BFS *convergence*. Don't chase the w-clip.
- **NOT** a GPU/shader/driver hang and **NOT** the probe firehose (both ruled out by the stack
and the probe-free repro).
---
## Reassessment — is the dirty-tree builder rewire sound? (post Option A)
Option A (drift-tolerant `CellView.Add` dedup, `CellViewDedupTests` green) was implemented and the
client relaunched. Result: the hang **moved out of `CellView.Add`** (A worked for its target) but
**relocated to `ScreenPolygonClip.ClipByEdge`** via `ApplyReciprocalClip` (second captured stack,
`hang-stack2.txt`). `ScreenPolygonClip.Intersect`/`ClipByEdge` are **both bounded `for` loops**
they cannot spin on one call — so the spin is the **outer `Build` BFS** still not terminating and
calling them a runaway number of times. **Option A is necessary but not sufficient.**
### Git evidence (what the dirty rewire changed re: termination)
- **HEAD (committed)** near-side portal clip = `PortalProjection.ProjectToNdc` (float-stable;
`git show HEAD:` line 146). **The dirty rewire switched it to `ProjectToClip`** (`ClipPortalAgainstView`,
dirty line 474) — the homogeneous near-eye clip, introduced to fix the near/grazing-doorway flap/void.
- The `MaxReprocessPerCell` **hard cap was removed earlier** (committed Phase U.2a `d880775`), replaced
by "fixpoint termination." **Neither HEAD nor the dirty tree has a hard iteration bound.**
- The dirty rewire's own comment (`PortalVisibilityBuilder.cs:519-522`) documents that
`ProjectToClip` "produced per-round float drift that defeated the CellView SamePolygon dedup" — and
applied that lesson **only to the reciprocal clip** (kept on `ProjectToNdc`), leaving the **near-side**
clip on the drift-prone `ProjectToClip`.
### Soundness verdict
The builder's termination model is **unsound by construction.** It relies on the clipped regions
reaching a geometric fixpoint — re-clipping a cell's view reproduces *exactly-equal* polygons that the
dedup recognises — with **no hard iteration bound.** That only holds if the clip is float-stable.
`ProjectToClip` (needed for faithful near-doorway projection) injects per-round drift, so re-clipping
never reproduces an exactly-equal polygon, the dedup never catches it, and the re-enqueue-on-grow flood
never converges → infinite loop. **You cannot have BOTH faithful near-doorway projection (`ProjectToClip`)
AND convergence-via-exact-dedup-without-a-bound.** HEAD got away with it because `ProjectToNdc` was
stable enough to converge (and it sealed — user-verified); the dirty switch tipped it into non-termination.
The rewire fixed the *projection* and, apparently never having been launched, shipped a hang.
A's drift-tolerant dedup *narrows* the gap but cannot *close* it: for some geometry the per-round drift
exceeds any fixed snap grid, so growth still produces new keys forever. Only a **hard bound** guarantees
termination.
### Paths (for the user to choose)
| | Path | Termination | Projection fidelity | Risk |
|---|---|---|---|---|
| **1** *(rec.)* | Keep `ProjectToClip` + add **enqueue-once** bound (D) — the builder's own comment already calls enqueue-once "the hard termination guarantee"; the re-enqueue-on-grow is the bug. Keep A. | Guaranteed (≤N pops) | Full (faithful doorway clip kept) | Minor under-inclusion of late growth → visual-verify; widen to a cap if needed |
| **2** | Keep `ProjectToClip` + add a **re-process cap** (B, restore `MaxReprocessPerCell`). Keep A. | Guaranteed (≤N×K) | Full | Less faithful than enqueue-once; a tuning constant |
| **3** | **Revert** the near-side `ProjectToClip → ProjectToNdc` (back to HEAD). | Restored (HEAD converged) | **Loses** the rewire's near-doorway fix → reintroduces the flap/void (separate bug) | Throws away this session's projection work; contradicts the keep-the-dirty-tree directive |
A bound (paths 1/2) is the sound fix: it makes termination independent of clip drift, so the faithful
`ProjectToClip` projection AND guaranteed termination coexist. **Recommendation: path 1** (enqueue-once +
keep A), visual-verify for under-inclusion. Reverting (path 3) only trades the hang back for the
flap/void.

View file

@ -0,0 +1,589 @@
# Handoff - M1.5 Indoor Render / Retail PView Replacement Attempts - 2026-06-06
This is a **stop-and-handoff** note for the next agent. It records what was tried, what changed on disk, what the user still sees, and what evidence should drive the next step.
The user explicitly stopped this thread after repeated visual regressions. Do **not** continue the same patching loop. Treat all current uncommitted render work as suspect until re-audited against named retail.
## Worktree And Rules
- Worktree: `C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b`
- Branch: `claude/thirsty-goldberg-51bb9b`
- Starting HEAD called out by the user: `8116d10`
- Do **not** branch or create a new worktree.
- Do **not** push without asking.
- Never run `git stash` or `git gc`.
- PowerShell on Windows.
- Launch logs are UTF-16.
- Build before launching.
- Use `apply_patch` for manual edits.
- Do not revert dirty changes unless the user explicitly asks.
Current child handoff thread created before this file:
- Child thread id: `019e9d5c-bb34-7fe3-85cc-6b9065b4e882`
- It was forked same-directory, not a new worktree.
- A follow-up prompt was sent there with the immediate evidence and constraints.
## User-Visible State At Stop
The latest user report, after the most recent relaunch:
- Transition flaps still happen between outdoor/indoor, room/room, and cellar.
- Ground floor became transparent instead of sealed.
- Cellar remains broken.
- Prior screenshots showed grey or black background filling cell openings.
- Prior screenshots showed indoor walls losing texture or drawing as clear/background color.
- Prior screenshots showed character cut in half on the cellar stairs.
- User explicitly says we are back to old bugs and nothing feels solid.
Important: **do not claim any current code is fixed**. Build/tests passed for some pieces, but visual acceptance failed.
## Current Dirty State
`git status --short --branch` showed these tracked files modified:
- `src/AcDream.App/Rendering/ClipFrameAssembler.cs`
- `src/AcDream.App/Rendering/ClipPlaneSet.cs`
- `src/AcDream.App/Rendering/GameWindow.cs`
- `src/AcDream.App/Rendering/InteriorEntityPartition.cs`
- `src/AcDream.App/Rendering/InteriorRenderer.cs`
- `src/AcDream.App/Rendering/ParticleRenderer.cs`
- `src/AcDream.App/Rendering/PortalView.cs`
- `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs`
- `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs`
- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
- `src/AcDream.Core/Rendering/RenderingDiagnostics.cs`
- `src/AcDream.Core/World/WorldEntity.cs`
- `tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs`
- `tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs`
- `tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs`
- `tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs`
- `tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherClipSlotTests.cs`
- `tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs`
- `tools/TextureDump/Program.cs`
Important untracked files include:
- `src/AcDream.App/Rendering/RetailPViewRenderer.cs`
- `docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md`
- many probe logs and local scripts/images, including `launch-flap-shell-capture-relaunch.log`, `launch-pview-watermark-probe.log`, `a8-current-room-cellar-audit.txt`, `texture-current-room-surfaces.txt`, `analyze_*.py`, `retail-*-trace.log`, and several screenshots.
Diff size before this handoff file:
- 19 tracked files changed.
- About 1593 insertions and 773 deletions.
## Validation That Passed But Did Not Prove Visual Correctness
After the last attempted PortalVisibilityBuilder patch:
```powershell
dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --filter "FullyQualifiedName~PortalVisibilityBuilderTests|FullyQualifiedName~PortalProjectionTests"
```
Passed: 29/29.
```powershell
dotnet build -c Debug --no-restore
```
Succeeded, with 9 known warnings.
```powershell
dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --no-build
```
Passed: 196/196.
These results only prove the pure/tested slices compile and pass. They did **not** solve the live render.
## Retail PView Reference Already Written
New pseudocode note exists:
- `docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md`
It summarizes:
- `SmartBox::RenderNormalMode @ 0x00453aa0`
- `RenderDeviceD3D::DrawInside @ 0x0059f0d0`
- `PView::DrawInside @ 0x005a5860`
- `PView::ConstructView @ 0x005a57b0`
- `PView::DrawCells @ 0x005a4840`
- `RenderDeviceD3D::DrawObjCellForDummies @ 0x005a0760`
Core retail model from that note:
- Outdoor: `LScape::draw`, then portal/interior peering through PView portal paths.
- Indoor: `DrawInside(viewer_cell)`.
- `PView::ConstructView` builds `cell_draw_list`, per-cell `portal_view`, and `outside_view`.
- `PView::DrawCells` draws outside landscape through `outside_view`, then reverse `cell_draw_list` exit masks, reverse shells, reverse object lists.
- No global indoor terrain/entity/particle pass should bypass PView membership.
## Retail Functions That Still Matter
Re-read named retail before more code:
- `PView::AddViewToPortals @ 0x005a52d0`
- `PView::ConstructView @ 0x005a57b0`
- `PView::ClipPortals @ 0x005a5520`
- `PView::FixCellList @ 0x005a5250`
- `PView::AdjustCellView @ 0x005a5770`
- `PView::OtherPortalClip @ 0x005a5400`
- `PView::GetClip` around `0x005a4320`
- `SmartBox::RenderNormalMode @ 0x00453aa0`
- `SmartBox::update_viewer @ 0x00453ce0`
- `RenderDeviceD3D::DrawObjCellForDummies @ 0x005a0760`
The critical retail detail not faithfully settled yet:
- Retail tracks `view_count` and `update_count`.
- When a cell view grows after the cell was already processed, retail calls `FixCellList` / `AdjustCellView`.
- Current acdream code only approximates this. It may not match draw-list ordering or downstream propagation.
## What We Tried
### 1. Treated symptoms as separate render leaks
The session started with symptoms that looked separate:
- dynamic objects and particles visible through ground when looking out from inside;
- outside ground texture covering cellar entrance when looking in;
- grey flaps when crossing cell boundaries;
- missing cellar floor / grey cellar;
- transparent or textureless interior walls.
The user correctly pushed back that these are probably one render-pipeline failure: indoor/outdoor, cells, shells, terrain, objects, particles, and doors must all agree on one visible-cell graph.
### 2. Gated dynamic objects and particles by ownership
Attempt:
- `WorldEntity.ParentCellId` was populated for player/spawns/teleports/motion updates.
- `InteriorEntityPartition` was changed so live dynamic entities with an indoor `ParentCellId` go into their cell bucket instead of a global live-dynamic overlay.
- `WbDrawDispatcher.ResolveEntitySlot` was changed so `ServerGuid != 0` no longer always means "draw unclipped indoors".
- Particles were moved toward PView-scoped / owner-scoped behavior instead of a global indoor scene pass.
Effect:
- User reported this stopped much of the obvious dynamic-object/particle bleeding when looking out.
- It did **not** fix grey/background transition flaps.
- It did **not** fix cellar/floor/walls.
Current risk:
- This direction is probably correct, but the exact routing must be audited. A later attempt also cleared clip routing to avoid character/shell cutting, so "PView membership" and "GPU clip slot routing" are currently mixed/confused.
### 3. Added/used a `RetailPViewRenderer`
Attempt:
- Added `src/AcDream.App/Rendering/RetailPViewRenderer.cs`.
- Moved part of indoor draw orchestration into `RetailPViewRenderer.DrawInside`.
- Added `DrawPortal` for outdoor-looking-in through `PortalVisibilityBuilder.BuildFromExterior`.
- The renderer currently does:
- `PortalVisibilityBuilder.Build`
- `ClipFrameAssembler.Assemble`
- `_envCells.PrepareRenderBatches(filter: drawableCells)`
- `InteriorEntityPartition.Partition`
- landscape through outside slices
- exit masks
- EnvCell shells
- object buckets
Effect:
- This is not a full retail replacement yet.
- User repeatedly saw unchanged or worse symptoms.
- FPS was reported drastically down after one iteration.
- Subsequent attempts produced missing textures / white or grey wall panels.
Current risk:
- `RetailPViewRenderer` is not truly verbatim retail. It keeps modern infrastructure and approximates PView with GPU clip slots and callbacks.
- The user asked "have you ported retail verbatim?" and the honest answer remains no.
- `GameWindow` still has a lot of orchestration, diagnostics, and render routing around this. It is not a small caller yet.
### 4. Reworked `ClipFrameAssembler` from one clip per cell to per-slice clip slots
Attempt:
- `ClipFrameAssembler` was rewritten toward per-polygon/slice output:
- `CellIdToViewSlices`
- `OutsideViewSlices`
- per-slice `ClipViewSlice`
- `TerrainClipMode` for outside-view landscape
- The goal was to represent retail `portal_view` slices more closely.
Effect:
- The code is plausible as a draw assist, but it is not retail membership.
- User saw regressions including black covers during transitions.
Current risk:
- The next agent must ensure `ClipFrameAssembler` never decides PView membership.
- It should be draw-assist only.
- Several failures looked like GPU clip slots cutting shells or characters at door/stair boundaries.
### 5. Disabled clip routing for shells/entities to stop character/stair cutting
Attempt:
- `RetailPViewRenderer.UseIndoorMembershipOnlyRouting` clears `_envCells.SetClipRouting(null)` and `_entities.ClearClipRouting()`.
- Comment says retail portal views decide eligibility, but feeding those 2D views into GL clip distances slices characters and shells at stair/door boundaries.
Effect:
- This was a reaction to user screenshots where the character was cut in half on stairs.
- It may reduce character slicing.
- It may also mean shells/objects are currently only membership-gated, not portal-view clipped.
Current risk:
- This is not a settled retail copy. It is an emergency compromise.
- Retail does use per-view setup (`CEnvCell::setup_view`) around shell/object drawing. We need to know whether our GL clip-plane model is simply the wrong mechanism for that setup.
### 6. Tried EnvCell / DAT polygon side handling changes
Attempt:
- `ObjectMeshManager` changed CellStruct polygon side handling:
- DAT `CullMode` interpreted as retail `CPolygon::sides_type`.
- `0 = pos`
- `1 = pos twice with reversed winding`
- `2 = pos + neg surface`
- `NoPos` / `NoNeg` still suppress faces.
- Added explicit normal inversion / winding reversal logic.
Effect:
- User saw missing textures/white/grey interior panels after some launches.
- The attempt did not fix the cellar or transition flaps.
Current risk:
- This may be correct retail interpretation or may be partially wrong.
- Audit with DAT dumps and retail/ACME references before keeping.
- `a8-current-room-cellar-audit.txt` and `texture-current-room-surfaces.txt` may contain useful surface/cell evidence.
### 7. Tried outside-looking-in via `BuildFromExterior`
Attempt:
- `PortalVisibilityBuilder.BuildFromExterior` seeds interior cell views through outside-facing exit portals.
- `RetailPViewRenderer.DrawPortal` calls it from outdoor branch.
- Tests were added:
- seeds interior cell through outside portal;
- does not seed when camera is on interior side;
- traverses deeper interior portals;
- max seed distance skips distant exit portal.
Effect:
- User initially reported walls became visible looking in from outside, but ground/cellar entrance composition stayed wrong.
- Later launches regressed to transparent/grey panels and missing textures.
Current risk:
- This is probably needed, but the exterior portal path is not proven retail-faithful.
- `BuildFromExterior` may now have duplicated-looking test diff context; inspect file carefully.
### 8. Tried broad "no hybrid" render routing in `GameWindow`
Attempt:
- `GameWindow` was changed so indoor path should call `RetailPViewRenderer.DrawInside`.
- Outdoor path should draw world and call `DrawPortal`.
- Global indoor terrain/entity/particle passes were reduced or bypassed.
- New render signature diagnostics log:
- `branch`
- `root`
- `viewerRoot`
- `playerRoot`
- `viewerCell`
- `playerCell`
- `gate`
- `terrain`
- `skyGate`
- `zclear`
- `sceneParticles`
- `outSlices`
- `outPolys`
- `ids`
- `draw`
- object partition counts
Effect:
- User explicitly asked whether the hybrid was totally gone.
- It is not safe to answer "yes" without auditing `GameWindow`.
- Symptoms persisted, so either the routing is still hybrid or the PView graph/draw setup is wrong enough that "no hybrid" alone does not solve it.
Current risk:
- `GameWindow.cs` has a very large diff, around 1000 lines touched.
- Next agent should not blindly keep it.
- Audit all remaining global passes while `clipRoot != null`.
### 9. Tried PView `update_count`-style reprocessing
Attempt in the last aborted step:
- `PortalView.CellView.Add` now returns `bool` and deduplicates near-identical polygons.
- `PortalVisibilityBuilder.Build` replaced `seen` with:
- `queued`
- `drawListed`
- `processedViewCounts`
- A cell can be requeued when its view grows.
- Each processing pass clips portals against only newly added view polygons.
- Similar logic was added to `BuildFromExterior`.
- Added tests:
- `Build_CollapsedInteriorPortalNearEyeBeyondHalfMeter_FloodsNeighbour`
- `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit`
Effect:
- Focused tests passed.
- Live probe after patch still showed `outPolys` toggling near root `0xA9B40172`.
- User then reported transition flaps still there and now ground floor transparent.
Current risk:
- This patch is **unaccepted** and may be wrong.
- It approximates retail `update_count`, but does not necessarily implement `FixCellList`, `AdjustCellPlace`, or retail draw-list ordering correctly.
### 10. Widened eye-standing-in-portal fallback
Attempt:
- `EyeStandingPerpDist` widened from `0.5f` to `1.75f`.
- Motivation: live cellar capture had `0174 -> 0175` traversable with `D=-1.41` but `ProjectToNdc` returned zero vertices.
- The fallback still requires the perpendicular projection to land inside the portal opening.
Effect:
- It made one unit test pass for the cellar-style collapsed portal.
- It did not solve live transitions.
Current risk:
- This may be a bandaid, not retail.
- It should be validated against `OtherPortalClip` / `GetClip` in named retail before keeping.
### 11. Tried reciprocal clip fallback for eye-in-opening
Attempt:
- Before `ApplyReciprocalClip`, code clones `clippedRegion` when `eyeInsideOpening`.
- If reciprocal clipping empties the region, it restores the pre-reciprocal region.
Effect:
- Tests passed.
- Live visual did not.
Current risk:
- This may over-include.
- It is not proven retail-faithful.
## Critical Evidence From Logs
### Cellar startup: root 0174 only sees itself
From `launch-flap-shell-capture-relaunch.log`:
```text
[flap] root=0xA9B40174 eye=(154.50,4.99,92.25) localEye=(7.43,2.51,-1.77) |
p0->0x0175 D=-1.41 TRV proj=0 clip=-1 || outPolys=0 vis=1
[flap-cam] root=0xA9B40174 viewerCell=0xA9B40174 playerCell=0xA9B40174
... terrain=Skip outVisible=False
[render-sig] frame=49 branch=RetailPViewInside root=0xA9B40174
... terrain=Skip/skip sky=n zclear=n sceneParticles=none
outSlices=0 outPolys=0 ids=[0xA9B40174] draw=[0xA9B40174]
```
Meaning:
- The player/viewer/root are in cellar cell `0174`.
- The only visible portal to stair connector `0175` is traversable.
- Projection produces zero vertices.
- The PView flood stops at the cellar.
- Only the cellar draws; stair/main-floor cells are not in the visible set.
This is a direct candidate cause for missing floor/grey composition.
### Root 0172: outside view toggles on/off
From `launch-pview-watermark-probe.log`:
```text
frame=3625 root=0xA9B40172
p0->0x0173 D=... TRV proj=4 clip=4
p1->0x016F D=5.28 TRV proj=0 clip=-1
outPolys=1 vis=6
ids include 0xA9B40170
terrain=Skip/draw sky=Y zclear=Y
frame=3626 root=0xA9B40172
p0->0x0173 D=... TRV proj=5 clip=5
p1->0x016F D=5.39 TRV proj=0 clip=-1
outPolys=0 vis=5
ids missing 0xA9B40170
terrain=Skip/skip sky=n zclear=n
frame=3647 root=0xA9B40172
outPolys=1 ids include 0xA9B40170 terrain draw
frame=3648/3649 root=0xA9B40172
outPolys=0 ids missing 0xA9B40170 terrain skip
```
Meaning:
- The same root cell can alternate between seeing outside and not seeing outside.
- `0x016F` is involved in the root flap line but projects to zero.
- Sometimes `0x0170` becomes reachable and outside terrain/sky/depth clear run; sometimes it disappears.
- The visible-cell list and outside-view list are not stable.
Open question:
- Is `0x016F` an outdoor/land cell, an env cell lookup miss, or a portal that retail handles differently?
- Is the toggling caused by projection/clip degeneracy, wrong portal reciprocal handling, update-count propagation, or camera/viewer-cell root?
### Earlier known evidence: root 0171 vs player 0174 contradiction
From the older `2026-06-05-shell-sealing-cellar-floor-handoff.md`:
```text
[flap-cam] root=0xA9B40171 viewerCell=0xA9B40171 playerCell=0xA9B40174
...
[flap] root=0xA9B40171 ... p1->0173 proj=0 ...
```
Meaning:
- Earlier, camera/root was the room while player was cellar.
- The flood did not seal the player's cell.
- Later, after branch/viewer changes, there are also frames where root/player/viewer are all `0174` but the flood still fails on `0174 -> 0175`.
This means the problem is probably not only "wrong root"; it also includes projection/portal traversal/flood propagation or mesh-shell handling.
## What Not To Retry Blindly
Do not simply:
- switch the root to player cell as a workaround;
- widen `EyeStandingPerpDist` further;
- globally draw all indoor shells;
- globally draw terrain/entities/particles while inside;
- turn off all clipping and hope depth sorts it;
- keep adding `if cellar` or Holtburg-cottage-specific handling;
- claim "no hybrid" without auditing all `GameWindow` indoor/outdoor passes;
- equate unit-test pass with visual correctness.
The user has explicitly asked for retail smoothness, not a new patch stack.
## Likely Root Problem Space
The next fix probably lives in one of these, but evidence must decide:
1. **PView graph construction is not retail-faithful.**
- Missing or wrong `update_count` / `FixCellList` / `AdjustCellView`.
- Wrong draw-list ordering when a processed cell receives new views.
- Downstream portal propagation incomplete.
2. **Portal projection/clip behavior differs from retail.**
- `0174 -> 0175` traversable but `proj=0`.
- `0172 -> 016F` traversable but `proj=0`.
- `OtherPortalClip` / `GetClip` may not match retail.
3. **Outdoor/exit portal classification is wrong.**
- `OtherCellId=0xFFFF` is treated as exit/outside, but `0x016F` may be another kind of outside/land portal or missing env cell.
- OutsideView may be created through a downstream path that acdream sometimes drops.
4. **Renderer draw setup is still hybrid or ordered wrong.**
- `GameWindow` may still draw or skip global passes inconsistently.
- Sky/terrain/depth clear decisions are visibly flapping with `outside_view`.
5. **EnvCell shell mesh/surface handling is wrong.**
- Missing/transparent/white walls and floors may be mesh/surface/cull-side regressions.
- Audit `ObjectMeshManager` side handling against retail and DAT dumps.
6. **GPU clip slots are being used as membership or hard clipping when retail uses view setup differently.**
- Character cut in half on stairs strongly suggests hard clip-plane use on avatars/shells is wrong or applied at wrong pass.
## Suggested Next Procedure
1. Stop patching. Inspect the dirty diff first.
2. Read `docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md`.
3. Re-read named retail functions listed above.
4. Parse `launch-flap-shell-capture-relaunch.log` and `launch-pview-watermark-probe.log` around the cited frames.
5. Add better probes if needed:
- cell id;
- portal index;
- other cell id;
- portal flags;
- other portal id;
- traversable decision;
- standing distance;
- projection vertex count;
- clip vertex count;
- reciprocal clip result;
- outside-view add/skip reason;
- cell view count / processed count / update count;
- queue/requeue reason;
- draw-list insertion/reorder.
6. Decide from evidence whether `0174 -> 0175` and `0172 -> 016F` fail because of projection, reciprocal clip, cell lookup/classification, or update propagation.
7. Patch only the retail mismatch.
8. Build/test before launch.
9. Launch with probes once.
10. Then launch clean for FPS/visual feel.
11. Do not call it done until the user visually confirms retail smoothness.
## Launch Command
Use PowerShell:
```powershell
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 3
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_PROBE_FLAP = "1"
$env:ACDREAM_PROBE_SHELL = "1"
$env:ACDREAM_PROBE_VIS = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Tee-Object -FilePath "launch-next-pview.log"
```
For clean visual/FPS run, remove the probe env vars.
## Minimal Prompt For Next Agent
```text
Continue acdream M1.5 indoor render in SAME worktree:
C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b
branch claude/thirsty-goldberg-51bb9b. Do NOT branch/worktree. Do NOT push. NEVER stash/gc.
The current dirty render code is not visually accepted. The user stopped the prior agent after repeated regressions.
Read docs/research/2026-06-06-retail-pview-renderer-replacement-attempts-handoff.md first.
Current symptoms: transition flaps still happen indoor/outdoor, room/room, cellar; ground floor is now transparent; cellar broken; prior runs showed grey/black clear color, missing wall textures, and character cut on stairs.
Do not patch first. Audit dirty diff, read named retail PView, parse launch-flap-shell-capture-relaunch.log and launch-pview-watermark-probe.log. Determine exactly why:
1) cellar root 0174 fails to traverse 0174 -> 0175 when proj=0;
2) root 0172 toggles outside_view/0170 reachability while 0172 -> 016F has proj=0;
3) shell/object/terrain/depth-clear decisions disagree.
Patch only the retail mismatch. Build/test before relaunch. Do not claim success before user visual confirmation.
```

View file

@ -0,0 +1,79 @@
# Pickup Handoff — Verbatim Retail Indoor Render Port (execute in a new session) — 2026-06-06
This session **designed and planned** the verbatim retail `DrawCells` port; the next session
**executes** it. Spec + plan are committed; render code is NOT yet changed for the port.
## Start here (read in order)
1. **Plan (execute this):** `docs/superpowers/plans/2026-06-06-verbatim-retail-indoor-render-port.md`
2. **Spec (the why):** `docs/superpowers/specs/2026-06-06-verbatim-retail-indoor-render-port-design.md`
3. **Retail model:** `docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md`
## What this fixes (the user's 2-week pain)
Interior walls/floor render **grey** (clear color shows through) and geometry **bleeds**
between cells; character cut in half on stairs; flap at transitions. Root, located in code:
`RetailPViewRenderer.cs:52` drops visible cells lacking a `ClipFrameAssembler` slot (grey), and
`:237` globally disabled the per-cell trim (bleed) because it was wrongly applied to objects
(half-character). The fix = port retail `PView::DrawCells` (0x5a4840) verbatim: draw **every**
`OrderedVisibleCells` cell's shell, trimmed **per-slice** via `ClipPlaneSet``gl_ClipDistance`;
objects membership+depth gated, **no** clip. Scope **A+B** (DrawInside + look-in DrawPortal).
## Current tree state
- Branch `claude/thirsty-goldberg-51bb9b`. **Committed this session (local, NOT pushed):** the
spec (`eb7b1fa`) and the plan + this handoff.
- **Uncommitted (dirty) — KEEP, do not revert:** this session's faithful work is the foundation
the plan builds on —
- `PortalProjection.ProjectToClip` / `ClipToRegion` = homogeneous `GetClip`/`polyClipFinish`
(NEW, tested). `PortalVisibilityBuilder` rewired to use them (merged with a concurrent
agent's `[pv-trace]` work). These are the membership + clip math; the plan does NOT touch them.
- The rest of the dirty render tree (RetailPViewRenderer approximation, ClipFrameAssembler,
GameWindow rework, ObjectMeshManager #6, etc.) is the tangle the plan rewrites/deletes.
- **Baselines (must hold at start):** `dotnet build -c Debug` 0 errors; App.Tests **205/205**;
Core.Tests **1331 pass / 4 fail / 1 skip** — the 4 fails are pre-existing Physics door/step-up
(`BSPStepUpTests.D4_Airborne…`, two `DoorBugTrajectoryReplay.LiveCompare_*`,
`DoorCollisionApparatus…DocumentsBug`), unrelated to render.
## Rules (user-set, this worktree)
PowerShell on Windows; launch logs UTF-16. Do **NOT** branch/worktree, push, `git stash`/`gc`,
or revert the dirty tree. Build before every launch. **Acceptance is the user's eyes** — do not
claim a GL task done on a green build; only on the user's visual confirmation (the plan gates each
GL task on a launch). Live server: ACE `127.0.0.1:9000`, account `testaccount`/`testpassword`,
char `+Acdream` (spawns at the Holtburg cottage). Graceful-close the client between launches
(hard-kill leaves the ACE session stuck ~3 min).
## DO NOT re-litigate (evidence-disproven)
- The grey is **shell-sealing / wrong-flood-root**, NOT the portal projection. Do not "harden the
w-clip" further — the clip math is already faithful this session. (Two handoffs contradicted on
this; the 2026-06-05 shell-sealing handoff + the live visual were right.)
- If, after every shell draws (per `[render-sig] draw=[…]`), walls are sealed but **untextured**
(grey-but-drawn, vs. clear-color grey), that is a **separate surface/texture bug** (HEAD commit
notes "interior walls grey") — file it; do not reopen membership/clip.
## Recommended execution
Subagent-driven (a fresh subagent per task, review between) or inline (`executing-plans`). The plan
is sequenced so **Task 2 alone should make the grey disappear** — verify that with the user before
continuing; it de-risks the whole effort.
## Copy-paste pickup prompt
```
Execute the verbatim retail indoor render port in worktree thirsty-goldberg-51bb9b
(branch claude/thirsty-goldberg-51bb9b). Do NOT branch/worktree, push, git stash/gc, or
revert the dirty tree. PowerShell; launch logs UTF-16; build before launch; acceptance is the
user's eyes (gate every GL task on a launch + the user's visual OK).
Read first:
1) docs/superpowers/plans/2026-06-06-verbatim-retail-indoor-render-port.md (execute this, task by task)
2) docs/superpowers/specs/2026-06-06-verbatim-retail-indoor-render-port-design.md
3) docs/research/2026-06-06-verbatim-drawcells-port-pickup-handoff.md (state + rules + do-not-relitigate)
Confirm baselines (build 0 errors; App 205/205; Core 1331/4 pre-existing/1), then use
superpowers:executing-plans (or subagent-driven-development) to implement. Task 2 should make the
grey disappear — get the user's visual confirmation before continuing. The grey is shell-sealing,
NOT the projection; the clip math is already faithful — do not harden the w-clip.
```

View file

@ -0,0 +1,198 @@
# Handoff — Cutover FLIP shipped; see-through + oscillation DIAGNOSED (evidence-based) — 2026-06-07 (PM)
> **CANONICAL PICKUP for the render-unification residuals.** Worktree `thirsty-goldberg-51bb9b`,
> branch `claude/thirsty-goldberg-51bb9b`, HEAD `774cb22`. The cutover flip is SHIPPED (one render
> path, no branch-toggle flap). It exposed two residuals — **see-through building walls** and
> **oscillation** — whose root causes are now PROVEN with a live probe (not guessed). The fixes are
> identified but NOT yet implemented. Read §3 (diagnosis) and §5 (do-not-retry) before touching code.
---
## 1. What shipped (committed, keep)
The CUTOVER FLIP from `2026-06-07-render-unification-cutover-flip-handoff.md` landed:
| Commit | What |
|---|---|
| `5379f6e` | Step A — `PortalVisibilityBuilder.Build` seeds a full-screen `OutsideView` when the root is the outdoor node (`LoadedCell.IsOutdoorNode`, set by `OutdoorCellNode.Build`). +2 UnifiedFloodTests, +2 flag assertions. |
| `445e861` | Step B — the flip: `GameWindow.cs:~7387` `clipRoot = viewerRoot ?? _outdoorNode`. Drops the `playerIndoorGate` gate. ONE path, no inside/outside branch. Preserves the `LiveDynamic` draw for the outdoor root. |
| `88caa0d` | depth-clear fix — `ClearDepthSlice = null` for the outdoor root (the full-screen depth clear was painting the cellar over the player; fixed). |
| `774cb22` | Revert of `0030dac` (the slot-0 skip — a FAILED fix, see §5). |
**The flip's PRIMARY goal succeeded:** `[render-sig]` shows `branch=RetailPViewInside` every frame,
**zero `OutdoorRoot` frames** across a whole session. The two-branch-toggle flap is gone by
construction. Baselines: build green, App.Tests 216/0.
---
## 2. The two residuals the flip exposed (user-observed)
1. **See-through building walls from outside** — standing outside a building you see *into* it through
the walls (doors closed).
2. **Oscillation** — the interior/walls flicker between "showing nothing", "see-through", and "full
interior" frame-to-frame while standing still.
---
## 3. ROOT CAUSE — proven by a live `[bshell]` probe (NOT guessed)
A throttled probe in `RetailPViewRenderer.DrawInside` (now stripped; re-add from git history of this
doc's session if needed) logged, for the outdoor-node root on a loaded frame at the Holtburg cottage:
```
[bshell] total=6 withMesh=6 inOutdoorPartition=6 envCellsFlooded=1 outdoorEntities=637
```
Interpretation (each number is decisive):
- **`total=6 withMesh=6 inOutdoorPartition=6`** — there ARE 6 building `ModelId` "shell" entities
(`WorldEntity.IsBuildingShell`, created in `LandblockLoader.cs:75-91` from `LandBlockInfo.Buildings[].ModelId`),
ALL carry meshes, ALL land in `partition.Outdoor` (they have `ParentCellId==null`;
`InteriorEntityPartition` line 47 → Outdoor; `WbDrawDispatcher.EntityPassesVisibleCellGate` returns
`true` for null `visibleCellIds`). **So the `ModelId` exterior DOES render.**
- **BUT** the earlier "skip all interior shell draws for the outdoor root" experiment (uncommitted,
reverted) made the building **fully see-through** — i.e. drawing ONLY the `ModelId` shells is NOT a
solid building. **Therefore the `ModelId` Setup is a partial frame, and the building's actual WALLS
are the EnvCell shell geometry** (`ObjectMeshManager.PrepareCellStructMeshData`, drawn by
`DrawEnvCellShells`).
- **`envCellsFlooded=1`** — in this frame the outdoor-node flood reached **ZERO** building interior
cells (only the node itself). Earlier `[render-sig]` frames at the same spot showed `ids=[node + ~12
building cells]` (≈13). **So the flood membership swings between 1 and ~13 frame-to-frame.**
### The two residuals, explained
1. **Oscillation = flood instability gating the walls.** The flip made wall-drawing depend on the
portal flood reaching each building's interior cells. That flood is unstable (1 ↔ ~13), so the
EnvCell walls blink in and out. ("showing nothing" = flood=1, no interior; "full interior /
see-through" = flood reached the building.)
2. **See-through = single-sided EnvCell walls.** Even when the walls DO draw, the EnvCell wall polys
are single-sided for `SidesType==CounterClockwise` (interior-facing). `PrepareCellStructMeshData`
(ObjectMeshManager ~1299-1310) builds the back face only for `SidesType==None` (front twice
reversed) and `SidesType==Clockwise` (neg surface). A `CounterClockwise` wall = front face only →
from outside you see its culled back → see-through.
---
## 4. Fix path (identified, NOT implemented)
Two independent fixes, both needed:
- **F1 — Stabilise the flood membership** so a building's interior cells are CONSISTENTLY in/out of
the visible set (no 1↔13 swing). This is the same metastability family as the indoor flicker. Likely
levers: ground the outdoor-node flood's building membership in the cell `stab_list`/PVS (stable,
precomputed) instead of the per-frame portal-side test + projection; or hysteresis on which buildings
are flooded. Probe to re-add: `envCellsFlooded` per frame (RLE it; it should be constant when standing
still).
- **F2 — Make the EnvCell walls solid from outside.** Either build the missing back faces for
`SidesType==CounterClockwise` walls in `PrepareCellStructMeshData`, or render those shells
double-sided (`CullMode.None`) when the viewer is outside the cell. Verify against retail: dump a real
Holtburg cell's wall-poly `SidesType` distribution first.
**Open research question (reconcile before F2):** pre-flip the buildings looked SOLID from outside.
What drew the solid walls pre-flip — a global EnvCell-shell render, the `DrawPortal`/`BuildFromExterior`
look-in, or were the `ModelId` shells solid then? Find what the flip replaced. The old outdoor `else`
block (`GameWindow.cs:~7557-7663`, now dead-when-clipRoot-non-null but still present) is the place to
read. This answers whether F2 is "build back faces" or "restore a pre-flip draw".
---
## 5. DO NOT RETRY (failed this session, with evidence)
- **Slot-0 skip** (`0030dac`, reverted `774cb22`): "for the outdoor root, skip flooded cells whose
clip degenerated to no-clip slot 0." Made the oscillation WORSE — slot-0-ness flickers per frame, so
cells blinked. Wrong: the see-through is not the slot-0 fallback.
- **Skip-all-interiors experiment** (uncommitted, reverted): "outdoor root draws terrain + ModelId
exteriors only, no EnvCell shells." Made buildings FULLY see-through + flashing — proved the `ModelId`
Setup is not the walls (the walls are the EnvCell shells). Do not ship this.
- **Backface-culling-of-shells hypothesis** (never coded): plausible but the cull mode is already
data-driven (`poly.SidesType`); the real gap is single-sided geometry (no back face built), not a
cull-state bug.
- The subagent hypothesis "ModelId exterior occludes; interior overdraws it; fix = gate DrawEnvCellShells
off for the outdoor root" is **disproven** — that gate IS the skip-all-interiors experiment, which
removed the walls entirely.
---
## 6. State + how to resume
- HEAD `774cb22`, tree clean, build green, App.Tests 216/0. The flip + depth-clear are committed; the
branch renders with the two residuals (see-through + oscillation).
- The flip is on this BRANCH only (main is unaffected). To get a stable client meanwhile, revert the
flip commits (`445e861` Step B is the behaviour change; reverting it alone restores the pre-flip
outdoor path — verify Step A `5379f6e` is inert without it).
- Re-add the `[bshell]` / `envCellsFlooded` probe (see this session's git reflog for the exact code) to
watch flood stability while working F1.
- Memory: `project_indoor_flap_rootcause` (update with this corrected diagnosis),
`reference_render_pipeline_state`, `feedback_render_downstream_of_membership` (the oscillation IS a
membership/flood-stability bug, per that note).
---
## 7. Next-session kickoff prompt — VERIFY-FIRST (the diagnosis above is a SUSPECT'S statement)
This session reached the §3 diagnosis only AFTER three wrong guesses, so the next session must verify it
cold before building any fix. Paste this to start:
```
Continue acdream M1.5 render unification. Branch claude/thirsty-goldberg-51bb9b, HEAD 774cb22.
PowerShell on Windows; build before launch; live ACE 127.0.0.1:9000, testaccount/testpassword, char
+Acdream (spawns at the Holtburg/Arcanum cottage, landblock 0xA9B4).
The render-unification CUTOVER FLIP is committed. It is CLAIMED to have killed the two-branch render
"flap" but left two residuals — see-through building walls and oscillation — and a root-cause diagnosis
was reached. That diagnosis was only reached AFTER THREE WRONG GUESSES in the same session, so DO NOT
trust it. Your FIRST job is to verify it cold, with fresh primary evidence, and you are explicitly
licensed to REFUTE it.
READ (as a suspect's statement, NOT as truth):
- docs/research/2026-06-07-cutover-flip-render-residuals-diagnosis-handoff.md (claimed diagnosis +
do-not-retry list + the probe numbers it rests on)
- memory project_indoor_flap_rootcause (corrected diagnosis claim)
=== TASK 1 — UNBIASED VERIFICATION (complete fully BEFORE proposing any fix) ===
Do not anchor on the handoff's conclusions — re-derive each from independent evidence. For each claim,
report CONFIRMED / REFUTED / CORRECTED with the evidence. If ANY load-bearing claim is refuted, the
diagnosis is wrong: STOP, re-diagnose, do not build a fix on it. Prefer dispatching the verification to a
fresh subagent that has NOT seen the conclusions, to avoid confirmation bias.
1.1 "The branch-toggle flap is gone (one render path)." Launch (ACDREAM_PROBE_FLAP=1); walk
indoor<->outdoor and pan the camera at a doorway; RLE the [render-sig] `branch` field. Expected if
true: zero `branch=OutdoorRoot` after spawn. Refute if OutdoorRoot reappears.
1.2 "Oscillation == outdoor-node flood membership instability." Add a probe logging
pvFrame.OrderedVisibleCells.Count per outdoor-root frame WHILE STANDING PERFECTLY STILL at the
cottage. Swings frame-to-frame (e.g. 1<->13) -> unstable (confirms). Constant while the user still
sees oscillation -> DIFFERENT cause (refute + re-diagnose). Correlate the swings with what visibly
flickers.
1.3 "See-through == single-sided EnvCell walls." Dump the actual sides_type distribution of a REAL
Holtburg building cell's wall polygons (Environment dat -> CellStruct, focused test/tool over the
cottage cell). Confirm walls are predominantly single-sided AND that PrepareCellStructMeshData
(ObjectMeshManager ~1299-1310) builds a back face only for SidesType None/Clockwise (not
CounterClockwise). FALSIFIABLE cross-check: temporarily force the EnvCell shell pass to CullMode.None
(double-sided) and confirm THAT alone makes the walls solid from outside; revert after.
1.4 "The building WALLS are the EnvCell shells; the ModelId 'shell' is only a partial frame." Re-add the
[bshell] probe (total/withMesh/inOutdoorPartition/envCellsFlooded). Independently inspect what the
building ModelId Setup geometry IS (poly count, bbox) vs the EnvCell shell. Reproduce or refute the
skip-all-interiors experiment (building went fully see-through).
1.5 (OPEN, decides the fix shape) "Pre-flip buildings were solid from outside — what drew the walls?"
Check out the pre-flip commit (parent of 445e861), launch, confirm buildings solid from outside,
trace what drew the solid walls (old outdoor `else` block GameWindow.cs ~7557-7663 /
DrawPortal+BuildFromExterior / a global EnvCell-shell render). Decides whether F2 is "build missing
back faces" or "restore a pre-flip draw the flip replaced".
DO-NOT-RETRY (proven failures last session): the slot-0 skip (made oscillation worse); skipping all
interior shells / gating DrawEnvCellShells off for the outdoor root (building fully see-through — already
ran); any render-side debounce/grace (forbidden, no-workarounds).
=== TASK 2 — only AFTER Task 1 confirms or corrects the diagnosis ===
Implement F1 (stabilise flood membership — e.g. ground building membership in the cell stab_list/PVS
instead of the per-frame portal-side test) and F2 (the verified wall-sidedness fix). TDD where possible;
each lands behind a USER VISUAL GATE at the cottage. Do not delete the dead DrawPortal/BuildFromExterior/
outdoor-else paths until the residuals are visually confirmed fixed.
```
**Why verify-first:** the fastest single decisive test is the §1.3 falsifiable cross-check (force
`CullMode.None`; if walls go solid from outside, the single-sided-wall hypothesis is confirmed and F2 is
"build back faces"). Run the verification under a fresh subagent so it can't rubber-stamp these
conclusions.

View file

@ -0,0 +1,160 @@
# Indoor Render — Session Handoff: HANG fixed + interior SEALS; the FLAP is next — 2026-06-07
> Worktree `thirsty-goldberg-51bb9b`, branch `claude/thirsty-goldberg-51bb9b`. PowerShell on Windows;
> launch logs UTF-16; build before launch; acceptance is the user's eyes. Live ACE `127.0.0.1:9000`,
> `testaccount`/`testpassword`, char `+Acdream` (spawns near the Holtburg / "Arcanum" cottage —
> landblock `0xA9B4`, cottage cells `0xA9B4016F0175`). Do NOT branch/worktree, push, or `git stash`/`gc`.
## TL;DR
The two-week indoor-render **HANG is FIXED** and the interior **SEALS** (walls/floor/ceiling draw,
textured) — both committed this session and live-verified by the user ("Ok now it runs!"). A
structured live test pinned the remaining dominant visible issue, the **FLAP at transitions**, as
**viewer-cell metastability**: the render roots at the camera-eye cell, which oscillates
outdoor↔indoor as the 3rd-person boom drifts across the doorway plane. **The flap is a SEPARATE,
already-designed fix — it is NOT the verbatim DrawCells port; finishing the port will not fix it.**
Next session: **fix the flap** (camera-boom stability + viewer-cell dead-zone). Tracked follow-ups:
#78 terrain gating, look-in-from-inside sealing, look-in FPS, L-spotlight.
## What shipped this session (committed — see `git log` on this branch)
### 1. The HANG fix (the blocker)
Indoor frames froze (`AppHangB1`; not a crash — captured the spinning managed stack via a
`dotnet-stack` hang-watcher). Root cause: `PortalVisibilityBuilder.Build`'s portal-visibility flood
**did not terminate** for real cottage geometry. Two layers, two fixes (both kept):
- **A — drift-tolerant `CellView.Add` dedup** (`src/AcDream.App/Rendering/PortalView.cs`). The flood
re-queues a cell every time its view GROWS; growth only stops when the dedup recognises a re-clipped
region as a duplicate. The faithful `ProjectToClip` near-side clip drifts per round, so the old
exact index-by-index match (eps 1e-4) never caught the near-duplicate → unbounded growth → O(n²)
CPU-spin in `CellView.Add`. Fix: key each polygon by its vertices **snapped to a 1e-3 NDC grid**,
consecutive-dedup'd, **canonically rotated** to a lex-min start → finite key space → convergence.
Tests: `tests/AcDream.App.Tests/Rendering/CellViewDedupTests.cs` (3).
- **B — bounded re-enqueue** (`src/AcDream.App/Rendering/PortalVisibilityBuilder.cs`). A alone did not
fully converge (the spin relocated to `ScreenPolygonClip.ClipByEdge` — bounded loops — inside the
still-non-terminating BFS). Restored the **`MaxReprocessPerCell = 16`** hard cap that Phase U.2a
deleted ("fixpoint termination" left the loop with NO bound). **Kept the re-enqueue** — it is
load-bearing for late-slice propagation (`Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit`).
Pure enqueue-once was tried and **broke that test**, so re-enqueue is kept and merely bounded.
- Deep diagnosis + the reassessment that led to B: `docs/research/2026-06-06-indoor-render-hang-rootcause.md`.
- **Verified:** clean exit (255→0); runs indoors with no freeze; the indoor flood converges in ~1
round/cell at normal positions (measured 35 pops/frame, 1 view-poly/cell). The cap only bites at
the metastable doorway.
### 2. The SEAL (verbatim DrawCells port — Task 2)
`RetailPViewRenderer.DrawEnvCellShells` now iterates `IndoorDrawPlan.ShellPass(pvFrame)` — **every**
visible cell's shell draws (was gated on `ClipFrameAssembler`'s slot filter → cells without a slot
were silently dropped → grey clear-color void). Verified: interior seals + textured. (Task 1
`IndoorDrawPlan` + its test committed earlier as `bff1955`.)
### 3. Look-in FPS
`GameWindow` exterior-look-in candidate cells limited to the player's landblock **±1** (was **all
~81 loaded landblocks** iterated every outdoor frame just to discard them via the 48 m seed cutoff).
Provably no behavior change (excluded cells are >48 m, already culled). Outdoor FPS improved but
still **~110 fps / ~9 ms (was ~200)** — `DrawPortal` still draws ~12 building interiors/frame (see
follow-up).
## Baselines (must hold at next session start)
- `dotnet build -c Debug` **0 errors**.
- App.Tests **210/210** (205 baseline + IndoorDrawPlanTests 2 + CellViewDedupTests 3).
- Core.Tests **1331 pass / 4 fail / 1 skip** — the 4 are pre-existing Physics door/step-up, unrelated.
## Structured live test — findings (Holtburg/Arcanum cottage, 2026-06-07)
User walked a 6-step protocol (inside-still → camera-pan → doorway-threshold → just-outside →
looking-at-cottage → cellar) and reported 8 behaviours; `ACDREAM_PROBE_FLAP` `[render-sig]`
correlated each.
| # | Observed | Cause | Bucket |
|---|---|---|---|
| 2,3,6,8 | walls briefly transparent / window+entrance "covered by the world background" / abrupt "teleport" through the doorway — all **at transitions (camera crossing a threshold)** | **THE FLAP** | viewer-cell stability (NEXT) |
| 1 | outdoor grass covers the cellar-entrance hole (steady, looking in from outside) | outdoor terrain not gated over the indoor floor opening | **#78** terrain gating |
| 7 | from inside, a building seen through the doorway has transparent walls (world-bg shows); pops back when you step outside | look-out shows other buildings unsealed | look-in/look-out completeness |
| 5 | spotlight blobs on textures from the ceiling lamp (always been there) | point-light artifact | **L-spotlight** (separate) |
| FPS | inside very high; outside **110 fps / ~9 ms** (was ~200) | `DrawPortal` draws ~12 interiors/frame | look-in cost |
| 4 | cellar transitions **stable** ✓ | vertical transition doesn't cross the outdoor boundary | — |
### The FLAP — pinned (render-sig evidence)
`[render-sig]` over the doorway shows the render branch + the cell it roots at flip-flopping while the
**player cell stays inside**:
```
50× branch=OutdoorRoot viewer=0xA9B40031 (outdoor) player=0xA9B40171 (indoor) gate=in
16× branch=RetailPViewInside viewer=0xA9B40170 (indoor) player=0xA9B40171 gate=in
113× branch=RetailPViewInside viewer=0xA9B40171 (indoor) player=0xA9B40171 gate=in
... oscillates 0x0031 ↔ 0x0170 ↔ 0x0171 frame-to-frame ...
```
**Mechanism:** the render roots at the **viewer (camera-eye) cell** (`clipRoot = viewerRoot`, Phase W
"one viewpoint"). The 3rd-person boom drifts the eye across the doorway plane; acdream re-resolves the
viewer cell fresh each frame with **no hysteresis** → it flips between outdoor `0x0031` and indoor
`0x0170/0x0171` → the render flips `OutdoorRoot``RetailPViewInside` → the indoor seal drops (walls
transparent, outdoor world/grass shows) then re-seals → **flapping**. This is exactly the 2026-06-05
viewer-cell-flicker diagnosis, now confirmed against the live render branch.
## RECOMMENDED NEXT WORK — fix the FLAP (separate, already-designed)
Per `docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md`, 3 retail-faithful parts:
1. **Viewer-cell dead-zone (do this first)** — ±0.2 mm cell hysteresis so a sub-mm eye drift can't flip
the cell (`PhysicsCameraCollisionProbe.SweepEye`; retail `point_inside_cell_bsp` 0x53c1f0). Highest
leverage — likely kills most of the flap on its own.
2. **Camera-boom stability** — stop the boom drifting at rest (`RetailChaseCamera.UpdateCamera`; retail
`UpdateCamera` 0x456660).
3. **w-space (w=0) portal clip** — close-portal projection degeneracy (`PortalProjection` /
`PortalVisibilityBuilder`; retail `GetClip` 0x5a4320 / `polyClipFinish` 0x6b6d00). Lower priority.
Apparatus ready: `ACDREAM_PROBE_FLAP` emits `[render-sig]` (branch/viewer/player/gate per frame),
`[flap]`, `[flap-cam]`, `[flap-sweep]` — light enough to launch with (the heavy `ACDREAM_PROBE_SHELL`
firehose is what previously caused an I/O stall; avoid it).
## Tracked follow-ups (logged; not yet fixed)
- **#78 terrain gating** — outdoor terrain (grass) draws over the indoor cellar-entrance hole (and likely
other indoor floors). Decomp anchor `CEnvCell::find_visible_child_cell` (`acclient_2013_pseudo_c.txt:311397`).
- **Look-in-from-inside** — buildings seen through your door/window from inside render unsealed
(transparent walls); the look-out pass doesn't draw other buildings' shells. DrawCells port Task 5/7
territory (or R2 "outside-looking-in").
- **Look-in FPS**`DrawPortal` draws ~12 building interiors every outdoor frame (~110 vs ~200 fps).
Optimize: only look into buildings whose exit portals are frustum-visible; skip when no door is in view.
- **L-spotlight** — ceiling-lamp point light makes spotlight blobs on textures. Pre-existing, separate.
## verbatim DrawCells port — remaining tasks (deferred)
Plan: `docs/superpowers/plans/2026-06-06-verbatim-retail-indoor-render-port.md`. Task 1 + Task 2 done.
**Task 3** (objects no-clip) is effectively already satisfied (objects draw membership-gated with no
clip; no half-characters observed). **Tasks 48** (per-slice trim, look-out, delete `ClipFrameAssembler`,
look-in, final) are **cleanup with no current visible payoff** — the seal works and there is **no visible
bleed** (the "glitches between cells" were the FLAP, not bleed). **Task 4 (trim) is intricate** (its
per-slice `_clipFrame.Reset()` is coupled with the landscape/particle passes that still read
`clipAssembly` slots) and **risks re-slicing the working seal** — do it carefully, fresh, and only when
clean architecture is the priority.
## DO NOT re-litigate
- The HANG fix (A drift-dedup + B bounded re-enqueue) is correct + verified. **Do NOT try pure
enqueue-once** — it breaks `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` (late-slice
propagation needs the re-enqueue; the cap, not removal, is the termination guarantee).
- The grey was the `drawableCells` / `ClipFrameAssembler` slot filter; Task 2 fixed it. The clip math is
faithful — do not "harden the w-clip".
- **The FLAP is NOT the DrawCells port.** It is viewer-cell metastability (camera/membership). Tasks 48
will NOT fix it.
- The render roots at the VIEWER (camera-eye) cell intentionally (Phase W "one viewpoint"). The flap fix
is to STABILISE the viewer cell (dead-zone + boom), NOT to re-root at the player cell (superseded).
## Copy-paste pickup prompt (next session)
```
Pick up the indoor-render work in worktree thirsty-goldberg-51bb9b (branch
claude/thirsty-goldberg-51bb9b). PowerShell; launch logs UTF-16; build before launch; acceptance is
the user's eyes. Do NOT branch/worktree, push, git stash/gc, or revert the dirty tree.
Read first: docs/research/2026-06-07-indoor-render-session-handoff.md (state, what shipped, the FLAP
diagnosis, do-not-relitigate). Then docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md
(the flap fix plan).
Confirm baselines: build 0 errors; App.Tests 210/210; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip.
The indoor HANG is fixed and the interior SEALS (shipped + committed last session). The remaining
dominant visible issue is the FLAP at transitions — viewer-cell metastability: the render roots at the
camera-eye cell, which oscillates outdoor↔indoor as the 3rd-person boom drifts across the doorway (no
hysteresis), confirmed in [render-sig]. FIX THE FLAP, starting with the viewer-cell dead-zone
(PhysicsCameraCollisionProbe.SweepEye; retail point_inside_cell_bsp 0x53c1f0), then camera-boom
stability (RetailChaseCamera.UpdateCamera; retail UpdateCamera 0x456660). Launch with ACDREAM_PROBE_FLAP
only (NOT ACDREAM_PROBE_SHELL — it stalls on I/O). Gate on the user's eyes at the cottage doorway.
Do NOT: retry pure enqueue-once (breaks late-slice propagation); re-root render at the player cell
(viewer-cell rooting is intentional); finish DrawCells port Tasks 4-8 expecting it to fix the flap (it
won't). Tracked follow-ups (not the flap): #78 terrain gating (grass over cellar hole), look-in-from-
inside sealing, look-in FPS (DrawPortal ~12 interiors/frame), L-spotlight.
```

View file

@ -0,0 +1,283 @@
# Handoff — Render Unification CUTOVER FLIP (the one step that fixes the flap) — 2026-06-07
> **CANONICAL PICKUP. Read this first.** Worktree `thirsty-goldberg-51bb9b`, branch
> `claude/thirsty-goldberg-51bb9b`. PowerShell on Windows; launch logs are UTF-16; build before
> launch; **acceptance is the user's eyes** at the Holtburg/Arcanum cottage. Do NOT branch/worktree,
> push, `git stash`/`gc`, or revert the dirty tree (it has pre-existing untracked files — leave them).
> Live ACE `127.0.0.1:9000`, `testaccount`/`testpassword`, char `+Acdream` (spawns at the cottage,
> landblock `0xA9B4`, cottage cells `0xA9B4016F0175`, outdoor cell id near spawn `0xA9B40031`).
---
## 0. TL;DR — you are ONE step from fixing the flap
The indoor render **FLAP** (textures "battle"/oscillate at every transition) is the **two-branch
render split** (`OutdoorRoot` vs `RetailPViewInside`) toggling as the 3rd-person eye crosses the
indoor/outdoor boundary. The fix (user-approved): make the **outdoor world a flood-graph cell** so
there is **one** render path (retail's `DrawInside(viewer_cell)`), with **no branch to flip**.
**~70% is built, validated, and committed.** The remaining step is **the CUTOVER FLIP**: root the one
draw path at the viewer cell (the outdoor node when the eye is outdoors), make terrain draw via the
existing OutsideView mechanism, then **launch → user visual gate → delete the dead old paths.** This
doc gives the exact, de-risked steps. **Do the flip with adequate context headroom — it is coordinated
surgery ending at a launch + visual gate, and a first attempt rarely renders right. Rushing a render
change before a visual gate is how the dead-zone regression happened on the morning of 2026-06-07.**
---
## 1. State — what is committed (branch HEAD `7b3091c`)
| Commit | What |
|---|---|
| `bb64a67` | Spec: [docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md](../superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md) |
| `06666b7` | Plan: [docs/superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md](../superpowers/plans/2026-06-07-render-unification-outdoor-as-cell.md) (Progress section is current) |
| `2a2cc97` | **Task 1**`OutdoorCellNode.Build` (`src/AcDream.App/Rendering/OutdoorCellNode.cs`) + 2 tests |
| `c5b4f77` | **Task 3** — outdoor-root flood VALIDATED (`tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs`) |
| `d01fe30` | **Task 2** — outdoor node built live each frame, additive (`_outdoorNode` in `GameWindow.cs`) |
| `7b3091c` | plan progress (cutover de-risked) |
**Baselines (MUST hold):** build 0 errors; App.Tests **214** pass; Core.Tests **1331 pass / 4 fail
(pre-existing door/step-up: 2× DoorBugTrajectoryReplay LiveCompare, BSPStepUpTests.D4,
DoorCollisionApparatus) / 1 skip**. Tree: no uncommitted tracked changes; pre-existing untracked
files (`*.txt/*.png/*.jsonl/*.py/*.log/*.ps1`, `lip-cells/`) are NOT ours — leave them.
**Verify on pickup:** `git log --oneline -6` shows the above; `dotnet build -c Debug` green;
`dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj -c Debug` → 214/0.
---
## 2. Why this design (don't relitigate — these are evidence-disproven)
The flap was pinned 2026-06-07 with live `ACDREAM_PROBE_FLAP` `[render-sig]`. The branch at
**`GameWindow.cs:7384-7388`** picks the path:
```csharp
bool playerIndoorGate = RenderingDiagnostics.ShouldRenderIndoor(playerCellId, playerRoot is not null);
var clipRoot = playerIndoorGate && viewerRoot is not null ? viewerRoot : null; // line 7387
string renderBranch = clipRoot is null ? "OutdoorRoot" : "RetailPViewInside";
```
`viewerRoot` is null when the eye is outdoors → `clipRoot` null → `OutdoorRoot`. The two branches draw
differently (terrain full vs door-clipped; 4 look-in cells vs 6 flood cells; depth-clear on/off), so the
eye crossing the boundary toggles them → the flap. **When the eye stays indoor (`0170``0171`) BOTH
draw the same 6 cells → no flap** — proving it's specifically the indoor/outdoor branch switch.
**DO NOT retry (all failed/dead-ends, with evidence):**
- **Viewer-cell dead-zone** (±0.2 mm in `PointInsideCellBsp`): the eye crosses by METRES; zero effect;
it REGRESSED the cellar roof (shifted the flood root via the pick). Reverted `2a2cc97`'s predecessor.
- **Gating the branch on the PLAYER cell**: documented dead-end at `GameWindow.cs:7207-7211` — forcing an
indoor draw while the camera is outside "drops the outdoor pass and leaves clear color around a floating
doorway slice." When the eye is genuinely outside, the outdoor view IS correct.
- **Render-side debounce/grace** on the branch: forbidden (no-workarounds rule).
- Part 1 (camera boom snap, `d2212cf`) + Part 3 (w-space portal clip, `ProjectToClip`/`ClipToRegion`) are
ALREADY shipped — the 2026-06-05 3-part viewer-cell-stability plan is exhausted.
Full root-cause memory: `project_indoor_flap_rootcause`. Retail oracle: `SmartBox::RenderNormalMode`
(`0x00453aa0`, pc:92635) → `RenderDeviceD3D::DrawInside` (`0x0059f0d0`) → `PView::DrawInside`
(`0x005a5860`, pc:433793). Retail ALWAYS calls `DrawInside(viewer_cell)`; the outdoor world is a cell
whose stab list carries the landscape. ONE path, no inside/outside branch.
---
## 3. What's already built + VALIDATED (so you trust it)
- **`OutdoorCellNode.Build(uint outdoorCellId, IReadOnlyList<LoadedCell> nearbyBuildingCells)`**
(`src/AcDream.App/Rendering/OutdoorCellNode.cs`) → a `LoadedCell` with `WorldTransform=Identity`,
`SeenOutside=true`, and `Portals`/`ClipPlanes`/`PortalPolygons` that point BACK into each building
cell (reverse of the building's `OtherCellId==0xFFFF` exit portal; entrance polygon → world space;
`InsideSide` flipped). Unit-tested.
- **The flood roots at the outdoor node with ZERO production changes** (Task 3,
`UnifiedFloodTests.cs`): `PortalVisibilityBuilder.Build(node, eye, lookup, viewProj)` returns the node
+ the buildings reached through its portals; the outdoor↔building cycle terminates (existing `queued`
HashSet + `MaxReprocessPerCell`). **This is the de-risk: the core hypothesis is proven.**
- **`_outdoorNode` is built live each outdoor frame** (Task 2, `GameWindow.cs` just before the branch,
~line 7360) from nearby building cells (Chebyshev ≤1 landblocks). It is **NOT yet consumed** (behaviour
unchanged). An `[outdoor-node]` probe (under `ACDREAM_PROBE_FLAP`) prints
`cell=0x.. nearbyCells=N portals=M`.
---
## 4. THE FLIP — exact steps (in order). Each builds green; the launch is the gate.
### Pre-flight (do FIRST — confirms the node finds real entrances)
Launch with `ACDREAM_PROBE_FLAP=1` (see §6), stand at the cottage, read the log:
```
Get-Content launch-*.log | Select-String "outdoor-node" -SimpleMatch | Select-Object -Last 5
```
**Expect `portals=M` with M ≥ 1** when standing outside near the cottage (the node found the cottage's
exit portals). If `portals=0` everywhere, STOP — the nearby-building enumeration or the exit-portal
detection is wrong; fix that before flipping (the flip is pointless if the node has no doorways).
### Step A — terrain for the outdoor-ROOT case (the only genuinely new draw code)
Indoor→outdoor terrain ALREADY works via the OutsideView→terrain-slice path
(`RetailPViewRenderer.DrawInside` line 79 → `DrawLandscapeThroughOutsideView` line 138; the assembler
turns `pvFrame.OutsideView.Polygons` into `OutsideViewSlices` at `ClipFrameAssembler.cs:134-165`;
`outdoorVisible = OutsideViewSlices.Length > 0` → terrain draws). The ONLY new piece: when `Build` is
rooted at the outdoor node, **outdoors is visible full-screen**, so add a **full-screen region to
`frame.OutsideView`**.
In **`PortalVisibilityBuilder.Build`** (`src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:63`), right
after the root is seeded full-screen (`frame.CellViews[cameraCell.CellId] = CellView.FullScreen()`, ~line
77), add:
```csharp
// Render unification: an OUTDOOR root (synthetic outdoor node, low cell id < 0x100) sees outdoors
// FULL-SCREEN. Feed that to OutsideView so DrawLandscapeThroughOutsideView draws the landscape as the
// node's shell (full-screen here; the doorway region when an interior root reaches outdoors via an exit
// portal — that path already exists at the OtherCellId==0xFFFF branch below).
if ((cameraCell.CellId & 0xFFFFu) < 0x0100u)
AddRegion(frame.OutsideView, /* full-screen region */);
```
**You must confirm the exact full-screen call.** Read `CellView.FullScreen()` and `AddRegion(...)` in
`PortalView.cs` / `PortalVisibilityBuilder.cs`. `AddRegion(CellView, List<...>)` takes a region (list of
NDC polygons); the root seed uses `CellView.FullScreen()`. The full-screen NDC quad is
`[(-1,-1),(1,-1),(1,1),(-1,1)]`. Use whatever representation `AddRegion`/`CellView` expects (mirror how
`CellView.FullScreen()` builds its polygon). `ClipFrameAssembler` handles a screen-covering OutsideView
poly as either 4 edge planes (clips nothing) or `cps.Count==0` → scissor fallback (full-screen) — both
yield `terrainMode != Skip` → terrain draws everywhere. Either is fine.
**Alternative if the OutsideView call proves fiddly (fallback, less unified but lower-risk):** in
`GameWindow`, when `clipRoot` is the outdoor node, draw terrain full-screen BEFORE `DrawInside` (the way
the old `else` block does at the current `_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb)`
call), and let `DrawInside` draw only the flooded building shells. Prefer the OutsideView approach (one
mechanism); use this only if blocked.
Build green. (No behaviour change yet — nothing roots at the node until Step B.)
### Step B — flip the routing (the behaviour change)
At **`GameWindow.cs:7387`** replace the gate so the eye's cell always roots the one path:
```csharp
// Render unification: ONE path rooted at the viewer cell. Eye indoors → its interior cell; eye
// outdoors → the synthetic outdoor node (built above). No inside/outside branch → no flap.
var clipRoot = viewerRoot ?? _outdoorNode;
```
i.e. drop `playerIndoorGate &&` and fall back to `_outdoorNode`. Keep `renderBranch` for the probe
(`clipRoot is null ? "OutdoorRoot" : "RetailPViewInside"` — now `OutdoorRoot` only when `_outdoorNode` is
also null, e.g. legacy camera). The `else` (outdoor) block becomes **dead** when `clipRoot` is non-null —
**leave it for now** (delete in Step D after the visual gate).
**The 4 cases this produces (all one path, no flap):**
- player out / eye out → root = node → terrain full + flood into visible buildings (the look-in). ✓
- player in / eye in → root = interior cell → flood + terrain through door (as today). ✓
- **player in / eye out (the flap case)** → root = node → terrain + flood into the building incl. the
player's cell. Same path as case 1 → no flap. ✓
- player out / eye in (eye pokes through a doorway) → root = interior cell → drawn from inside. ✓
### Step B integration checklist (verify each — these are where it can "screw up")
- **`ComputeVisibilityFromRoot(viewerRoot, ...)`** at `GameWindow.cs:7204` returns null for a null root.
After the flip you pass `clipRoot` (= node) into `DrawInside` via `RootCell`, but the separate
`visibility = ComputeVisibilityFromRoot(viewerRoot, ...)` call still uses `viewerRoot` (the interior
one). Decide: either also feed the node to that call, or confirm `cameraInsideCell`/`rootSeenOutside`
still behave. `rootSeenOutside = viewerRoot?.SeenOutside ?? true` (line 7211) → with the node it'd be
`true` (node.SeenOutside) IF you point it at the node; with the interior `viewerRoot` (null outdoors)
it's `true`. Either way `renderSky` (line 7314 `viewerRoot is null || rootSeenOutside`) stays true
outdoors. **Verify sky still draws outdoors after the flip.**
- **`DrawInside` is rooted at `clipRoot`** (`RetailPViewDrawContext.RootCell = clipRoot`, line 7455) —
already correct; it just now receives the node sometimes.
- **Shell pass is a safe no-op for the node** (`DrawEnvCellShells`
`_envCells.Render(pass, {nodeId})` renders nothing for an id with no prepared EnvCell geometry,
`RetailPViewRenderer.cs:190-202`). No exclusion needed — confirmed.
- **`PrepareRenderBatches(filter: drawableCells)`** will include the node id; it should no-op for an
unknown EnvCell id. Confirm no throw.
- **Entities**: `InteriorEntityPartition.Partition(drawableCells, ...)` with the node id in the set —
outdoor scenery/buildings are entities; confirm they still draw (membership-gated). The old `else`
block drew outdoor entities via `_interiorRenderer.DrawEntityBucket(... outdoorPartition.Outdoor ...)`
— make sure outdoor entities still draw under the unified path (they may need the node id in their
membership, or a dedicated outdoor bucket draw inside the DrawInside path).
### Step C — BUILD → LAUNCH → USER VISUAL GATE (do not skip; do not delete anything yet)
`dotnet build -c Debug` green, then launch (`ACDREAM_PROBE_FLAP=1`, §6). **Hand to the user** at the
cottage: walk in/out, pan the camera at the threshold, cellar down/up, look at the cottage from outside.
**Acceptance:** no flap; no missing wall/roof textures; terrain + sky correct; no see-through walls;
pure-outdoor FPS unchanged. Capture `[render-sig]`: `branch` should be `RetailPViewInside` continuously
(no `OutdoorRoot` toggling) and `viewerCell`/`draw` transition cleanly with no 4↔6 cell-set jump.
**If broken, iterate Steps A/B — do NOT proceed to deletes.**
### Step D — only AFTER the user confirms: delete the dead paths (Task 7 + Phase 4)
Delete `PortalVisibilityBuilder.BuildFromExterior`; `RetailPViewRenderer.DrawPortal`; the dead `else`
(outdoor) block in `GameWindow` (the look-in enumeration + `_exteriorPortalCandidateCells` plumbing +
`DrawPortal` call); and, if now unused, the `OutsideView`-only helpers. Reconcile the `[render-sig]`
probe (`GameWindow.cs:~9039-9082`) to the single path (drop `extPortal/extIds/outdoorRoot*`). Build
green; tests baseline. Update memory `project_indoor_flap_rootcause` + `reference_render_pipeline_state`
+ the roadmap/milestones with the shipped outcome. Commit per step.
---
## 5. Pure-outdoor regression guard (spec §10 — don't skip)
The open-world case (no building in view) MUST stay byte-identical to today: full-screen terrain, no
clip. After Step A/B, when the outdoor node has **zero** portals (no building nearby), the flood is just
`{node}` and OutsideView is the full-screen region → terrain draws full-screen, no interior cells → same
as today. Add/keep a unit test asserting: `Build(emptyPortalNode, ...)``OrderedVisibleCells == {node}`
and OutsideView is full-screen (so `terrainMode != Skip`). Visual-gate the open field too, not just the
cottage.
---
## 6. Launch (PowerShell; UTF-16 log; background)
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"; $env:ACDREAM_TEST_HOST = "127.0.0.1"; $env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"; $env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_PROBE_FLAP = "1" # ONLY this probe. NOT ACDREAM_PROBE_SHELL (it stalls on I/O).
dotnet build -c Debug # MUST be green before launch
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-flip.log"
```
Run in the background; give it ~12 s to reach in-world. Read with `Get-Content launch-flip.log -Tail N`
and `Select-String`. The client exits cleanly (exit 0) when the user closes the window → ACE session
clears. Probes: `[outdoor-node]` (node portal count), `[render-sig]` (branch/viewer/player/draw/miss per
frame), `[flap-sweep]` (camera sweep). RLE the `viewerCell`/`branch` sequences to check for clean
monotonic transitions vs toggling.
---
## 7. Files & anchors (current line numbers, HEAD 7b3091c)
- `GameWindow.cs`: `_outdoorNode` field + helpers ~line 188; node build ~7355-7380 (before the branch);
`viewerRoot`/`viewerCellId` resolve 7192-7204; `clipRoot`/`renderBranch` **7384-7388** (the flip);
indoor `DrawInside` block 7448-7515; dead-after-flip `else` (outdoor) block 7516-7614 (terrain `_terrain?.Draw`
~7425, look-in `DrawPortal` ~7570); `DrawRetailPViewLandscapeSlice` ~9239; `[render-sig]` emit ~9039-9082.
- `RetailPViewRenderer.cs`: `DrawInside` 39; `DrawPortal` 88 (delete in D); `DrawLandscapeThroughOutsideView`
138; `DrawEnvCellShells` 180 (node no-op); shells use `_envCells.Render(pass, {id})`.
- `PortalVisibilityBuilder.cs`: `Build` 63 (root seed ~77 → add full-screen OutsideView for outdoor root);
exit-portal branch 234 (`OtherCellId==0xFFFF``AddRegion(frame.OutsideView, ...)` — the indoor→outdoor
path that already works); `BuildFromExterior` 339 (delete in D); `CameraOnInteriorSide` 664.
- `ClipFrameAssembler.cs`: `Assemble` 78; OutsideView→slices 134-165 (`outdoorVisible = slices.Length>0`).
- `OutdoorCellNode.cs`: `Build`. `CellVisibility.cs`: `LoadedCell` (class, `CellId` field line 29),
`CellPortalInfo`/`PortalClipPlane`, `TryGetCell` 276, `GetCellsForLandblock` 266 (returns
`IReadOnlyList<LoadedCell>`), `ComputeVisibilityFromRoot` 338 (null root → null).
---
## 8. DO / DON'T
**DO:** flip in order A→B→C (gate)→D; build green between steps; verify `[outdoor-node] portals≥1` BEFORE
flipping; keep the `else` block until the user confirms; keep the pure-outdoor case byte-identical.
**DON'T:** retry dead-zone / player-cell branch-gating / debounce (§2); delete the old paths before the
visual gate; switch the FLOOD root to the player cell (root at the VIEWER cell — the node when eye
outdoors); use `ACDREAM_PROBE_SHELL` (I/O stall); rush the flip on low context (visual-gated render
surgery — the dead-zone regression came from exactly that).
---
## 9. Copy-paste kickoff prompt
```
Continue acdream M1.5 render unification: do the CUTOVER FLIP that fixes the indoor FLAP. Worktree
thirsty-goldberg-51bb9b, branch claude/thirsty-goldberg-51bb9b. PowerShell; launch logs UTF-16; build
before launch; acceptance is the user's eyes at the Holtburg cottage. Do NOT branch/worktree, push,
git stash/gc, or revert the dirty tree.
READ FIRST: docs/research/2026-06-07-render-unification-cutover-flip-handoff.md (THIS doc — exact steps,
de-risking, do-not list). Then the spec (2026-06-07-render-unification-outdoor-as-cell-design.md) and the
plan Progress section (2026-06-07-render-unification-outdoor-as-cell.md).
State: ~70% built + validated (HEAD 7b3091c). Outdoor node builder (2a2cc97), outdoor-root flood proven
with zero prod changes (c5b4f77), node built live each frame (d01fe30). Baselines: App 214, Core 1331/4/1,
build green.
DO THE FLIP (handoff §4), in order, building green between steps: A) feed a full-screen region to
frame.OutsideView when Build roots at the outdoor node ((CellId & 0xFFFF) < 0x100) so terrain draws
full-screen — confirm the exact CellView.FullScreen()/AddRegion call; B) at GameWindow.cs:7387 flip to
`clipRoot = viewerRoot ?? _outdoorNode` (drop the playerIndoorGate gate) — work the Step-B integration
checklist (sky, ComputeVisibilityFromRoot, outdoor entities); C) build → launch (ACDREAM_PROBE_FLAP only)
→ USER VISUAL GATE at the cottage; D) ONLY after the user confirms, delete BuildFromExterior/DrawPortal/
the dead else block/OutsideView-only plumbing + cleanup. Pre-flight: verify [outdoor-node] portals≥1
before flipping. Keep the pure-outdoor case byte-identical (regression guard, §5).
DON'T (§2/§8): retry dead-zone / player-cell branch-gating / debounce (evidence-disproven); delete old
paths before the visual gate; root the flood at the player cell; use ACDREAM_PROBE_SHELL.
```

View file

@ -0,0 +1,127 @@
# Handoff/Findings — the "physics rest µm-jitter" flap diagnosis is REFUTED; the flap is a RENDER membership instability at the grazing doorway portal — 2026-06-08 (PM)
> **Supersedes** `docs/research/2026-06-08-flap-rootcause-physics-rest-handoff.md` (the AM handoff). That
> handoff asked to be treated as a suspect statement and verified — this session verified it with primary
> evidence and it does **not** hold. The flap is **not physics** and **not camera drift**. It is the
> portal-flood **membership flickering non-monotonically at the grazing doorway portal as the camera eye
> sweeps** (i.e. while you turn the camera at the doorway). This is the SPEC's §2.2 diagnosis
> (`2026-06-08-portal-flood-membership-stability-design.md`), NOT its refuted §4 enqueue-once fix.
---
## 1. What was claimed (AM handoff) vs what the evidence shows
**AM claim:** the flap's varying input is a **physics resting-position µm jitter**`_body.Position`
blips ~1 ULP between ticks at rest → `RenderPosition` (Lerp of physics) jitters → eye jitters → flood
flips. Fix = physics rest-stability (broaden `kill_velocity`, hold contact plane).
**Refuted by three independent pieces of primary evidence:**
### A. The physics body is bit-stable at standstill — it does NOT blip
`door-recheck-capture.jsonl` (515 MB, 238,342 `ResolveWithTransition` records, captured standing at the
doorway, cells `0xA9B40170/0171/0174/0175/0031`):
- **216,300 true-standstill records** (zero velocity, `currentPos==targetPos`).
- **0** resolve re-snaps (`result.position != input` never happens at standstill).
- **0** cross-tick `currentPos` drift (the body position is carried forward byte-identically).
- The `grounded-but-cp=none` contradictory state DOES occur (3.5% of frames) but produces **no** position
blip.
Confirmed independently with **4 new deterministic tests** (all GREEN — they PROVE rest is bit-stable):
- `PlayerMovementControllerTests.Update_AtRestNoInput_RenderPositionBitStableAcrossManyFrames` (flat terrain)
- `PlayerMovementControllerTests.Update_WalkThenStop_SettlesToBitStableRest` (flat terrain, post-motion)
- `CellarUpTrajectoryReplayTests.IndoorCellarFloor_AtRestZeroOffset_BodyPositionBitStable` (indoor cell, resolver loop)
- `CellarUpTrajectoryReplayTests.IndoorCell_FullController_AtRestNoInput_RenderPositionBitStable` (indoor cell, full controller loop)
### B. On the actual flap frames, the PLAYER position is byte-identical
`pvinput.log` window 77487758 (a clean `flood 6↔2` flap), player `RenderPosition` for **11 consecutive
frames**: `(155.632858, 13.527222, 94.000000)`**byte-identical**. The physics output the camera reads
does not move at all during the flap. (The 1-ULP *player* blip the AM handoff cited is at the **outdoor**
`flood=1` records — a red herring, not the indoor flap.)
### C. The EYE moves while the player is still — and the camera DOES settle when idle
Same window: the eye orbits smoothly ~1 mm/frame (X ↓, Y ↑, Z constant) — a slow **camera rotation**
around the stationary player. And:
- Of **888** flood flips in the capture, only **1** had a byte-identical eye (and that one is the
outdoor→indoor root switch). Every other flip had a moved eye → **the flood is deterministic in the
eye; it changes only when the eye moves** (matches `Build_IsDeterministic_*`).
- Longest **indoor byte-identical-eye runs: 203 / 181 / 178 frames (~3.4 s)** — within each, the flood is
a **single constant value** (no flicker). **61%** of indoor frames have a byte-stable eye.
- ⇒ The camera **settles** at rest (no boom drift, no spring oscillation). When the eye is still, the
flood is stable. The flap fires **only while the eye is moving**.
## 2. The actual mechanism
When the camera eye **sweeps** through the grazing doorway portal (you turn the camera at the threshold),
the deep cell cluster `{0172,0173,0174,0175}` flickers in/out — flood `6,6,6,2,2,6,6,6,2,6,2` — i.e.
**non-monotonic membership across a monotonic eye sweep**. A correct visibility flood would transition
the deep cluster in/out **once** as the grazing portal closes; instead its clip flips empty↔non-empty as
the eye crosses and re-crosses the knife-edge. This is the SPEC's §2.2 diagnosis (the grazing portal's
clip / re-clip drift makes `clippedRegion.Count` flip `0↔N`, dropping the deep cluster on empty-clip
frames).
It is **NOT** physics (A, B). It is **NOT** camera drift/oscillation (C: eye byte-stable ~3.4 s when
idle). It is a **render-side portal-flood membership instability at grazing angles**, surfaced by camera
rotation.
## 3. Status of prior fixes / diagnoses
- **AM physics-rest fix** — would not have fixed the flap (physics rest is already bit-stable). Do not pursue.
- **SPEC §2.2 diagnosis (grazing-portal membership instability)** — CONFIRMED by this evidence.
- **SPEC §4 enqueue-once fix** — already refuted in the AM handoff (retail propagates late slices via
`AddToCell`, decomp :433494; broke `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit`). So the
correct fix is a render-side membership stabilization that is monotonic under a sweeping eye **without**
breaking late-slice propagation — design TBD (brainstorm).
## 4. Apparatus (this session)
- 4 GREEN rest-stability tests (above) — keep as regression guards + evidence that physics rest is bit-stable.
- Analysis scripts (ad-hoc, `/tmp`): door-recheck standstill survey; pvinput flood-flip vs eye/player
delta buckets; indoor byte-stable-eye-run scan. Re-derivable from `pvinput.log` + `door-recheck-capture.jsonl`.
- Existing probes `[pv-input]` (`ACDREAM_PROBE_PVINPUT`) and `[render-sig]` remain the live gate.
## 5. Next step (proposed)
Brainstorm + design a render-side fix that makes the deep-cluster membership **monotonic/stable as the eye
sweeps the grazing portal** (candidates: more robust grazing-portal clip, a retail-faithful single-process
traversal that doesn't re-clip-drift, or matching retail's exact `GetClip`/`polyClipFinish` epsilon). Then
TDD a builder test that sweeps the eye across the grazing angle and asserts monotonic membership, and
visual-gate by turning the camera at the cottage doorway.
## 6. LIVE-CONFIRMED (2026-06-08 PM, targeted doorway capture, user-driven)
A fresh instrumented capture (`launch-flap-capture.ps1`; `[pv-input]` enhanced with `rawPlayer`=raw
physics body pos + `yaw`; cells `0170-0175` dumped to `tests/.../Fixtures/flap-doorway/`; 84K frames)
confirms the diagnosis across every state and decomposes the flap into THREE render sub-issues. **In
every case the player render-pos AND raw physics-pos are byte-identical (0 µm) — physics is conclusively
exonerated; the flap is 100% camera-eye-driven.**
| State (user-driven) | player moves | eye moves | flood |
|---|---|---|---|
| Idle, hands fully off | no (0 µm) | **no (0 µm)** | **stable** (no flap) |
| Turn / walk | no (0 µm) | yes (mm, yaw) | oscillates |
| Camera smoothing-glide after a turn (yaw byte-constant, eye glides monotonically, decelerating) | no | yes (mm) | **oscillates 8↔3** ← this is the "flickers while idle" the user perceived |
Key burst (row 11167): **yaw byte-constant**, eye X glides monotonically 155.109→155.435 (18→5 mm/frame,
decelerating), flood `8→3→8…3→8`. Monotonic eye ⇒ non-monotonic membership ⇒ **render** instability (not
camera hunting).
**Three sub-issues (all eye-driven, physics out):**
- **A — Membership oscillation:** flood non-monotonic as the eye sweeps within a *stable* root.
outside-looking-in `8↔3`; outdoor-root `17↔33` (21 flips/2500 frames); indoor-root `2↔6`.
- **B — Root toggle (the big one):** at the threshold, `outRoot` flips outdoor↔indoor as the eye crosses
the door plane → wholesale visible-set swap **≈18-33 cells ↔ 2 cells** (4 toggles in 2500 frames). This
is the "two-branch" outdoor-node-vs-indoor-cell root switch the unification was meant to remove — still
present.
- **C — Indoor-root under-inclusion:** eye just inside ⇒ `outRoot=n` flood **= 2, stable** for 2438
frames → outdoors + other rooms missing (the indoor flood does not reach back out the exit portal / to
adjacent cells). C is B's partner: the swap *to* indoor loses the scene → "textures missing."
**Fix scope:** core render pipeline (root resolution + flood + grazing-clip), NOT physics, NOT camera.
Spec §2.2 (membership instability) is right for A; B+C are the threshold root-resolution/flood issues.
Spec §4 enqueue-once stays refuted. Design needs brainstorming (saga has reverted speculative render
fixes — see `feedback_render_one_gate`, `feedback_verify_render_seal_before_layering`).
Apparatus added: `launch-flap-capture.ps1`, `analyze_flap_live.py`, `find_burst.py`, fixtures
`tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B4017{0..5}.json`, `flap-doorway-resolve.jsonl`.
**Memory to correct:** `project_indoor_flap_rootcause` (root is render: A membership instability + B
root-toggle + C indoor under-inclusion, all under a moving camera eye — NOT physics rest, NOT camera
drift; the "two-branch split" B is still live).

View file

@ -0,0 +1,119 @@
# Handoff — the indoor FLAP traced to a physics rest µm-jitter; prior diagnoses REFUTED — 2026-06-08
> **CANONICAL PICKUP for the indoor render flap.** This session refuted the 2026-06-07 cutover-flip
> diagnosis AND an enqueue-once attempt, confirmed the real mechanism with primary evidence, and traced
> the root all the way down to a **physics resting-position µm jitter**. The fix is in physics
> (rest-stability), is teed up, and needs one more **higher-precision** trace to pin the exact cause
> before porting. Spec: `docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md`
> (its §4 enqueue-once design is REFUTED — see §3 here; its §6 physics contingency is now the active
> direction).
---
## 1. What the flap IS (confirmed, primary evidence)
At the Holtburg cottage **doorway threshold**, the portal-visibility flood set oscillates frame-to-frame
(`ids=[0170,0171,0172,0173,0174,0175]``[0170,0171]`, i.e. 6↔2 cells) **from a stable viewer cell**
(`root=0xA9B40170`, `outRoot=n`). The deep `0172-0175` cluster pops in/out → textures "battle."
- It is **NOT** see-through walls from outside (standing outside with the door closed is **stable**
user visual gate), **NOT** the outdoor node, **NOT** a root toggle, **NOT** nondeterminism.
- `PortalVisibilityBuilder.Build` is a **pure deterministic** function (proved by
`PortalVisibilityBuilderTests.Build_IsDeterministic_*`, passes). So the flip requires a **varying
input**.
- The high-precision `[pv-input]` probe (6 dp) shows the camera eye AND the **player `RenderPosition`**
carry perpetual **~18 µm** float jitter at rest (e.g. player Z `94.000000 ↔ 94.000008`). At the
threshold a grazing portal's clip is so knife-edge that this µm jitter flips its empty/non-empty
result → the flood membership flips → the flap.
**Mechanism chain:**
`physics resting position blips ~µm → ComputeRenderPosition Lerp surfaces it as µm eye jitter → the
portal-flood clip (clip-non-empty membership) is µm-sensitive at the grazing threshold portal → flips →
flap.` Retail is flap-free because its authoritative local position is bit-stable at rest (so its same
clip-non-empty membership never crosses the boundary).
## 2. REFUTED — the 2026-06-07 cutover-flip diagnosis (do NOT act on its F1/F2)
`docs/research/2026-06-07-cutover-flip-render-residuals-diagnosis-handoff.md` is wrong on its
load-bearing claims (primary evidence in this session):
- "See-through from outside" — **not reproduced** (outside, door closed, is stable).
- "Walls ARE the EnvCell shells; ModelId is a partial frame" — **refuted**: the cottage ModelId GfxObj
`0x01000A2B` is a full closed exterior (76 render polys, bbox 20×18×10.4 m, 46 outward-facing walls +
roof — `tools/A8CellAudit gfxobj 0x01000A2B`). EnvCell shells are interior-facing. **F2 (EnvCell
back-faces) targets the wrong geometry.**
- "Oscillation = outdoor-node flood (1↔13)" — **corrected**: it is the *indoor* flood, stable root,
2↔6. F1 targeted the wrong root.
- "branch=RetailPViewInside every frame proves the flap is gone" — **tautological** (post-flip
`clipRoot = viewerRoot ?? _outdoorNode` is ~never null, so `branch` can't report `OutdoorRoot`).
## 3. REFUTED — enqueue-once traversal (TDD caught it)
Hypothesis: the flap is acdream's `MaxReprocessPerCell` re-enqueue drift; restore retail's enqueue-once
(first-discovery only, no re-enqueue). **Refuted:** retail does NOT stop at first discovery — its
`AddViewToPortals` growth branch calls **`AddToCell`** (decomp :433494), so a cell's later-grown view
IS propagated (late slices reach exit portals). The existing test
`PortalVisibilityBuilderTests.Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` encodes exactly
this retail behavior; enqueue-once broke it. The change + its test were reverted (tree clean, 27 portal
tests green). **The divergence is the re-clip DRIFT, not the propagation — and underneath, the flap is
the µm input jitter (which removing the drift would only *reduce*, not eliminate; `Build` is
deterministic so only a bit-stable INPUT guarantees no flap).**
## 4. The root — physics resting position not bit-stable
`PlayerMovementController.ComputeRenderPosition` (line 810): `Vector3.Lerp(_prevPhysicsPos,
_currPhysicsPos, alpha)`. `Lerp(a, a, t) == a` exactly, so the µm `RenderPosition` jitter means
**`_prev != _curr`** — the physics body's resting position blips ~µm between ticks. Retail's
`kill_velocity` (`OBJECTINFO::kill_velocity` = `set_velocity(0)`, decomp :274467) is called by
`validate_transition` (:272567) on **every** grounded collision/slide with a valid contact plane,
keeping rest bit-stable.
acdream rest path:
- `calc_acceleration` (PhysicsBody.cs:191) zeroes gravity only when **`Contact && OnWalkable &&
!Sledding`**.
- `UpdatePhysicsInternal` (PhysicsBody.cs:352) skips position integration when `velocity <= 0`.
- Player flags set per tick in `PlayerMovementController` (1271-1301): `Contact|OnWalkable` only when
`resolveResult.IsOnGround && Velocity.Z <= 0`; else cleared → gravity.
- acdream's `kill_velocity` (PhysicsEngine.cs:837) is **narrower than retail's** — fires only on
`ObjectInfo.VelocityKilled` (the airborne steep-roof/wall reset), NOT on every grounded contact.
So at a *clean* rest the position is bit-stable; the blip is an **intermittent** failure (a stray
gravity tick / µm velocity residual / contact-plane not re-established). The `[resolve]` probe (3 dp)
shows the body stable to **mm** at spawn rest (`94.000` repeated) — confirming the blip is **sub-mm**,
below that probe's precision — and shows `groundedIn=True` but `walkable=False cp=none` (no contact
plane established at rest), a lead toward the Contact/contact-plane path.
## 5. NEXT STEPS (the physics rest-stability fix)
1. **Higher-precision physics rest trace (REQUIRED before fixing).** The 3-dp `[resolve]` probe is too
coarse. Add a 6-dp per-tick probe of the resting body: `_body.Position`, `Velocity`, `Acceleration`,
`TransientState` (Contact/OnWalkable), `resolveResult.IsOnGround`, contact-plane valid. Launch, let
the character sit at spawn (no input needed — autonomous), capture ~10 s, and find the tick where the
position blips µm and which condition failed (gravity applied? velocity residual? resolve re-snap?
Contact cleared?).
2. **Port the retail-faithful rest-stability fix** for the pinned cause — most likely one of:
(a) broaden `kill_velocity` to match retail's `validate_transition` (zero velocity on every grounded
contact with a valid contact plane, :272567); (b) ensure the `Contact` flag / contact plane is
re-established on the zero-distance rest sweep so `calc_acceleration` keeps gravity off; (c) a
retail-faithful "supported body at rest is frozen" (skip integration/resolve when grounded + zero
velocity + no movement input). TDD: a test asserting the resting body position is **bit-stable across
N ticks** with no input.
3. **Visual gate** at the cottage doorway threshold: hold still — the 2↔6 oscillation is gone (re-run
`[pv-input]`/`[render-sig]`, flood `ids=` constant at rest).
**DO NOT RETRY:** the overlap-predicate render band-aid (rejected by user — not retail); enqueue-once
(refuted, §3); any render-side debounce/grace (forbidden).
## 6. Apparatus (committed this session) + state
- **Keep (real regression value):** `PortalVisibilityBuilderTests.Build_IsDeterministic_*` (proves Build
deterministic); `tools/A8CellAudit` `gfxobj` mode (dumps render geometry — used to refute the ModelId
claim).
- **Diagnostic probes (env-gated, inert off; KEEP for the physics trace + flap visual gate, strip after
the fix ships):** `[pv-input]` (`ACDREAM_PROBE_PVINPUT`, 6-dp Build inputs + flood count,
RenderingDiagnostics + GameWindow); the `outRoot=`/`bshell=` fields on `[render-sig]`;
`launch-pvinput.ps1`, `launch-bshell-probe.ps1`, `launch-resolve.ps1`.
- Tree: PortalVisibilityBuilder.cs reverted to the re-enqueue (no functional change shipped). Build
green; App.Tests green (portal-visibility 27/27).
- Memory to update: `project_indoor_flap_rootcause` (root is the physics rest µm-jitter, not the render
diagnosis or enqueue-once).

View file

@ -0,0 +1,545 @@
# HANDOFF — Full Retail Render Port (Option A): one `DrawInside(viewer_cell)` path, no inside/outside branch
**Date:** 2026-06-08 (evening)
**Branch:** `claude/thirsty-goldberg-51bb9b` (HEAD `9b1857a`)
**Status:** Design DECIDED (Option A). No implementation started. This is the canonical pickup
document for a FRESH session. Read it top-to-bottom before touching code.
**Author's note to the next session:** this is the payoff of a ~4-week saga + one long
measurement session. The information below was *expensive* to obtain (live cdb on the real
2013 retail client). Do not re-derive it; do not re-guess. Build from it.
---
## 0. TL;DR (read this, then read the rest)
The indoor "flap"/flicker is **not a bug to be fixed with a point change.** It is the symptom
of a **structural divergence** from how retail renders. We confirmed this by attaching cdb to
the **live retail client** and reading the decompilation. The findings are unambiguous:
- **Retail has exactly ONE render path: `DrawInside(viewer_cell)`, every frame.** There is **no
inside/outside render branch.** The "outside" branch in `RenderNormalMode` is dead code
(compiler-constant). `is_player_outside` only gates sky/weather/lighting, never the render path.
- **"Entering a building" is NOT a rendering event in retail.** It is *only* the camera sweep
resolving a different `viewer_cell` (an outdoor `CLandCell` → an indoor `CEnvCell`). The render
code never asks "am I inside?". Same path before and after the threshold → **no seam → no flap.**
- **Retail's eye JITTERS ~36 µm at rest** (measured, live). Retail's membership is stable anyway.
So retail's stability is **structural** (coarse per-building visibility robust to jitter), NOT
from a stable eye. **Chasing a byte-stable eye is the wrong target** — retail itself doesn't
have one.
- **We diverged in three ways:** (1) we invented an inside/outside branch + a synthetic
`_outdoorNode`; (2) we do ONE giant unified flood where retail does many small per-building
floods; (3) our camera boom jitters ~36× more than retail's.
**The decision (user-approved 2026-06-08): Option A — full retail structural port.** Rip out the
branch and the outdoor node. Always root at the real `viewer_cell`. One `DrawInside`. Render
terrain + per-building interiors from *within* that path the way retail does. Phase it; conformance-
test each phase against the measured retail values in this doc; visual-gate.
**Next session's first move:** run `superpowers:brainstorming` is NOT needed (design is decided);
go to `superpowers:writing-plans` to turn §6 (the design) into a phased implementation plan, then
`superpowers:executing-plans`/`subagent-driven-development`. But FIRST close the open traces in §8.
---
## 1. The decision and its scope
**Option A — Full retail structural port.** In scope:
1. **Remove the inside/outside render branch.** Today `GameWindow.cs:7498` does
`if (clipRoot is not null) { DrawInside } else { DrawPortal }`, where
`clipRoot = viewerRoot ?? _outdoorNode` (`GameWindow.cs:7396`). Retail has no such branch.
2. **Root always at the real `viewer_cell`** (the cell the camera-collision sweep resolves — an
outdoor `CLandCell` or an indoor `CEnvCell`), never a synthetic outdoor node.
3. **One `DrawInside(viewer_cell)` per frame.** Terrain + sky draw from *within* it when the flood
"sees outside"; per-building interiors draw per-portal via the terrain BSP.
4. **Per-building view construction** (retail does ~7 small per-building floods/frame), replacing
our single unified flood.
Explicitly NOT the goal: "make the eye byte-stable" (retail's isn't); "add hysteresis/dead-zone to
the clip" (band-aid, forbidden); "bound the portal re-enqueue churn" (there is no churn — measured).
The user's words: *"Yes lets do full retail! A!"* and earlier *"NO code of the project is frozen so
all options on the table."* Nothing is frozen. This is a render-orchestration rewrite, done
retail-faithfully, in phases.
---
## 2. Why this took ~4 weeks (the pattern is the diagnosis)
Over ~4 weeks the "root cause" was declared, with apparatus, **at least seven times**: two-pipe
split → root-at-player-cell → viewer-cell metastability → camera-boom drift → physics rest-jitter →
portal-flood re-enqueue churn → render-position jitter. **Every one was a real, measured
perturbation. Every fix failed or moved the symptom.** That pattern is the signature of a
**system-level problem attacked one stage at a time** (systematic-debugging skill, Phase 4.5:
"3+ fixes failed → question the architecture").
**The fundamental issue.** The flicker is a **binary** decision ("is this cell visible: yes/no")
made at a **grazing knife-edge** (the doorway portal, near-zero-area sliver), fed by a **long,
coupled chain that amplifies**:
```
physics body → render-position interpolation → camera boom → camera-collision sweep
→ viewer cell → render branch → portal flood → clip → VISIBLE / NOT VISIBLE
```
Measured amplification: physics body byte-stable → render position jitters µm → eye jitters
~1.3 mm → at the end the continuous wobble is forced into a yes/no at a knife-edge → cell pops in
and out. **It is a pencil balanced on its tip:** it doesn't matter which draft of air tips it,
there's always another. You cannot stabilize a pencil-on-tip by hunting individual air currents.
Every "I found the jitter source!" fix closed one draft while the pencil stayed on its point.
**Why retail has the same knife-edge but doesn't flicker:** retail uses the *exact same* grazing
clip (we ported it). Retail doesn't flicker because **its structure is robust to the jitter**
many small per-building visibility decisions, not one giant knife-edge flood. Retail did NOT remove
the jitter (its eye jitters ~36 µm too); it made the *decision* robust to it. **That is the thing
we never did, because we kept patching the noise instead of the structure.**
---
## 3. THE ORACLE — how retail actually renders (measured + decompiled GROUND TRUTH)
This is the irreplaceable part. It was obtained from **the live retail client** (cdb) + the named
decomp. Cite it; do not re-derive it.
### 3.1 Retail render architecture: ONE path
`SmartBox::RenderNormalMode` (`0x453aa0`, decomp line 92635) **always** calls
`DrawInside(viewer_cell)`. The "outside" branch (`LScape::draw`) is **dead code** — the branch
predicate is the Binary-Ninja artifact `edi_2 = -((edi - edi))` = `xor edi,edi; neg edi` = **always
0**, so the inside branch is taken every frame. `is_player_outside` (`0x451e80`, line 90996) returns
nonzero for an outdoor land cell (low 16 bits of `objcell_id` in `[1, 0xFF]`) but is **not called
from the render dispatch** — only from `GameSky::Draw`, UI, and lighting. **There is no
inside/outside render branch in retail.**
### 3.2 Call graph (from the decomp-flow research agent, verified against addresses)
```
SmartBox::RenderNormalMode (0x453aa0, line 92635)
└─ ALWAYS: RenderDevice::vtable->DrawInside(viewer_cell)
→ RenderDeviceD3D::DrawInside (0x59f0d0, line 427843)
→ PView::DrawInside(indoor_pview, viewer_cell) (0x5a5860, line 433793)
→ CEnvCell::curr_view_push(viewer_cell)
→ PView::add_views(this, cell->num_stabs, cell->stab_list)
→ Render::copy_view(cell->portal_view[-1], null, 4)
→ PView::ConstructView(this, viewer_cell, 0xffff) [CEnvCell overload, 0x5a57b0]
→ PView::DrawCells(this, result) (0x5a4840, line 432709)
├─ if outside_view.view_count > 0: LScape::draw(lscape) [terrain + sky]
└─ for each cell in cell_draw_list: draw portals, env geometry, objects
PView::DrawCells → LScape::draw (0x506330, line 267912)
→ GameSky::Draw
→ for each land block: RenderDeviceD3D::DrawBlock (0x5a17c0, line 430027)
→ DrawLandCell (terrain) ; DrawSortCell → DrawBuilding (0x59f2a0, line 427938)
outdoor_pview->outdoor_portal_list = building->portals <<< KEY
→ terrain BSP walk reaches BSPPORTAL leaves (magic "PORT" 0x504f5254):
BSPPORTAL::portal_draw_portals_only (0x53d870, line 326881)
→ for i in num_portals: RenderDevice::vtable->DrawPortal(in_portals[i], frame, 1)
→ RenderDeviceD3D::DrawPortal (0x59f0e0, line 427852)
→ PView::DrawPortal(outdoor_pview, portalPoly, ...) (0x5a5ab0, line 433895)
CBldPortal* bp = outdoor_portal_list[portalPoly->portal_index]
PView::add_views(this, bp->num_stabs, bp->stab_list)
PView::ConstructView(this, bp, portal, ...) [CBldPortal overload, 0x5a59a0]
viewpoint side-test vs portal plane
PView::GetClip(...) ; CEnvCell::GetVisible(bp->other_cell_id)
Render::copy_view(...)
PView::ConstructView(this, other_cell, bp->other_portal_id) [recurse]
if result: PView::DrawCells(this, ...) [draw that building's interior]
SmartBox::update_viewer (0x453ce0, line 92761)
→ compute pivot from part_array + camera_manager->pivot_offset
→ choose start cell: indoor (objcell low16 >= 0x100) → AdjustPosition(pivot); outdoor → player->cell
→ CTransition: init_object(player, 0x5c) ; init_sphere(1, viewer_sphere, 1.0) ; init_path(cell)
→ find_valid_position:
success → set_viewer(sphere_path.curr_pos, 0) ; viewer_cell = sphere_path.curr_cell
else AdjustPosition(sought_eye) → set_viewer ; viewer_cell = that cell
else set_viewer(player->m_position, 1) ; viewer_cell = null
NO snap / NO quantize / NO dead-zone. (The eye jitters anyway — see §3.4.)
```
### 3.3 Verbatim decomp excerpts (the load-bearing ones)
**(a) `RenderNormalMode` branch — the "always DrawInside" proof (lines 9263592702):**
```c
this = RenderDevice::render_device->m_bOpenScene;
if (this != 0) {
int32_t edi_2 = -((edi - edi)); // == 0 ALWAYS (xor edi,edi; neg edi)
int32_t ebx_1 = (edi_2 != 0 || this_1->viewer_cell->seen_outside != 0) ? 1 : 0;
// ... FOV ...
if (edi_2 == 0) { // ALWAYS taken — the INSIDE path
if (ebx_1 != 0) { // viewer cell can see outside →
uint32_t eax_1 = Position::get_outside_cell_id(&this_1->viewer);
LScape::update_viewpoint(this_1->lscape, eax_1); // aim terrain viewpoint outside
}
Render::update_viewpoint(&this_1->viewer);
RenderDevice::render_device_2->vtable->DrawInside(rd2, this_1->viewer_cell);
} else { // DEAD CODE — edi_2 is constant 0
LScape::update_viewpoint(...); Render::update_viewpoint(...);
Render::set_default_view(); Render::useSunlightSet(1);
LScape::draw(this_1->lscape);
}
}
```
**(b) `PView::DrawInside` (lines 433793433823) — how the indoor flood is set up:**
```c
void PView::DrawInside(PView* this, CEnvCell* arg2) {
CEnvCell::curr_view_push(arg2);
PView::add_views(this, arg2->num_stabs, arg2->stab_list);
Render::copy_view(arg2->portal_view.data[arg2->num_view - 1], null, 4);
edx_2 = PView::ConstructView(this, arg2, 0xffff); // flood from viewer_cell
PView::DrawCells(this, edx_2);
PView::remove_views(this, arg2->num_stabs, arg2->stab_list);
}
```
**(c) `PView::ConstructView(CEnvCell*, 0xffff)` (lines 433750433789) — the flood loop:**
```c
void PView::ConstructView(PView* this, CEnvCell* arg2, uint16_t arg3) {
this->outside_view.view_count = 0;
PView::master_timestamp += 1;
this->cell_todo_num = 0;
this->cell_draw_num = 0;
PView::InitCell(this, arg2, arg3);
PView::InsCellTodoList(this, arg2, 0.0);
while (this->cell_todo_num > 0) {
CEnvCell* cell = cell_todo_list.data[this->cell_todo_num - 1]->cell;
if (cell == 0) return;
this->cell_todo_num -= 1;
cell_draw_list.data[this->cell_draw_num++] = cell; // <- membership append
cell->portal_view.data[cell->num_view - 1]->cell_view_done = 1;
if (PView::ClipPortals(this, cell, 0) != 0) // clip → enqueue neighbours
PView::AddViewToPortals(this, cell);
}
}
```
**(d) Per-building portal loop — `BSPPORTAL::portal_draw_portals_only` (lines 326940326953):**
```c
// Reached at each BSPPORTAL leaf during the terrain BSP walk (front-to-back vs viewer):
int32_t i = 0;
if (this_1->num_portals > 0) do {
int32_t edx_4 = this_1->in_portals[i]; // CPortalPoly*
RenderDevice::render_device->vtable->DrawPortal(/*portal*/edx_4, /*frame*/arg2, /*mode*/1);
i += 1;
} while (i < this_1->num_portals);
```
…and `PView::DrawPortal` (lines 433895433933) looks up `outdoor_portal_list[portalPoly->portal_index]`,
`add_views`, then `ConstructView(CBldPortal*)` → if non-empty, `DrawCells` that building's interior.
**This is the ~7 `cv-bld` calls/frame we measured. Per-building, small, robust.**
**(e) `update_viewer` eye-set (lines 9276192892) — NO stabilization:** see the call graph §3.2.
The eye is the result of a per-frame `CTransition::find_valid_position` sweep from the pivot to the
sought eye. **No snap / quantize / dead-zone.** (The research agent *inferred* "stable because inputs
stable"; the LIVE trace contradicts that — the eye jitters ~36 µm — see §3.4. The agent did NOT trace
where `viewer_sought_position` is written; that is open trace #1 in §8.)
### 3.4 LIVE MEASUREMENTS (cdb on retail at the Holtburg cottage doorway, 2026-06-08)
Retail binary: `C:\Turbine\Asheron's Call\acclient.exe`, **MATCHES** our PDB
(`refs/acclient.pdb`, GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`). PID this session: 32360.
- **Membership at rest is STABLE.** Camera held still: `PView.cell_draw_num` settled to a long
unbroken run of **2** (brief `4` only at startup). `SmartBox.viewer_cell` pointer = **1 distinct
value** across the whole capture (byte-stable cell). Retail does NOT flap at rest.
- **Retail does PER-BUILDING floods.** `ConstructView(CBldPortal*)` (`0x5a59a0`) fired ~7×/frame,
each `cell_draw_num ≈ 2`. The `CEnvCell` overload (`0x5a57b0`) fired far less. Retail does NOT do
one unified flood.
- **Retail's EYE JITTERS ~36 µm at rest** — the decisive measurement. Reading
`SmartBox.viewer.frame.m_fOrigin` (raw float bits) with the camera held still:
```
pub=(431a51ab, 41d1d3c4, 42c0a914) x≈154.32 y≈26.23 z≈96.33 (raw IEEE-754 hex)
pub=(431a51ab, 41d1d3c1, 42c0a914)
pub=(431a51ac, 41d1d3bf, 42c0a914) ← X flips 1 ULP
pub=(431a51ab, 41d1d3cc, 42c0a915) ← Z flips 1 ULP
pub=(431a51ac, 41d1d3b9, 42c0a913) ← Y spans ~19 ULPs
```
Decoded jitter: **X ~15 µm, Y ~36 µm, Z ~8 µm.** `pub == sought` (eye uncollided at the open door,
so the jitter is the camera boom itself, not the collision sweep). **Retail's eye is NOT byte-stable.**
- **Compare to acdream** (measured earlier this session via `[pv-input]` at the same doorway): our eye
jitters **~1.3 mm in Y** (≈36× retail), our `RenderPosition` shows 15 distinct values at rest, our
membership oscillates (flood `8↔3`, `6↔3`, etc.). Our physics body (`rawPlayer`) IS byte-stable —
the jitter enters in the camera chain, NOT physics.
### 3.5 Struct offsets + symbols (from `flap-render-lookup.cdb` / `flap-pos-lookup.cdb`)
```
acclient!SmartBox::update_viewer @ 0x453ce0
acclient!SmartBox::RenderNormalMode @ 0x453aa0
acclient!SmartBox::is_player_outside @ 0x451e80
acclient!PView::ConstructView(CEnvCell*, ushort) @ 0x5a57b0
acclient!PView::ConstructView(CBldPortal*, CPolygon*,int,int) @ 0x5a59a0
acclient!PView::DrawInside(CEnvCell*) @ 0x5a5860
acclient!RenderDeviceD3D::DrawInside @ 0x59f0d0
struct PView:
+0x000 outside_view : portal_view_type
+0x048 draw_landscape : Int4B
+0x04c outdoor_portal_list : CBldPortal** (set per-building by DrawBuilding)
+0x050 cell_draw_list : DArray<CEnvCell*>
+0x060 cell_draw_num : Uint4B (THE membership count)
+0x064 cell_todo_list : DArray<CellListType*>
+0x074 cell_todo_num : Uint4B
+0x078 lscape : LScape*
struct SmartBox:
+0x008 viewer : Position (the published eye)
+0x050 viewer_cell : CObjCell* (the cell the eye occupies)
+0x058 viewer_sought_position : Position (pre-sweep desired eye)
+0x0f8 player : CPhysicsObj*
struct Position: +0x004 objcell_id:Uint4B +0x008 frame:Frame
struct Frame: +0x000 qw,qx,qy,qz:Float +0x010 m_fl2gv[9]:Float +0x034 m_fOrigin:Vector3
⇒ SmartBox.viewer.objcell_id = +0x0c ; viewer origin x/y/z = +0x44 / +0x48 / +0x4c
⇒ SmartBox.viewer_sought_position.origin = +0x94 / +0x98 / +0x9c
```
---
## 4. Our divergences (precise, with file:line)
| # | Divergence | Where (acdream) | Retail truth |
|---|---|---|---|
| D1 | **Inside/outside render branch** | `GameWindow.cs:7498` `if (clipRoot is not null){DrawInside}else{DrawPortal}`; root at `GameWindow.cs:7396` `clipRoot = viewerRoot ?? _outdoorNode` | No branch. Always `DrawInside(viewer_cell)`. |
| D2 | **Synthetic `_outdoorNode`** (outdoor-as-cell) as root when eye outside | `GameWindow.cs:7396`, `OutdoorCellNode.cs`, `PortalVisibilityBuilder.Build` `if (cameraCell.IsOutdoorNode)` (`PortalVisibilityBuilder.cs:88`) | Root is the real outdoor `CLandCell` the eye occupies. |
| D3 | **One unified flood** (`PortalVisibilityBuilder.Build` from one root) | `RetailPViewRenderer.DrawInside``PortalVisibilityBuilder.Build` (`RetailPViewRenderer.cs:43`); look-in via `DrawPortal``BuildFromExterior` (`RetailPViewRenderer.cs:92`) | Many small per-building floods via terrain BSP → `DrawPortal``ConstructView(CBldPortal)`. |
| D4 | **`MaxReprocessPerCell = 16` cap** (re-enqueue band-aid) | `PortalVisibilityBuilder.cs:51` | No cap; bounded structurally. (And: there is no re-enqueue *churn* — measured `maxPop=1`.) |
| D5 | **`EyeInsidePortalOpening` guard** (degenerate-portal hack) | `PortalVisibilityBuilder.cs` (`EyeInsidePortalOpening`, ~235244, 793833) | Retail's 3D clip needs no such special case. |
| D6 | **Reciprocal clip on `ProjectToNdc` not `ProjectToClip`** | `PortalVisibilityBuilder.ApplyReciprocalClip` (~697747) | acdream split to dodge drift. |
| D7 | **Render-position interpolation layer** (ours, not retail) | `PlayerMovementController.ComputeRenderPosition` (`PlayerMovementController.cs:810`) `Lerp(prev, curr, accumFrac)` | Retail renders at the authoritative position; the only nearby retail cite is the 30 Hz *physics* tick gate (`CPhysicsObj::update_object` :283950), NOT a render-interp. |
| D8 | **Camera boom ~36× looser than retail** | `RetailChaseCamera` (`RetailChaseCamera.cs`) damping + `ApplyConvergenceSnap` (SnapEpsilon 0.0004 m); collision sweep `PhysicsCameraCollisionProbe.SweepEye` | Retail boom jitters ~36 µm; no snap in `update_viewer`. SECONDARY — fix the structure first. |
D1D3 are the **primary** structural divergences Option A removes. D4D8 are accumulated band-aids /
secondary; most fall away once D1D3 are done, but each must be removed deliberately (each was added
to paper over a real problem — see §7 DO-NOT and the in-code comments).
---
## 5. The render pipeline as it exists today (so you know what you're rewriting)
- Entry: `GameWindow.cs` render loop, ~71807800. `RetailChaseCamera.Update` produces `Position`
(eye) + `ViewerCellId`. `viewerRoot` resolved ~72097211; `clipRoot = viewerRoot ?? _outdoorNode`
(7396). Branch at 7498: `DrawInside` (indoor/unified) vs `DrawPortal` (exterior look-in).
- `RetailPViewRenderer` (`src/AcDream.App/Rendering/RetailPViewRenderer.cs`):
`DrawInside(ctx)``PortalVisibilityBuilder.Build(rootCell, eye, lookup, viewProj)` (line 43);
`DrawPortal(ctx)``PortalVisibilityBuilder.BuildFromExterior(candidateCells, …)` (line 92).
Post-flood: `ClipFrameAssembler.Assemble`, then `DrawLandscapeThroughOutsideView`,
`DrawExitPortalMasks`, `DrawEnvCellShells`, `DrawCellObjectLists`.
- `PortalVisibilityBuilder` (`src/AcDream.App/Rendering/PortalVisibilityBuilder.cs`): the flood. Ports
`ConstructView`/`ClipPortals`/`AddViewToPortals` BUT as ONE flood with the `MaxReprocessPerCell`
cap, the `EyeInsidePortalOpening` guard, and the NDC reciprocal. `OutsideView` is the
terrain-through-door region. `IsOutdoorNode` special-cases the synthetic outdoor root.
- `PortalProjection` (`PortalProjection.cs`): `ProjectToClip` + `ClipToRegion`**faithful** port of
retail `PView::GetClip` (`0x5a4320`/`:432344`) + `ACRender::polyClipFinish` (`:702749`, the w=0
clip). KEEP THIS — it is correct; the problem is never the clip math, it's what feeds it.
- `CellVisibility` (`CellVisibility.cs`): cell membership / `stab_list` / `seen_outside` / InsideSide
side-test — faithful to `CellManager::ChangePosition` (`0x4559B0`) + `grab_visible_cells` (`:311878`).
- Camera: `RetailChaseCamera.cs` (boom, `ApplyConvergenceSnap` from `d2212cf`),
`PhysicsCameraCollisionProbe.cs` (`SmartBox::update_viewer` sweep port), `CameraController.cs`
(picks RetailChaseCamera vs legacy `ChaseCamera`).
---
## 6. The design — Option A (phased; each phase conformance-tested + visual-gated)
> The fresh session should run `superpowers:writing-plans` to expand this into a task plan. The
> phases below are the architecture; the plan adds the bite-sized steps.
**Guiding invariant (retail):** every frame, root the render at the *real* cell the camera eye is
in (`viewer_cell`), and run ONE `DrawInside`. Outdoor terrain + per-building interiors are products
of that single path, not of a separate branch.
**Phase R-A1 — Collapse to one root, one path (remove D1 + D2).**
- Make `clipRoot` = the real cell the camera-collision sweep resolved (`RetailChaseCamera.ViewerCellId`
→ the actual `LoadedCell`, outdoor `CLandCell` or indoor `CEnvCell`). Delete the `?? _outdoorNode`
fallback and the `IsOutdoorNode` special-case in `PortalVisibilityBuilder`.
- Delete the `else { DrawPortal(...) }` branch (`GameWindow.cs:76137690`). One call site:
`DrawInside(viewer_cell)` every frame.
- Requires: an outdoor `CLandCell` must be a valid `DrawInside` root whose flood immediately "sees
outside" (`OutsideView` full) so terrain draws. This is the retail behavior (`viewer_cell` is a
land cell when outside). **Open design point:** acdream's `LoadedCell` model may not currently
represent the outdoor land cell as a floodable cell — see open trace #3 (§8). Resolve before coding.
- Conformance: at the doorway, the frame *before* and *after* crossing the threshold run the same
code path; `[pv-input]` `outRoot` stops toggling (there is no outRoot concept anymore).
**Phase R-A2 — Per-building floods (remove D3).**
- Replace the single unified `Build` (when looking at buildings from outside) with retail's
per-building constructions: during the terrain/landblock draw, for each visible building portal,
run a small `ConstructView` rooted at that building portal (the `CBldPortal` overload), flooding
only that building's cells. Port `BSPPORTAL::portal_draw_portals_only` (`0x53d870`) →
`PView::DrawPortal` (`0x5a5ab0`) → `ConstructView(CBldPortal)` (`0x5a59a0`).
- This is the **robustness mechanism** (small coarse per-building visibility absorbs eye jitter).
- Conformance: capture `cell_draw_num` per building ≈ 2 (matches retail §3.4); membership stable as
the (jittering) eye moves within a cell.
**Phase R-A3 — Remove the band-aids (D4, D5, D6) made dead by R-A1/R-A2.**
- With per-building bounded floods, `MaxReprocessPerCell` (D4), `EyeInsidePortalOpening` (D5), and the
NDC reciprocal (D6) should be removable. Remove each deliberately, re-running the conformance + the
existing `PortalVisibilityBuilderTests`. Do NOT remove `ProjectToClip`/`ClipToRegion` (faithful).
**Phase R-A4 (optional, secondary) — Tighten the camera boom toward retail (D8); reconsider the
render-position interpolation (D7).**
- Only if, after R-A1R-A3, residual flicker remains AND it correlates with eye jitter > retail's
~36 µm. Match retail's boom damping/snap. Do NOT chase byte-stable (retail isn't). Treat the
render-position interpolation as suspect but DO NOT rip it out blindly (it prevents 30 Hz judder;
removing it regressed the branch last time — see §7).
**Testing strategy (critical — this is how we stop shipping unverified changes):**
- **Conformance tests against the measured retail values in §3.4** (cell_draw_num per building ≈ 2,
membership stable under eye jitter, one path across the threshold). These run WITHOUT the live
client.
- Keep all existing `PortalVisibilityBuilderTests` green where still applicable.
- Keep the 14 `PlayerMovementControllerTests` green.
- **Visual gate is the acceptance test** (the user at the doorway). But the conformance tests are the
PRE-gate — never ship to the visual gate on a red/absent conformance test again.
- Re-attach cdb to retail to capture any NEW retail value the implementation needs (the workflow in
§7 is proven and fast).
---
## 7. DO NOT RETRY (every one of these is evidence-disproven — re-trying wastes days)
- **"Make the eye byte-stable at rest."** Retail's eye jitters ~36 µm (§3.4, MEASURED). Byte-stable
is the wrong target. My render-position rest-snap fix this session did this, **failed (no change)
AND regressed the inside/outside flap**, and was reverted (`cd974b2` → revert `9b1857a`). The
jitter source is also NOT `RenderPosition` (stabilizing it changed nothing) — it is downstream in
the camera boom / sweep. Don't re-snap RenderPosition.
- **"Bound the portal re-enqueue churn" / bounded-propagation / enqueue-once.** There is **no churn**:
measured `maxPop = 1` across 13k oscillating frames; 0 of 63k reciprocals ever clipped empty
(`ACDREAM_PROBE_PORTAL_CHURN`, this session). The whole bounded-propagation plan
(`docs/superpowers/plans/2026-06-08-portal-flood-bounded-propagation.md` + the
`2026-06-08-portal-flood-enqueue-once-port-design.md` spec) is REFUTED by measurement. The
apparatus commits (`687040b`, `e6fe4c6`, `a866c51`) are fine to keep as probes; the *fix premise*
is dead.
- **Physics rest µm-jitter** (`d6aa526` era). Refuted: 216k standstill records, 0 re-snaps; body
byte-stable. 4 GREEN rest-stability tests prove it.
- **Camera-drift / viewer-cell metastability dead-zone** (2026-06-05 3-part plan). The dead-zone is
±0.2 mm in `point_inside_cell_bsp`; the eye crosses by metres, not sub-mm — irrelevant to this
symptom. The boom snap (Part 1, `d2212cf`) is already shipped and KEPT.
- **Two-pipe inside/outside split** — that was the ORIGINAL approach, abandoned 2026-05-30. Do not
resurrect it. Retail has neither two pipes NOR a branch — it has ONE path (§3.1).
- **Render-side debounce/grace/hysteresis on the branch or the clip** — forbidden band-aid
(`feedback_no_workarounds`).
- **Trusting a decomp INFERENCE about runtime behavior without a live trace.** This session burned a
fix on the inference "RenderPosition jitter → eye jitter." The cdb-on-retail workflow (§ below) is
the antidote: MEASURE, don't infer.
---
## 8. OPEN TRACES — finish these BEFORE writing the implementation plan
The oracle is ~90% complete. Three things must be traced/decided first (each is a focused cdb capture
and/or decomp read; the workflow below makes each ~10 min):
1. **Where `SmartBox::viewer_sought_position` is written** (the camera boom that produces the
~36 µm-jittering sought eye). The decomp agent did NOT find the write site (it's in the
`camera_manager` / spring-arm chain). Trace it (cdb bp on writes to `SmartBox+0x58`, or read
`CameraManager` methods) to know exactly how tight retail's boom is and what to match in D8.
2. **`PView::ClipPortals` (`0x5a4...`) and `PView::AddViewToPortals` (`0x5a52d0` :433446)** —
the per-cell flood propagation. Not yet read in detail. Needed for a faithful per-building
`ConstructView` port (Phase R-A2). Read both.
3. **How retail's `DrawInside`/`ConstructView` handle a `CLandCell` (outdoor) `viewer_cell`** — i.e.
the pure-outdoor and outside-looking-in root. Confirm the outdoor land cell floods such that
`outside_view` is full and per-building portals render via the terrain BSP. AND decide how
acdream's `LoadedCell`/cell model represents the outdoor land cell as a floodable root (Phase R-A1
open design point). This is the single biggest unknown for the rewrite.
Also nice-to-have: `viewer_sphere` radius used in `update_viewer` (the agent didn't look it up; our
port uses 0.3 m — `PhysicsCameraCollisionProbe.ViewerSphereRadius`).
---
## 9. Apparatus (this session — REUSE IT, don't rebuild it)
**Retail-debugger toolchain (PROVEN this session):**
- Binary: `C:\Turbine\Asheron's Call\acclient.exe` — verified pairs with `refs/acclient.pdb`
(`py tools/pdb-extract/check_exe_pdb.py "C:/Turbine/Asheron's Call/acclient.exe"` → MATCH).
- cdb: `C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe`.
- Attach + capture pattern (background, tee to a log):
```powershell
& "C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe" -p <PID> -cf <script>.cdb *>&1 |
Tee-Object -FilePath <log>
```
- **Watchouts learned this session:** (1) inside a bp action use **`.detach`**, NOT `qd` (cdb ignores
`qd` in bp actions — per `retail-viewer-cell.cdb`); top-level `qd` is fine. (2) The captures exit
with **code 5** but still produce the full log — exit 5 is expected here, not a failure. (3) The
bps fire every render frame regardless of camera motion, so a counter-bounded capture auto-completes
fast; cue the user to be doing the thing (sweeping) the whole window, or just capture the still pose.
(4) Read floats as raw hex (`%08x` of `poi(addr)`) to see byte-stability directly. (5) `dt
acclient!Type @ecx field` reads a named field with no manual offset.
**cdb scripts created (in `tools/cdb/`):**
- `flap-render-lookup.cdb` — symbol + PView/SmartBox type dump (the source of §3.5).
- `flap-pos-lookup.cdb` — Position/Frame type dump (eye-origin offsets).
- `flap-render-capture.cdb` — per-frame `cell_draw_num` + `viewer_cell` ptr (both ConstructView
overloads). The membership-stability capture. (`.logopen` path is hardcoded — edit per run.)
- `flap-eye-stability.cdb` — per-frame eye origin (raw hex) pub + sought. The ~36 µm finding.
**Capture logs (worktree root, UNTRACKED — large; gitignore or delete after):**
- `flap-render-retail.log` (still pose), `flap-render-retail-sweep.log`, `flap-eye-stability.log`,
`flap-render-lookup.log`, `flap-pos-lookup.log`, and the `*-cdb-console.log` files.
**acdream-side probes (still in code, useful for the diff):**
- `ACDREAM_PROBE_PVINPUT=1``[pv-input]` (eye/player/rawPlayer/yaw + flood count, 6dp) — the acdream
eye/membership signal. `launch-flap-verify.ps1` runs it light.
- `ACDREAM_PROBE_PORTAL_CHURN=1``[portal-churn]` (per-Build re-enqueue + reciprocal pre/post) — the
apparatus that proved churn=0. Heavy (1.5 KB/line) — lags the client; use sparingly.
- `ACDREAM_PROBE_FLAP=1``[flap]`/`[flap-cam]`/`[flap-sweep]`/`[pv-trace]` — per-frame side-test +
projection. Heavy.
- All gated in `AcDream.Core.Rendering.RenderingDiagnostics` (and `PhysicsDiagnostics`).
- **These are throwaway apparatus.** Strip them once the port ships and is visual-gated.
**Launch scripts:** `launch-flap-verify.ps1` (light pv-input), `launch-flap-churn.ps1`,
`launch-flap-capture.ps1`. acdream live-launch env vars: see CLAUDE.md "Running the client".
---
## 10. Git state & test baseline (start point for the fresh session)
- Branch `claude/thirsty-goldberg-51bb9b`, HEAD **`9b1857a`** = revert of the failed rest-snap fix.
Working tree is at the **known-good baseline** (the cutover-flip state: the *huge* inside↔outside
flap is GONE; only the grazing-doorway flicker residual remains).
- This session's commits (newest first): `9b1857a` (revert) ← `cd974b2` (the bad rest-snap fix,
reverted) ← `b3a9884` (launch-flap-churn.ps1) ← `a866c51` (churn anchor test) ← `e6fe4c6`
(churn probe) ← `687040b` (churn flag) ← `a3dadbf` (bounded-propagation plan — now REFUTED) ←
`ab6ed90`/`6c3a96b`/`d6aa526`/… (the refuted-diagnosis trail).
- **The churn probe (`687040b`/`e6fe4c6`/`a866c51`) is fine to keep** (inert when off; it's the
apparatus that disproved the churn hypothesis). The bounded-propagation *plan/spec* are refuted —
mark them superseded by this handoff.
- Test baseline (post-revert, verified): `PlayerMovementControllerTests` 14/14 green;
`PortalVisibilityBuilderTests` (App) green; build green. (Full-suite has documented static-leak
flakiness — run targeted.)
- The two NEW tests I added for the rest-snap were removed by the revert (correct — the fix was wrong).
---
## 11. How the fresh session should start (concrete)
1. **Read this whole doc**, then §3 (the oracle) again. Read the two memory entries
`project_indoor_flap_rootcause` (will be updated to point here) and
`reference_render_pipeline_state`.
2. **Confirm the baseline:** `git log --oneline -3` (HEAD `9b1857a`); `dotnet build` green;
the targeted tests green.
3. **Close the §8 open traces** (viewer_sought_position write site; ClipPortals/AddViewToPortals;
the outdoor `CLandCell` root). Use the §9 cdb workflow on live retail if the user can run it; else
decomp-read. **Do not start the implementation plan until these three are answered** — they
determine the Phase R-A1 design (especially how to represent the outdoor land cell as a floodable
root).
4. **`superpowers:writing-plans`** → expand §6 into a phased task plan (R-A1 → R-A4) with conformance
tests against §3.4's measured values.
5. Implement phase-by-phase; conformance test PRE-gate; visual gate is acceptance; cdb-measure any new
retail value you need. **Never ship to the visual gate on an unverified change again.**
6. When it lands and is visual-verified: strip the apparatus (§9 probes), update the roadmap +
milestones (M1.5), update memory, mark the flap CLOSED.
---
## 12. The one-sentence version (for when you're tired)
Retail draws inside and outside with **one path rooted at whatever cell the eye is in**, and it
survives a jittering eye because its **per-building visibility is coarse and robust** — so stop
patching the noise, delete our inside/outside branch and unified flood, and **build the one retail
path.**

View file

@ -0,0 +1,204 @@
# HANDOFF — Indoor crossing flap: MEASURED vs HYPOTHESIS vs OPEN (read before ANY code)
**Date:** 2026-06-08 (late). **Branch:** `claude/thirsty-goldberg-51bb9b`.
**HEAD:** the `docs(render): plan status` commit (after `c7069cf`). Working tree clean.
---
## ⚠️ 0. CALIBRATION NOTE — read this first
This session's diagnosis of the indoor flap **shifted four times** as measurements came in:
`per-building``camera-jitter (R-A4)``camera swept/published eye``flood edge-on`. **Each
"confident" claim was refuted by the NEXT measurement, in the same session.** The author (me) also
stated a final scoreboard ("conclusively pinned / camera ruled out") with **more confidence than the
data earns.**
So this handoff is structured to **prevent the next session from inheriting an overclaim:**
- §2 = **MEASURED** (high confidence, with the data + file refs).
- §3 = **LEADING HYPOTHESIS** (medium — explicitly NOT isolated).
- §4 = **ALTERNATIVE NOT RULED OUT** (a real competing explanation I waved away).
- §5 = **WHAT TO VERIFY FIRST** — do this BEFORE building anything. Do not trust §3 until §5 confirms it.
- §6 = **DO-NOT** (genuinely refuted, high confidence — safe to not re-try).
**The honest one-liner:** the flap is a **motion-time** grey-flash at openings; eye-jitter-at-rest is
ruled out (measured); but whether the cause is the **clip math at edge-on** (§3) or the **camera not
pulling the eye in like retail** (§4) is **NOT yet disambiguated** — the one "clean" pass wasn't clean
enough (it had back-and-forth). Disambiguate (§5) before choosing the fix.
---
## 1. What SHIPPED this session (all green, visual-confirmed by the user)
| Commit | What | Verified |
|---|---|---|
| `7fe9809` | R-A1 — canonicalize outdoor root on `LoadedCell.IsOutdoorNode` (behavior-preserving) | tests |
| `c62663d` | R-A2 — per-building floods (`ConstructViewBuilding`; `OutdoorCellNode` portal-less; per-building merge in `RetailPViewRenderer.DrawInside`) | **user: outside-looking-in flap GONE** |
| `2ec189c` | R-A2 seam fix — flood null-`BuildingId` cells (don't drop) | **user: missing textures GONE** |
| `3c178b2` | `tools/cdb/flap-cam-measure.cdb` — retail eye + CameraManager capture | apparatus |
| `c7069cf` | `[flap-sweep]` probe: F6 `in=`(desiredEye) / `out=`(eye) | apparatus |
| `docs…` | plan-doc STATUS note (R-A4 ruled out) | docs |
Test baseline: App `~Rendering` **207**, Core `PlayerMovementControllerTests` **14**. Build green.
Plan: `docs/superpowers/plans/2026-06-08-full-retail-render-port-option-a.md`.
**The user's remaining symptom (verbatim):** *"grey background color flapping entrances between rooms
and cellar entrance and when going outside."* I.e., the clear/background color flashes through openings
during MOTION; at rest it does not flap.
---
## 2. MEASURED FACTS (high confidence — data + file refs)
### 2.1 acdream eye at REST ≈ 1 µm stable (NOT a jitter problem)
`flap-cam-measure.log` (acdream run, user held completely still, 768 samples). Last 150 frames:
`in` (desiredEye) Y range **0.000 mm** (1 distinct value); `out` (eye) Y range **0.001 mm** (2 distinct);
`pulledIn=0`. → The convergence snap (`RetailChaseCamera.ApplyConvergenceSnap`, `SnapEpsilon 0.0004`)
holds; there is **no at-rest jitter**. (acdream at rest is actually steadier than retail's settled eye.)
### 2.2 The earlier "~1.3 mm jitter at rest" was MOTION misread as rest
`launch-flap-pin2.log` `[pv-input]` showed ~1.3 mm; that was during the crossing. The F2-precision print
rounded sub-cm walking to "d=0", which I mis-analyzed as a static eye. **The flap is motion-time only.**
### 2.3 On a (mostly) one-way pass: eye SMOOTH, but visible-cell count OSCILLATES
`launch-camprobe.log`, the clean-pass tail (~25.7k sweep frames):
- **Eye:** 3D path = 34.1 m, net = 8.2 m (**ratio 4.2×**), direction reversals **X=3, Y=18** (i.e. few
large direction changes — the motion is smooth, not jittery).
- **Visible cells (`[flap] vis=`):** oscillated **414×** between **1 and 6**; **648 `clip=0`** events.
Distribution `1:4084 2:6300 3:3860 4:10634 5:439 6:427`. Roots: `0171` (room) 21396, `0031` (outdoor)
4084, others tiny.
- **⚠️ CAVEAT (why this is NOT isolated):** ratio 4.2× means the pass was **not** purely one-way — it had
back-and-forth + indoor↔outdoor. The 414 oscillations therefore **mix** (a) indoor-flood oscillation
(root `0171`, vis 2↔6) AND (b) indoor↔outdoor **root swaps** (`0171``0031`, the "going outside"
grey; the 4084 vis=1 frames are root `0031` = legitimately just the outdoor node). These were **not
separated.** So "smooth eye + oscillating vis ⇒ flood bug" is **indicated but not proven** — some of
the oscillation is the back-and-forth and the root-swap.
### 2.4 Retail eye at the cottage doorway: COLLIDED 93%, settled jitter ~tens of µm
`flap-cam-measure.log` (retail, `acclient.exe`, PDB **MATCH** `refs/acclient.pdb`), 768 samples:
- **`pub != sought` 716/768 = 93% COLLIDED** — retail's boom hits the cottage; the eye is pulled toward
the player (a more **head-on** view of the doorway, not floating 3 m back).
- Settled jitter (quarters Q2Q4) ≈ **93 µm**; Q1's 1.4 mm was the user walking up (settling motion),
verified by the quarter split.
- Offsets: `CameraManager.t_stiffness@+0x08`, `r_stiffness@+0x0c`, `viewer_offset@+0x48`;
`SmartBox.camera_manager@+0xa0`. acdream's damping already matches retail's formula
(`alpha = clamp(stiffness·dt·10,0,1)`, stiffness 0.45) — `RetailChaseCamera.ComputeDampingAlpha`.
---
## 3. LEADING HYPOTHESIS (medium confidence — NOT isolated; see §2.3 caveat)
The flood/clip's **"is the room behind this opening visible?"** decision is **non-monotonic near a
doorway's EDGE-ON angle.** The portal projects to an on-screen area that hovers at ~zero (coin-on-edge);
small smooth eye steps flip the clip 0↔nonzero → the cell behind drops → grey flash. This matches the
"grey at room↔room / cellar entrances." Mechanism candidates inside this hypothesis: `ProjectToClip` /
`ClipToRegion` collapsing at edge-on (`PortalProjection.cs`), and/or the band-aids `MaxReprocessPerCell`
(D4) + `EyeInsidePortalOpening` (D5) re-processing differently frame-to-frame during motion.
---
## 4. ALTERNATIVE NOT RULED OUT (a real competing explanation)
**The edge-on viewing might be a CONSEQUENCE of the camera not pulling the eye in like retail.** §2.4:
retail's eye is **collided 93% (head-on)**; acdream's `[flap-sweep]` showed it **uncollided much more
often (floats free, 3 m back → edge-on view of the doorway)**. A doorway viewed edge-on is exactly when
the clip collapses. So the root cause could be **camera collision / eye position** (acdream's spring-arm
not pulling in where retail's does), NOT the clip math.
- **What IS ruled out:** eye-jitter / wobble / at-rest instability (§2.1, §2.3 — smooth + 1 µm).
- **What is NOT ruled out:** eye **position** (collision pull-in). These are different. I conflated them
in the scoreboard. Disambiguate in §5.2.
- Separately, the **"going outside" grey** is the root `0171↔0031` wholesale swap (§2.3) — likely a
distinct sub-issue (indoor↔outdoor root transition), not the same as the room↔room edge-on flap.
---
## 5. VERIFY FIRST — before building ANY fix (this is the anti-overclaim gate)
1. **Isolate pure indoor-flood oscillation.** Do a **genuinely one-way, slow** walk through **ONE
interior doorway** (room→room), **no going outside, no back-and-forth, camera fixed**. Capture
`ACDREAM_PROBE_FLAP=1`. Confirm whether `vis` (root `0171` only) oscillates 2↔6 for a **monotonic**
eye (check `[flap-sweep] out=` is monotonic — ratio ≈ 1, ~0 reversals). **If it does NOT oscillate
when the eye is truly monotonic → the flap was the back-and-forth / root-swap, not a flood bug** (then
§3 is wrong). The clean pass in §2.3 had ratio 4.2× — not clean enough to conclude.
2. **Disambiguate clip-math (§3) vs camera-position (§4).** At the SAME interior doorway, measure
acdream's eye-to-doorway angle vs retail's (collided) — is acdream viewing it edge-on where retail
views it head-on (because retail's eye is pulled in)? Add the doorway-plane signed distance + the
collide state to `[flap-sweep]`. If acdream floats edge-on where retail pulls in → the fix is the
**camera collision**, not the flood.
3. **Read retail's edge-on clip handling (the fix ORACLE — do NOT guess the fix without this):**
`PView::GetClip` (`0x5a4320`, decomp ~`:432344`), `PView::ClipPortals` (`0x5a5520`, `:433572`),
`ACRender::polyClipFinish` (the w=0 clip, `:702749`). Questions: does retail's clip collapse to zero
at edge-on like ours, or stay robust? Does retail keep a flooded cell once added? Cross-check
`PortalProjection.cs` (our port — handoff says it's faithful; verify at edge-on specifically).
---
## 6. DO-NOT RETRY (genuinely refuted this session — high confidence)
- **Camera eye-JITTER / R-A4 stiffness change.** Eye is smooth (3/18 reversals) + 1 µm at rest
(§2.1, §2.3). acdream's damping already matches retail's stiffness 0.45. Do not "reduce jitter."
- **RenderPosition rest-snap** (`cd974b2`, reverted). The eye isn't the jitter source; at rest it's 1 µm.
- **"1.3 mm jitter at rest"** — it was MOTION (§2.2).
- **A robust-MEMBERSHIP scheme retail lacks.** Retail's flood is clip-driven too — `PView::add_views`
(`0x5a5210`) only pre-pushes view slices onto the stab_list cells via `curr_view_push`; it does NOT
ground `cell_draw_list` membership. So the robustness must live in the **clip's edge-on behavior**
(§3) or the **camera position** (§4), NOT a stab_list membership-grounding.
- **Two-pipe inside/outside split; bounded-propagation/churn; byte-stable eye** — all refuted earlier
(see the 2026-06-08 OPTION-A handoff §7).
---
## 7. The fix (R-A2b) — GATED on §5
Do NOT design until §5 disambiguates. Two branches:
- **If §5.1 + §5.3 show the clip collapses at edge-on (clip-math):** port retail's edge-on clip
robustness into `PortalProjection` / `PortalVisibilityBuilder`; conformance-test "smooth monotonic eye
through a doorway ⇒ monotonic vis (no oscillation)"; visual-gate.
- **If §5.2 shows acdream's eye floats edge-on where retail pulls in (camera-position):** the fix is the
camera collision (`PhysicsCameraCollisionProbe.SweepEye` / `RetailChaseCamera`) — make the eye pull in
like retail (93% collided at the doorway). This is the camera **position**, distinct from the
ruled-out eye-jitter.
- The "going outside" grey (§4, root `0171↔0031` swap) is likely a separate, smaller task.
---
## 8. Apparatus (reuse — don't rebuild)
- **acdream probes** (gated in `AcDream.Core.Rendering.RenderingDiagnostics`):
- `ACDREAM_PROBE_FLAP=1``[flap]` (per-portal `D`/side-test `TRV/CULL`/`clip=` vertex count, `vis=`),
`[flap-sweep]` (camera sweep: `pulledIn`, and **`in=`(desiredEye) / `out=`(eye) at F6** — added
`c7069cf`), `[flap-cam]`. HEAVY (per-frame per-portal) — short captures only.
- `ACDREAM_PROBE_PVINPUT=1``[pv-input]` (eye/player/rawPlayer F6 + flood count). Lighter.
- **cdb on retail:** `tools/cdb/flap-cam-measure.cdb` (eye origin per frame `pub`+`sought` raw float
hex + `CameraManager`/`SmartBox` type dumps). Attach: `Get-Process acclient` → pid;
`cdb.exe -p <pid> -cf tools\cdb\flap-cam-measure.cdb`. Exit code **5 is expected** (clean detach).
PDB `refs/acclient.pdb` — verify with `py tools/pdb-extract/check_exe_pdb.py "C:/Turbine/Asheron's Call/acclient.exe"` (MATCH).
- **Logs (UNTRACKED, large — gitignore/delete):** `launch-camprobe.log`, `launch-flap-pin2.log`,
`flap-cam-measure.log`, `launch-ra2.log`.
- **Launch:** CLAUDE.md "Running the client" env block + `$env:ACDREAM_PROBE_FLAP="1"`.
- **Build-while-client-running gotcha:** the running client locks the DLLs → build fails MSB3027. Close
the client (graceful `CloseMainWindow`) before building.
---
## 9. Exact pickup (next session)
1. Read this doc top-to-bottom, then memory `project_indoor_flap_rootcause` (2026-06-08 late CORRECTION).
2. `git log --oneline -8` (HEAD = the plan-status docs commit). `dotnet build` green; App `~Rendering`
207 / Core `PlayerMovementControllerTests` 14 green.
3. **Do §5 FIRST** (isolate indoor-flood vs camera-position vs root-swap). **Do NOT write a fix until §5
tells you which of §3 / §4 is real.** This is the whole point of the calibration note.
4. Then §5.3 (retail clip oracle) → design the gated fix (§7) → conformance-test PRE-gate → visual-gate.
5. Strip the throwaway probes after the visual gate; update memory + the plan + milestones.
---
## 10. One-paragraph version (for when you're tired)
Outside-flap and seams are FIXED and confirmed. The indoor flap is a motion-time grey-flash at openings.
We MEASURED that it's not eye-jitter (eye is smooth + 1 µm at rest). We did NOT prove it's the clip math:
the one "clean" pass had back-and-forth (ratio 4.2×) so the 414 oscillations mix flood-edge-on,
back-and-forth, and indoor↔outdoor root-swaps. And we did NOT rule out the camera *position* (retail's
eye is pulled-in/head-on 93% at the doorway; ours floats edge-on). So: isolate a truly one-way single
interior-doorway pass, compare acdream's vs retail's eye angle at that doorway, and read retail's
edge-on clip code — THEN choose between the clip-math fix and the camera-position fix. Don't pick one
until those three are done.

View file

@ -0,0 +1,95 @@
# #105 capture analysis — outdoor membership FREEZES at landblock boundaries (#106 PINNED)
**Date:** 2026-06-09 (evening). **Branch:** `claude/thirsty-goldberg-51bb9b`.
**Evidence:** `flap-105-capture.log` (53 MB UTF-16LE, `ACDREAM_PROBE_FLAP=1`, 136,736 lines,
30,698 `[flap-cam]` frames, 16,587 `[render-sig]`, 750 `[pv-trace]`), analyzed by 3 parallel
workers (throwaway scripts `q105_*.py` in the worktree root). User live-reproduced during
capture: entered a cottage and "EVERYTHING disappears, like I'm standing outside."
---
## 0. TL;DR
1. **#106 PINNED: the player's OUTDOOR cell membership never crosses a landblock boundary.**
`playerCell` froze at `0xA9B40031` (line 93363) and stayed frozen for **10,449 frames**:
~130 m of outdoor walking south across the `A9B4→A9B3` boundary (player y reaching 109.65
in A9B4-local coords = ~82 m inside A9B3) plus an 8,929-frame stand INSIDE an A9B3 cottage.
Zero indoor candidates ever flickered. Within-landblock outdoor transitions are clean
(96/96, e.g. `0x0031↔0x0029` flipping exactly at the x=144 cell line).
2. **The discriminator landed hard:** all **10 successful** outdoor→indoor entries this
session (7 distinct buildings: 0170, 0150, 0164, 016E, 016C, 010B, 0118) were buildings in
the SAME landblock as the player's outdoor cell — each a single clean flip with the viewer
cell trailing ~48 frames (camera boom still outside), no flicker. The ONE failing entry was
the only CROSS-landblock building entered.
3. **Rendering is downstream and healthy:** while membership said "outdoor," `[render-sig]`
shows the flood drawing the A9B3 interior cells (`0xA9B30100/0103/.../0110`) — the renderer
could see the rooms membership refused to enter. 89 distinct indoor cells flood under
outdoor roots across the session; ids==draw (no misses); **no never-flooding building
exists**; **no indoor flood ever collapsed empty** (all 17 indoor roots always vis≥2).
`feedback_render_downstream_of_membership` proven again.
4. **The outdoor "running distortion" is the same freeze:** the render root stays anchored at
the stale outdoor cell while the player runs away from it (10,415-frame root run at
`0x0031`), so the whole view is built from a wrong anchor. The capture REFUTES flood-level
causes outdoors: in all 26,960 outdoor-root frames, `outPolys=1` and `vis=1` with zero
exceptions.
> ⚠️ **Attribution PARTIALLY corrected post-fix (2026-06-09, #106 gate 4):** with #106
> fixed and the anchor following correctly (49 clean transitions incl. cross-block),
> the user still reports transient parts-of-screen-turn-background-color artifacts while
> running and at cottage/room enterexit. The stale anchor amplified the artifact but was
> not its only cause — the residual is the §4 flap family (render digest), not membership.
5. **The §4 indoor flap is unchanged and separate:** vis oscillation exists only under indoor
roots (520 changes, longest 9 consecutive flips, cells 0x013F/0x0143/0x0150/0x0171) plus
~11 one-to-three-frame OUT↔IN root blips at doorways (e.g. lines 31704..31713) — the known
doorway flap, untouched today.
## 1. What #106 explains vs what stays in #105
- **#106 explains:** "enter house → everything disappears, looks like outside" (cross-landblock
buildings), most of the day's per-session "some houses broken" reports (which houses break
depends on where you've walked = which boundaries you crossed), and the outdoor running
distortion (stale render anchor).
- **Still #105 (residual, twice-observed earlier):** a single wall section missing
(sky/clear color) while membership/viewer were demonstrably INDOOR-correct
(`viewerCell=0171`, props drawn, collision present). All data/upload/registration layers are
exonerated by 4 silent tripwire rounds; if it reproduces, the draw-level clip path (§4
family) is the remaining suspect space.
## 2. Fix pointers for #106 (next session)
- **Membership chokepoint:** the outdoor candidate-cell proposal. The capture shows the
resolver flips outdoor cells fine WITHIN a landblock but never proposes another landblock's
outdoor cell. Look at `PhysicsEngine.ResolveCellId` + `CellTransit.AddAllOutsideCells`
(its coord-convention bug was fixed 2026-05-25 — `feedback_latent_bug_masked_by_fallback`;
cross-landblock proposals may be missing or clamped to the current landblock's 8×8 grid).
- **Check the `b3ce505` #98 stopgap first:** it GATED the outdoor sweep (a flagged WORKAROUND
that already caused #99). If the gate suppresses the sweep that would propose
neighboring-landblock cells, #106 is its fallout — fixing the root (per-cell shadow
architecture A6.P4) or narrowing the gate may be the real fix. Read
`claude-memory/project_physics_collision_digest.md` DO-NOT-RETRY before touching anything.
- **Retail oracle:** `CObjCell::find_cell_list` Position-variant
(`acclient_2013_pseudo_c.txt:308742-308783`) + `LandDefs` outside-coordinate handling
(`get_outside_lcoord` family) — retail resolves outdoor cells from GLOBAL coordinates, so
landblock crossings are inherent. Cross-check ACE `Physics/Common/LandDefs.cs`.
- **Cheap probe for the fix loop:** `ACDREAM_PROBE_CELL=1` (`[cell-transit]`, low volume)
while walking across each Holtburg boundary; acceptance in #106's entry.
## 3. Apparatus notes (carried forward)
- `[pv-trace]` is hard-gated to roots `0x016F..0x0175` (`PortalVisibilityBuilder.cs:606-647`)
and `BuildFromExterior` has **NO trace hook** — outside-looking-in cull reasons are
unobservable until one is added (needed if #105's residual reproduces).
- `[flap]`'s `vis`/`outPolys` are PRE-merge (emitted in `Build`, before
`MergeNearbyBuildingFloods` in `RetailPViewRenderer.cs:48-61`); post-merge truth is
`[render-sig] ids=/draw=`.
- Tripwires still live (keep until #105/#106 close): rounds 13 + `[up-null]` (known-benign
ids `0x010002B4`, `0x010008A8` — deterministic empty meshes, ignore).
- Capture + scripts: `flap-105-capture.log`, `q105_*.py` (untracked throwaways).
## 4. The day's full arc (for the next session's orientation)
dat-reader direction chosen → read path EXONERATED (audit + 1.1M-read hammer, `b3920d8`) →
teardown dispose-during-read AV FIXED (`8fadf77`) → white-walls trapped through 4 tripwire
rounds (all silent) → live capture → **#106 pinned (membership, cross-landblock)**. The day's
"render bug" was a physics bug all along, exactly per
`feedback_render_downstream_of_membership`. Next session: fix #106 (membership/A6 territory,
read the physics digest first).

View file

@ -0,0 +1,131 @@
# Dat-reader thread-safety investigation — read path EXONERATED, teardown bug FIXED, white-walls tripwired
**Date:** 2026-06-09. **Branch:** `claude/thirsty-goldberg-51bb9b`.
**Trigger:** white cottage walls reproduced on a CLEAN (probe-free) launch, refuting the prior
session's "heavy probes starve the dat-reader" framing as the whole story; user picked the
dat-reader stability fix as the session direction.
> Read this before touching any dat-threading code. It REVERSES the long-standing
> "DatCollection is NOT thread-safe (for reads)" lore for the current package version, and it
> records exactly what was verified, what was refuted, and what remains open.
---
## 0. TL;DR
1. **Concurrent READS on Chorizite.DatReaderWriter 2.1.7 are SAFE** — verified two independent
ways: (a) a line-level audit of the actual `release/2.1.7` source (cross-checked against the
decompiled NuGet DLL); (b) an in-tree hammer test
(`tests/AcDream.Core.Tests/Conformance/DatConcurrencyStressTests.cs`) that replays our
four-population access pattern — **~1.1 M concurrent reads, 8 threads, shuffled orders, raw
block path AND full typed unpack path — zero anomalies, byte-identical to single-threaded
golden fingerprints.**
2. **The verified crash bug is teardown, not reads:** `ObjectMeshManager.Dispose` never
quiesced its `Task.Run(ProcessQueueAsync)` decode workers and `LandblockStreamer.Dispose`
gave up its join after 2 s — then `GameWindow.OnClosing` disposed the `DatCollection`, which
unmaps the dats' memory-mapped views (`MemoryMappedBlockAllocator.DestroyMappedFile`
`_viewPtr = null`). A worker still inside `ReadBlock` then dereferences the dead view
pointer → **uncatchable AccessViolationException with `ReadBlock` on the stack** — the
recorded crash signature, firing on close/relaunch during decode storms. **FIXED this
session** (quiesce-before-dispose in `ObjectMeshManager.Dispose` + entry/loop guards;
15 s loud join in `LandblockStreamer.Dispose`).
3. **The white-walls mechanism is STILL OPEN** (issue #105) — but it is now narrowed and
instrumented: every silent dat-failure exit on the walls-relevant paths has a tripwire log
line (`[dat-miss]` / `[tex-miss]` / `[tex-skip]` / `[cell-miss]`). They fire ONLY on anomaly
(zero cost when healthy) and stay in the build; the next organic occurrence self-attributes
in the launch log.
4. **Two false trails refuted:** both investigation subagents confidently described a
`ReadBlock` instance-field cursor race — the fields don't exist in 2.1.7 (all cursor state
is method-local over a stable read-only view). One also cited a vendored
`references/DatReaderWriter` copy that doesn't exist. Verify agent claims against source.
---
## 1. The threading topology (verified, file:line)
ONE `DatCollection` (`GameWindow.cs:1166`, `DatAccessType.Read`) → four `DatDatabase`s
(Portal/Cell/HighRes/Local), each with its own `MemoryMappedBlockAllocator`. Reader
populations on the SAME instances:
| Population | Entry | Serialization |
|---|---|---|
| Render thread | `CellLoader`-side hydration, `TextureCache.DecodeFromDats`, `TickAnimations`/`MotionResolver`, `WorldPicker` | `_datLock` at 3 sites only (`GameWindow.cs:2285,5603,8948,11224`); the rest **unlocked** |
| Streamer worker | `BuildLandblockForStreaming` (`GameWindow.cs:5142`) | the whole load under `GameWindow._datLock` |
| Decode pool (≤4) | `ObjectMeshManager.ProcessQueueAsync``_dats.*` via `DatCollectionAdapter` | `DatDatabaseWrapper._lock` (per-db, `DatCollectionAdapter.cs:120`) — **a different lock object than `_datLock`** |
| Decode pool, raw | `DatCollectionAdapter.ResolveId` (`DatCollectionAdapter.cs:74-96`) | **none** (raw `Tree.TryGetFile`) |
Two disjoint lock domains + unlocked paths ⇒ concurrent same-instance access is constant.
**Post-investigation verdict: that concurrency is fine for reads** (see §2) — the locks are
not protecting against anything real on the read path. Do NOT "unify the lock domains" as a
fix for a read race that doesn't exist; the locks' remaining value is incidental (they
serialize some of our own cache fills).
## 2. Why the read path is safe (audit summary, v2.1.7 source)
- `MemoryMappedBlockAllocator.ReadBlock` (`Lib/IO/BlockAllocators/MemoryMappedBlockAllocator.cs:137-160`):
chain cursor + buffer offset are **locals**; copies are destination-bounded; `_viewPtr` is
written only in the ctor and `Expand` (write-path only — never runs for `DatAccessType.Read`).
- BTree: `DatBTreeReaderWriter` node cache is an internally-locked LRU (`:62-126`);
traversal state is local; nodes are immutable post-unpack.
- `DatDatabase.TryGet<T>`: rents from the thread-safe `ArrayPool`, fills via `ReadBlock`,
unpacks through a **fresh** `DatBinReader` (per-instance `_offset`), caches into a
`ConcurrentDictionary`. `ObjectFactory` = `ConcurrentDictionary.GetOrAdd`;
`DBObjAttributeCache` = benign last-wins lazy init of immutable dictionaries.
- Hammer test confirms empirically (see §0.1). Known minor wart (not our symptom):
`_fileCache[fileId] = value` is published one statement before `value.Id = fileId`
(`DatDatabase.cs:389-393`) — a racing reader can see `Id == 0`. Upstream PR candidate.
**Correction to project lore:** `feedback_phase_a1_hotfix_saga.md`'s "DatBinReader holds a
buffer position field per database; concurrent `Get<T>` corrupts" does NOT describe 2.1.7 —
`DatBinReader` is created per call. The A.1-era crash predates Phase O's single-reader
unification and was likely a different stack (or version). The A.5 spec's "single-threaded by
construction" claim was composition-false the day it shipped (N.4's pool decodes already
existed) — but per this investigation it also didn't matter for correctness.
## 3. The teardown fix (this session)
- `ObjectMeshManager.Dispose` (`src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs`): sets
`IsDisposed` under the queue lock, cancels + drains `_pendingRequests`, then waits (≤10 s,
5 ms poll) for `_activeWorkers == 0` before returning; logs an error if workers outlive the
wait. `ProcessQueueAsync` re-checks `IsDisposed` before every dequeue; both `Prepare*Async`
entries and enqueue blocks early-out when disposed.
- `LandblockStreamer.Dispose` (`src/AcDream.App/Streaming/LandblockStreamer.cs`): join
extended 2 s → 15 s with a loud `[streamer]` error line on timeout (cancellation is honored
between jobs, so the wait is bounded by one landblock load).
- `GameWindow.OnClosing` order was already correct (streamer → mesh adapter → … → `_dats`
last); with both Disposes now actually quiescing, the order is meaningful.
- Audio was checked and is NOT a dat-reading population (no `_dats` access under
`src/AcDream.App/Audio/`).
Smoke: 3× close-mid-decode-storm launches, clean exits, no crash signatures (see commit).
## 4. The white-walls residual (#105) + tripwires
Observed signature (2× user-confirmed): cottage wall surfaces render as background/clear while
the cell's statics (paintings, furniture, windows) draw; once broken, broken for the session;
strictly intermittent (one clean launch had it, a heavier probe launch didn't); **zero error
output ever** — today's white-wall launch log was 35 lines, no `[wb-error]`.
All silent exits found and tripwired (they print ONLY on anomaly; keep them):
| Line | Where | Meaning when it fires |
|---|---|---|
| `[dat-miss]` | `DatDatabaseWrapper.TryGet` (`DatCollectionAdapter.cs`) | a read failed for an id whose BTree entry EXISTS — the smoking gun for any real dat-layer fault |
| `[tex-skip]` | `ObjectMeshManager` GfxObj + CellStruct texture chains (5 exits) | a polygon batch (incl. WALL batches) was silently dropped on a dat miss |
| `[tex-miss]` | `TextureCache.DecodeFromDats` (3 exits) | render-thread texture decode fell back to magenta |
| `[cell-miss]` | `GameWindow` interior hydration (EnvCell / Environment null) | a cell's walls were silently never registered — the exact white-walls geometry signature |
Discriminator for the next occurrence: **magenta walls** → TextureCache path; **see-through
walls + `[tex-skip]`/`[dat-miss]`** → mesh-build path; **see-through + `[cell-miss]`**
hydration; **white/missing with NO tripwire output** → the failure is GL-side (staged upload /
bindless residency under load) — instrument `WbMeshAdapter.Tick`'s upload drain next.
## 5. Apparatus
- `tests/AcDream.Core.Tests/Conformance/DatConcurrencyStressTests.cs` — the hammer
(8 threads × 25 shuffled passes; raw `TryGetFileBytes` + typed `TryGet` with
`FileCachingStrategy.Never`; golden FNV-1a fingerprints). Skips cleanly when dats absent.
KEEP as the regression guard for any future dat-reader version bump.
- Package source for reference: `git clone --depth 1 --branch release/2.1.7
https://github.com/Chorizite/DatReaderWriter` (tag verified == shipped NuGet DLL).
- The tripwire lines (§4) — grep any launch log for `dat-miss|tex-miss|tex-skip|cell-miss`.

View file

@ -0,0 +1,105 @@
# HANDOFF — §4 outdoor FULL-WORLD flap: onset pinned to the building-flood merge
**Date:** 2026-06-09 (late evening). **Branch:** `claude/thirsty-goldberg-51bb9b`, HEAD `fafe5d6`.
**Status:** trigger pinned frame-exact; kill mechanism NOT yet pinned — one purpose-built
probe (or RenderDoc) decides it. Read this top-to-bottom before touching code.
---
## 0. TL;DR
1. **The user-visible bug:** standing/running OUTDOORS at specific spots, the WHOLE world
(terrain + buildings + entities + the player model + sky) drops to the fog-tinted clear
color. It strobes at onset then HOLDS; rotating the camera pops it back; walking
forward through the trigger zone reproduces it; sidestep/backwards through the same
zone does not. Confirmed distinct from #106 (membership healthy throughout).
2. **Frame-exact onset (the day's key result):** the flap begins at EXACTLY the frame the
nearby cottage's per-building flood merges into the outdoor root —
`[pv-input] flood=1 → flood=5` with player/yaw frozen and only the camera boom settling
(~3 cm/frame). Same frame, the `[gl-state]` tripwire shows the leftover scissor box
flip from full-screen to a **drifting 9×21 px doorway footprint** (the cottage doorway
projected to screen, moving with the eye's micro-settle). Onset evidence:
`flap-glstate-capture.log` (gl-state frame 4977 = log line 5006); the same transition
with full render-sig fields: `flap-residual-capture.log` (38 such transitions, e.g.
frame 1745→1746: ONLY `ids=`/`draw=` gain a cell — every other field identical,
`out=10529` instances submitted in BOTH frames).
3. **Massive exoneration chain (all probe-verified, do not re-tread):** membership/root/
viewer cell stable; `res=None`; camera view-projection matrix sane and NaN-free
(11,767 frames, 6 dp); eye 1.62.1 m ABOVE terrain (buried-eye refuted); flood/
outPolys/outSlices/outMode constant; full-screen quad clip planes mathematically
cannot cull; `MergeNearbyBuildingFloods` does NOT touch OutsideView; cross-frame GL
state leak refuted (`[gl-state]` shows scissor test OFF + sane depth/blend/cull/vp/fbo
entering every frame, `err=0x0`); `ClipFrameAssembler` slices carry their own plane
arrays (slot repacking can't swap planes per se); `ClipFrame.AppendSlot/UploadShared`
have dynamic capacity + full re-upload (no overflow).
4. **What remains (the kill mechanism, one of):**
a. **Per-instance clip-slot routing** (`WbDrawDispatcher.SetClipRouting` +
`ResolveEntitySlot`, binding=3 slot SSBO): the landscape slice installs routing
UNCONDITIONALLY (`RetailPViewRenderer.cs:215`) even for OUTDOOR roots, while the
U.4 contract (`WbDrawDispatcher.cs:309-331`) says outdoor frames should
`ClearClipRouting`. When the flood merges, `CellIdToSlot` gains cells and slot
indices REPACK (cells pack before the outside view in the assembler) — if any
instance's slot resolution or the slot SSBO content goes stale/wrong, the world's
instances clip against the cottage's doorway planes (or CULL).
b. **Terrain/sky UBO content at draw time**`SetTerrainClip(slice.Planes)` should
hold the full-screen planes; if the merge path overwrites it with doorway planes
(or count) before terrain samples it, terrain + sky die together.
c. Something in the per-slice draw orchestration (`DrawLandscapeThroughOutsideView`,
`RetailPViewRenderer.cs:208-230`) that behaves differently when cell slices exist.
5. **Why this matters beyond the spot:** the same merge boundary is crossed every time
you run past cottages (the original "parts of the screen flash while running") and at
cottage enter/exit — this is very likely THE remaining §4 visible flap, with the
edge-on doorway grey (2a) and corner seal (2b) as siblings in the same family.
## 1. The decisive next probe (do this first)
Add a `[clip-route]` print-on-change probe (gate: reuse `ACDREAM_PROBE_GLSTATE` or a new
var) emitting, per frame:
- In `RetailPViewRenderer.DrawLandscapeThroughOutsideView`: `slice.Slot`, `slice.Planes`
values (all 48 vec4s), `clipAssembly.CellIdToSlot` contents, and the first 16 bytes of
`_clipFrame`'s terrain bytes (the count + first plane) as uploaded.
- In `WbDrawDispatcher.Draw` (when routing active): a histogram of `ResolveEntitySlot`
outcomes — instances per slot index + CULL count.
One repro run (the user triggers the flap, holds 3 s, rotates, closes) then diff the
held-flap frames vs healthy frames. Whichever of (a)/(b) shows wrong values is the bug.
If BOTH look correct → RenderDoc frame capture during the flap (the GPU truth).
## 2. Repro protocol (user-validated, fast)
Spot: Holtburg south slope, player ≈ (167169, 31..37) world frame (A9B4 anchor), the
slope SE of town with the A9B3 cottage. Walk FORWARD downhill through the zone → strobe →
hold. Rotate camera → recovers. `eyeAbove` stays ~+2 m (do not chase buried-eye).
Launch with `ACDREAM_PROBE_PVINPUT=1` + `ACDREAM_PROBE_GLSTATE=1` (+ the new probe);
AVOID `ACDREAM_PROBE_FLAP` for visual judgment runs (timing skew — render digest landmine).
## 3. Evidence inventory (this session's captures, worktree root, untracked)
| File | What it holds |
|---|---|
| `flap-residual-capture.log` | Full flap probes; 38 flood-merge transitions w/ render-sig field diffs; the held-flap [flap-cam]/[flap] stability blocks |
| `flap-pvinput-capture2.log` | 11,767 pv-input frames; NaN-free matrix proof; held blocks at 6 dp |
| `flap-eyeterr-capture.log` | eyeAbove (terrain-burial refutation) |
| `flap-glstate-capture.log` | GL-state tripwire; frame-exact onset marker (frame 4977) + doorway-box fingerprint |
## 4. DO-NOT-RETRY additions from this session (full chain in §0.3)
- Camera matrix NaN / degenerate orientation — REFUTED (6 dp capture).
- Eye buried in terrain — REFUTED (`eyeAbove` +1.6..2.1 m).
- Cross-frame GL scissor/depth/blend leak — REFUTED (`[gl-state]` stable + scis=0 entering frames).
- Full-screen outdoor quad plane collapse / winding — impossible (static CCW NDC quad).
- `MergeNearbyBuildingFloods` contaminating OutsideView — code-verified NOT (explicitly skipped).
- `ClipFrame` slot-capacity overflow — dynamic capacity, full re-upload, code-verified.
- The earlier "stale render anchor explains the running distortion" attribution — PARTIAL
only; #106 fixed the anchor, this flap persists (correction noted in the #105/#106 docs).
## 5. Probe-semantics gotchas (cost an hour today)
- `[render-sig]`'s `terrain=` prints a STALE pre-DrawInside local (always `Skip` when a
clipRoot exists); the REAL assembler mode is `[flap-cam]`'s `terrain=` and render-sig's
`outMode=`. Don't diff the wrong field.
- `[render-sig]` prints on signature change only; the EYE coords are part of the
signature → a settling boom spams lines with nothing else changing.
- `Tee-Object` writes UTF-16LE; Python analyzers must BOM-detect (`b'\xff\xfe'`).
- The first pv-input run produced 0 lines because the client was closed pre-entry —
check `auto-entered player mode` exists before analyzing.

View file

@ -0,0 +1,183 @@
# LandDefs + add_all_outside_cells — retail pseudocode (issue #106)
**Date:** 2026-06-09. **Purpose:** ground the #106 fix (outdoor membership freezes at
landblock boundaries) in the retail decomp. The pre-fix acdream port of
`AddAllOutsideCells` clamped candidates to the current landblock's 8×8 grid; retail
has NO such clamp — its cell math runs in a **global** landcell coordinate space
(`lcoord`, 0..2039) spanning the entire map, so landblock crossings are inherent.
**Sources:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (primary);
`references/ACE/Source/ACE.Server/Physics/Common/LandDefs.cs` + `LandCell.cs`
(cross-check, in the MAIN repo checkout — this worktree only vendors WorldBuilder).
---
## 1. The coordinate model
- A **landblock id** packs the map grid into the high word of a cell id:
bits 3124 = `block_x` (eastwest), bits 2316 = `block_y` (northsouth).
- An **outdoor cell** low word is `1 + cell_x*8 + cell_y` (`cell_x`,`cell_y` ∈ 0..7,
24 m cells, row-major with X outer).
- A **global lcoord** is `(block_x*8 + cell_x, block_y*8 + cell_y)` — a flat map-wide
cell grid, valid range `[0, 0x7F8)` = `[0, 2040)` (255 blocks × 8).
- Positions in retail `Position` structs are **block-local** (`[0,192)` per axis).
acdream divergence to bridge: physics positions are in a **floating world frame**
anchored at the login/teleport landblock (origin = that block's SW corner;
`GameWindow.ApplyLoadedTerrainLocked` registers each block's origin as
`((lbX centerX)·192, (lbY centerY)·192)`). Convert world → current-block-local by
subtracting the current block's registered origin (`CellGraph._terrain` stores it),
then retail's math applies verbatim.
## 2. LandDefs functions (verbatim-equivalent pseudocode)
### in_bounds (pc:68509, @0x0043d650)
```
in_bounds(x, y) := x >= 0 && y >= 0 && x < 0x7F8 && y < 0x7F8
```
### blockid_to_lcoord (pc:68520, @0x0043d680)
```
blockid_to_lcoord(cellId, out lx, out ly):
if cellId == 0: return false
lx = ((cellId >> 24) & 0xFF) * 8 // decomp form: (cellId >> 21) & 0x7F8
ly = ((cellId >> 16) & 0xFF) * 8 // decomp form: (cellId >> 13) & 0x7F8
return in_bounds(lx, ly)
```
⚠️ **BN artifact:** the decomp renders the Y extraction as `(int8_t)(cellId >> 16) << 3`
— a signed cast that would explode for block_y ≥ 0x80 (e.g. 0xB4). ACE's
`blockid_to_lcoord` (LandDefs.cs:169) confirms plain zero-extended masks. Use unsigned.
### inbound_valid_cellid (pc:163438, @0x004979a0)
```
inbound_valid_cellid(cellId):
low = cellId & 0xFFFF
if !(low in [1,0x40] || low in [0x100,0xFFFD] || low == 0xFFFF): return false
lx = (cellId >> 21) & 0x7F8; ly = (cellId >> 13) & 0x7F8
return in_bounds(lx, ly)
```
⚠️ **ACE divergence:** ACE's `inbound_valid_cellid` (LandDefs.cs:241) checks only
`block_x`. Retail checks both axes. Port retail.
### gid_to_lcoord (pc:163500, @0x00497a90) — outdoor cells only
```
gid_to_lcoord(cellId, out lx, out ly):
if !inbound_valid_cellid(cellId): return false
if (cellId & 0xFFFF) >= 0x100: return false // outdoor only
lx = block_x*8 + ((low1) >> 3)
ly = block_y*8 + ((low1) & 7)
return in_bounds(lx, ly)
```
### lcoord_to_gid (pc:171859, @0x004a19a0)
```
lcoord_to_gid(lx, ly):
if !in_bounds(lx, ly): return 0
low = (ly & 7) + (lx & 7)*8 + 1
block = ((lx >> 3) << 8) | (ly >> 3) // decomp: ((lx & ~7) << 5) | (ly >> 3)
return (block << 16) | low
```
Cross-block is transparent: `lx = 1439``block_y = 179 (0xB3)` even when the input
block was 0xB4.
### get_outside_lcoord (pc:438690, @0x005a9b00)
```
get_outside_lcoord(cellId, blockLocalPos, out lx, out ly):
if cellId low16 not in {1..0x40, 0x100..0xFFFD, 0xFFFF}: return false
blockid_to_lcoord(cellId, out lx, out ly)
lx += floor(pos.x / 24); ly += floor(pos.y / 24) // floor() — negative-safe
return in_bounds(lx, ly)
```
`floor(pos/24)` may be negative or ≥ 8 — **this is the landblock crossing**. No clamp.
### adjust_to_outside (pc:438719, @0x005a9bc0)
```
adjust_to_outside(ref cellId, ref blockLocalPos):
low = cellId & 0xFFFF
if low in valid ranges (as above):
if |pos.x| < 0.000199999995: pos.x = 0 // retail EPSILON snap
if |pos.y| < 0.000199999995: pos.y = 0
if get_outside_lcoord(cellId, pos, out lx, out ly):
cellId = lcoord_to_gid(lx, ly)
pos.x -= floor(pos.x / 192) * 192 // re-normalize to NEW block
pos.y -= floor(pos.y / 192) * 192
return true
cellId = 0
return false
```
⚠️ **BN artifact:** the decomp shows `floor(pos.x / 0f) * 0f` — the constant was
dropped. ACE (LandDefs.cs:140-141) confirms `BlockLength = 192`.
## 3. CLandCell::add_all_outside_cells — sphere variant (pc:317499, @0x00533630)
```
add_all_outside_cells(position, numSphere, spheres[], cellArray):
if cellArray.added_outside: return
cellArray.added_outside = 1
if numSphere == 0:
cellId = position.objcell_id; pos = position.frame.origin
if adjust_to_outside(ref cellId, ref pos) && gid_to_lcoord(cellId, out lx, ly):
add_outside_cell(cellArray, lx, ly)
return
for each sphere (center copied):
cellId = position.objcell_id
if !adjust_to_outside(ref cellId, ref center): BREAK // break, not continue
if gid_to_lcoord(cellId, out lx, out ly):
add_outside_cell(cellArray, lx, ly)
point.x = center.x mod 24; point.y = center.y mod 24 // center is [0,192) post-adjust
check_add_cell_boundary(cellArray, point, lx, ly, minRad=radius, maxRad=24radius)
```
### add_outside_cell (pc:317056, @0x00532ec0)
```
add_outside_cell(cellArray, lx, ly):
if in_bounds(lx, ly):
cellArray.add_cell(lcoord_to_gid(lx, ly), LScape::get_landcell(...))
```
**NO same-block filter.** ⚠️ ACE's `add_cell_block` (LandCell.cs:198) inserts
`if (id >> 16 != cellID >> 16) continue;` annotated `// FIXME!` — an ACE divergence
that drops cross-landblock cells. Do NOT copy it.
### check_add_cell_boundary (pc:317229, @0x00533260)
```
check_add_cell_boundary(cellArray, point, lx, ly, minRad, maxRad): // strict > / <
if point.x > maxRad:
add_outside_cell(lx+1, ly)
if point.y > maxRad: add_outside_cell(lx+1, ly+1)
if point.y < minRad: add_outside_cell(lx+1, ly1)
if point.x < minRad:
add_outside_cell(lx1, ly)
if point.y > maxRad: add_outside_cell(lx1, ly+1)
if point.y < minRad: add_outside_cell(lx1, ly1)
if point.y > maxRad: add_outside_cell(lx, ly+1)
if point.y < minRad: add_outside_cell(lx, ly1)
```
A sphere exactly tangent to a 24 m boundary does NOT add the neighbour (strict
comparisons — matches ACE).
## 4. The containing-cell pick (CObjCell::find_cell_list, pc:308742-308869, @0x0052b4e0)
After the transit set is built, retail iterates candidates IN ORDER; for each it
subtracts `LandDefs::get_block_offset(position.block, candidate.block)` from the
sphere center (cross-block-aware) and calls the cell's `point_in_cell` (vtable
+0x84). Any containing cell sets the running result; an INTERIOR containing cell
breaks immediately (interior-wins); outdoor cells are disjoint columns so at most one
contains the point. acdream adaptation (landcells have no BSP `point_in_cell`): the
containing outdoor cell is `adjust_to_outside(currentCellId, blockLocal(center))`
the global XY-column id — compared by identity against candidates. Pre-#106 this was
computed with a `[0,8)` grid clamp under the current block's prefix, which is what
froze membership at boundaries.
## 5. Worked goldens (from the #106 capture geometry)
Anchor A9B4 at world origin; A9B3 is one block SOUTH (origin (0, 192)).
| Input | Result |
|---|---|
| `blockid_to_lcoord(0xA9B40031)` | (1352, 1440) |
| `gid_to_lcoord(0xA9B40031)` (low 0x31 = 49 → cell 6,0) | (1358, 1440) |
| `lcoord_to_gid(1358, 1440)` | 0xA9B40031 (roundtrip) |
| `lcoord_to_gid(1358, 1439)` | **0xA9B30038** (block_y 179, cell 6,7) |
| `adjust_to_outside(0xA9B40031, (150, 1))` | cellId **0xA9B30038**, pos (150, **191**) |
| `adjust_to_outside(0xA9B40031, (150, 109.65))` | cellId **0xA9B30034** (floor(109.65/24) = 5 → ly 1435), pos (150, 82.35) |
| `adjust_to_outside(0xA9B30038, (150, 193))` from A9B3 frame | cellId **0xA9B40031**, pos (150, 1) — northbound return |

View file

@ -0,0 +1,219 @@
# HANDOFF — R-A2b shipped (churn killed) · flap residual is §4 · the texture RED-HERRING
**Date:** 2026-06-09. **Branch:** `claude/thirsty-goldberg-51bb9b`. **HEAD:** `485e44d`.
**Milestone:** M1.5 (indoor world feels right). **Phase:** full retail render port (Option A) → R-A2b done; §4 open.
> Read this top-to-bottom before any code. This session shipped a real, MEASURED fix (R-A2b) but the
> VISIBLE flap is only partly resolved, and a **separate runtime bug (the dat-reader) + a self-inflicted
> diagnostic mistake (heavy probes)** caused a long, confusing detour ("missing textures"). The §5 lesson
> at the bottom is the most important durable takeaway.
---
## 0. TL;DR
1. **R-A2b SHIPPED + MEASURED (commit `485e44d`):** removed the `&& !eyeInsideOpening` bypass from the
portal-flood **side-cull** so back portals cull like retail's `PView::InitCell` side test. This kills
the `0171↔0173` re-enqueue **churn** — measured `maxPop` **16 → 1** (44 % of frames churning → 0 %,
across 1.3 M frames). The flood is now deterministic. 218 App tests green, including the void-fix and
#95 over-inclusion guards.
2. **But R-A2b did NOT fix the VISIBLE flap.** The remaining grey flicker at doorways/windows is **§4**, a
*different* mechanism: the openings project **edge-on** from the 3rd-person eye and the clip collapses
(geometric — retail's clip collapses too; retail avoids it by keeping the eye head-on/collided). R-A2b
killed the *churn* layer; the *edge-on* layer remains.
3. **The camera-damping attempt FAILED and was reverted** (laggy, no flap improvement). Do not re-try it.
4. **The "missing textures" scare was a RED HERRING, not a code bug.** The cottage walls rendered WHITE
because the **heavy debug probes** (`ACDREAM_PROBE_FLAP=1` + per-frame `Tee` to multi-hundred-MB logs)
**starve the thread-unsafe dat-reader**, which then fails to decode the wall textures. **A CLEAN launch
(no probes) renders correctly** — user-confirmed. The underlying dat-reader thread-safety bug (the
`AccessViolation` crashes) is real and FILED.
5. **Repo is clean at HEAD `485e44d`** (R-A2b in). Only uncommitted tracked change: the plan-doc PINNED
note (committed alongside this handoff). Throwaway `analyze_*.py` + `*.log` are untracked.
---
## 1. What shipped — R-A2b (the churn fix), commit `485e44d`
**The change (one functional edit + a probe):** in `PortalVisibilityBuilder.cs`, the side-cull was
`if (i < ClipPlanes.Count && !CameraOnInteriorSide(...) && !eyeInsideOpening) continue;`. R-A2b drops the
`&& !eyeInsideOpening`, in BOTH `Build` and `BuildFromExterior`. Retail's `PView::InitCell` side test
(decomp `:432962`) culls a back-facing portal by the side test ALONE — there is no eye-in-opening bypass.
The forward-portal clip-empty **void rescue** (`Build` ~`clippedRegion.Count == 0` branch, the 2026-06-05
fix) is a SEPARATE code path and is **untouched**`Build_EyeStandingInInteriorPortal_FloodsNeighbour`
stays green.
**The pin (B1, not B2):** live capture `flap-sidechk.log` showed every back portal (`0173→0171`,
`0172→0173`) with `camInterior=False` (our side test already agrees with retail — it WANTS to cull) and
traversed **only when `eyeIn=True`** (eye within 1.75 m of the shared doorway). So the cycle was the
`eyeInsideOpening` **bypass**, not a `CameraOnInteriorSide` convention bug. Forward portals showed
`camInterior=True` (unaffected; void rescue preserved).
**Measured result:**
- `launch-churn-confirm.log` (pre-fix walk): `maxPop` worst **16**, **44 %** of frames `≥2`.
- `flap-fix-verify.log` / `flap-structured.log` (post-fix): `maxPop` worst **1**, **0 %** `≥2` across
~1.3 M frames. The back portal is now `skip=side` (culled) even at `eyeIn=True`. **Churn eliminated.**
- New RED→GREEN test `Build_BackFacingPortal_EyeStandingInOpening_StillCulled`; full App suite 218 green.
**Docs:** spec `docs/superpowers/specs/2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md`
(REVISION → Option B), plan `docs/superpowers/plans/2026-06-09-portal-flood-r-a2b-side-cull.md` (with the
PINNED B1 note). Commits: `3fd71a1` (spec) → `7b8a490` (plan) → `89a2032` (sidechk probe) → `485e44d` (fix).
**R-A2b status / disposition:** committed at HEAD. It is a real, retail-faithful, measured improvement
(no more churn → deterministic flood, better perf). It is **innocent of the texture issue** (§5). It does
NOT, by itself, make the visible flap go away (that's §4). **Recommendation: keep it.** Next session
should do ONE clean (no-probe) launch to re-confirm it renders + the churn stays gone, then move to §4.
(Plan Phase 3 — removing the now-dead `MaxReprocessPerCell` cap — was NOT done; it's optional cleanup,
risky for the synthetic cyclic tests that have no ClipPlanes. Leave the cap as a harmless backstop, or do
it carefully with fixture ClipPlanes.)
---
## 2. The VISIBLE flap residual = §4 (NOT fixed by R-A2b)
Two DISTINCT sub-issues, both ending in "background where geometry should be." Do not conflate them.
### 2a. Doorway / window grey flap (room↔room) — RENDER-side edge-on
- Measured (`flap-structured.log`, the user's room→room pass): root `0171` `vis` oscillates **2↔3↔4**
(1,725 transitions); `OutsideView` `outPolys` oscillates **0↔1** (1,544 transitions) → the
outdoor-through-window region flips **empty(grey)↔drawn**; **8,574** frames have an edge-on `clip=0`.
- The eye is **collision-correct** here: `viewerCell=0171` (the adjacent INTERIOR cell), ~1.7 m back, no
wall hit. So it is NOT penetration. The openings simply project **edge-on** from that legitimate
position → the clip collapses to <3 verts grey.
- **§5.3 (decomp) established the clip collapse is GEOMETRIC — retail's `polyClipFinish` collapses at
edge-on too. There is no clip robustness to port.** Retail avoids the flicker because its eye is
**collided/head-on 93 %** at the doorway (`flap-cam-measure.log`), so it rarely views openings edge-on.
Ours **floats** (`pulledIn~0`/`collNormValid=False` **97 %**).
- So 2a is bounded by the camera, not the clip. But the camera-damping attempt to address it FAILED (§3).
### 2b. Corner see-through — CAMERA-SEAL failure (eye penetrates wall)
- Live-captured (`flap-corner.log` tail): pressing the camera into a corner → eye escapes to **outdoor
cell `0xA9B40031`** at `X=165.22` (well outside the cottage), `pulledIn=0`, `collNormValid=False` (NO
collision stopped the 2.61 m boom) → render roots **outside** → the ENTIRE interior drops → bluish
background. Retail "under no circumstances sees through the wall."
- The viewer sweep **does** query the cottage exterior-shell GfxObj (`FindObjCollisions` passes
`isViewer`, `TransitionTypes.cs:2376`), so it's not a "shell not queried" gap. The boom slips through
somewhere (corner seam? the interior doorway is open so the boom legitimately enters the next room, but
in a corner it reaches the exterior). **Not fully root-caused.** This is the most CONCRETE, tractable §4
bug — fixing the camera seal (keep the eye inside, like retail) would also reduce 2a (head-on eye).
---
## 3. DO-NOT-RETRY (failed/refuted this session)
- **Camera-damping spring-arm on the published eye** (a separate `_publishedEye` damped toward the
collision result, `RetailChaseCamera.cs`). TRIED, **FAILED**: laggy camera feel, zero flap improvement
(the forward-crossing flap has no collision to damp; the eye is already smooth). Fully reverted
(`git checkout`, verified). Do not re-try damping the published eye.
- **"It's the corner" / "runtime-state, relaunch fixes it" / "disk full"** — all WRONG explanations I gave
for the white walls. The truth is §5 (probes starve the dat-reader). Don't repeat these.
- Carried-over DO-NOTs (still valid): byte-stable eye; bounded-propagation/churn as the WHOLE story (it
was the churn LAYER, R-A2b fixed it); two-pipe split; PVS membership grounding retail lacks; the §3
"port retail's edge-on clip robustness" (retail has none).
---
## 4. WHAT TO WORK ON NEXT (recommendation)
This flap has consumed many sessions for partial results. Honest options, in rough preference order:
1. **Re-validate + keep R-A2b, then attack §2b (the corner camera-seal).** It's the most concrete,
measured-wrong behavior (eye demonstrably escapes to an outdoor cell). Root-cause WHY the viewer-sphere
sweep finds no collision when the boom reaches the exterior in a corner (the sweep queries the shell but
`collNormValid=False`). Fixing the seal (eye stays inside) is retail-faithful AND would dampen 2a (a
head-on eye doesn't view openings edge-on). Decomp oracle: `SmartBox::update_viewer` (`:92761`), the
`viewer_sphere` sweep + cell enclosure.
2. **Accept/defer the flap and move to milestone work.** The §2a edge-on flicker is genuinely hard
(retail relies on the camera; our 3rd-person floating eye is the divergence) and the visible payoff per
session has been low. Per the milestone discipline, M2 ("kill a drudge": F.2/F.3/F.5a/L.1c/L.1b) or
other M1.5 issues may be a better use of effort than continued flap grinding. **This is a legitimate
call — flag it to the user.**
3. **Fix the dat-reader thread-safety bug** (the real underlying cause of both the crashes and the
white-wall load failures). See §5. It's a correctness/stability win independent of the flap. Filed as a
background task this session.
Do NOT silently keep grinding §2a with more camera tweaks — the camera-damping already failed. If pursuing
the flap, §2b (seal) is the tractable lever; if not, say so and move on.
---
## 5. THE TEXTURE RED-HERRING — the durable lesson
**Symptom:** mid-session, the cottage walls rendered WHITE (geometry present — windows/painting/floor/NPCs
drew — but the wall surfaces were the clear/background color). I called it "missing textures."
**My failure:** I thrashed — blamed the corner, then "runtime state," then disk, then implied my code,
across ~5 messages, while the user (rightly) got furious. I should have checked **baseline reproduction**
immediately.
**Actual root cause (proven):**
- The wall data wasn't loading. `[flap]` probe showed cell `0171` with **identity transform + zero
portals** = an unhydrated/empty cell (a dat-LOAD failure, not a flood/visibility bug — R-A2b only
*reads* cell data, it cannot make a cell load empty).
- **It reproduced on the EXACT baseline** (`git checkout 8f879bd`, `diff` empty, rebuilt) — so NOT my code.
- Disk fine (1074 GB free); dats intact (normal sizes); no corrupt cache.
- **The trigger: the heavy per-frame probes** (`ACDREAM_PROBE_FLAP=1` writing `[flap]`/`[pv-trace]`/
`sidechk` every frame + `Tee-Object` to hundreds-of-MB logs) **load the render thread enough to skew the
timing of the background mesh/texture-decode thread**, which races on the **thread-unsafe dat-reader**
(`MemoryMappedBlockAllocator.ReadBlock` — the same code that throws `AccessViolation` and crashed several
launches). Under that timing the texture/cell load loses the race → empty/white.
- **FIX: launch CLEAN (no probes).** `launch-final.log`: no load errors, render path + all atlases loaded,
**user confirmed the walls render.** This is also how the user normally runs the client.
**DURABLE RULES (add to memory):**
- **Never run normal play or a visual gate with `ACDREAM_PROBE_FLAP` (or other per-frame probes) on.**
They starve the thread-unsafe dat-reader → intermittent white-wall / empty-cell load failures (and raise
the `AccessViolation` crash rate). Use probes ONLY for short, targeted captures, then relaunch clean.
- **For any render/load symptom, check baseline reproduction FIRST** (`git stash`/`git checkout <baseline>`
+ relaunch) before theorizing or blaming a change. One reproduction test beats five wrong explanations.
**The real bug (separate task, FILED):** `DatReaderWriter ... MemoryMappedBlockAllocator.ReadBlock`
`AccessViolation` — the dat-reader is not thread-safe under the concurrent mesh-decode/streaming access.
Fix = serialize dat block reads behind a per-DatDatabase lock, or thread-local buffers, or a private
`MemoryMappedViewAccessor` per reader. Files: `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs`,
`DatDatabaseWrapper.cs`, the `DatReaderWriter` block allocator. This is the highest-value stability fix
and is independent of the flap.
---
## 6. Apparatus (reuse; STRIP after §4 ships)
- **Probes** (gated in `AcDream.Core.Rendering.RenderingDiagnostics`):
- `ACDREAM_PROBE_FLAP=1``[flap]` (root cell per-portal D/TRV/CULL/proj/clip + outPolys + vis),
`[flap-sweep]` (camera sweep: `pulledIn`/`collNormValid`/`viewerCell`/`in=`/`out=`),
`[pv-trace]` (per-cell flood decisions, signature-dedup'd ≤160), and the **`sidechk`** line added this
session (per-portal `camInterior`/`eyeIn`/`D`). **HEAVY — see §5; do not leave on for play.**
- `ACDREAM_PROBE_PORTAL_CHURN=1``[portal-churn]` (per-Build `maxPop` + reciprocal pre→post).
- **Throwaway analyzers (untracked, in the worktree root):** `analyze_flap_vis.py` (same-root vs root-swap
split), `analyze_churn_confirm.py` (maxPop distribution + flap reproduction; takes `<log> [startByte]`),
`analyze_segment.py` (windowed segment: churn/cull/flap/outPolys/camera pull-in from a byte offset).
All auto-detect UTF-16 (PowerShell `Tee-Object` writes UTF-16LE — Python must BOM-detect or it reads 0
lines).
- **cdb on retail:** `tools/cdb/flap-cam-measure.cdb` (retail eye + CameraManager; PDB `refs/acclient.pdb`
= MATCH). Exit code 5 = clean detach.
- **The `sidechk` probe** (in `PortalVisibilityBuilder.Build`, after `eyeInsideOpening`) is throwaway —
strip it with the rest when §4 ships.
---
## 7. Repo state (exact)
- **HEAD = `485e44d`** (R-A2b fix). Working tree `src/` matches HEAD (verified `git diff HEAD -- src/`
empty). RetailChaseCamera.cs is clean (camera-damping fully reverted — `grep _publishedEye` = 0).
- **Uncommitted tracked:** `docs/superpowers/plans/2026-06-09-portal-flood-r-a2b-side-cull.md` (the PINNED
B1 note) — committed with this handoff.
- **Untracked (throwaway, gitignore/delete):** `analyze_*.py`, `flap-*.log`, `launch-*.log`, the older
`*.jsonl`/`*.png` from prior sessions.
- **The user's last running client was the clean (no-probe) launch and renders correctly.** The source now
has R-A2b restored, so the next build will include R-A2b (rebuild + clean-launch to align).
---
## 8. First moves next session
1. Read this doc + memory `project_indoor_flap_rootcause`.
2. `git log --oneline -6` (HEAD `485e44d`); confirm `git diff HEAD -- src/` empty.
3. **Clean launch — NO PROBES** (omit `ACDREAM_PROBE_FLAP`). Confirm the cottage renders (walls textured)
and, if you want, that R-A2b's churn stays gone (one short `ACDREAM_PROBE_PORTAL_CHURN=1` capture, then
relaunch clean). This re-validates R-A2b after the chaos.
4. Decide §4 direction with the user (per §4): §2b corner-seal (tractable) vs defer-the-flap vs the
dat-reader thread-safety fix. Do NOT re-try camera-damping.

View file

@ -0,0 +1,88 @@
# CLOSED — #105 white indoor walls × #110 near-plane correlation: one root cause
**Date:** 2026-06-10 (evening). **Branch:** `claude/thirsty-goldberg-51bb9b`.
**Commits:** `c787201` (#105 fix + apparatus) · `d4b5c71` (#110 close + znear=0.1 re-land).
**Supersedes the plan in** `2026-06-10-105-110-white-textures-nearplane-handoff.md`
(its §3 anatomy and §4 "only credible link" both turned out exactly right — the staged
plan was short-circuited by a static find before any stochastic repro was needed).
## The root cause (#105)
`TextureAtlasManager.AddTexture` only **stages** texture content: pixel bytes go into a
per-array PBO (`ManagedGLTextureArray.UpdateLayerInternal`) plus a `_pendingUpdates`
list. The actual `TexSubImage3D` copies into the texture array layers + mipmap
regeneration happen in `ProcessDirtyUpdates()` — and WB drives that **once per frame**
from its render loop:
> `references/WorldBuilder/Chorizite.OpenGLSDLBackend/GameScene.cs:975`
> `_meshManager?.GenerateMipmaps();` (immediately before the first opaque pass)
`GameScene.cs` is the host-loop file the N.4/O-T4 extraction replaced with our
`GameWindow` — so the per-frame driver was silently dropped (`git log -S` shows
`GenerateMipmaps` arrived with `d16d8cd` O-T4 and never gained a caller). The only
remaining flush was the incidental one inside `UpdateLayerInternal` when a PBO must
**grow** (it flushes pending updates before orphaning the buffer). Consequence: every
layer staged after an array's *last* PBO growth kept **undefined `TexStorage3D`
content** behind a perfectly valid, resident bindless sampler handle.
That one sentence explains every #105 observation:
| Observation | Explanation |
|---|---|
| Dat tripwires silent on every bad run | dat → decode → stage all delivered; the loss was the missing flush after staging |
| White/garbage surfaces, `zh==0` | handle valid + resident; *content* undefined |
| Intermittent, per-run lottery | background decode-completion order shuffles which textures land in the post-last-growth tail |
| Persists the whole run at standstill | nothing grows a PBO at standstill ⇒ nothing flushes |
| Indoor walls only | only `ObjectRenderBatch.BindlessTextureHandle` consumers are affected = `EnvCellRenderer` cell shells; entities resolve per-frame via `TextureCache` (immediate `TexImage2D`), terrain via `TerrainAtlas` (immediate `GenerateMipmap`) |
| Struck on `znear=1.0` builds too (2026-06-09 clean launch) | the tail exists on every run; visibility of it is luck |
## The fix
`WbMeshAdapter.Tick()` now calls `_meshManager.GenerateMipmaps()` after the
staged-upload drain. `Tick()` runs at the top of `GameWindow` `OnRender`, before all
draw passes — the exact WB-equivalent position. One call; no retry loops, no
back-patching machinery needed.
## Evidence chain (apparatus: `ACDREAM_PROBE_TEXFLUSH=1`, kept env-gated)
- **Pre-fix** (`texflush-prefix.log`): pending updates climb `0→48→…→142`, dip only at
PBO-growth crossings (`86→76`, `87→68` — the incidental flush, live), then **park at
126 across 34/34 atlas arrays forever** (19 heartbeats at standstill). Deterministic,
first run — the broken contract did not need a stochastic white-wall repro.
- **Post-fix** (`texflush-postfix.log`): `after=0` on every line — staged updates drain
the same frame they are staged.
- **0.1-arm verification** (`nearplane-reland-1.log`, `nearplane-reland-2.log` — the arm
that struck 2-of-3 on 2026-06-10): `after=0` on all 45/39 tex-flush lines; 68,291 +
56,097 `[shell]` lines with **zero** `zh>0` batches; all four dat tripwires silent;
zero `[wb-error]`.
## #110 resolution
The near plane was **mechanism-innocent** — precisely the handoff's only-credible-link:
`znear=0.1` makes close-up geometry newly visible → more prepare/upload pressure indoors
→ a larger never-flushed tail → higher #105 strike probability. With the flush restored,
retail `Render::znear = 0.1` (decomp `:342173`, initializer `:1101867`) is re-landed on
all four cameras (`d4b5c71`), closing the §4 corner see-through (0.1 < the 0.3 m
camera-collision sphere, so a pressed wall no longer near-clips away).
**Pending user re-gate:** (a) corner press — the wall must stay solid at the camera;
(b) a distance scan for z-shimmer (none expected — retail ships 0.1 with D24);
(c) general indoor texture watch over the next several launches.
## Durable lesson
Memory: `feedback_extraction_perframe_drivers.md` — when extracting a library from a
host app, the host loop's per-frame calls *into* the library are invisible contracts;
grep the host's frame loop and re-wire every one. Staged/deferred APIs are the worst
case: everything looks wired and works most of the time via incidental side-effect
flushes.
## Status of the old #105 exonerations (all stand)
Concurrent dat reads SAFE (hammer-verified); teardown AVs were dispose-during-read
(fixed `8fadf77`); probes don't cause white walls; membership/flood healthy. The four
dat-side tripwires (`7433b70`) stay as permanent anomaly logging.
**Next per the priority order:** #107 indoor-login spawn wedge
(`ACDREAM_CAPTURE_RESOLVE` apparatus ready) → #108 cellar grass-sweep + #109 far-door
oscillation → #99/A6.P4 per-cell shadow architecture.

View file

@ -0,0 +1,216 @@
# HANDOFF — #105 intermittent missing indoor textures × #110 near-plane correlation
> **✅ CLOSED 2026-06-10 (same day).** Root cause: the per-frame staged-texture flush
> (WB `GameScene.cs:975``ObjectMeshManager.GenerateMipmaps()`) was dropped in the
> N.4/O-T4 extraction; fix `c787201`, znear=0.1 re-landed `d4b5c71`. §4's "only credible
> link" (upload pressure) was exactly right. **Read the close-out instead:**
> `2026-06-10-105-110-CLOSED-staged-texture-flush-drop.md`. This document is historical.
**Date:** 2026-06-10 (late). **Branch:** `claude/thirsty-goldberg-51bb9b`, HEAD `8bd3492`.
**Status:** #105 struck twice today with the dat-side tripwires SILENT (= GL-side); the
retail near-plane fix (`137b4f2`, 0.1 m) was bisect-implicated in those two runs and
REVERTED (`8bd3492`) pending this investigation. **One investigation, three payoffs:**
attribute + kill the chronic #105, settle whether the near plane is innocent (#110), and
re-land `znear=0.1` which closes the §4 corner see-through-wall.
Read this top-to-bottom before touching code. The render digest
(`claude-memory/project_render_pipeline_digest.md`) carries the distilled state + the
DO-NOT-RETRY table — this doc is the deep-dive for THIS investigation.
---
## 0. TL;DR
1. **#105 (chronic since ~2026-06-08):** indoor wall/cell textures intermittently render
wrong ("missing" — exact appearance white-vs-invisible NOT yet confirmed by the user;
question outstanding). Today it struck on 2 consecutive launches. The four dat-side
tripwires (`[dat-miss]`/`[tex-miss]`/`[tex-skip]`/`[cell-miss]`, commit `7433b70`)
produced ZERO output on both bad runs → per the #105 protocol the failure is
**GL-side**: staged mesh/texture upload, bindless handle creation/residency, or the
per-batch handle plumbing — NOT a failed dat read.
2. **#110:** both bad runs were on the near-plane build (`znear=0.1`, `137b4f2`); the
very next run with `znear=1.0` (working-tree bisect) rendered clean. 2-bad-on-0.1 /
1-good-on-1.0 is *suggestive, not conclusive*#105 is intermittent and could have
coincided. No mechanism is known by which znear touches texturing (see §4 for the
honest candidate list). The change is reverted; all four cameras carry a ⚠️ comment
pointing here.
3. **The leverage:** if #105 is independent (most likely), fixing it exonerates the near
plane → re-land `0.1` → §4 corner fix complete. If the near plane genuinely raises
#105's trigger probability (e.g. more close-up geometry → more upload pressure), the
fix is still in the #105 path — the near plane just becomes the best repro lever.
## 1. Today's run matrix (the evidence)
| Run (log, worktree root, untracked) | Build (near) | Indoor textures | Notes |
|---|---|---|---|
| `flood-fix-gate.log` | `dac8f6a` (1.0) | OK | user gated the flood fix: transitions clean |
| `flood-fix-gate2.log` | `dac8f6a` (1.0) | OK (no complaint) | #107 spawn wedge run |
| `flood-fix-gate3.log` | `dac8f6a` (1.0) | OK (no complaint) | #107 again |
| `nearplane-gate.log` | `137b4f2` (0.1) | UNKNOWN | user asked to relaunch without detail |
| `nearplane-gate2.log` | `137b4f2` (0.1) | **MISSING** | tripwires silent (checked: 0/0/0/0) |
| `nearplane-gate3.log` | `137b4f2` (0.1) | **MISSING** | fresh launch, same |
| `nearplane-bisect.log` | tree (1.0 on RetailChaseCamera) | **OK** | the bisect run |
Also relevant: the clean-launch #105 occurrence on 2026-06-09 (35-line log, zero errors,
PRE-near-plane) — proof #105 strikes on `znear=1.0` builds too. The near plane cannot be
the sole cause; the open question is independence vs trigger-probability.
## 2. #105 history — what is already settled (DO NOT REDO)
From `docs/research/2026-06-09-dat-reader-thread-safety-investigation.md` + the digest:
- **Concurrent dat READS are SAFE** (Chorizite.DatReaderWriter 2.1.7): source audit + the
in-tree hammer `DatConcurrencyStressTests` (~1.1 M concurrent reads, zero anomalies).
The "thread-unsafe dat reader" lore is refuted for the read path. Do not re-litigate.
- **The teardown AccessViolations were dispose-during-read** (decode pool + streamer not
quiesced before `DatCollection.Dispose` unmapped views) — FIXED `8fadf77`.
- **The "heavy probes cause white walls" framing is PARTIAL at best** — a clean 35-line
launch reproduced white walls; a heavily-probed run rendered fine. Probe load skews
timing (still avoid `ACDREAM_PROBE_FLAP` for visual gates) but is not the cause.
- **Every silent dat-miss exit is tripwired** (`7433b70`): `[dat-miss]` (DatCollection
returns null), `[tex-miss]`/`[tex-skip]` (texture resolve/upload skips), `[cell-miss]`
(EnvCell load misses). Zero output when healthy. Both of today's bad runs: zero output
→ **the dat → decode → staged-data side delivered; the loss is between staging and the
draw.**
## 3. The GL-side texture path — anatomy + where it can lose textures
The modern pipeline (N.4/N.5, mandatory — see `reference_modern_rendering_pipeline.md`):
1. **Decode/stage:** `ObjectMeshManager.PrepareMeshDataAsync(id, isSetup)` background-
decodes mesh + texture data → auto-enqueues to `_stagedMeshData`.
2. **Drain:** `WbMeshAdapter.Tick()` (render thread, per frame) drains the staged queue,
creates GL resources, populates `AcSurfaceMetadataTable` (per-batch translucency /
luminosity / fog metadata).
3. **Texture upload:** `TextureCache` `GetOrUpload*Bindless` → GL texture (parallel
Texture2DArray uploads via `UploadRgba8AsLayer1Array`) → `glGetTextureHandleARB`
`glMakeTextureHandleResidentARB`. Returns the 64-bit handle.
4. **Per-batch plumbing:** the handle lands in `ObjectRenderBatch.BindlessTextureHandle`.
- Entities: `WbDrawDispatcher` Phase 5 uploads `_batchSsbo` (binding=1,
`(uvec2 handle, uint layer, uint flags)` per group).
- Cell shells: `EnvCellRenderer.RenderModernMDIInternal` packs `ModernBatchData
{ TextureHandle, TextureIndex }` → `_modernBatchBuffer` (SSBO binding=1, bound at
EnvCellRenderer.cs:1211).
5. **Sampling:** `mesh_modern.frag` constructs `sampler2DArray(handle)` from the uvec2.
**A zero handle samples garbage/black/white (undefined)** — this is the classic
"white walls" appearance.
Loss candidates between staging and draw (ranked):
- **(a) Zero handle at draw time** — the batch was prepared before its texture upload
completed, and nothing back-patches the handle. Known to exist transiently (textures
pop in); a bug would make it PERSIST. ⚠️ **There is an EXISTING probe for exactly
this:** `ACDREAM_PROBE_SHELL=1` (`RenderingDiagnostics.ProbeShellEnabled`) prints, per
visible cell, gfxObj/batch counts AND `zh=` (zero-bindless-handle batch count) — see
RenderingDiagnostics.cs:120-130. **One bad-run launch with this probe splits the
search space in half** (zh>0 ⇒ upload/handle side; zh==0 ⇒ resident-but-wrong-content
or sampling/state side).
- **(b) Residency loss / never-made-resident** — handle non-zero but
`MakeTextureHandleResidentARB` skipped or undone → same visual, but zh probe reads 0.
Needs a residency assert (glIsTextureHandleResidentARB sweep) or RenderDoc.
- **(c) Upload raced/dropped under pressure** — `MaxCompletionsPerFrame` (QualityPreset)
caps streaming completions per frame; a drop/requeue bug under burst load would lose
whole cells' textures. Would likely show as *some* cells white, others fine.
- **(d) Texture content wrong but handle valid** — array-layer mixups (zh==0, content
white). RenderDoc territory.
## 4. #110 — what `znear` can and cannot plausibly do (senior-dev honest list)
`znear` enters the system in exactly one object: the projection matrix
(`CreatePerspectiveFieldOfView(FovY, aspect, znear, 5000f)` in RetailChaseCamera /
ChaseCamera / FlyCamera / OrbitCamera — all currently 1.0 with ⚠️ comments).
Downstream consumers of `viewProj`:
| Consumer | Effect of 0.1 vs 1.0 | Texture relevance |
|---|---|---|
| Rasterization | geometry 0.11.0 m from the eye now draws; depth distribution shifts (D24 @ 5 m: ~1.5 µm → ~15 µm) | none direct; z-fighting would flicker, not "lose" textures |
| Frustum culls (terrain/entity/EnvCell prepare) | strictly MORE visible (near plane closer) | **more batches prepared per frame → more uploads in flight → raises (a)/(c) trigger probability** ← the only credible #105×#110 link found |
| PortalVisibilityBuilder flood | viewProj changes per-vertex w by near-plane row only in z-row; flood clip planes are (nx,ny,0,dw) — x,y,w-based; the flood is conformance-gated (`CornerSweep_FloodIsCompleteAndMonotone`) | none |
| gl_ClipDistance regions / terrain UBO | x,y,w-based, near-independent | none |
| Doorway scissor | computed from NdcAabb, projection-independent at the box level | none |
**Conclusion to verify, not assume:** the most credible story is that `znear=0.1` makes
close-up geometry (the wall right behind the camera, the doorframe you're brushing)
*newly visible*, inflating per-frame prepare/upload pressure indoors, which raises the
probability of the pre-existing #105 loss. If true: fixing #105 exonerates the near
plane entirely. The alternative (0.1 breaks texturing via a mechanism not in this table)
needs RenderDoc evidence before being believed.
## 5. Investigation plan (staged, evidence-first)
**Phase A — attribute with the existing probe (cheap, decisive split):**
1. Launch with `ACDREAM_PROBE_SHELL=1` (+ the always-on dat tripwires). Flip-launch until
a bad run reproduces (today it was 2/3 on the 0.1 build — consider temporarily
re-applying 0.1 to the working tree as the REPRO LEVER ONLY, clearly uncommitted).
2. On a bad run: read `[shell]` lines for the affected cells. `zh>0` ⇒ path (a)/(b):
zero/never-patched handles — go to Phase C1. `zh==0` ⇒ path (b)/(d) — go to Phase C2.
3. ALSO capture the user's answer: **white surfaces vs invisible walls** (outstanding
question — invisible would point at visibility/depth instead and reshape this plan).
**Phase B — settle the #110 correlation statistically (parallel, mechanical):**
Alternate launches 0.1 / 1.0 (working-tree flip on RetailChaseCamera only), ≥4 runs per
arm, record texture state per run (the `[shell] zh=` counts make this detectable WITHOUT
user eyes if path (a)). Independence ⇒ bad runs appear in both arms. The 2026-06-09
clean-launch occurrence already proves 1.0 is not immune.
**Phase C — root-cause:**
- **C1 (zero handles):** instrument the staging→handle path: log every batch that reaches
the draw SSBO with handle==0 (entity + EnvCell sides), plus `WbMeshAdapter.Tick` drain
counts and `TextureCache` upload completions per frame. Find who created a batch before
its texture and never back-patched. Fix = the back-patch / ordering, NOT a retry loop.
- **C2 (valid handles, wrong output):** RenderDoc the bad frame (GPU truth): inspect
binding=1 SSBO contents, handle residency, sampled texture content, and the draw state
of an affected wall batch.
**Phase D — close out:** fix #105 root cause → flip-test again (both arms clean) →
re-land `znear=0.1` (re-apply the `137b4f2` payload: 4 cameras + restore the retail
citation comments) → user re-gates the §4 corner press (wall must stay solid at the
camera) + a distance scan for any new z-shimmer (none expected; retail ships 0.1).
## 6. Repro notes + session-ops gotchas (cost real time today)
- **Repro spot:** Holtburg houses near the player's parked position (the user was trying
on a house interior; exact house id unconfirmed — textures were missing across the
interior). Frequency today: 2 of 3 launches on the 0.1 build.
- **#107 interference:** logging in while parked INDOORS wedges the player (stuck in
air/wall, 3-for-3 today — filed). For THIS investigation prefer ending test sessions
with the character OUTDOORS so logins are clean. If wedged: relaunch; it intermittently
recovers.
- **ACE session hold:** graceful window close ⇒ ~35 s; hard kill ⇒ ~3 min of
`session failed` (exit 29). The launch protocol + wait loop used all day is in this
session's transcript; `auto-entered player mode` is the in-world marker.
- **⚠️ PowerShell 5.1 `Get-Content`/`Set-Content` MANGLES UTF-8 source files** (reads
CP1252, writes mojibake — corrupted all four camera files today; recovered via
`git checkout` + redoing edits with the Edit tool). **Never bulk-edit source with
PS5.1 string replace.**
- **Tee-Object logs are UTF-16LE** — Python analyzers must BOM-detect; PowerShell
`Select-String` handles them natively.
- Probes: `ACDREAM_PROBE_SHELL` is heavy-ish (per-prepare dumps) — short runs. The dat
tripwires are always-on and free. NEVER judge visuals under `ACDREAM_PROBE_FLAP`.
## 7. What ELSE is open (do not drift into these)
Priority order (set 2026-06-10, digest carries it): **this investigation (#105+#110)**
**#107** indoor-login spawn wedge (physics; `ACDREAM_CAPTURE_RESOLVE` apparatus ready) →
**#108** cellar-ascent grass sweep + **#109** far-exit-door oscillation (one render
session, probe captures at their spots) → **#99/A6.P4** per-cell shadow architecture
(planned phase). The §4 flood strobe is FIXED (`dac8f6a`, user-gated) — its conformance
gate (`CornerSweep_FloodIsCompleteAndMonotone`) and the corner-seal characterization
(`CameraCornerSealReplayTests`) must stay green through any change here.
## 8. Today's commit ledger (context for blame/diff archaeology)
| Commit | What |
|---|---|
| `682cba3` | [clip-route] probe apparatus (outdoor flap) |
| `c4df241` | outdoor full-world flap FIX (EnvCellRenderer DepthMask(false) leak → depth-clear no-op) |
| `df2ef7c` | flap close-out doc |
| `b21bb28` | corner-seal replay — camera-penetration hypothesis REFUTED (openings, not walls) |
| `482b0de` | corner-seal handoff doc |
| `dac8f6a` | §4 flood strobe FIX (homogeneous reciprocal clip + collinear-aware dedup) — user-gated |
| `137b4f2` | near plane 1.0→0.1 (retail znear) + issues #107#109 filed |
| `8bd3492` | near plane reverted to 1.0 pending #110; #110 filed |
Test baseline: App **223**, Core **1377** green + 4 pre-existing #99-era failures
(DoorBugTrajectoryReplay ×2 / DoorCollisionApparatus / BSPStepUp) + 1 skip, UI **420**,
Net **294**. ACE on `127.0.0.1:9000`, `testaccount/testpassword`, `+Acdream`.

View file

@ -0,0 +1,90 @@
# §2b corner "camera-seal" REFUTED — both §4 siblings converge on edge-on clip collapse
**Date:** 2026-06-10. **Branch:** `claude/thirsty-goldberg-51bb9b`.
**Commits this arc:** `b21bb28` (corner-seal replay apparatus + characterization).
**Earlier same day:** `c4df241` closed the outdoor full-world flap (depth-mask leak —
see `2026-06-10-flap-outdoor-fullworld-CLOSED-depthmask-leak.md`).
---
## 0. TL;DR
After the depth-mask fix, the user re-validated the §4 siblings and reported two symptoms:
(1) background covering windows/entrances/passages during ALL transition types
(room↔room, indoor↔outdoor, cellar↔floor), and (2) background at certain angles when
pressing the camera into an interior corner ("like the camera is clipping into the wall").
A fresh `[flap-sweep]` capture + a dat-backed replay harness settle it:
- **The §2b "eye penetrates the wall → camera-seal failure" hypothesis is REFUTED.**
Every zero-contact camera traversal in the capture runs through a **real opening**:
- the viewer-outdoor "escapes" (48 frames) go through **0170's exit-door portal**
(`other=0xFFFF`, local x[5.55,7.45] z[0,2.5]) — the player was standing at the
threshold (`playerCell` flickering 0031↔0170);
- the corner-press frames (player=0172, viewer=0171, 2,963 frames) go through the
**0171↔0173↔0172 doorway chain** — 0173 is a 20 cm doorway-threshold cell (two
parallel portal planes at local x=4.10 / x=3.90). The captured eyes land **inside
the door-opening rectangle**, 0.35 m past the doorway plane.
- **Camera collision is healthy:** 8,703 of 14,230 indoor sweeps in the same capture
collided (pull-in up to 2.77 m, valid normals). Nothing penetrates solid geometry.
- Therefore the corner symptom is **render-side**: with the eye hovering at/near an
opening's plane, the portal's screen projection collapses **edge-on**, the flood
through the opening dies for those frames, and the room/world behind it (often the
room the player is standing in) is not drawn → background color. That is the **same
§2a mechanism** as the transition flashes. **§4 is now ONE mechanism, not two.**
## 1. Evidence chain
1. **Capture:** `corner-seal-capture.log` (untracked, worktree root) — clean run,
user did corner presses + doorway passes. Probes: `ACDREAM_PROBE_FLAP` +
`ACDREAM_PROBE_PVINPUT`.
2. **Sweep classification:** 17,999 `[flap-sweep]` frames; 48 player-indoor →
viewer-outdoor "escapes", ALL `ok=True pulledIn≈0 collNormValid=False`; plus the
player=0172/viewer=0171 corner population (2,963 frames, same signature).
3. **Replay harness:** `tests/AcDream.Core.Tests/Physics/CameraCornerSealReplayTests.cs`
(`b21bb28`) — loads the real Holtburg building cells (0x016F0x0175), replays the
captured sweeps with the camera's exact call shape, reproduces the zero-contact
traversals deterministically, and maps the geometry:
- full portal/room map (printed by the Diagnostic fact): 016F + 0170 carry the exit
doors; 0173 is the 0171↔0172 threshold; 0175/0174 are the cellar chain below 0171.
- containment walk: no point of the "escape" paths lies inside solid cell volume.
- the assertion fact is now a **characterization**: those opening paths must pass
clean (no collision, full traversal) — pins the verified-correct behavior.
4. **`SmartBox::update_viewer` port verified verbatim** against decomp :92761-92892
(including both fallbacks — retail also places the camera at the un-collided sought
eye when the sweep fails). No divergence in the camera layer.
## 2. What this kills (DO-NOT-RETRY — added to the render digest)
- Any "camera-seal" / camera-collision fix for the corner symptom. The camera is right.
- Re-opening the membership/physics side for these symptoms — the digest's #106/#98
exonerations stand; `playerCell` flicker at thresholds is the player genuinely
straddling a portal.
## 3. NEXT (the §4 fix arc — gated on the retail oracle read)
Per the 2026-06-08 handoff §5.3 (never done): **read retail's edge-on clip handling
BEFORE designing anything**:
- `PView::GetClip` (0x5a4320, decomp ~:432344)
- `PView::ClipPortals` (0x5a5520, :433572)
- `ACRender::polyClipFinish` (the w=0 clip, :702749)
Questions: does retail's clip collapse to zero area at edge-on exactly like ours; does
retail keep a flooded cell once added (per-frame hysteresis); what does retail draw for
the 12 frames the eye is inside a threshold cell (0173-style reveal slab)? Cross-check
our `PortalProjection.cs` at edge-on specifically. The fix lands either in clip
robustness (`PortalProjection` / `PortalVisibilityBuilder`) or in eye dynamics (retail
holds the eye collided/head-on 93% at doorways while ours floats — `[flap-sweep]`
pull-in distribution is now measurable in the harness).
Conformance pre-gate for whatever fix: "smooth monotonic eye through/at a doorway ⇒
monotonic vis (no oscillation)" — buildable on the CameraCornerSealReplayTests fixture.
## 4. Apparatus inventory (reuse, don't rebuild)
- `CameraCornerSealReplayTests` — dat-backed sweep replay + room map + containment
(Diagnostic fact prints everything; extend its sample table from any new capture).
- `corner-seal-capture.log` — the full corner+doorway capture (UTF-16LE).
- `[flap-sweep]` / `[flap-cam]` / `[pv-input]` probes — unchanged.
- The §4 outdoor flap probes (`ACDREAM_PROBE_CLIPROUTE`, `[gl-state]`) remain in-tree.

View file

@ -0,0 +1,114 @@
# CLOSED — §4 outdoor FULL-WORLD flap: EnvCellRenderer DepthMask(false) leak no-oped the frame depth clear
**Date:** 2026-06-10. **Branch:** `claude/thirsty-goldberg-51bb9b`.
**Commits:** `682cba3` ([clip-route] probe apparatus), `c4df241` (the fix).
**Status:** FIXED — user visual gate passed + probe-verified (0 leaked frames in the
38k-line verification capture). Closes the investigation opened by
`2026-06-09-flap-outdoor-fullworld-building-flood-merge-handoff.md`.
---
## 1. Root cause
`EnvCellRenderer.RenderModernMDIInternal` established the Transparent pass state —
`Enable(GL_BLEND)` + `glDepthMask(false)`**before** the batch pass-filter loop. A
flooded cell whose batches are **all opaque** (a plain cottage interior: walls only, no
transparent surfaces) produced `totalDraws == 0` in the Transparent pass and hit the
early `return` **without ever reaching the end-of-pass restore**. The frame ended with
`dmask=0 blend=1`.
The kill is one GL semantic away: **`glClear(GL_DEPTH_BUFFER_BIT)` honors
`glDepthMask`.** With the mask left false, the next frame's depth clear silently no-oped.
The depth buffer kept the previous frame's values, and every world fragment — terrain,
entities, the player, the sky — z-tested `GL_LESS` against its **own previous-frame
ghost** at virtually identical depths → never strictly closer → rejected. The screen
showed only the color clear, which is set to the fog color. Hence: whole world replaced
by fog-tinted clear.
A second early-out of the same shape (`globalVao == 0`, after the SSBO uploads) could
leak identically; it was fixed in the same commit.
This is the **4th instance** of the `feedback_render_self_contained_gl_state` bug class —
in the **same function** that carried the 1st (the U.4 blend/depth-mask establish was
itself the fix for instance #3; its early-out paths were the gap).
## 2. Why the symptoms looked the way they did
| Symptom | Mechanism |
|---|---|
| Onset frame-exact at the building-flood merge | The merge is the first frame the flooded building shell draws → first run of the empty Transparent pass → leak arms. |
| Strobes at onset | The flood flickers in/out at the boundary while the eye settles → alternating no-op / working depth clears. |
| HOLDS (one capture: 145,238 consecutive frames) | The leak re-arms every merged frame; the depth buffer stays stale indefinitely. |
| Camera rotation recovers instantly | The doorway clips out of view → cell drops from the flood → the leaking pass stops running → frame ends `dmask=1` → next clear works. |
| Whole world INCLUDING sky | Sky also depth-tests; old-sky pixels hold depth 1.0 and `1.0 < 1.0` fails `GL_LESS`. Everything dies uniformly. |
| "Parts of the screen flash while running past cottages" / cottage enterexit artifacts | Same family: every brief merge = a 1-frame no-op depth clear. |
| The 9×21 px doorway scissor box in `[gl-state]` | Fingerprint only — `DrawRetailPViewCellParticles`' `BeginDoorwayScissor` leftover (test off, harmless). It marked "cell passes ran this frame", never the kill. |
## 3. The evidence chain (one probe run decided it)
The `[clip-route]` apparatus (`682cba3`, gate `ACDREAM_PROBE_CLIPROUTE=1`) instrumented
all three surviving suspects from the 2026-06-09 handoff in one repro run
(`flap-cliproute-capture.log`, user walk-through at the Holtburg south-slope anchor):
- **`[clip-route]`** — outside slice slot + NDC AABB + planes, CellIdToSlot, region-SSBO
bytes decoded at the routed slot, terrain-UBO head as uploaded: **full-screen planes on
both sides of every merge transition** (slot repacks 2↔3 with correct content). Suspect
(b) UBO content — exonerated.
- **`[clip-route-disp]`** — per-slot instance histogram as staged for binding=3: all
41,373 instances tracked the repacked outdoor slot exactly, `cullEnt=0` throughout.
Suspect (a) clip-slot routing — exonerated.
- **`[clip-route-scis]`** — actual GL scissor box for the landscape pass: full-screen the
entire run (printed once). Scissor — exonerated.
- **`[gl-state]`** — the answer: frames entered with `dmask=0 blend=1` for **exactly** the
merged stretches (armed at frame 239 ≈ the cottage shell finishing its streaming after
spawn-in, stable 145,238 frames through the held window, flipping in lockstep with each
end-of-run strobe/recover cycle).
Draw inputs provably correct + fragments not landing ⇒ the GPU rejected them at the
depth test ⇒ the only anomaly (`dmask=0` entering frames) was the cause. Code reading
then pinned the exact early-out: `RenderModernMDIInternal`'s `totalDraws == 0` return
between the state-set and the restore.
## 4. The fix (`c4df241`) — all paths root-cause
1. **EnvCellRenderer:** the pass-state establish moved **below** the `totalDraws == 0`
early-out; the `globalVao == 0` check hoisted above the state-set. Mutated GL state is
now established only on a path that always reaches the end-of-pass restore —
set→restore is return-free.
2. **GameWindow frame clear:** asserts `glDepthMask(true)` immediately before the
`glClear` — the clear *depends on* the depth write mask, so per the project's
self-contained-GL-state rule it sets the state it consumes rather than inheriting it.
The `[gl-state]` tripwire still detects any future leak (blend etc.).
## 5. Verification
- Build green; 218 app-layer tests green (294+218+420 suite baseline unchanged).
- **Probe gate:** `flap-fix-gate-capture.log` (38,116 lines, same spawn-by-the-cottage
conditions): **zero** `dmask=0` `[gl-state]` frames — vs the broken run where the leak
armed by frame 239. Frames stay `dmask=1 blend=0` with the cottage shell drawing.
- **Visual gate (user, 2026-06-10):** forward walk through the trigger zone + running
past cottages — no strobe, no fog hold; "seems to work."
## 6. What remains in §4 + apparatus inventory
- **(2a) edge-on doorway grey** and **(2b) corner camera-seal** remain open as the §4
siblings — **re-validate both against this fixed baseline first**: the no-op depth
clear may have inflated their apparent severity (any 1-frame merge during those repros
produced full-frame artifacts unrelated to their own mechanisms).
- Probes kept in-tree (env-gated, zero cost when off):
`ACDREAM_PROBE_CLIPROUTE``[clip-route]` / `[clip-route-disp]` / `[clip-route-scis]`;
`ACDREAM_PROBE_GLSTATE``[gl-state]` tripwire; `ACDREAM_PROBE_PVINPUT``[pv-input]`.
Strip once §4 (2a)/(2b) are resolved.
- Captures (worktree root, untracked): `flap-cliproute-capture.log` (the deciding run),
`flap-fix-gate-capture.log` (the verification run).
## 7. Durable lessons (memory updated)
- `feedback_render_self_contained_gl_state` — instance #4 recorded, with two new
corollaries: (a) GL-state ownership includes **exit paths** — establish mutated state
as late as possible, after every early-out; (b) **`glClear` is a state consumer** —
depth clears are gated by `glDepthMask`, so clear sites must assert the masks they
depend on.
- New symptom→cause mapping: *whole world drops to the clear color at an event boundary,
holds, recovers when the event stops* → leaked `DepthMask(false)` no-oping the frame
depth clear.

View file

@ -0,0 +1,102 @@
# HANDOFF — #113 phantom exterior staircase (A9B3) / misplaced-interior-cell suspect
**Date:** 2026-06-10 (very late). **Branch:** `claude/thirsty-goldberg-51bb9b`, HEAD `77d7ea1`.
**Pick rationale:** the user's standing directive from tonight — *"we should not have to
verify all houses like this"* — wants systemic fixes. #113 is the cheapest
highest-information probe: if the misplaced-cell hypothesis holds, ONE bug class explains
phantom geometry + containment gaps (#112) + some collision gaps across every affected
house. Rider task: the #112 residual oracle read (same code area, ~30 min).
Read the **physics digest** (`claude-memory/project_physics_collision_digest.md`) first —
its top banners carry tonight's #107/#111/#112 arc. Render digest unchanged except #105.
---
## 1. The issue (user side-by-side vs retail, 2026-06-10)
An A9B3 building (near the #112 hill cottage; NPCs **Aun Kielerea** + **The Sentry**
adjacent; the user stood at ~world (183, 111, 116), which is A9B3-local (183, 81, 116))
shows a **stone-and-wood staircase attached to its exterior wall** in acdream. Retail
shows a plain wall with a window. Screenshots are in the session transcript
(2026-06-10); the spot is trivially re-findable — it's next to the A9B3 hill cottage
with interior cells 0x100/0x103/0x104 at z=116.
**Key fact:** the stairs are NOT pickable — clicks select the NPCs behind them
(`issue112-gate1.log:37032+`). Not a stab/WorldEntity ⇒ building-shell geometry or an
interior EnvCell's geometry drawn outside.
## 2. The hypothesis to test FIRST
**A misplaced interior EnvCell** — an indoor staircase cell whose world transform
(Position.Origin/orientation from the dat, or our application of it) places its geometry
outside the shell. One misplaced cell would unify, at this building:
- the phantom exterior stairs (its geometry pokes through/outside the wall),
- an interior containment dead-zone (#112's gap = the volume is where the geometry
ISN'T — note the #112 scan found a real gap inside the HILL COTTAGE; check whether
the staircase building and the hill cottage are the same building or neighbors),
- missing collision on objects inside (their owning cell volume is elsewhere).
## 3. Attribution plan (cheap, ordered)
1. **ACViewer oracle** (`feedback_acviewer_as_oracle`): open landblock **A9B3** in
ACViewer and look at the same building. Stairs present there too ⇒ shared
dat-interpretation (both projects misread the dat — go to the dat bytes); absent ⇒
OUR transform/draw path (EnvCell world-transform application or the building shell
draw). This single check halves the space.
2. **Identify the building + cell**: dump A9B3's `LandBlockInfo.Buildings` (model id,
Position) + every interior `EnvCell.Position` (`dats.Get<EnvCell>(0xA9B301xx)`).
The conformance harness pattern is ready-made: `ConformanceDats.LoadEnvCell` +
a scratch test with `ITestOutputHelper` (see `Issue112MembershipTests.cs` for the
loader shape; A9B3-local frame: world y + 192, world x unchanged, center A9B4).
Look for a cell whose origin/AABB escapes its building's footprint, and specifically
for a STAIR-shaped Environment near the user's spot (stairs = the EnvironmentId's
CellStruct will have ramp polys).
3. **Cross-check the #112 gap**: the gap point A9B3-local (184.9, 82.5, 116.5) — which
cell SHOULD cover it per the dat layout? If the misplaced cell's correct position
covers the gap, the hypothesis is proven end-to-end.
4. Fix per finding; conformance test the transform (golden origins from the dat).
## 4. Rider: #112 residual (same session, ~30 min)
`Issue112MembershipTests.A9B3CottageGap_..._DocumentsResidual` pins it: at the doorway
gap, the pick demotes via the NORMAL outdoor-candidate path (NOT the removed hatch).
Oracle read needed: retail `CEnvCell::find_transit_cells` (around pc:317499) — is
`add_all_outside_cells` gated on sphere-proximity to the exterior portal POLYGON or on
mere graph reachability? If proximity-gated, tighten our port and flip the
DocumentsResidual assert to keep-curr (`0xA9B30104`). Re-promotion at doorways already
works — this residual self-heals walking in; it's polish, not a stranding.
## 5. Tonight's shipped state (do not redo — see digests + ISSUES.md)
| Issue | State | Commits |
|---|---|---|
| #105 white walls | CLOSED (per-frame texture flush restored) | `c787201` |
| #110 near plane | CLOSED (retail 0.1 re-landed, corner gated) | `d4b5c71` |
| #107 login wedge | CLOSED (snap validation + PortalSpace fix + wire pairs + hold) | `1090189`, `e4f6750` |
| #111 login placement | CLOSED user-gated (claim-authoritative snap, walkable-poly grounding, entity parity) | `5f1eb7c`, `5706e0e`, `2735695` |
| #112 transparent-while-walking | PRIMARY FIX shipped (hatch removed, lateral stab recovery, retail keep-curr); residual documented; live cottage re-walk gate pending | `2d6954e` |
| #113 phantom stairs | FILED (this handoff) | `77d7ea1` |
Key new invariants: server (cell,pos) pairs are VALIDATED at the snap
(`PhysicsEngine.Resolve` head: retail `AdjustPosition` :280009 + walkable-poly grounding
`WalkableFloorZNearest` + `[snap]`/`[spawn-adjust]` log lines, one per login/teleport);
the per-tick pick keeps curr_cell on null result (retail pc:308788-308825) with lateral
stab-graph recovery. ACE persists ITS OWN physics state, not our reports
(`SetRequestedLocation` — ACE source confirmed) — every restore shape it produced
tonight is survivable.
## 6. Ops notes
- Launch protocol: standard PowerShell launch (CLAUDE.md) + `ACDREAM_PROBE_RESOLVE=1`
+ `ACDREAM_PROBE_CELL=1`; tee a log. In-world marker `auto-entered player mode`.
Graceful close ⇒ ~5 s ACE hold; hard kill ⇒ ~3 min exit-29.
- Character `+Acdream` is parked near the A9B3 staircase building (outdoors) — login is
clean with tonight's fixes regardless of where ACE restores.
- ⚠️ Never bulk-edit source with PS5.1 Get/Set-Content (mojibake). Tee logs = UTF-16LE.
- Baseline: Core **1383** green + 4 pre-existing #99-era failures (DoorBugTrajectoryReplay
×2 / DoorCollisionApparatus / BSPStepUp) + 1 skip; App **223** / UI **420** / Net **294**.
Gates that must stay green: P1 membership conformance (FindCellListConformance,
ThresholdPortalCrossing, CottageDoorway, CameraCornerSeal), CornerFloodReplayTests,
Issue107SpawnDiagnosticTests, Issue112MembershipTests.
- After #113: #108 (cellar grass-sweep) + #109 (far-door oscillation) render residuals,
then #99/A6.P4 per-cell shadow architecture (the big systemic debt).

View file

@ -0,0 +1,438 @@
# acdream vs retail — building/interior render architecture comparison
**Phase A deliverable of the holistic building-render investigation**
(mandate 2026-06-11: *"map acdream's way vs retail, then make a plan how to port
retail's way of doing it once and for all"*; charter:
[2026-06-11-building-render-holistic-port-handoff.md](2026-06-11-building-render-holistic-port-handoff.md)).
**Branch:** `claude/thirsty-goldberg-51bb9b`. **Method:** two ultracode workflow
fan-outs — 12 mapping areas (11 completed; transparency/sorting re-running),
~90 agents, every retail claim cited to Ghidra decompile (port 8081, verified
PDB program) / named pseudo-C (`pc:LINE`) / `acclient.h`, every acdream claim
cited `file:line`, one adversarial verifier per claimed divergence
(Ghidra-first; BN pseudo-C distrusted per project history). Raw per-area
mapping outputs (the full evidence record) live in
[`2026-06-11-holistic-map/`](2026-06-11-holistic-map/).
**Companion deliverable (Phase B):** the phased port plan at
[`docs/plans/2026-06-11-building-render-port-plan.md`](../plans/2026-06-11-building-render-port-plan.md)
— awaiting user approval before any implementation.
---
## 1. Executive summary
The investigation **confirms the mandate's premise and overturns two of our
working theories.** Retail has one drawing discipline; we have fragments of it
plus three mechanisms it doesn't use. The headline findings:
1. **Retail flattens GfxObjs and cells at load, exactly like we do.**
`CGfxObj::InitLoad` builds a flat D3D mesh from the *full* polygon
dictionary (`D3DPolyRender::ConstructMesh`, Ghidra 0x0059dfa0) and then
**deletes every non-portal node from the drawing BSP**
(`BSPTREE::RemoveNonPortalNodes`, Ghidra 0x0053a040). There is no per-frame
BSP traversal of ordinary geometry. Our flatten-at-decode global-VAO
pipeline is retail-faithful as a *base* — the bindless MDI architecture
survives the port intact.
2. **The phantom staircase and the vanished doors are one mechanism, and it is
a draw-time *surface* gate, not a poly filter.** Building/cell meshes skip
surface batches whose `CSurface.type` has neither `BASE1_IMAGE` (0x2) nor
`BASE1_CLIPMAP` (0x4) — the `skipNoTexture` rule (D3DPolyRender inner draw,
Ghidra 0x0059d4a0; default on, data 0x00820e30). Dat-confirmed on our side
(commit `e223325` + `DumpPortalFillSurfaceTypes`): **every** portal-fill
quad on all 13 Holtburg-area building models — door fills, window fills,
*and* the meeting-hall phantom stair-ramp — is `Base1Solid` (untextured).
Retail draws none of them, ever. The doors players see are **door
entities**. *(Execution-day correction, §5: acdream's extractors already
skip all of these via `NoPos` — proven equivalent to retail's rule by
`StipplingSurfaceEquivalenceTests`. The phantom residual is cell-side,
not these fills; see the §5 banner.)*
3. **Retail never geometrically clips cell or shell geometry. Pixel exactness
at apertures is a DEPTH discipline.** Production cell draws are whole
prebuilt meshes (`use_built_mesh`, `DrawEnvCell` pc:427905); the famous
`planeMask=0xffffffff` per-poly path is a legacy fallback whose mask means
*skip all clip edges*. What makes doorways pixel-perfect is:
**(a)** an invisible *depth punch* — the portal polygon, software-clipped
against the accumulated view, drawn depth-always/z-write/alpha-0 at far-Z
(`maxZ1`, opens a building aperture before its interior draws) or at its
true depth (`maxZ2`, seals indoor exits after the landscape draws);
**(b)** far→near cell order with a draw-once frame stamp; **(c)** the
z-buffer. This **reframes #114**: our `gl_ClipDistance` shell chop was
chasing retail's fallback path. The accumulated portal views exist for
*admission*, *object culling*, and *punch shapes* — never to cut geometry.
4. **Per-frame portal machinery is the heart, and we half-have it.** Building
shells draw in two passes (`DrawBuilding`, Ghidra 0x0059f2a0): a
portal-only BSP walk that dispatches each aperture through
`PView::DrawPortal``ConstructView(CBldPortal)` (eye-side vs
`portal_side` at ε=0.0002 → screen-clip vs the current view → cell-loaded
check) — flood + punch on success, *nothing* on failure — then the whole
shell mesh. The `PortalRef.PortalIndex` in the dat indexes the building's
`CBldPortal` array via `outdoor_portal_list` (pc:433920). Our flood port
(R-A1/A2/A2b, keep-listed) already implements faithful analogues of the
admission gates; what's missing is the *trigger* (we use a 48 m seed
constant instead of the shell's own draw), the *depth machinery* (punch
path exists as an unwired no-op), multi-view union (first-wins drops
second apertures), and per-view object culling (`viewconeCheck`).
5. **The adjunct systems diverge in ways users feel.** Camera: retail's boom
damping interpolates *from the published collided viewer* each frame —
shorten-fast/ease-out is emergent; we damp from our own previous damped
eye, severing that feedback (**#115 root cause, verified**). Lighting:
retail bakes *all* static cell lights per-vertex and adds a viewer light;
we cap at 8 viewer-nearest dynamic lights and sun-light interiors when the
player-cell gate says "outside" (unverified, high-confidence). Collision:
retail registers objects into per-cell shadow lists via a sphere-overlap
portal flood at registration; our landblock-wide registry is the #99/A6.P4
debt (**verified**, design corrections noted in the area file).
**Bug attribution coverage:** #113 phantom class → finding 2; #114 indoor crop
→ finding 3; doors-vanish mystery → finding 2 (solved, `e223325`); #108
grass-sweep → missing aperture depth seal + scissored AABB clear (finding 3);
#109 far-door oscillation → 48 m flood pop + first-wins view loss + missing
punch (findings 34); particles-through-walls → scissor-AABB gating instead of
per-view cone culling (finding 4); #99 doors run-through → finding 5
(collision); #115 camera drag → finding 5 (camera).
---
## 2. The retail architecture (2013 client, decomp-cited)
What follows is the synthesis; each area file in
[`2026-06-11-holistic-map/`](2026-06-11-holistic-map/) carries the full
call-chains and citations.
### 2.1 Load time: flatten + prune
- `CGfxObj::InitLoad` (Ghidra 0x005346b0): `BSPTREE::RemoveNonPortalNodes`
prunes the drawing BSP to a skeleton of portal-bearing nodes, then
`D3DPolyRender::ConstructMesh` flattens **all** polygons (portal fills
included) into a surface-batched mesh. EnvCells get the same at
`CEnvCell::UnPack` (ConstructMesh at pc:311085).
- Portal polys live in the same polygon array; `BSPPORTAL` nodes reference
them as `CPortalPoly { portal_index, CPolygon* }` (acclient.h:39075) — the
dat's `PortalRef { PolyId, PortalIndex }` (our `e223325` finding).
### 2.2 Frame composition (outdoor root)
`SmartBox::RenderNormalMode` (0x453aa0): viewer in an EnvCell →
`DrawInside(viewer_cell)`; viewer outdoors → full-screen view + `LScape::draw`
→ per landblock `DrawBlock` → per land cell in view: `DrawLandCell` (terrain)
then `DrawSortCell` = `DrawBuilding(cell->building)` + `DrawObjCell(cell)`
**a building draws exactly when its host land cell draws** (per-cell
interleave, far→near blocks; `CSortCell.building` is the one-building-per-cell
slot, acclient.h:31880).
### 2.3 The building two-pass + portal machinery
`RenderDeviceD3D::DrawBuilding` (0x0059f2a0):
1. `outdoor_pview->outdoor_portal_list = building->portals` — installs the
CBldPortal lookup the shell's portal polys index.
2. **Pass 1 (portals):** `CPhysicsPart::Draw(part, 1)` → the pruned BSP walk
(`build_draw_portals_only` modes 1 then 2) submits each portal poly →
`PView::DrawPortal` (0x005a5ab0) resolves
`outdoor_portal_list[portal_index]`, pushes fresh `portal_view` slots onto
every stab-list cell, and runs `ConstructView(CBldPortal)` (0x005a59a0):
- eye side vs `portal_side` at ε=0.0002 — in-plane rejects outright;
- `GetClip` — the portal polygon software-clipped against the **current**
view (full screen outdoors; a doorway slot if this building is itself
seen through a portal); empty → fail, *no distance constant exists*;
- `CEnvCell::GetVisible` — target cell loaded;
- success mode 1 → **far-Z depth punch** of the clipped aperture
(`DrawPortalPolyInternal`, alpha-0, depth-always, z-write, `maxZ1`);
- success mode 2 → recurse `ConstructView(CEnvCell)` (the BFS flood) and
`DrawCells` draws the interior into the punched aperture.
The `building_view` latch binds nested floods to the view slot they were
discovered under (saved/-1/restored around `DrawPortal`, pc:427906-427914).
3. **Pass 2 (shell):** `ObjBuildingOrBuildingPart=1`;
`CPhysicsPart::Draw(part, 0)` draws the whole constructed mesh — with
`skipNoTexture` skipping every untextured (solid) surface batch. Interior
pixels survive only inside the punched aperture.
### 2.4 The indoor flood + DrawCells
`PView::DrawInside` (0x005a5860): root view = full-screen quad;
`ConstructView(CEnvCell)` floods via `InitCell` (eye-side per portal,
ε=0.0002; entered-portal back-walk block) + `ClipPortals` (per accumulated
view: project portal, homogeneous Sutherland-Hodgman `polyClipFinish`
near-W clip first, then each view edge; pixel-exact, no plane budget) +
`AddViewToPortals` (first discovery enqueues; growth propagates **in place**
via `AddToCell`/`FixCellList`/`AdjustCellView` with the `update_count`
watermark; termination from `copy_view`'s 1-pixel vertex dedup, not a cap).
A multi-portal cell accumulates a **list** of view polygons — union-as-list,
all views consumed downstream.
`PView::DrawCells` (0x005a4840):
1. If any outside views: `PortalList=&outside_view`**`LScape::draw`** (the
landscape through the accumulated doorway views) → conditional full
**depth clear** (gated on `portalsDrawnCount`) → far→near per cell per
view: **z-seal** every portal leading outside (`other_cell_id==0xFFFF`) at
its true projected depth (`maxZ2`) — terrain seen through the door keeps
its pixels; interior geometry farther than the door plane z-fails inside
the aperture.
2. Cells far→near per view via `DrawEnvCell` — whole prebuilt mesh, drawn
once (`GetDrawnThisFrame` frame-stamp), **never clipped**.
3. Per cell: `PortalList = cell's view stack``DrawObjCell` — every object
sphere-tested per view by `Render::viewconeCheck` (0x0054c250: sphere vs
eye plane + each `view_vertex.plane`); objects are **culled, never
clipped**. Translucent batches defer to the AlphaList and flush sorted.
### 2.5 The adjuncts
- **Camera** (`wf2-camera-viewer.md`): `CameraManager::UpdateCamera`
interpolates the sought pose **from the published collided viewer**
(`PlayerPhysicsUpdatedCallback` passes `&this->viewer`) with
α = stiffness·dt·10 (stiffness 0.45); `SmartBox::update_viewer` re-sweeps
pivot→sought *every frame* (0.3 m sphere) and publishes the raw collided
stop + `viewer_cell = sphere_path.curr_cell`. No explicit boom smoothing
exists — the feel is emergent from the collided-feedback loop. Player mesh
fades over the 0.45→0.20 m approach band
(`SetTranslucencyHierarchical`).
- **Lighting** (`wf2-indoor-lighting.md`, unverified): per-cell static lights
burn into vertices; interiors are never sun-lit; a white viewer light rides
above the player; per-object light selection against object bounds.
- **Sky/weather/scenery** (`wf2-sky-weather-scenery.md`, unverified): weather
gates on `is_player_outside`; rain cylinder at world-absolute z; scenery
draws per land cell, participating in the same per-cell interleave.
- **Collision** (`wf1-interior-collision.md`, verified): registration builds
the cell set by a sphere-overlap **portal flood** (`add_shadows_to_cells` /
`find_cell_list` family); queries iterate per-cell `shadow_object_list`;
buildings dispatch through `CSortCell.building` (per-cell channel);
`check_building_transit` gates `other_portal_id >= 0` (sign-extension
Ghidra-proven — BN renders it unsigned, the invented-sign failure mode).
- **Picking** (`wf2-picking-selection.md`, unverified): object-sphere/poly
arbitration inside the draw traversal's visibility, not a parallel ray.
---
## 3. The acdream architecture today
Mapped in full in the area files; the short form, with keep/replace verdicts:
| Subsystem | Today | Verdict |
|---|---|---|
| Mesh pipeline (flatten → global VAO → bindless MDI) | `ObjectMeshManager``WbDrawDispatcher`, ~12-15 GL calls/frame | **KEEP** — matches retail's flatten-at-load |
| PView flood (admission) | `PortalVisibilityBuilder` — faithful homogeneous clipper, side tests, reciprocal clip, exact-match skip | **KEEP** (R-A1/A2/A2b + dac8f6a, conformance-gated) — adjust constants/heuristics per ledger |
| Flood trigger | 48 m per-building seed over Chebyshev≤1 landblocks, outdoor roots only | **REPLACE** with shell-draw-driven `DrawPortal` (retail has no distance constant) |
| Aperture enforcement | `gl_ClipDistance` shell chop (outdoor-scoped, #114) + scissored AABB depth clear + unwired `DrawExitPortalMasks` | **REPLACE** with retail depth punch/seal/clear discipline; shells draw whole |
| Portal-fill suppression | GfxObjs: none (fills drawn — phantom class); cells: build-time `NoPos`/`NoNeg` stippling drop | **REPLACE/ALIGN** with draw-time `skipNoTexture` surface gate (dat-confirmed equivalent on audited cells) |
| Object/particle visibility | Cell-membership buckets; particles scissor-AABB | **EXTEND** with per-view `viewconeCheck`; route particles through the same gate |
| Building physics/collision | Landblock-wide `ShadowObjectRegistry` + `b3ce505` gate (#99) | **REPLACE** per A6.P4 (verified, with corrections: signed `OtherPortalId` + `>=0` gate, per-cell building channel) |
| Membership / straddle gate / streaming / camera collision / znear / texture flush | P1 9/9 golden; `414c3de`; A.5; verbatim `update_viewer`; 0.1; `c787201` | **KEEP** (charter keep-list, re-confirmed by verifiers) |
| Visibility computations | **TWO live systems**: `CellVisibility.ComputeVisibilityFromRoot` (ACME BFS) *and* `PortalVisibilityBuilder` (verified) + legacy remnants (`InteriorRenderer`, `IndoorDrawPlan`, dual frustum impls) | **CONSOLIDATE** to one gate (PView) + delete dead paths |
| Camera boom | Damps from own previous damped eye; fade computed, never applied | **FIX** feedback anchor (#115, verified) + apply fade |
| Lighting | Single scene UBO, 8 viewer-nearest lights, player-cell sun gate | **EXTEND** per lighting area (burn-in, viewer light, interior sun mask) — pending verification |
---
## 4. The divergence ledger
76 divergences across 11 mapped areas (the 12th, transparency/sorting, is
re-running). Ranked within area; verification = adversarial re-derivation
(Ghidra-first). `UNVERIFIED` rows had their verifier interrupted by the
session token limit — a resume is in flight; treat them as high-confidence
mapped claims, not yet adversarially proven. Full evidence per row in the
area files.
### Where each open bug lands
| Bug | Primary divergences |
|---|---|
| #113 phantom geometry class | `solid-surface-skip-missing` (gfxobj), `portal-polys-baked-unconditional` (shells) |
| Door-vanish mystery | **SOLVED**`e223325` + surface-type dump; same rows as #113 |
| #114 indoor crop | `shell-chop-vs-depth-discipline`, `missing-aperture-depth-punch`, `multiview-loss-first-wins`, `knife-edge-epsilon-and-rescue` (interior-cells) |
| #108 grass-sweep | `missing-portal-depth-fence` (culling, **confirmed**), `depth-clear-shape-and-order`, `eight-plane-budget-passall` |
| #109 far-door oscillation | `building-flood-seeding-48m-cutoff` (adjusted: pop mechanism confirmed, 48 m linkage unmeasured), `multiview-loss-first-wins`, `missing-portal-depth-fence` |
| particles-through-walls | `particles-not-cell-resident` (statics), `object-particle-gating` (interior-cells), `particles-third-gate-tier` (gates) |
| #99 door run-through | `registration-cell-set-not-portal-flood`, `flat-object-query-not-per-cell` (collision, both **confirmed**) |
| #115 camera drag | `boom-no-collided-feedback` (**confirmed**) |
| Indoor "feels right" (M1.5) | the six `indoor-lighting` rows (unverified) |
### Ledger (severity / verdict / one-line)
**Area 1 — GfxObj draw** (`wf1-gfxobj-draw.md`)
| Sev | Verdict | Divergence |
|---|---|---|
| CRIT | adjusted | `portal-poly-conditional-pass-missing` — no per-frame z-punch/z-seal/ConstructView pass on portal polys |
| HIGH | **REFUTED for fills** (BR-1 pre-check, §5 banner) | `solid-surface-skip-missing` — acdream's NoPos build-time skip already covers them, proven equivalent |
| MED | confirmed | `degrade-lod-scoped-to-humanoids` — retail degrades every non-player part per frame |
| MED | adjusted | `no-per-view-entity-pass` — no per-portal-view re-cull of objects |
| MED | confirmed | `stippling-semantics-divergence` — WB's NoPos/NoNeg side-drop vs retail batch flag + sides_type |
| LOW | adjusted | `no-frame-dedup` — no GetDrawnThisFrame frame-stamp |
**Area 2 — Building shells** (`wf1-building-shells.md`)
| Sev | Verdict | Divergence |
|---|---|---|
| CRIT | **acdream half REFUTED** (BR-1 pre-check); retail half + missing PortalIndex→CBldPortal pairing stand | `portal-polys-baked-unconditional` — fills are NOT drawn (NoPos skip); the un-consumed pairing remains real (BR-4) |
| CRIT | unverified | `no-per-slot-building-draw` — building never draws per view slot; floods not shell-draw-driven |
| HIGH | adjusted | `flood-gate-shape` — 48 m seed + 0.01 ε + eye-inside rescue vs retail's no-distance chain (analogues otherwise faithful) |
| HIGH | unverified | `aperture-depth-machinery` — far-Z punch missing; particles scissor-only |
| MED | confirmed | `building-not-in-physics-cell-graph` — per-cell building channel missing; `other_portal_id>=0` gate missing (sign-extension proven) |
| LOW | unverified | `leaf-cells-unported` — retail path itself appears dormant; do not port without runtime proof |
**Area 3 — Interior cells** (`wf1-interior-cells.md`)
| Sev | Verdict | Divergence |
|---|---|---|
| CRIT | unverified | `shell-chop-vs-depth-discipline` — we clip shell geometry; retail clips nothing (depth discipline) |
| CRIT | unverified | `missing-aperture-depth-punch` — DrawExitPortalMasks unwired; AABB far-clear wrong shape/value |
| HIGH | unverified | `multiview-loss-first-wins` — MergeBuildingFrame drops views; CellIdToSlot keeps slices[0] |
| HIGH | unverified | `eight-plane-budget-passall` — >8 edges → slot-0 PASS-ALL; scissor fallback unimplemented |
| HIGH | unverified | `knife-edge-epsilon-and-rescue` — 0.01 vs 0.0002 ε + non-retail 1.75 m full-view rescue |
| MED | unverified | `growth-requeue-vs-in-place` — re-enqueue + cap-16 vs retail in-place propagation + 1-px dedup floor |
| MED | unverified | `object-particle-gating` — membership-only culling; particles scissored |
| MED | unverified | `portal-poly-suppression-criterion` — build-time stippling vs retail draw-time surface gate |
**Area 4 — Statics + dynamics** (`wf1-statics-dynamics.md`)
| Sev | Verdict | Divergence |
|---|---|---|
| CRIT | unverified | `building-portal-polys-unconditional` — (cross-ref Area 2) |
| CRIT | adjusted | `particles-not-cell-resident` — owner-bucket + 2D scissor vs emitter-cell residency + per-slot cone |
| HIGH | confirmed | `single-cell-buckets-vs-shadow-parts` — one ParentCellId bucket vs register-in-every-overlapped-cell + draw-once |
| HIGH | confirmed | `shells-drawn-whole-in-retail-production` — the #114 reframe anchor |
| MED | confirmed | `no-per-slot-viewcone-for-meshes` |
| MED | **refuted** | `livedynamic-dropped-indoors` — claim did not survive; see area file |
| LOW | adjusted | `outdoor-objects-redrawn-per-slice` |
| LOW | adjusted | `per-cell-depth-sort-missing` |
**Area 5 — Culling/frame composition** (`wf1-culling.md`)
| Sev | Verdict | Divergence |
|---|---|---|
| CRIT | confirmed | `missing-portal-depth-fence` — the maxZ2 fence after the clear is absent; hook unwired |
| CRIT | adjusted | `approximate-portal-clip-for-landscape` — ≤8 GL planes + AABB scissor vs exact software clip |
| HIGH | adjusted | `depth-clear-shape-and-order` — per-slice scissored clears after all slices vs one gated full clear |
| HIGH | unverified | `portal-poly-conditional-draw` — (cross-ref Areas 1/2) |
| HIGH | adjusted | `building-flood-seeding-48m-cutoff` — pop mechanism confirmed; #109@48 m unmeasured |
| MED | confirmed | `entity-cull-no-portal-viewcone` |
| MED | adjusted | `weather-gate-player-vs-viewer` — rain through doorways while inside |
| MED | adjusted | `unattached-particles-dropped-outdoors` |
| LOW | confirmed | `global-passes-vs-per-cell-interleave` |
**Area 6 — Interior collision** (`wf1-interior-collision.md`)
| Sev | Verdict | Divergence |
|---|---|---|
| CRIT | confirmed | `registration-cell-set-not-portal-flood` — XY grid vs sphere-overlap portal flood |
| CRIT | confirmed | `flat-object-query-not-per-cell` — one radial query vs per-cell shadow-list iteration |
| HIGH | adjusted | `building-shell-as-shadow-object` — landblock-wide entries vs per-LandCell building channel |
| HIGH | confirmed | `check-other-cells-env-only` — retail runs env AND shadow objects per other cell |
| MED | adjusted | `a6p5-topology-widening` — wider than retail's straddle gate (pending A6.P4) |
| MED | confirmed | `single-landblock-grid-clamp` — registration clamps to own landblock |
| LOW | confirmed | `movement-reregistration-source` — fresh grid vs transition's own cell array |
**Area 2.1 — Camera/viewer** (`wf2-camera-viewer.md`)
| Sev | Verdict | Divergence |
|---|---|---|
| HIGH | confirmed | `boom-no-collided-feedback` — sought eye never re-anchors to published collided viewer (#115 root cause) |
| MED | confirmed | `player-fade-computed-not-applied` |
| LOW | confirmed | `sought-position-lacks-cell-identity` |
| LOW | adjusted | `camera-input-scalars-unverified` |
**Area 2.2 — Indoor lighting** (`wf2-indoor-lighting.md`) — all unverified
| Sev | Divergence |
|---|---|
| CRIT | `interior-sun-bleed` — interiors sun-lit when player-cell gate says outside |
| HIGH | `no-static-light-burnin` — interiors capped at 8 viewer-nearest lights vs all static lights baked |
| MED | `no-per-object-light-selection`; `no-viewer-light`; `surface-luminosity-diffuse-ignored` |
| LOW | `dynamic-entity-lights-unregistered` |
**Area 2.3 — Sky/weather/scenery** (`wf2-sky-weather-scenery.md`) — all unverified
| Sev | Divergence |
|---|---|
| CRIT | `outside-portal-zstamp-missing` — (same family as the depth fence) |
| HIGH | `weather-indoor-gate`; `particles-not-portal-clipped` |
| MED | `no-nested-building-flood-through-outside-view` |
| LOW | `outdoor-objects-flat-bucket`; `rain-anchor-z-relative`; `weather-enabled-toggle-absent` |
**Area 2.5 — Visibility-gates audit** (`wf2-visibility-gates-audit.md`)
| Sev | Verdict | Divergence |
|---|---|---|
| HIGH | confirmed | `object-lists-skip-portal-view-gate` |
| HIGH | confirmed | `indoor-shell-clip-disabled` — indoor roots have NO draw-side discipline today |
| HIGH | unverified | `particles-third-gate-tier` |
| MED | confirmed | `dual-live-visibility-computations` — ACME BFS + retail flood both run per frame |
| MED | adjusted | `landscape-redrawn-per-outside-slice` |
| MED | unverified | `flood-convergence-heuristics`; `drawportal-membership-rule-mismatch`; `livedynamic-invisible-under-interior-roots` |
| MED | confirmed | `exit-portal-mask-pass-dormant` |
| MED | adjusted | `legacy-outdoor-branch-remnant` — clipRoot==null second path |
| LOW | unverified | `dual-frustum-implementations` |
**Area 2.6 — Picking** (`wf2-picking-selection.md`) — all unverified, all ≤ medium
`pick-outside-draw-traversal`; `occluder-stricter-and-looser-than-retail`;
`no-poly-stage-no-poly-beats-sphere`; `no-selected-in-view-tracking`.
---
## 5. Mysteries resolved this session
> **⚠ EXECUTION-DAY CORRECTION (2026-06-11, BR-1 pre-check).** The claim that
> acdream *draws* the solid portal fills is **FALSE** — all four extraction
> paths skip `Stippling.NoPos` positive sides
> (`ObjectMeshManager.PrepareGfxObjMeshData:1046`,
> `PrepareCellStructMeshData:1394`, `CellMesh.Build:44`,
> `GfxObjMesh.Build:71`), and the fills have no negative surface
> (`ReplicateProductionEmission_OnPortalFills`: pos=False/neg=False for every
> fill). The equivalence pin (`StipplingSurfaceEquivalenceTests`, 2,607
> polys, 0 violations) proves our build-time skip ⇔ retail's draw-time
> `skipNoTexture` on this content. Consequences: the ledger rows
> `solid-surface-skip-missing` (Area 1) and the acdream half of
> `portal-polys-baked-unconditional` (Area 2) are **REFUTED for the fills**
> (the retail-side mechanism descriptions stand); the e46d3d9 user-gate
> observations were confounded (the filter was a provable mesh no-op on
> shells and doors); and the **#113 phantom residual is cell-side** —
> flood-admitted cells drawn with the pass-all `NoClipSlice` when slot-less
> (`RetailPViewRenderer.cs:71`) and/or unclipped un-viewcone'd cell statics
> (`object-lists-skip-portal-view-gate`, confirmed). BR-2 opens with the
> probe that pins which. The mapping agents missed the `:1046` skip — score
> one for "verify what call sites actually pass."
1. **Door-vanish (charter §4.1)** — SOLVED, dat-proven (`e223325` +
`DumpPortalFillSurfaceTypes`): the e46d3d9 filter walked only
`node.Polygons`, never `node.Portals` (`PortalRef`); every dropped poly was
a portal fill; all fills are `Base1Solid`; retail skips them via
`skipNoTexture`; visible doors are entities. **No static filter can be
correct** — and per the execution-day correction above, no filter was
*needed*: the fills were never drawn, and the gate's "doors vanished"
observation was confounded.
2. **#114's real shape (charter §4.2)** — retail does not crop indoor
geometry; it punches/seals depth at apertures and draws far→near. The
"admission-quality vs draw-quality regions" framing dissolves: regions
only ever needed to be admission-quality (+ punch shapes).
3. **#115 (charter §4.5)** — root cause confirmed: missing collided-viewer
feedback into the damping origin; plus the player fade is computed but
never applied.
4. **#108/#109 (charter §4 re-test list)** — concrete mechanisms named (depth
fence, clear shape, flood pop, view loss) — see ledger.
5. **Charter §4.3 (cottage entry transparency)** — not separately
investigated; the straddle gate + flood stability work landed earlier;
re-test after the port phases land.
## 6. Open questions carried into the port plan
The area files carry ~30 open questions; the load-bearing ones:
1. ~~Where does retail draw the textured fill of a building portal poly?~~
**Answered**: nowhere at Holtburg — all fills untextured
(`DumpPortalFillSurfaceTypes`). A dat-wide sweep should pin the invariant
before relying on it globally (plan P1 acceptance).
2. `LScape::draw` internals — does retail clip terrain polys against
outside views or only cull blocks/cells per view? (Affects how faithful
our per-slice terrain clip needs to be once the punch exists.)
3. `PView::DrawPortal` mode 3 (seal-on-failure) — who calls it; matters for
unstreamed interiors.
4. Window-type CBldPortals — do they flood (stab lists + GetVisible-able
targets)? Decides window treatment in P1/P4 gates.
5. `cdstW` near-W constant; `CBldPortal.sidedness` semantics; the
`DrawMesh` skipNoTexture else-branch latch — pin during P1/P2
implementation.
6. Dungeon same-volume overlap sweep — z-only indoor compositing assumes
non-overlapping cell volumes; one-off dat sweep before declaring P3 done.
## 7. Verification status + how to finish it
- 40/76 divergences adversarially verified (1 refuted, 11 adjusted with
corrected claims — the corrections are folded into this doc's ledger).
- Both workflows are resumable; a resume was launched for the remaining
verifiers, the transparency/sorting map, and both completeness critics
(run IDs `wf_475e012b-f74`, `wf_dd8381c7-c0a`). This doc's ledger should be
refreshed from the final JSONs when they land.

View file

@ -0,0 +1,242 @@
# HANDOFF — Holistic building/interior render port (the end of whack-a-mole)
**Date:** 2026-06-11. **Branch:** `claude/thirsty-goldberg-51bb9b` (worktree), HEAD `124c6cb`.
**Nothing is on main.** All session work is branch-only, per the user's explicit instruction.
## 0. The mandate (user, 2026-06-11, verbatim intent)
> "We can't go on like this. We need to solve this holistic once and for all. Not cottage
> by cottage or bug by bug. Check our code vs how retail draws buildings, interior,
> collision interior, dynamic objects, clipping and culling. I want one solution that
> works every time I walk to a new landblock and walk into a dungeon. […] map acdream's
> way vs retail, then make a plan how to port retail's way of doing it once and for all.
> We have come a long way and our code is worth saving."
This supersedes the per-issue grind (#105#113 and the §4 residual family). The next
session is an **investigation + plan** session: NO production code until the plan is
approved. The deliverables are (1) an acdream-vs-retail architecture comparison document
and (2) a phased port plan. Code is worth saving: this is a port-the-missing-architecture
plan, not a rewrite.
## 1. Branch state (what's in, what's off, how to verify)
| Commit | What | State |
|---|---|---|
| `927fd8f` | #113 fix 1: GL clip distances enabled for the PView shell pass | IN (scoped by 9ce335e) |
| `414c3de` | #112 rider: retail straddle gate for outdoor-cell admission (membership pick) | IN — solid, live-binary verified, conformance-pinned |
| `8259598` | docs: ISSUES updates | IN |
| `9ce335e` | #114 scope: shell clip outdoor-eye roots only (indoor regions not draw-quality) | IN |
| `6c9bbce` | docs: #114 + #115 filed | IN |
| `e46d3d9` | #113 fix 2: DrawingBSP poly filter in GfxObj mesh extraction | **UN-APPLIED by 124c6cb** (door regression); helper + tests remain |
| `124c6cb` | revert of the filter application; keeps `CollectDrawingBspPolygonIds` + dat pins | IN |
**Visible state when launched:** doors work; the phantom staircase on the Holtburg
meeting hall is VISIBLE again (known, documented — it is the un-filtered no-draw
geometry, see §2.3); indoor rendering is the pre-#113 state (unclipped); outdoor
interior-cell rendering is clipped to door apertures (the only part of the clip work
that was validated).
**Test baseline:** Core 1392 green + 4 pre-existing #99-era failures
(DoorBugTrajectoryReplay ×2, DoorCollisionApparatus, BSPStepUp) + 1 skip;
App 226; UI 420; Net 294. Gates: P1 membership goldens, CornerFloodReplayTests,
Issue107SpawnDiagnosticTests, Issue112MembershipTests (one renamed:
`...DemotesRetailFaithfully`; two new straddle-gate pins), Issue113 dump + filter pins.
## 2. What this session PROVED (evidence inventory — all reusable)
### 2.1 The retail draw-side portal clip exists and we half-have it
- Retail clips drawn **cell geometry** to the accumulated portal view:
`Render::set_view` (pc:343750) installs the view polygon's edge planes;
`DrawEnvCell` submits every cell polygon with `planeMask=0xffffffff` (pc:427922)
through `ACRender::polyClipFinish`. Characters/meshes are NOT poly-clipped
(viewcone-check path, `Render::viewconeCheck` + BoundingType handling in
`DrawMeshInternal`).
- Our equivalent (`UseShellClipRouting``mesh_modern.vert` `gl_ClipDistance`,
region SSBO binding=2 + per-instance slot binding=3) was **routed but inert since
birth** (`1405dd8`) — `GL_CLIP_DISTANCEi` was never enabled for the shell pass.
`927fd8f` enabled it; the **outdoor** case is validated
(`Issue113MeetingHallFloodTests`: per-cell regions are tight 46-plane door-aperture
boxes across a 21-step eye sweep, 0 unclipped fallbacks).
- **Indoor clip regions are admission-quality, NOT draw-quality** (#114): enabling the
clip indoors chopped real geometry (hall interior stairs, candle-holder area, walls
vanishing at exits; user screenshots in the session transcript 2026-06-11). Suspects:
knife-edge regions when the eye nears a portal plane; `MergeBuildingFrame`
first-view-wins dropping additional apertures for multi-portal cells; the >8-plane
slot-0 fallback drawing pass-all (the assembler's scissor fallback contract was never
implemented — `ClipFrameAssembler.cs:13-15`).
### 2.2 The membership outdoor-admission gate is now retail-exact
`414c3de` (KEEP THIS — it is correct and orthogonal to the render mess):
`CEnvCell::find_transit_cells` (live binary 0052c820, x87 decoded at 0052c8e5-0052c92d;
Ghidra decompile confirms char-for-char) admits outdoor cells IFF a path sphere
STRADDLES an exterior portal plane (`|dist| < radius + F_EPSILON(0.0002)`).
The membership PICK now gates on it; the collision cell SET keeps the A6.P5 topology
widening until A6.P4. Conformance-pinned on real dat geometry.
### 2.3 The REAL phantom staircase: no-draw (drawing-BSP-orphaned) polygons
Dat-proven (`Issue113PhantomStairsDumpTests.DumpHallModel_PolyFlagHistogram`):
- Retail renders a GfxObj by **traversing its DrawingBSP**; polygons present in the
`Polygons` dictionary but referenced by **no** DrawingBSP node are never drawn.
- Holtburg meeting hall `0x010014C3`: dictionary polys **{0,1}** are a ~5×11 m
stair-ramp spanning local z 0→8.5 — in the PhysicsBSP (ACE walks The Sentry on it at
z 117118; **invisible-but-walkable in retail**) but orphaned from the draw tree —
at **every** degrade level (LOD theory dead: `Degrades[0]` IS the base model).
- Hill cottage `0x01000827`: orphans **{0..7}**.
- Our `ObjectMeshManager.PrepareGfxObjMeshData` iterates the dictionary → draws the
collision skeleton: the wall staircase up close, the "flying stairs" over the
cottage roofline from afar (orphan ramp spans world (221232, 104..109, z 116124.5)).
- **The naive filter broke doors** (`e46d3d9` → un-applied `124c6cb`): filtering the
dictionary to the PosNode/NegNode-walked id set made doors vanish across Holtburg.
OPEN MYSTERY #1 (first diagnostic of the new session): run the histogram fact on a
door GfxObj — either DatReaderWriter's `DrawingBSPNode` exposes polys some other way
for those trees (portal-type nodes? `Portals` list? leaf indexing?), or the parse is
incomplete, or doors' visible polys genuinely live outside `node.Polygons`. The
correct retail shape is **BSP-traversal-order drawing**, not dictionary-with-filter —
the filter was a minimal approximation.
### 2.4 Earlier session facts that still stand
- A9B3 has ONE building (the hill cottage); the #112 gap is a real 20 cm doorway
micro-gap; all 17 cottage cells share one identical Position — "misplaced interior
cell" is refuted. The phantom-stairs building is the AAB3 meeting hall.
- Door alignment between independently-placed interior cells and the rotated shell is
exact (rotation conventions are right); the inn's server-placed door aligning with our
shell is the world-anchored confirmation.
- ACE patrol z-values are DB-authored, not physics-grounded — weak as a geometry oracle.
- `[resolve]` lines in our logs show our client grounding the elevated Sentry at terrain
z=116 vs the server's 117.2 — remote-entity grounding vs invisible-walkable geometry
(#41-family data point).
## 3. The architecture gap map (what the investigation must complete)
What we believe we know, per area — every row needs decomp-verified confirmation and a
"theirs vs ours" write-up. 🟢 = we have a verified port, 🟡 = partial/approximation,
🔴 = missing/unmapped.
| Area | Retail mechanism (anchors) | acdream today | Status |
|---|---|---|---|
| **Object draw (GfxObj)** | DrawingBSP traversal (`D3DPolyRender::ConstructMesh` 0x0059dfa0 noted in code; `CPhysicsPart::Draw`); per-poly planeMask clip; degrade table per part (`GfxObjDegradeResolver` doc) | Flattened dictionary mesh into global VAO + MDI; degrades only for humanoid setups (#47); no-draw orphans rendered | 🔴 core divergence |
| **Building shells** | `CBuildingObj` with `leaf_cells` (CPartCell) + portals; drawn via LScape → DrawSortCell → DrawBuilding; shell parts drawn per portal-view slot with `set_view` clip + viewconeCheck (pc:429282-429295) | One WorldEntity per BuildInfo, whole-model mesh, frustum cull only | 🔴 |
| **Interior cells (render)** | PView::DrawInside flood (ConstructView/ClipPortals/InitCell, pc:432896-433895) + per-cell portal_view slices + **per-poly clip to the view** | Flood ported (R-A1/A2/A2b + dac8f6a, conformance-gated); draw-side clip outdoor-only; indoor regions not draw-quality (#114) | 🟡 |
| **Statics in cells** | Drawn with their cell, per portal-view slot, viewcone-checked (`CEnvCell::draw` → object lists) | Per-cell buckets via flood (DrawCellObjectLists), unclipped, separate particle gating (particles not flood-gated the same way → flames through walls) | 🟡 |
| **Dynamic objects (doors, NPCs, items)** | CPhysicsObj part arrays; portal-view visibility checks; never hard poly-clipped | WbDrawDispatcher MDI, frustum + visibleCellIds routing | 🟡 (audit needed) |
| **Culling** | viewer-cell rooted; portal-clipped BFS is THE cull indoors; outdoor = LScape draw + building portal floods; viewconeCheck per mesh | Option A DrawInside is in; outdoor per-building floods (48 m seed); cell-particle scissor partial | 🟡 |
| **Interior collision** | Per-cell `shadow_object_list`, portal-aware registration (`add_shadows_to_cells`) | Landblock-wide `ShadowObjectRegistry` + `b3ce505` gate (workaround) → #99 | 🔴 (= A6.P4, already designed) |
| **Degrade/LOD selection** | Per-part current GfxObj chosen from degrade table by distance/quality | Base model everywhere except humanoid setups | 🔴 |
| **Cell-struct no-draw polys** | Same drawing-BSP rule presumably applies to CellStruct (cells have their own DrawingBSP) | Dictionary iteration (site 2, `ObjectMeshManager` ~:1343) | 🔴 unverified |
**The unifying theme:** retail has ONE drawing discipline — *BSP/portal-driven
traversal decides what is drawn, and the portal view clips what survives* — applied
uniformly to terrain peeks, shells, cells, statics, and meshes. We replaced traversal
with flattened-mesh iteration + a separately-bolted visibility filter, and every bug in
the #105#113 family is a place where the two disagree.
## 4. Open mysteries (carry into the investigation)
1. **Door vanish under the BSP filter** (§2.3) — first diagnostic, 15 min with the
existing dump harness. Identify a door GfxObj id via ACE weenie data or by clicking
a door and reading `[B.7] pick-info ... setup=` from the log.
2. **Indoor clip-region draw-quality** (#114) — knife-edge/multi-view/8-plane-fallback.
3. **Entry transparency at the hilltop cottage** (user: still intermittent) — may be
render (flood at entry) or membership at the threshold; needs a probe capture.
4. **Particles visible through walls** — particle pass is not gated/clipped like meshes.
5. **Camera drag/jitter in cramped interiors** (#115) — retail boom smoothing
(`SmartBox::update_viewer` region) unread.
## 5. Tooling inventory (everything the investigation needs is live)
- **Ghidra MCP, port 8081, CORRECT program** (patchmem 2013 v11.4186 + full PDB —
verified: `find_transit_cells @ 0052c820` matches). Endpoints:
`/decompile_function?address=0x...`, `/searchFunctions?query=...`,
`/function_xrefs?name=...`, `/list_functions`. This is the best decomp source —
Ghidra renders x87 correctly where BN pseudo-C invents branches (proven twice).
- **Named pseudo-C**: `docs/research/named-retail/acclient_2013_pseudo_c.txt` +
`acclient.h` (verbatim structs) + `symbols.json`.
- **Live cdb attach** (read-only disassembly protocol proven this session; static
`cdb -z uf` mis-decodes at OMAP boundaries — use live attach): see CLAUDE.md
"Retail debugger toolchain"; the user will launch the 2013 client on request.
- **Dat dump harness**: `tests/AcDream.Core.Tests/Conformance/Issue113PhantomStairsDumpTests.cs`
— buildings, cells, statics, portal planes, poly-flag histograms, DrawingBSP
orphan diff, degrade chains, top-down ASCII maps. Extend freely.
- **Flood replay harnesses**: `CornerFloodReplayTests` (indoor),
`Issue113MeetingHallFloodTests` (outdoor per-building flood + assembler).
- **Key decomp anchors already mined**: PView::InitCell :432896 / ClipPortals :433572 /
AddViewToPortals :433446 / ConstructView :433750+:433827 / DrawInside :433793 /
DrawPortal :433895; Render::set_view :343750; DrawEnvCell poly submit :427922;
DrawBuilding :429282; CEnvCell::find_transit_cells 0052c820;
CBuildingObj/CBldPortal/CCellPortal/portal_view_type structs in acclient.h
:31908/:32094/:32300/:32346.
## 6. Investigation charter (for the new session)
**Phase A — Map (workflows, parallel subagents).** One mapping agent per area in §3's
table, each producing "RETAIL: call-chain + data structures + exact gates (verbatim
decomp lines)" vs "ACDREAM: call-chain + data structures (file:line)" vs "DIVERGENCES:
ranked by user-visible blast radius". Adversarially verify each claimed divergence
(the BN-invents-branches lesson; the A6.P5 caller/callee conflation lesson). Areas:
1. GfxObj draw path (BSP traversal, no-draw, degrades, planeMask clip)
2. Building shells (CBuildingObj, leaf_cells, per-portal-view drawing)
3. Interior cells (PView slices → draw-side clip; what makes regions pixel-exact)
4. Statics + dynamics in cells (object lists, viewconeCheck, particles)
5. Culling end-to-end (LScape outdoor walk → building floods → indoor BFS)
6. Interior collision (per-cell shadow lists — fold in the existing A6.P4 design)
**Phase B — Plan.** One phased port plan with: an invariant ("one drawing discipline"),
per-phase acceptance criteria (conformance tests + which user-visible bugs each phase
closes: phantom geometry class, #114, #108, #109, doors, #99, particles-through-walls),
explicit keep-list (the flood port, the straddle gate, membership, streaming, bindless
MDI — the code worth saving), and a migration order that keeps the client playable
between phases. The plan goes to the user for approval BEFORE any production code.
**Ground rules:** investigation-first (no production edits); every retail claim needs a
decomp citation or live-binary proof; every acdream claim needs file:line; tests can
codify bugs — verify what call sites actually pass.
## 7. Paste-ready prompt for the new session
```
Pick up acdream as a SENIOR 3D ENGINE DEVELOPER for the HOLISTIC BUILDING-RENDER
INVESTIGATION (mandated by the user 2026-06-11: "solve this holistic once and for
all... map acdream's way vs retail, then make a plan how to port retail's way" —
no more cottage-by-cottage fixes). Worktree branch claude/thirsty-goldberg-51bb9b,
HEAD 124c6cb. NOTHING goes to main; no production code this session — the
deliverables are (1) an acdream-vs-retail architecture comparison and (2) a phased
port plan for user approval.
READ FIRST (in order):
1. docs/research/2026-06-11-building-render-holistic-port-handoff.md ← THE charter:
branch state, the full evidence inventory (orphan no-draw polys, the door-vanish
mystery, draw-side clip status, straddle gate), the gap map (§3), the open
mysteries (§4), tooling (§5), and the investigation phases (§6).
2. Memory digests: project_render_pipeline_digest + project_physics_collision_digest
(DO-NOT-RETRY tables apply).
3. docs/architecture/worldbuilder-inventory.md + docs/ISSUES.md (#113/#114/#115/#99).
DO:
- Phase A: ultracode Workflow fan-out — one mapping agent per area (GfxObj draw,
building shells, interior cells, statics/dynamics, culling, interior collision),
each delivering RETAIL (verbatim decomp, Ghidra MCP port 8081 is live with the
correct PDB; live cdb attach available on request — static cdb -z misdecodes)
vs ACDREAM (file:line) vs RANKED DIVERGENCES; adversarially verify divergences
(BN pseudo-C invents branches — proven twice; prefer Ghidra/live-binary).
- First 15-min diagnostic: why the DrawingBSP filter (e46d3d9, un-applied in
124c6cb) made DOORS vanish — run Issue113PhantomStairsDumpTests' histogram on a
door GfxObj (get the id from ACE weenie data or a [B.7] pick line).
- Phase B: write the phased port plan (one drawing discipline: BSP/portal-driven
traversal + portal-view clip), per-phase acceptance criteria naming which bugs
close (phantom-geometry class, #114 indoor crop, #108, #109, doors, #99,
particles-through-walls), an explicit keep-list (flood port, straddle gate,
membership, streaming, bindless MDI), and a migration order that keeps the
client playable. STOP for user approval before any implementation.
Baseline: Core 1392 + 4 pre-existing #99-era failures + 1 skip / App 226 / UI 420 /
Net 294. The branch state is honest: doors work, the phantom staircase is visible
again (documented), outdoor shell clip on, indoor clip off (#114).
```
## 8. Session artifacts (untracked, worktree root)
`issue113-user-screenshot-{1,2}.png` (the original gate pair, extracted from the
transcript), `issue113-fix-screenshot{1,2}.png` (post-clip pre-gate),
`issue113-bisect-*.png/log` (pre-session build bisect), `issue112-ftc-live-disasm.log`
(the live-binary disassembly of find_transit_cells — keep, it is the straddle-gate
proof), `issue113-user-gate{,2,3}.log` (the three gate launches).

View file

@ -0,0 +1,98 @@
# AREA 2 — Building shells (CBuildingObj / CBldPortal / DrawBuilding vs acdream's building-as-WorldEntity)
## RETAIL
DATA STRUCTURES. A building in retail is a real physics object woven into the cell graph, not a decoration. `CBuildingObj : CPhysicsObj` (acclient.h:31908) carries exactly three building-specific things: `num_portals + CBldPortal** portals` (the doorway/window list), `num_leaves + CPartCell** leaf_cells` (one optional object-holding bucket per drawing-BSP leaf), and a `shadow_list` of CShadowPart. It is built from the dat's `BuildInfo` (acclient.h:32035: model id, placement frame, num_leaves, num_portals, portals). `CBldPortal` (acclient.h:32094) = {int portal_side; uint other_cell_id; int other_portal_id; int exact_match; num_stabs + uint* stab_list; float sidedness}. The dat flag word decodes (CBldPortal::UnPack, Ghidra 0x0053bc40, confirmed against the binary): **bit 0 = exact_match**, **bit 1 INVERTED = portal_side** (`portal_side = (~flags >> 1) & 1`); then other_cell_id (low-16, OR'd with the landblock prefix), other_portal_id (signed; -1 = no matching cell portal), and a stab_list of cell ids — the precomputed set of interior cells potentially visible through this portal. Meanings, read from the code that consumes them: `portal_side` says which side of the portal polygon's plane you must stand on to see through it — PView::ConstructView(CBldPortal) (0x5a59a0, pc:433750-433792) computes the eye's signed distance to the portal plane (ε=0.0002) and requires NEGATIVE when portal_side!=0, POSITIVE when portal_side==0 (pc:433771-433788); wrong side → return 0, no flood, nothing drawn. `exact_match` says the two cells share the identical portal polygon, so the view needs no second clip — PView::ClipPortals (0x5a5520) at pc:433655 reads CCellPortal offset +0x14 (exact_match) and +0x10 (other_portal_id): `if (exact_match != 0 || other_portal_id < 0)` propagate the clipped view directly, else first call PView::OtherPortalClip (0x5a5400, pc:433521) to also clip against the far cell's own portal polygon. There is no "show/no-show" flag — visibility of a portal's surface is decided per frame by the view construction, never by static data. `CSortCell : CObjCell` (acclient.h:31880) adds one field: `CBuildingObj* building` — every outdoor land cell can host one building. `CEnvCell` (acclient.h:32072) carries `num_view + DArray<portal_view_type*> portal_view`: a STACK of view contexts; `portal_view_type` (acclient.h:32346) = {DArray<portal_info> portal; view_type view; max_indist; view_count; cell_view_done; timestamps}. A "view" is a screen-space convex polygon + bounding rect: Render::set_view (0x54d0e0, pc:343750-343765) installs `portal_view_num=slot`, the slot's clip polygon vertices and xmin/xmax/ymin/ymax — every subsequent polygon submission is clipped against it and every mesh is sphere-tested against it (viewconeCheck). That 2D-screen-clip is retail's entire portal-clipping mechanism (software, pre-rasterization — not hardware clip planes).
CONSTRUCTION + CELL-GRAPH MEMBERSHIP (Q4). CLandBlock::init_buildings (0x52fd80, pc:313855-313915): for each BuildInfo → CBuildingObj::makeBuilding(model_id, num_portals, portals, num_leaves) (0x6b53a0, pc:701293-701350: InitPartArrayObject builds the shell's CPhysicsPart from the GfxObj/Setup; leaf_cells allocated num_leaves entries ALL NULL; CBldPortal pointers copied) → set_initial_frame → CBuildingObj::add_to_cell(get_landcell(...)) (0x6b5550, pc:701398: CSortCell::add_building sets cell->building=this (0x534030, pc:318292); set_cell_id; this->cell=cell) → CBuildingObj::add_to_stablist accumulates every portal's stab_list into the landblock's stablist (0x6b51b0, pc:701185). From then on the building participates in EVERYTHING through its host cell: CSortCell::find_transit_cells (0x534060, pc:318309) → CBuildingObj::find_building_transit_cells (0x6b5230, pc:701214) → per portal CBldPortal::GetOtherCell → CEnvCell::check_building_transit(other_portal_id, …) (0x52c5d0, pc:309827-309860: gate `other_portal_id >= 0`, then each sphere transformed to the cell's local frame and tested with CCellStruct::sphere_intersects_cell; hit → sphere_path.hits_interior_cell=1 + CELLARRAY::add_cell) — outdoor→indoor membership promotion. CSortCell::find_collisions (0x5340a0, pc:318337) → CBuildingObj::find_building_collisions (0x6b5300, pc:701260: sets sphere_path.bldg_check=1, runs the shell part's PHYSICS BSP). CSortCell::get_object even searches INTO the interior cells recursively through the building's portals (0x5340c0, pc:318346 → CEnvCell::recursively_get_object, pc:701421). leaf_cells: the render hook RenderDeviceD3D::DrawBuildingLeaf (0x5a07e0, pc:429223-429240) draws CBuildingObj::curr_leaf_cells[i] via DrawPartCell with pushLevelOffset=1 — but Ghidra xrefs to the curr_leaf_cells global (0x8fa9bc) show READS ONLY, no writer found; the per-leaf object-bucket path appears dormant in the 2013 build.
DRAW CHAIN FROM OUTSIDE (Q2). Top of frame: SmartBox::RenderNormalMode (0x453aa0, pc:92636-92690) — viewer in an EnvCell → render_device->DrawInside(viewer_cell) (pc:92675); viewer outdoors → Render::set_default_view() (full-screen view, no PortalList) + LScape::draw (pc:92683). LScape::draw (0x506330, pc:267912-267950): GameSky::Draw(0), then per landblock render_device->DrawBlock (pc:267937). RenderDeviceD3D::DrawBlock (0x5a17c0, pc:430027-430143): per land cell in draw_array, if in view: DrawLandCell (terrain polys) then **DrawSortCell** (pc:430124). RenderDeviceD3D::DrawSortCell (0x59f140, pc:427872-427884): `if (cell->building) DrawBuilding(building); DrawObjCell(cell)` — a building draws exactly when its host land cell draws. RenderDeviceD3D::DrawBuilding (0x59f2a0, pc:427938-427961) does four things: (1) hands the building's CBldPortal array to the outdoor PView: `outdoor_pview->outdoor_portal_list = building->portals` (pc:427940) — this is the lookup table the shell's portal polys will index; (2) sets building detail surface + FlushAlphaList; (3) **portal pass**: CPhysicsPart::Draw(part0, 1) (pc:427955); (4) **mesh pass**: ObjBuildingOrBuildingPart=1; CPhysicsPart::Draw(part0, 0) (pc:427957). Both passes funnel through RenderDeviceD3D::DrawMesh (0x5a0860, pc:429247-429320), which is where the "portal-view slot" machinery lives: if Render::PortalList is null (plain outdoor view) → one viewconeCheck(drawing_sphere) + one DrawMeshInternal; if PortalList is non-null (e.g. the landscape is being drawn through doorway views from an indoor root) → **loop i over PortalList->view_count: if (building_view == -1 || building_view == i) { Render::set_view(&PortalList->view, i); viewconeCheck; DrawMeshInternal }** (pc:429290-429310) — the shell is drawn once per surviving view slot, clipped to that slot's screen polygon. `building_view` is a latch: DrawMeshInternal's portal pass sets it to the current slot while walking the shell BSP (pc:427988), and RenderDeviceD3D::DrawPortal saves/sets-(-1)/restores it around the nested flood (0x59f0e0, pc:427906-427914) — binding each building's flood to the view slot it was discovered under. DrawMeshInternal (0x59f360, pc:427966-428000): portal pass → BSPTREE::build_draw_portals_only(drawing_bsp, 1) then (drawing_bsp, 2) (pc:427993-427994); mesh pass → D3DPolyRender::DrawMesh(constructed_mesh) — the whole prebuilt shell mesh (the BSP was pruned to portal-only nodes at load by CGfxObj::InitLoad → BSPTREE::RemoveNonPortalNodes, 0x5346b0, pc:318766-318787).
PORTAL POLYS AND THE INDEX CORRESPONDENCE (Q3). The drawing BSP's portal nodes are `BSPPORTAL : BSPNODE` = {num_portals; CPortalPoly** in_portals} (acclient.h:57768); `CPortalPoly` = {unsigned portal_index; CPolygon* portal} (acclient.h:39075). BSPPORTAL::UnPackPortal (0x53db70, pc:327167-327256) shows the dat wire format exactly matching the e223325 finding: per portal node, num_polys ordinary polys (node.Polygons) + num_portals pairs, each pair = {portal = &polygons[PolyId], portal_index = PortalIndex}. build_draw_portals_only (Ghidra 0x53c100) walks the BSP front-to-back by the viewpoint's side of each splitting plane; at each PORT node BSPPORTAL::portal_draw_portals_only (0x53d870, pc:326881-327058) calls render_device->DrawPortal(in_portals[i], 1, pass) for every portal poly (pc:326921). PView::DrawPortal (0x5a5ab0, pc:433895-433940) resolves **CBldPortal* bld = this->outdoor_portal_list[portalPoly->portal_index]** (pc:433920) — THE index correspondence: the GfxObj's PortalRef.PortalIndex indexes the BuildInfo.Portals/CBldPortal array of the building instance being drawn. It then PView::add_views(bld->num_stabs, bld->stab_list) (pc:433922 — pushes a fresh portal_view slot onto every stab cell via curr_view_push; 0x5a5210, pc:433382), calls ConstructView(bld, poly, 1, pass) (pc:433924), and on success with pass==2 calls PView::DrawCells (pc:433934); finally remove_views pops the slots. ConstructView(CBldPortal) (0x5a59a0): portal_side gate → PView::GetClip (clip the portal polygon against the CURRENT view; empty → fail) → CEnvCell::GetVisible(other_cell_id) (0x52dc10, pc:311378 — hash lookup of loaded+visible cells; not loaded → fail) → Render::copy_view(targetCell->portal_view[top], clip) → if pass != 2: D3DPolyRender::DrawPortalPolyInternal(poly, pass==1); if pass != 1: recursive ConstructView(CEnvCell) (0x5a57b0, pc:433749 — the BFS flood: reset outside_view, InitCell, ClipPortals/AddViewToPortals loop building cell_draw_list). So pass 1 = construct views + portal-poly mask; pass 2 = flood + draw interior cells. **What DrawPortalPolyInternal actually draws (Ghidra 0x59bc90, decisive):** an UNTEXTURED triangle fan (SetStageTexture null) whose vertex alpha byte computes to 0x00 for both config values (maxZ1=7, maxZ2=6: `~(maxZ<<30) & 0x80000000` = 0) — i.e. an INVISIBLE quad — drawn DEPTHTEST_ALWAYS with depth-write on (bit 2), at the FAR plane z=0.999999 when flag=1 (maxZ1 bit 0) or at the polygon's real depth when flag=0 (maxZ2). It is a depth punch/seal, not a visible door. It also skips polys whose vertices all lie on a ±12 m cell-boundary plane (dungeon seam portals). portalsDrawnCount increments only for flag=0; PView::DrawCells (0x5a4840, pc:432706-432800) uses it to gate a depth clear: PortalList=&outside_view → LScape::draw (the whole landscape re-drawn through the accumulated doorway views, pc:432719) → Clear(depth) if any portal was drawn (pc:432730) → reverse (far-to-near) per-cell loop 1: per view slot setup_view + DrawPortalPolyInternal(poly, 0) for every cell portal with other_cell_id==0xFFFFFFFF (leads outside) (pc:432783-432786) — re-sealing doorway depth — → loop 2: cell shells + contents per slot.
FLOOD DECISION FROM OUTSIDE (Q5). Retail floods a building's interior exactly when, during the normal draw of the building's shell, the drawing-BSP portal walk reaches a portal poly AND PView::ConstructView succeeds: (a) eye on the portal_side-correct side of the portal plane; (b) the portal polygon clipped against the CURRENT view (full screen outdoors; a doorway slot view if the building is itself seen through a doorway) is non-empty — this is the only "distance" gate retail has (far buildings fail viewconeCheck or clip to nothing sub-pixel… they never fail by a distance constant); (c) CEnvCell::GetVisible(other_cell_id) — target cell loaded. The flood then BFSes the interior cell graph (ConstructView(CEnvCell) → ClipPortals/AddViewToPortals) and DrawCells draws it. Note the outdoor PView was constructed with draw_landscape=0 (PView ctor 0x5a5270 pc:433413-433444; outdoor_pview=PView(0) pc:427813, indoor_pview=PView(1) pc:427800), so floods INTO buildings never accumulate outside views — you don't recursively re-draw the landscape through a building's back door; only the indoor pview (rooted at the viewer's cell) draws landscape through portals.
## ACDREAM
SPAWN / DATA MODEL. One WorldEntity per BuildInfo with the whole shell model as a flat mesh: LandblockLoader.BuildEntitiesFromInfo (src/AcDream.Core/World/LandblockLoader.cs:75-92) creates `WorldEntity { SourceGfxObjOrSetupId = building.ModelId, IsBuildingShell = true, BuildingShellAnchorCellId = first non-0xFFFF portal cell }` with id 0xC0XXYY00+n; ParentCellId stays null. The BuildInfo.Portals list is consumed in three other places, never by the shell draw: (1) GameWindow.cs:5959-5996 caches `BldPortalInfo{otherCellId, otherPortalId, flags}` per host landcell via PhysicsDataCache.CacheBuilding (src/AcDream.Core/Physics/PhysicsDataCache.cs:433) for physics; (2) BuildingLoader.Build (src/AcDream.App/Rendering/Wb/BuildingLoader.cs:53-140) BFSes cell portals to derive each Building's EnvCellIds + exit-portal polygons + occlusion-query state (src/AcDream.App/Rendering/Wb/Building.cs), registered per landblock at GameWindow.cs:6001-6045; (3) the anchor cell id. Interior cells are hydrated by GameWindow.BuildInteriorEntitiesForStreaming (GameWindow.cs:5488+): per EnvCell, CellMesh.Build + EnvCellRenderer.RegisterCell; cell statics become 0x40xxxxxx WorldEntities.
SHELL MESH EXTRACTION. ObjectMeshManager.PrepareGfxObjMeshData (src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1040-1056) iterates the GfxObj `Polygons` DICTIONARY — every poly, including the portal-fill door/window quads and the meeting-hall stair-aperture quads (e223325: every dictionary poly absent from node.Polygons is a portal poly). The #113 DrawingBSP filter is explicitly NOT applied (comment at ObjectMeshManager.cs:1019-1040; e46d3d9 reverted by 124c6cb after the door regression); CollectDrawingBspPolygonIds (ObjectMeshManager.cs:1004-1010) walks only node.Polygons and never reads node.Portals. Cell-struct meshes by contrast suppress portal polys via dat NoPos/NoNeg stippling (ObjectMeshManager.cs:1385-1400).
DRAW PATH. clipRoot = viewerRoot ?? _outdoorNode (GameWindow.cs:7497; OutdoorCellNode.cs builds a portal-less synthetic outdoor cell). RetailPViewRenderer.DrawInside (src/AcDream.App/Rendering/RetailPViewRenderer.cs:44-113): PortalVisibilityBuilder.Build floods from the root; for outdoor roots MergeNearbyBuildingFloods (lines 115-149) groups nearby cells by BuildingId and runs PortalVisibilityBuilder.ConstructViewBuilding per building with OutdoorBuildingSeedDistance = 48f (line 30); the candidate cells come from a Chebyshev<=1 landblock gather (GameWindow.cs:7461-7477). ConstructViewBuilding = BuildFromExterior (PortalVisibilityBuilder.cs:390-538, 548-554): seeds from every CELL exit portal (OtherCellId==0xFFFF) whose nearest vertex is within 48m and whose outside face the camera is on, clips the portal polygon in screen space (ClipPortalAgainstView), then BFSes inward with reciprocal clipping (ApplyReciprocalClip skips when dat flag bit 0 ExactMatch is set, lines 775-789; side test via plane.InsideSide, line 740). Draw order in DrawInside: DrawLandscapeThroughOutsideView (lines 214-241: per OutsideView slice → SetTerrainClip + SetClipRouting(slot) + DrawRetailPViewLandscapeSlice — GameWindow.cs:9465-9552: scissor to slice NDC AABB, sky/terrain/outdoor entities incl. building shells with gl_ClipDistance enabled, visibleCellIds:null so shells pass on frustum only); DrawExitPortalMasks (325-343, depth-seal port of DrawCells loop 1); DrawEnvCellShells (345-400: reverse far-to-near, per cell per slice, gl_ClipDistance enabled ONLY for outdoor-eye roots — clipShells gate, #114 scope per 9ce335e); DrawCellObjectLists (401-427: cell statics drawn UNCLIPPED by design; particles per slice via scissor-rect only, GameWindow.cs:9555-9582). Shells draw through WbDrawDispatcher (src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:592-685): landblock AABB + per-entity AABB frustum cull; EntityPassesVisibleCellGate (1816-1837) makes shells (ParentCellId null) FAIL any cell filter — they draw only in the visibleCellIds:null landscape pass. InteriorEntityPartition (src/AcDream.App/Rendering/InteriorEntityPartition.cs) routes shells to the Outdoor bucket. For INDOOR roots, NearbyBuildingCells is null (GameWindow.cs:7610) — no other building's interior is ever flooded from inside; ClearDepthSlice is null for outdoor roots by design (GameWindow.cs:7644-7654). DrawPortal (RetailPViewRenderer.cs:162-212) is a separate outdoor look-in product driven by _exteriorPortalCandidateCells (GameWindow.cs:7778).
PHYSICS. CellTransit.CheckBuildingTransit (src/AcDream.Core/Physics/CellTransit.cs:353-398): per cached BldPortalInfo, foot-sphere vs the target cell's WHOLE CellBSP (BSPQuery.SphereIntersectsCellBsp), invoked from the ResolveCellId path (CellTransit.cs:633). Shell collision comes from per-part ShadowObjectRegistry registration of the 0xC0-prefixed shell entity (GameWindow.cs:6122-6135 + Task-7 loop), a landblock-wide registry rather than retail's cell→building dispatch (the known A6.P4 debt).
## DIVERGENCES
### [CRITICAL] portal-polys-baked-unconditional (UNVERIFIED (verifier hit token limit)) — Shell portal polys (doors/windows/apertures) baked into the static mesh and drawn unconditionally; PortalRef.PortalIndex → CBldPortal pairing never consumed
- blastRadius: #113 phantom staircase (the meeting-hall stair-aperture portal quads {0,1} draw as visible ramps); the e46d3d9 door regression in BOTH directions (filter on → doors vanish because the visible 'door' IS the baked portal quad; filter off → phantom apertures return) — proves no static filter can be correct; predicted artifact: a server door swinging open still leaves the baked fill quad sealing the doorway behind it (double-representation of doors); interiors can never become visible through window apertures.
- retailEvidence: Portal polys live only on BSPPORTAL nodes as CPortalPoly{portal_index, portal} (acclient.h:57768, :39075; BSPPORTAL::UnPackPortal 0x53db70 pc:327167-327256 maps dat PortalRef {PolyId, PortalIndex} onto them). They are dispatched PER FRAME: build_draw_portals_only (Ghidra 0x53c100) → portal_draw_portals_only (0x53d870 pc:326921) → DrawPortal → PView::DrawPortal resolves outdoor_portal_list[portal_index] = the building's CBldPortal (0x5a5ab0 pc:433920) → ConstructView decides per frame (portal_side gate pc:433771-433788, screen clip, GetVisible) whether to flood the interior (pass 2 → DrawCells pc:433934) — and the only direct portal-poly draw is DrawPortalPolyInternal (Ghidra 0x59bc90), an INVISIBLE (alpha=0) depth-punch/seal quad, never a textured surface.
- acdreamEvidence: ObjectMeshManager.PrepareGfxObjMeshData iterates the whole Polygons dictionary into the static mesh (ObjectMeshManager.cs:1040-1056); the DrawingBSP filter is documented as NOT applied (ObjectMeshManager.cs:1019-1040); node.Portals (PortalRef) is never read (CollectDrawingBspPolygonIds walks only node.Polygons, ObjectMeshManager.cs:1004-1010). BuildInfo.Portals is consumed only by physics + BuildingLoader (GameWindow.cs:5959-5996, BuildingLoader.cs:53-140), never paired with the shell GfxObj's PortalIndex.
- portShape: Split GfxObj mesh extraction into (a) the unconditional set = union of node.Polygons across the DrawingBSP and (b) one separately-batched aperture quad per PortalRef, keyed by PortalIndex. At draw time pair PortalIndex with the owning BuildInfo.Portals[i] (the retail outdoor_portal_list correspondence) and run the retail decision per frame: ConstructView-success → suppress the quad's textured draw, flood + draw the interior, write the far-Z punch; failure → seal (depth and/or the quad — pending the open question on whether retail ever draws the textured fill). The swinging-door entity remains the visible door.
### [CRITICAL] no-per-slot-building-draw (UNVERIFIED (verifier hit token limit)) — Building never draws per portal-view slot and never spawns floods from its own shell; one frustum-culled draw + geometric 48m seeding instead
- blastRadius: #109 far-door oscillation (acdream's 48m seed-distance constant + per-frame BFS replaces retail's screen-clip-survival gate — a binary distance threshold the eye crosses); looking out of one building at another building's open doorway shows the baked fill instead of its interior (indoor roots pass NearbyBuildingCells=null); #114 indoor clip-region quality (acdream's slot system is a flat per-frame slice table, not retail's per-cell portal_view STACK pushed/popped around each building flood via stab lists).
- retailEvidence: RenderDeviceD3D::DrawMesh loops every view slot with set_view + viewconeCheck and the building_view latch (0x5a0860 pc:429290-429310); DrawMeshInternal binds the shell's portal walk to the current slot (building_view = portal_view_num, pc:427988); PView::DrawPortal pushes a fresh portal_view slot onto every stab cell before flooding and pops after (add_views/remove_views 0x5a5210 pc:433382, pc:433922/433939); the flood trigger is the shell's OWN drawing-BSP portal walk under whatever view the shell is being drawn in (DrawBuilding pass 1, 0x59f2a0 pc:427955 → pc:427993-427994).
- acdreamEvidence: Shells draw once via WbDrawDispatcher frustum cull in the landscape slice (DrawRetailPViewLandscapeSlice GameWindow.cs:9503-9512, visibleCellIds:null; gate at WbDrawDispatcher.cs:1816-1837); per-building floods are seeded geometrically: every exit portal within OutdoorBuildingSeedDistance=48f of the eye, outdoor roots only (RetailPViewRenderer.cs:30,115-149; PortalVisibilityBuilder.cs:404-451; NearbyBuildingCells=null for interior roots at GameWindow.cs:7610).
- portShape: Make the building's shell draw the flood trigger: when the shell entity survives the cull for a given view slice, walk its (restored, portal-only) DrawingBSP portal list under that slice's clip region; each surviving aperture launches ConstructView(CBldPortal) for that slice, replacing the 48m constant with retail's screen-clip gate. Give cells a portal_view slot stack (push via the CBldPortal stab_list before each building flood, pop after) so the same cell seen in two contexts holds two views, and bind each flood to its originating slot (the building_view latch).
### [HIGH] flood-gate-shape (adjusted) — Flood admission gate: distance constant + camera-side heuristic vs retail's portal_side + screen-clip + GetVisible chain
- correctedClaim: Flood admission gate (corrected): retail admits a building-interior flood ONLY through the draw path — building must survive per-view viewconeCheck on its drawing_sphere (RenderDeviceD3D::DrawMesh 0x5a0860) before its portal-BSP walk (BSPPORTAL::portal_draw_portals_only 0x53d870) ever submits a portal; then ConstructView(CBldPortal) 0x5a59a0 gates on eye-side vs portal_side (eps=0.0002), GetClip vs the current view polygon (empty = no flood, NO exceptions), CEnvCell::GetVisible (loaded-cell hash 0x52dc10), and copy_view!=0; no distance constant exists anywhere on the chain. acdream already implements faithful analogues of the side test (CameraOnInteriorSide, PortalVisibilityBuilder.cs:423-424/734-741, eps=0.01), the seed-time screen clip (:430-435), and the loaded-cell check (:510-512); the REAL divergences are (1) an added 48m hard seed threshold with no retail counterpart (RetailPViewRenderer.cs:30/141-142, GameWindow.cs:7795) producing binary interior pop at ~48m — the plausible (unproven-at-48m) #109 mechanism; (2) no per-building view-cone pre-gate, costing per-portal arithmetic over Chebyshev<=1 landblocks of candidates (GameWindow.cs:7461-7477) but NOT extra BFS floods (empty seed clip already rejects behind-the-viewer portals); and (3) the EyeInsidePortalOpening full-screen fallback (:437-442/:501-507) which ADMITS floods retail's empty-clip rule strictly rejects — a non-retail bypass the port must retire by fixing near-eye clip degeneracy natively. Port shape stands: per-building portal walk under the active view inherits cone+clip gating, drop the 48m constant, keep ClipPortalAgainstView/ApplyReciprocalClip incl. the exact_match skip (:777-789), and remove the eye-standing bypass. Severity: high (confirmed visible pop mechanism; #109 linkage plausible, #108 speculative).
- verifier notes: RETAIL SIDE — re-derived from Ghidra decompiles (not BN pseudo-C), all claimed gates CONFIRMED:
(1) PView::ConstructView(CBldPortal*) @ 0x5a59a0 (Ghidra): eye-side of the portal polygon's plane computed against Render::FrameCurrent->viewer.viewpoint with epsilon ::F_EPSILON = 0.000199999995 (~0.0002, BN pc:433848 shows the literal); portal_side==0 requires POSITIVE side, portal_side!=0 requires NEGATIVE — exactly as claimed. Then GetClip(side, portalPoly, clip_view, &outPoly, viewIdx); outPoly==null (empty clip) -> return 0, NO flood. Then CEnvCell::GetVisible(other_cell_id) must be non-null; 0x52dc10 (Ghidra) confirms GetVisible is a pure hash lookup in visible_cell_table, i.e. "cell loaded/registered". Then Render::copy_view must return nonzero (sub-pixel/duplicate view rejection) before recursing into ConstructView(CEnvCell) @ 0x5a57b0. NO distance constant anywhere.
(2) Upstream chain confirmed: RenderDeviceD3D::DrawBuilding @ 0x59f2a0 (Ghidra) sets outdoor_pview->outdoor_portal_list = building->portals and draws via CPhysicsPart::Draw; portal polys are submitted by the building's portal-BSP walk BSPPORTAL::portal_draw_portals_only @ 0x53d870 (Ghidra: plane-side recursion, render_device vtable+0x4c per in_portal, F_EPSILON only, no distance) -> RenderDeviceD3D::DrawPortal @ 0x59f0e0 -> PView::DrawPortal @ 0x5a5ab0 (xref-verified) -> ConstructView(CBldPortal). Citation nit: 0x5a0860 is RenderDeviceD3D::DrawMesh (not "DrawBuilding"); it DOES gate per active view with Render::viewconeCheck(gfxobj->drawing_sphere) (Ghidra, both PortalList==0 and per-view loop branches), so far/off-screen buildings never draw and their portal polys are never encountered — far-building dropout = view-cone + clip-to-nothing, never a distance constant. Claim materially right, symbol name wrong.
ACDREAM SIDE — production path confirmed by reading code:
GameWindow.cs:7459-7478 gathers candidate cells from Chebyshev<=1 landblocks into _outdoorNodeBuildingCells; :7610 passes it as NearbyBuildingCells only on outdoor-node frames; RetailPViewRenderer.cs:60,115-145 groups by BuildingId and calls ConstructViewBuilding(group, ..., OutdoorBuildingSeedDistance) with the constant 48f at RetailPViewRenderer.cs:30 (second call site: GameWindow.cs:7795 MaxSeedDistance=48f -> RetailPViewRenderer.cs:166-171). ConstructViewBuilding (PortalVisibilityBuilder.cs:548-554) = BuildFromExterior; per exit portal the seed gate is: CameraOnInteriorSide skip (:423-424; impl :734-741, cell-local plane + InsideSide + PortalSideEpsilon=0.01f at :38), NearestPortalVertexDistance > maxSeedDistance skip (:426-428), THEN ClipPortalAgainstView vs FullScreenRegion (:430-435) with empty-clip -> skip UNLESS EyeInsidePortalOpening, which substitutes a FULL-SCREEN region (:437-442; same bypass on inner hops :501-507). Inner-hop loaded check lookup(neighbourId)==null -> skip (:510-512).
JUDGMENT — the divergence is REAL but the claim's framing of acdream is wrong in three load-bearing ways:
(a) acdream's seed gate is NOT "distance + camera-side heuristic" in opposition to retail's "portal_side + screen-clip + GetVisible": acdream ALREADY has all three retail gates at seed time — CameraOnInteriorSide IS the portal_side analogue (epsilon 0.01 vs retail 0.0002, both cell-local plane-side tests), the screen clip runs AT the seed (:430-435, not merely "once seeded"), and the loaded-cell check is the GetVisible analogue. The genuine deltas are narrower: (i) the ADDED 48m hard threshold with no retail counterpart; (ii) the MISSING upstream per-building view-cone rejection (retail kills whole buildings on a sphere-vs-cone test before touching any portal); (iii) a delta the claim missed that cuts the other way: the EyeInsidePortalOpening full-screen fallback (:437-442, :501-507, EyeStandingPerpDist=1.75m at :869) ADMITS floods retail strictly rejects (retail returns 0 on empty clip, no bypass) — a non-retail workaround for near-eye projection degeneracy that retail's homogeneous GetClip handles natively.
(b) Blast radius "excess flood work for buildings behind the viewer ... before any BFS" is overstated: behind-the-viewer portals project to <3 clip verts -> empty region -> the seed is SKIPPED (:430-442), so no BFS flood occurs; the excess is per-portal side-test/distance/projection arithmetic across up to 9 landblocks of candidate cells, plus the eye-standing bypass edge case. Retail rejects per-building, acdream per-portal — a cost divergence, not an admission-set divergence for that case.
(c) #109: the 48m gate is a strict binary pop (seed admitted iff dist<=48 at :427, view grown-once at :445-448), and retail's equivalent is a smooth clip shrink + cone fade — the popping MECHANISM is confirmed; but nothing in the evidence ties #109's observed oscillation distance to 48m specifically (no repro measurement examined), and the "grazing angles" sub-claim is weak because both implementations run the same clip at grazing angles (the only residual is the 0.01-vs-0.0002 epsilon band, ~1cm at the portal plane). #108 downstream linkage remains speculative as the claim itself says.
Port-shape check: sound — under a per-building portal walk driven by the active view, the distance constant becomes droppable and cone/clip gating is inherited; exact_match skip exists at PortalVisibilityBuilder.cs:777-789 (code comment cites decomp:433689; claim's pc:433655 is the ClipPortals gate a few lines up — same mechanism, not load-bearing). Caveat to add: the port must ALSO retire the EyeInsidePortalOpening full-screen bypass (retail: empty clip = no flood, period) by making the seed clipper robust at near-eye degeneracy, or the one-drawing-discipline invariant is still violated from the other side.
- blastRadius: #109 (oscillation exactly at the 48m boundary and at grazing angles where retail's clip would shrink smoothly to nothing); #108 grass-sweep plausibly downstream (outside-slice set changes as floods pop in/out, re-routing the terrain clip); excess flood work for buildings behind the viewer that retail's view clip rejects before any BFS.
- retailEvidence: ConstructView(CBldPortal) 0x5a59a0 pc:433750-433792: (a) eye side vs portal_side (ε=0.0002), (b) GetClip against the CURRENT view polygon — empty clip = no flood; no distance constant exists, (c) CEnvCell::GetVisible (0x52dc10 pc:311378) = cell loaded/registered. Far buildings drop out via viewconeCheck on drawing_sphere (0x5a0860 pc:429297) and sub-pixel clips.
- acdreamEvidence: BuildFromExterior seeds on NearestPortalVertexDistance <= maxSeedDistance (48m) + CameraOnInteriorSide outside-face test (PortalVisibilityBuilder.cs:425-429,404-410); candidate gather is Chebyshev<=1 landblocks (GameWindow.cs:7461-7477). The screen-space ClipPortalAgainstView IS faithful once seeded (PortalVisibilityBuilder.cs:430-445).
- portShape: Drop the distance constant once divergence #2 lands (the trigger becomes the shell's portal walk under the active view, which inherits retail's frustum/clip gating for free); keep the existing ClipPortalAgainstView/ApplyReciprocalClip machinery — it already matches GetClip/OtherPortalClip incl. the exact_match skip (PortalVisibilityBuilder.cs:775-789 vs pc:433655).
### [HIGH] aperture-depth-machinery (UNVERIFIED (verifier hit token limit)) — Missing far-Z punch for outdoor building apertures; particles clipped by scissor-rect only
- blastRadius: particles-through-walls (cell particles are gated by an axis-aligned scissor rectangle, not the portal view polygon — emitters near a doorway bleed outside the aperture's true shape); ordering fragility at apertures: acdream relies on shell-then-interior draw order + depth test, retail explicitly punches far-Z through each visible aperture (pass 1) before the interior draws and re-seals indoor doorways at real Z (DrawCells loop 1) with a portal-gated depth clear in between.
- retailEvidence: DrawPortalPolyInternal Ghidra 0x59bc90: alpha=0 fan, DEPTHTEST_ALWAYS, depth-write on, z=far when flag=1 (building pass 1, maxZ1=7) / real z when flag=0 (indoor seal, maxZ2=6); DrawCells 0x5a4840: LScape::draw through outside_view (pc:432719) → Clear(depth) gated on portalsDrawnCount (pc:432730) → per-slot outside-portal seal draws (pc:432783-432786).
- acdreamEvidence: DrawExitPortalMasks ports the indoor seal (RetailPViewRenderer.cs:325-343); ClearDepthSlice is intentionally null for outdoor roots (GameWindow.cs:7644-7654); there is no outdoor-pass far-Z punch anywhere; DrawRetailPViewCellParticles uses BeginDoorwayScissor(NdcAabb) only (GameWindow.cs:9555-9582).
- portShape: Once aperture quads are separate batches (#1), emit them as depth-only far-Z punches on flood success (flag=1 semantics) and real-Z seals on the indoor path (flag=0), matching maxZ1/maxZ2 bit semantics; route particle draws through the same per-slice clip planes the shells use (gl_ClipDistance), not the scissor AABB.
### [MEDIUM] building-not-in-physics-cell-graph (confirmed) — Building is not a cell-graph member on the physics side: landblock-wide shadow registry + portal-id gate missing in CheckBuildingTransit
- verifier notes: RETAIL SIDE — re-derived from Ghidra decompiles (not BN pseudo-C) for every branch/sign-sensitive point:
1. Per-cell building dispatch: CSortCell::add_building (Ghidra 0x534030) stores the building on the cell with first-wins semantics (`if (this->building == 0)`); CSortCell::find_transit_cells (0x534060 sphere variant, 0x534080 part variant) tail-calls CBuildingObj::find_building_transit_cells; CSortCell::find_collisions (0x5340a0) tail-calls CBuildingObj::find_building_collisions (returns OK_TS when no building); CSortCell::get_object (0x5340c0) falls back to CBuildingObj::get_object. All five Ghidra-decompiled; the cited pc:318309-318362 window matches. CLandBlock::init_buildings (Ghidra 0x52fd80) confirms registration: makeBuilding → adjust_to_outside on the building frame origin → get_landcell → CBuildingObj::add_to_cell(building, (CSortCell*)landcell) AND CBuildingObj::add_to_stablist(&this->stablist, ...). Both registration claims check out.
2. The portal-id gate: CEnvCell::check_building_transit (Ghidra 0x52c5d0) opens with `if ((-1 < param_1) && ...)` — i.e. skip when other_portal_id < 0 then per-sphere Frame::globaltolocal + CCellStruct::sphere_intersects_cell(this->structure, ...) and on overlap sets SPHEREPATH::hits_interior_cell=1 + CELLARRAY::add_cell. The caller CBuildingObj::find_building_transit_cells (Ghidra 0x6b5230) confirms the gated argument IS `portal->other_portal_id` (via CBldPortal::GetOtherCell null-check first). CBldPortal.other_portal_id is signed `int` (acclient.h:32100); the ctor (Ghidra 0x53bb30) initializes it to -1.
3. NEW STRENGTHENING EVIDENCE the claim didn't have: CBldPortal::UnPack (Ghidra 0x53bc40) reads the dat's 16-bit field as a SIGNED short and sign-extends: `sVar3 = *(short *)*param_2; this->other_portal_id = (int)sVar3;` — so a dat value of 0xFFFF becomes -1 at runtime and the >= 0 gate is LIVE for dat content, not just for default-constructed portals. Notably the BN pseudo-C (pc:325027) renders this read as `*(uint16_t*)` (zero-extend), which would have implied the gate is dead code for dat data — exactly the invented-sign failure mode this project has been burned by. The Ghidra decompile settles it in favor of the claim. Same sign-extension in CCellPortal::UnPack (Ghidra 0x53bab0). CCellStruct::sphere_intersects_cell (pc:317666 region, 0x533900) tail-calls BSPTREE::sphere_intersects_cell_bsp(this->cell_bsp) — so acdream's CellBSP-based test is the matching half; the gate is the only in-function divergence.
ACDREAM SIDE — read at the cited locations plus production call sites:
4. CellTransit.CheckBuildingTransit (src/AcDream.Core/Physics/CellTransit.cs:353-398): `foreach (var portal in building.Portals)` with NO portal-id test of any kind; skips only on `GetCellStruct(portal.OtherCellId)?.CellBSP?.Root is null`; tests BSPQuery.SphereIntersectsCellBsp(otherCell.CellBSP.Root, localCenter, sphereRadius). BldPortalInfo.OtherPortalId is `ushort` (src/AcDream.Core/Physics/BuildingPhysics.cs:37) — retail's -1 sentinel is unrepresentable — and the field is never consumed anywhere in src/ (grep: only stored at GameWindow.cs:5972 from the dat's `bp.OtherPortalId`). Production callers confirmed: PhysicsEngine.cs:378-382 and CellTransit.cs:631-633, both keyed by outdoor landcell id via PhysicsDataCache.GetBuilding (PhysicsDataCache.cs:445; CacheBuilding at :433-444, first-wins per landcell id).
5. Building shell collision is NOT dispatched through any cell-graph member: the 0xC0XXYY00 landblock-stab entity registers EACH physics part into ShadowObjectRegistry (GameWindow.cs ~6132-6190; Register call ~6178 with partId = entity.Id*256+partIndex, cellScope: entity.ParentCellId ?? 0u — outdoor stabs have no ParentCellId so cellScope=0 → the landblock-wide grid). ShadowObjectRegistry.GetNearbyObjects (ShadowObjectRegistry.cs:434+) is a landblock+8-adjacent radial sweep patched with portalReachableCells iteration and the #98 indoor-primary early-return (~:496) — and its own doc comments (:398-431) narrate the cottage GfxObj as "landblock-wide ... registered with cellScope=0" and cite the #98/#99 saga. The cellScope mechanism exists but does NOT cover building shells (they register at scope 0), so the "landblock-wide" characterization is accurate for buildings specifically.
JUDGMENT: both sides check out; the divergence is real, not behaviorally-equivalent-elsewhere — acdream's BuildingPhysics carries only portal data for membership (CheckBuildingTransit), while collision/get_object never route through a per-cell building reference; that is the documented A6.P4 debt behind #99 (per the physics digest and the in-code #98/#99 commentary). The missing >= 0 gate is a genuine correctness divergence made live by the Ghidra-proven sign-extension. Severity "medium" and the proposed port shape (fold into A6.P4 per-cell shadow architecture; widen OtherPortalId to signed; add the gate) are consistent with the evidence.
CAVEATS (do not change the verdict): (a) whether any Holtburg-area building portal actually carries 0xFFFF→-1 in the dat is unverified — no portal dump exists in-tree (Issue113PhantomStairsDumpTests would print it but running tests is out of scope here), so the gate gap's practical trigger set at Holtburg may be empty; (b) in acdream a sentinel-bearing portal would often be skipped anyway by the GetCellStruct null-check if its OtherCellId doesn't resolve to a cached EnvCell — the gate gap only bites for a portal with a VALID destination cell but a negative other_portal_id. Both caveats are consistent with the claim's own "medium / subtle membership differences" framing.
- blastRadius: #99 door run-through (the known A6.P4 per-cell shadow debt — retail dispatches collision per-cell through CSortCell.building, acdream through a landblock-wide ShadowObjectRegistry); subtle membership differences at building entries: retail's check_building_transit skips portals with other_portal_id < 0 and tests sphere_intersects_cell on the structure, acdream loops every cached portal without the >=0 gate (BldPortalInfo stores OtherPortalId as ushort, so retail's -1 sentinel cannot be expressed).
- retailEvidence: CSortCell::find_transit_cells/find_collisions/get_object all route through cell->building (0x534060/0x5340a0/0x5340c0 pc:318309-318362); CEnvCell::check_building_transit gates `other_portal_id >= 0` (0x52c5d0 pc:309838); building registered into its host cell at init_buildings (0x52fd80 pc:313907) and into the landblock stablist (pc:313910).
- acdreamEvidence: CheckBuildingTransit iterates all cached portals with no portal-id gate, sphere vs whole CellBSP (CellTransit.cs:353-398, BldPortalInfo at BuildingPhysics.cs:25-40 with ushort OtherPortalId); shell collision via per-part ShadowObjectRegistry entries from the 0xC0 entity (GameWindow.cs:6122+); BuildingPhysics keyed by host landcell id only (PhysicsDataCache.cs:433).
- portShape: Fold into the already-planned A6.P4 per-cell shadow architecture: give the host outdoor cell a building reference and dispatch transit/collision/get_object through it; widen OtherPortalId to a signed value and add the >=0 gate; this is a correctness alignment, not a redesign.
### [LOW] leaf-cells-unported (UNVERIFIED (verifier hit token limit)) — leaf_cells/CPartCell per-BSP-leaf object buckets have no acdream equivalent
- blastRadius: None demonstrated — the retail path itself appears dormant in the 2013 build (no writer of CBuildingObj::curr_leaf_cells found; Ghidra xrefs to 0x8fa9bc are reads only), and acdream's depth-buffered entity pass makes BSP-ordered interleaving unnecessary for correctness.
- retailEvidence: CBuildingObj.leaf_cells allocated num_leaves NULL slots at makeBuilding (0x6b53a0 pc:701299-701311); DrawBuildingLeaf draws leaf_cells[i] via DrawPartCell with pushLevelOffset=1 (0x5a07e0 pc:429223-429240); BuildInfo.num_leaves from the dat (acclient.h:32035).
- acdreamEvidence: No counterpart anywhere; BuildInfo.NumLeaves is read but unused beyond makeBuilding parity (LandblockLoader.cs:75-92 ignores it).
- portShape: Do not port unless a runtime trace shows the 2013 client actually populating leaf cells; file the open question instead.
## OPEN QUESTIONS
- Where (if anywhere) does retail draw the TEXTURED surface of a building portal poly? The portal polys sit in the same CGfxObj.polygons array that D3DPolyRender::ConstructMesh consumes (0x59ea90 pc:427540-427543; BSPPORTAL::UnPackPortal points in_portals into pack_poly, 0x53db70 pc:327256), yet always drawing them would seal every aperture against the flood — so an exclusion must exist (ConstructMesh per-poly filter? a stippling bit on GfxObj portal polys that DatReaderWriter maps differently than the cell-struct NoPos case? exclusion at CGfxObj unpack?). The only confirmed portal-poly draw (DrawPortalPolyInternal, Ghidra 0x59bc90) is alpha=0 invisible. Resolve by Ghidra-reading ConstructMesh's poly loop (0x59dfa0, the section past 0x59e132) and dumping the dat Stippling/SidesType bits of a known Holtburg door-fill quad. This decides whether acdream's flood-fail case should draw the textured quad or nothing+seal — the e223325 inference that 'doors are usually visible' in retail may actually be the separate swinging-door ENTITY, not the baked quad.
- Who calls RenderDeviceD3D::DrawBuildingLeaf and who writes CBuildingObj::curr_leaf_cells/curr_num_leaves? Ghidra xrefs to 0x8fa9bc show reads only (DrawBuildingLeaf), and no named writer exists in the 1.4M-line pseudo-C — the building leaf-cell render path looks dormant in the 2013 build, but a vtable-indirect or memcpy-style writer could have been missed. Settle with a cdb breakpoint on DrawBuildingLeaf at a Holtburg building before deciding the port scope.
- Exact semantics of PView::DrawCells' Clear(4, 0x820fc0, 1.0) (pc:432730-432734) — which buffers flag 4 clears (depth assumed from the 1.0 z argument) and what forceClear is for; matters for ordering parity when porting the punch/seal machinery.
- The building_view latch under NESTED floods (building seen through a doorway which floods another building) was traced statically (save/-1/restore at 0x59f0e0 pc:427906-427914, slot bind at 0x5a08c0-0x5a08cd) but never runtime-verified; a cdb trace logging (portal_view_num, building_view) per DrawPortal would confirm the multi-slot binding before porting it.
- Do window-type CBldPortals flood in retail (do they carry stab lists and a GetVisible-able other_cell_id with a passable portal_side), i.e. are cottage windows see-through to the interior in retail or permanently failed views? Determines whether window aperture quads need different treatment from door quads in the port.
- CBldPortal.sidedness (float, acclient.h:32101) is read by none of the functions examined this session — likely a runtime-computed plane-side cache, but unverified; check before assuming it can be dropped from the port.

View file

@ -0,0 +1,237 @@
# Area 5 — Culling and frame composition end-to-end (incl. #108 grass-sweep + #109 far-door oscillation)
## RETAIL
RETAIL FRAME COMPOSITION (all claims verified against Ghidra decompile and/or pc lines; vtable offsets verified against the RenderDevice vtable dump at pc:1037033-1037066 / 0x7e5500).
== 1. Top of frame: SmartBox::RenderNormalMode (Ghidra 0x00453aa0, pc:92635) ==
One function decides the whole frame. It computes two booleans: `viewer_is_outside` = (viewer.objcell_id & 0xffff) < 0x100 (low word under 0x100 means a LANDCELL, i.e. the camera is outdoors), and `can_see_outside` = viewer_is_outside OR viewer_cell->seen_outside (Ghidra 0x453aa0). Then:
- OUTSIDE: LScape::update_viewpoint(viewer cell id) + Render::update_viewpoint + Render::set_default_view + Render::useSunlightSet(1) + LScape::draw(lscape) — the landscape pass IS the frame.
- INSIDE: if seen_outside, LScape::update_viewpoint(Position::get_outside_cell_id(viewer)) keeps the landscape system centered; then Render::update_viewpoint + device->vtable+0x48(viewer_cell). Offset +0x48 in RenderDeviceD3D's vtable (0x7e5548 minus base 0x7e5500) is **DrawInside** — so the inside path is exactly DrawInside(viewer_cell). NOTE: there IS a top-level outside/inside branch in retail, but both arms drive the SAME machinery (LScape::draw is also what the indoor path calls for its outdoor view; building interiors are drawn by the same PView flood from inside the outdoor pass) — the project's "one path" rule is about DrawInside(viewer_cell) being the single indoor entry and the landscape being reached through it, which this confirms.
- After either arm: D3DPolyRender::FlushAlphaList(0.0) — the global delay-rendered alpha list ("Alpha=2, Translucent=4, ClipMap=8" registry text at 0x7e5648) flushes once more at end of scene.
== 2. The outdoor pass: LScape::draw (Ghidra 0x00506330, pc:267912) ==
Order inside the landscape pass:
(a) **Sky first**: GameSky::Draw(sky, 0). GameSky::Draw (Ghidra 0x00506ff0, pc:268704) draws the celestial CPhysicsObjs with zfar temporarily ×4 and SetDepthBufferMode(DEPTHTEST_ALWAYS, zwrite=0) (0x507055-0x507063) — the sky always passes depth and never writes it, so everything later paints over it; restores DEPTHTEST_LESSEQUAL + zwrite=1 after (0x5070fc — this also confirms LESSEQUAL+write as the world default). Pass-0 (dome) runs unconditionally; pass-1 (weather cell, DrawObjCellForDummies(after_sky_cell)) is gated `SmartBox::is_player_outside() || pass==0` (0x507009) — i.e. **is_player_outside gates ONLY weather**, matching the project rule.
(b) **Block culling**: LScape::draw_check_blocks (Ghidra 0x00505f80, pc:267678). If Render::PortalList is non-null (set by the indoor path, see §4) it loops `Render::set_view(&PortalList->view, i)` over every accumulated portal view and merges results — landscape CULLING is done per portal-clipped view, not per global frustum. Per block it builds a grid of view-interval columns via Render::get_clip_height and calls Render::block_check(corner intervals, max_zval, min_zval) → block->in_view; then landcell_check (Ghidra 0x005050a0) repeats the column test per 8×8 landcell → cell->in_view (LOD blocks short-circuit to all-in).
(c) **Blocks far→near**: block_draw_list is built by LScape::get_block_order (Ghidra 0x00504c50): index 0 = the viewer's own block, then expanding rings outward — and LScape::draw iterates it from the END (`while (--i >= 0)`), i.e. farthest ring first, viewer block last. Painter's order at block granularity.
(d) **Per block: RenderDeviceD3D::DrawBlock (pc:430027 / 0x5a17c0)**, two sub-passes: pass A per cell sorts the cell's object parts (CShadowPart::insertion_sort) after UpdateObjCell; pass B per cell INTERLEAVES: DrawLandCell (thunk pc:427860 → ACRender::landPolysDraw(cell->polygons, 2) — terrain polys go through the software poly-clip pipeline, clipped against the CURRENT view incl. portal views) → DrawSortCell → FlushAlphaList(flush) per cell. So terrain, buildings, and objects are drawn cell-by-cell, not in global passes.
(e) **DrawSortCell (Ghidra 0x0059f140)**: building first (vtable+0x68 = DrawBuilding), then cell contents (vtable+0x60 = DrawObjCell → DrawPartCell → CShadowPart::draw per sorted shadow part, pc:429198-429220).
(f) Weather last: GameSky::Draw(sky, 1) after the block loop (0x506396), only if weather_enabled (and internally only if player outside).
== 3. Buildings + interior flood from outdoors: DrawBuilding → DrawPortal ==
RenderDeviceD3D::DrawBuilding (Ghidra 0x0059f2a0, pc:429282): sets outdoor_pview->outdoor_portal_list = building->portals, then TWO part draws: `CPhysicsPart::Draw(part, 1)` (PORTALS-ONLY pass) followed by `ObjBuildingOrBuildingPart=1; CPhysicsPart::Draw(part, 0)` (SHELL pass, the constructed mesh via D3DPolyRender::DrawMesh 0x59d4a0). The portals-only pass reaches DrawMeshInternal (pc:427978 / 0x59f360) which calls BSPTREE::build_draw_portals_only(drawing_bsp, **1**) then (drawing_bsp, **2**) (0x59f3cc/0x59f3d9 — the only two call sites of that walk, modes 1 and 2 only). BSPPORTAL::portal_draw_portals_only (pc:326881 / 0x53d870) walks the drawing BSP by viewer plane-side and fires device->DrawPortal(in_portals[i], 1, mode) for every BSPPORTAL node's CPortalPoly.
PView::DrawPortal (Ghidra 0x005a5ab0, pc:433895): flush queued polys, backup state, look up the CBldPortal, add_views(portal stab list), then ConstructView(CBldPortal, poly, 1, mode) (Ghidra-verified 0x005a59a0, pc:433827): hard plane-side gate of the VIEWER against the portal polygon's plane (epsilon F_EPSILON=2e-4) honoring CBldPortal::portal_side; GetClip clips the portal polygon against the current view to a portal-shaped sub-view; CEnvCell::GetVisible(other_cell_id); Render::copy_view pushes the clipped view onto the destination cell's portal_view stack; then `if (mode != 2) DrawPortalPolyInternal(poly, mode==1)`; `if (mode != 1)` recurse into the CEnvCell ConstructView = interior flood. Back in DrawPortal: on success `if (mode != 1) DrawCells(this, 1)` — the interior cells are drawn nested INSIDE this building's draw, through this aperture. On flood-fail, `if (mode == 3) DrawPortalPolyInternal(poly, false)` — mode 3 has no caller in the built-mesh path.
**Mode semantics (load-bearing, Ghidra-verified)**: mode 1 = draw the portal poly as a FAR-PLANE DEPTH PUNCH and do NOT flood; mode 2 = flood + DrawCells with NO portal poly. So per building portal the sequence is: punch the aperture's depth to the far plane, then draw the interior into the clean hole with normal LESSEQUAL depth. D3DPolyRender::DrawPortalPolyInternal (Ghidra 0x0059bc90, pc:424468): transforms the portal poly, software-clips it against the current view (polyClipFinish), then draws it with NO texture, alpha-test off, SRCALPHA/INVSRCALPHA blend, CULL_NONE, **SetDepthBufferMode(DEPTHTEST_ALWAYS, zwrite = flagbit2)**, z = ~1.0 (far plane, literal 0x3f7fffef) if flagbit0 else true z/w, vertex alpha forced 0 (invisible) when flagbit1 — flags from globals maxZ1=7 (param true → far punch) and maxZ2=6 (param false → TRUE-DEPTH invisible fence; also portalsDrawnCount++) (statics at pc:1105964-1105965; cliplandscape=1 pc:1106108; forceClear=0 pc:1366769).
== 4. The indoor pass: PView::DrawInside → ConstructView → DrawCells ==
RenderDeviceD3D::DrawInside thunk (Ghidra 0x0059f0d0) → PView::DrawInside(indoor_pview, viewer_cell) (Ghidra 0x005a5860, pc:433793): curr_view_push, add_views(cell stab list), positionPush, copy_view(full-screen 4-pt view onto the root cell), ConstructView(cell, 0xffff), DrawCells. indoor_pview is constructed with draw_landscape=1 and outdoor_pview with draw_landscape=0 (RenderDeviceD3D::Init 0x0059efb0, pc:427788-427814) — only the indoor PView accumulates an outside view and re-draws the landscape; the nested outdoor-side floods never do.
**ConstructView (0x005a57b0, pc:433750)**: resets outside_view.view_count=0, master_timestamp++, InitCell(root)+InsCellTodoList(root, 0.0), then loop: pop a cell from the todo list, append to cell_draw_list, ClipPortals, AddViewToPortals. InsCellTodoList (Ghidra 0x005a4f50) is an insertion sort DESCENDING by distance (verified: `if (param_2 < existing->dist) break` walking from the top) and the loop pops from the END — i.e. pops the NEAREST cell first, so **cell_draw_list is ordered near→far**.
**ClipPortals (0x005a5520, pc:433572)**: sets Render::PortalList = the cell's newest portal_view; for each view and each seen portal, GetClip clips the portal polygon against that view; a portal whose other_cell_id == 0xffffffff leads OUTSIDE → if draw_landscape && cliplandscape, Render::copy_view accumulates the clipped region into **this->outside_view** (the union of every doorway/window region that reaches outdoors, in screen space); a portal to another EnvCell pushes the clipped view onto that cell's portal_view (with OtherPortalClip 0x005a5400 re-clipping through non-exact-match paired portals). AddViewToPortals (0x005a52d0, pc:433446) enqueues newly-seen neighbor cells (InitCell + InsCellTodoList with their viewer distance).
**PView::DrawCells (0x005a4840, pc:432703)** — THE composition answer, four stages:
Stage 1 (only if outside_view.view_count > 0): Render::useSunlightSet(1); Render::PortalList = &outside_view; **LScape::draw** — the FULL landscape machinery (sky included) runs, with blocks culled per accumulated portal view and every terrain poly software-clipped to the exact outside_view polygons; D3DPolyRender::FlushAlphaList(0); frameStamp++; then **Clear(D3DCLEAR_ZBUFFER(4), color, z=1.0)** — the WHOLE depth buffer is cleared, gated on `forceClear || portalsDrawnCount != 0` (0x5a4893-0x5a48a9); then the **portal depth FENCE**: for every cell in cell_draw_list (reverse = far→near), per view (CEnvCell::setup_view), for every portal with other_cell == 0xffffffff: DrawPortalPolyInternal(portal_poly, 0) → an invisible DEPTHTEST_ALWAYS zwrite-on quad at the aperture's TRUE depth (maxZ2). Net effect: outdoor color exists only inside the exact clipped apertures; outdoor depth is then thrown away entirely; the fence re-establishes the doorway's depth so any interior geometry FARTHER than the doorway can never overdraw the outdoor view, while interior geometry nearer than it still can (correct).
Stage 2: useSunlightSet(0) + restore_all_lighting; reverse cell_draw_list (far→near): per view setup_view then device->DrawEnvCell (Ghidra 0x0059f170, pc:427879) — once per cell per frame (GetDrawnThisFrame guard); built-mesh path = SetStaticLightingVertexColors + D3DPolyRender::DrawMesh; legacy path submits ALL structure->polygons with planeMask=0xffffffff (pc:427922) into the software-clipped poly list. Cell shell geometry is therefore always software-clipped to the current portal view.
Stage 3: reverse cell_draw_list: Render::PortalList = the cell's newest portal_view, then DrawObjCellForDummies (pc:429177 / 0x5a0760): re-sort shadow parts, DrawObjCell → DrawPartCell → CShadowPart::draw per part. Per-mesh culling here is **Render::viewconeCheck (Ghidra 0x0054c250)**: the part's bounding sphere is tested against the viewer plane AND the planes of the CURRENT portal view (portal_npnts loop) — every object is culled against the portal-clipped view it is drawn through, but its polygons are NOT hard-clipped (BoundingType PARTIALLY_INSIDE just disables trivial-accept).
Stage 4: restore object scale, useSunlightSet(1).
== 5. Depth-state summary ==
World default: DEPTHTEST_LESSEQUAL + zwrite on (restored at 0x5070fc). Sky: ALWAYS + no write. Portal punch: ALWAYS + write, z=far (maxZ1=7). Portal fence: ALWAYS + write, z=true (maxZ2=6), invisible. Indoor frames partition depth by ONE full Z-clear after the outdoor stage plus per-aperture fences; outdoor building apertures are partitioned by per-portal far punches before each nested interior draw. There is no per-portal-slice scissor anywhere — exactness comes from the software polygon clipper (ACRender::polyClipFinish) which every terrain/cell/portal poly passes through.
## ACDREAM
ACDREAM FRAME COMPOSITION (file:line, worktree root C:/Users/erikn/source/repos/acdream/.claude/worktrees/thirsty-goldberg-51bb9b).
== Frame entry: GameWindow.OnRender (src/AcDream.App/Rendering/GameWindow.cs:7124) ==
(1) ClearColor=fog tint; DepthMask(true) asserted then Clear(COLOR|DEPTH|STENCIL) (GameWindow.cs:7141-7156). Frame-global CullFace(Back)+FrontFace(CW) (7162-7163). WbMeshAdapter.Tick (7178). Animations (7188).
(2) Viewpoint roots: lighting root = PLAYER cell (7291-7296); render root = VIEWER cell from RetailChaseCamera.ViewerCellId (7301-7313); renderSky = viewerRoot null || rootSeenOutside (7423).
(3) Outdoor-as-cell cutover: when the eye is outdoors, _outdoorNode = OutdoorCellNode.Build(viewerCellId) with nearby building cells gathered from Chebyshev<=1 landblocks (7458-7482); clipRoot = viewerRoot ?? _outdoorNode (7497). clipRoot is null only pre-spawn/legacy-camera.
(4) clipRoot == null safety path (7546-7587): sky first via SkyRenderer.RenderSky (7560; depth test DISABLED + DepthMask(false), Sky/SkyRenderer.cs:194-195, restored 440-441) + SkyPreScene particles (7565), then TerrainModernRenderer.Draw (7580). Then InteriorEntityPartition outdoor bucket via InteriorRenderer.DrawEntityBucket (7737-7746), the 48m exterior look-in RetailPViewRenderer.DrawPortal (7778-7798, MaxSeedDistance=48f at 7795), LiveDynamic bucket (7813-7823); scene particles (7846-7868: clipRoot==null only); weather post-scene RenderWeather + SkyPostScene particles (7874-7889).
(5) clipRoot != null (the normal path, indoor AND outdoor): RetailPViewRenderer.DrawInside (7604-7663) with DrawLandscapeSlice = DrawRetailPViewLandscapeSlice (7624-7634), ClearDepthSlice = scissored depth clear for INTERIOR roots only, null for the outdoor node (7644-7652), DrawCellParticles (7653). Outdoor-node LiveDynamic drawn after, unclipped (7716-7724).
== RetailPViewRenderer.DrawInside (src/AcDream.App/Rendering/RetailPViewRenderer.cs:44-109) ==
Order: PortalVisibilityBuilder.Build (flood from root; root seeded at distance 0 mirroring InsCellTodoList(0f), PortalVisibilityBuilder.cs:94; exit portals union into frame.OutsideView, PortalVisibilityBuilder.cs:279; outdoor node seeds OutsideView with a FULL-SCREEN quad, PortalVisibilityBuilder.cs:80-89) → MergeNearbyBuildingFloods for the outdoor node (RetailPViewRenderer.cs:60-61, 115-145: group nearby cells by BuildingId, one ConstructViewBuilding per building with OutdoorBuildingSeedDistance=48f at line 30; ConstructViewBuilding = BuildFromExterior, PortalVisibilityBuilder.cs:548-554 with per-portal NearestPortalVertexDistance > maxSeedDistance cutoff at 426-427) → ClipFrameAssembler.Assemble + UploadClipFrame (63-64) → drawableCells = ALL OrderedVisibleCells (71) → EnvCellRenderer.PrepareRenderBatches (74-80) → InteriorEntityPartition (82) → then the draw stages:
(a) **DrawLandscapeThroughOutsideView (214-238)**: per OutsideView slice: SetTerrainClip(slice.Planes) + upload + entity clip routing (225-227), then the GameWindow callback **DrawRetailPViewLandscapeSlice (GameWindow.cs:9465-9551)**: scissor to slice.NdcAabb (9477, BeginDoorwayScissor 9707-9724 = NDC AABB → pixel scissor), sky (clip distances enabled, 9484-9487), SkyPreScene particles (9490-9492), TERRAIN (9494-9496, clipped by the TerrainUbo planes via gl_ClipDistance + the scissor), outdoor entity bucket via WbDrawDispatcher (9503-9512), outdoor-entity particles (9519-9530), WEATHER inside the slice (9532-9541). After ALL slices are drawn: ClearDepthSlice per slice (234-235) — for interior roots a **scissored AABB depth-only clear** (GameWindow.cs:7646-7652); for the outdoor node NO clear at all (7644 rationale comment).
(b) **DrawExitPortalMasks (325-343)**: the fence hook — iterates reverse OrderedVisibleCells and invokes ctx.DrawExitPortalMasks per slice, but **no production caller sets that callback** (grep over src/: only RetailPViewRenderer.cs and the context type definitions reference it; GameWindow.cs:7604-7663 sets DrawLandscapeSlice/ClearDepthSlice/DrawCellParticles only). The retail depth fence is UNWIRED.
(c) **DrawEnvCellShells (345-399)**: IndoorDrawPlan.ShellPass = reverse OrderedVisibleCells far→near (IndoorDrawPlan.cs:21-33); per cell per slice: UseShellClipRouting + EnvCellRenderer.Render(Opaque) + Render(Transparent) (388-393). GL_CLIP_DISTANCE is enabled ONLY for outdoor-node roots (clipShells, 104-105, 378-380, 396-398) — **indoor roots draw shells UNCLIPPED** (#114 scope note at 96-103). EnvCellRenderer establishes its own blend/depthmask per pass (Wb/EnvCellRenderer.cs:1085-1094: opaque = blend off + DepthMask(true); transparent = blend on + DepthMask(false); restore at 1277-1278).
(d) **DrawCellObjectLists (401-426)**: reverse OrderedVisibleCells; per-cell entity buckets via WbDrawDispatcher with **membership-only routing** — UseIndoorMembershipOnlyRouting (420, 439-450) deliberately does NOT plane-clip entities (rationale comment: retail uses viewconeCheck not hard clip); per-cell particles scissored to the slice AABB only (GameWindow.cs:9553-9580; particle.vert has no gl_ClipDistance per the 9701-9706 comment).
DrawPortal (162-212, the legacy clipRoot==null look-in) mirrors the same stages from BuildFromExterior with the 48m seed.
== Culling ==
Terrain: per-landblock-slot frustum AABB cull only (TerrainModernRenderer.cs:206-223); no per-portal-view CPU cull — the portal restriction is GPU clip planes (<=8) + scissor; sets no cull/depth state of its own (inherits frame state). ClipFrameAssembler caps a slice at 8 planes; regions needing more set a scissor-only fallback (ClipFrameAssembler.cs:136-169 outsideHasScissorFallback → TerrainClipMode.Scissor; per RetailPViewRenderer.cs:368-369 the >8-plane shell fallback is unimplemented = pass-all). Entities: per-landblock frustum AABB cull + per-entity 5m-radius AABB cull (WbDrawDispatcher.cs:208-210, 593-595), clip-slot routing by cell membership (394-488), opaque groups sorted front-to-back / transparent back-to-front (1163-1204, 1439-1445); no per-portal-view sphere test. EnvCell shells: drawableCells filter + per-slice routing.
== Sky/weather/particles ==
Sky always first in whichever pass draws it, no depth test/write (matches retail's ALWAYS+no-write). Weather: drawn inside each landscape slice when renderSky (GameWindow.cs:9532-9541) and post-scene on the null path (7874-7889) — keyed on seen_outside of the VIEWER root, not on is_player_outside. Scene particles: depth test on, depth write off (ParticleRenderer.cs:141-143); on clipRoot!=null frames only entity-attached emitters draw (slice filter 9528-9529 and cell filter 9575-9576 both require AttachedObjectId != 0); the unattached-emitter draw exists only on the clipRoot==null path (7856).
## DIVERGENCES
### [CRITICAL] missing-portal-depth-fence (confirmed) — Retail's invisible portal depth fence (DrawPortalPolyInternal maxZ2) after the Z-clear is entirely missing; the DrawExitPortalMasks hook exists but is wired to nothing
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C), every load-bearing gate checked:
1. PView::DrawCells (Ghidra 0x005a4840): confirmed sequence — LScape::draw(this->lscape) → D3DPolyRender::FlushAlphaList(0.0) → vtable+0x2c Clear(4, RGBAColor_Black, 0x3f800000=1.0f), gated on (forceClear || portalsDrawnCount != 0) with portalsDrawnCount reset to 0 at the check → FIRST reverse loop over cell_draw_list × CEnvCell::setup_view(cell, view) × portals where other_cell_id == -1 (0xffffffff) → D3DPolyRender::DrawPortalPolyInternal(portal_poly, false) → THEN the geometry loops (vtable+0x5c cell draw, vtable+0x64 second pass). So the exit-portal fences are drawn AFTER the depth clear and BEFORE cell geometry — exactly the claimed re-fencing role.
2. Clear flag semantics verified via RenderDeviceD3D::Clear (Ghidra 0x0059fd30): engine flag bit 1→D3DCLEAR_TARGET, bit 2→D3DCLEAR_STENCIL (cap-gated), bit 4→D3DCLEAR_ZBUFFER. Clear(4, …, 1.0f) is a depth-only clear to z=1.0. Claim's "Clear(D3DCLEAR_ZBUFFER, z=1.0)" correct.
3. D3DPolyRender::DrawPortalPolyInternal (Ghidra 0x0059bc90): mode flag selects maxZ1 (param true) vs maxZ2 (param false). Globals maxZ2=6 @0x00820e14 (pc:1105964), maxZ1=7 @0x00820e18 (pc:1105965). Bit decode from the decompile: SetDepthBufferMode(DEPTHTEST_ALWAYS, zwrite=(maxZ>>2)&1) → bit2=1 in both 6 and 7 → z-write ON; per-vertex z = (maxZ&1)==0 ? zw/w (TRUE projected depth) : 0x3f7fffef≈0.99999994 (far plane) → maxZ2=6 draws at true depth, maxZ1=7 punches to far; vertex alpha top bit = ~(maxZ<<30)&0x80000000 bit1=1 in both alpha 0x00, with SRCALPHA/INVSRCALPHA blend invisible. SetStageTexture(0,null), SetCullMode(NONE). Mode-false draws increment portalsDrawnCount the very counter that gates next frame's Z-clear in DrawCells (self-consistent loop). Claim's "invisible (alpha 0), DEPTHTEST_ALWAYS, zwrite ON, at TRUE depth" for maxZ2 and "far punch z1.0" for maxZ1: all confirmed.
4. Building twin verified: RenderDeviceD3D::DrawMeshInternal (Ghidra 0x0059f360) portals-only path calls BSPTREE::build_draw_portals_only(drawing_bsp, 1) then (…, 2); mode threaded verbatim through BSPNODE::build_draw_portals_only (0x0053c100) → BSPPORTAL::portal_draw_portals_only (0x0053d870, per-portal virtual at render_device vtable+0x4c with (in_portals[i], 1, mode)) → PView::ConstructView(CBldPortal…) (Ghidra 0x005a59a0): `if (param_4 != 2) DrawPortalPolyInternal(poly, param_4 == 1)` and recursion into the other cell only `if (param_4 != 1)`. So mode 1 = punch-without-recurse, mode 2 = recurse-without-punch — the far punch lands before nested interior view construction, as claimed.
ACDREAM SIDE — confirmed at the cited lines:
- RetailPViewRenderer.cs:325-343: DrawExitPortalMasks early-returns at :331-332 when ctx.DrawExitPortalMasks is null; otherwise iterates reverse OrderedVisibleCells × slices — the hook exists and sits at the retail-correct position (called at :95 in DrawInside and :204 in DrawPortal, immediately after DrawLandscapeThroughOutsideView whose tail invokes ClearDepthSlice per OutsideView slice at :234-235, and before DrawEnvCellShells :104 / DrawCellObjectLists :106).
- Grep over all of src/ finds DrawExitPortalMasks ONLY in RetailPViewRenderer.cs (method, two invocations, three nullable Action property declarations :497/:534/:558). Neither production context initializer sets it: GameWindow.cs:7604-7663 (DrawInside ctx — sets DrawLandscapeSlice, ClearDepthSlice, DrawCellParticles, EmitDiagnostics, NOT DrawExitPortalMasks) and GameWindow.cs:7780-7798 (DrawPortal ctx — sets no draw callbacks at all). The hook is wired to nothing; the method is a per-frame no-op.
- The only depth partition is the scissored NDC-AABB depth clear: GameWindow.cs:7644-7652 (BeginDoorwayScissor(true, slice.NdcAabb) + glClear(DEPTH)), and it is explicitly null for outdoor-node roots (7644-7645, with the "no clear outdoors" compromise rationale in the comment block 7635-7643).
DIVERGENCE IS REAL, NOT EQUIVALENT-ELSEWHERE: after acdream's per-slice clear, aperture depth is 1.0 and nothing re-writes the doorway's true depth before shells/objects draw — any later geometry at any depth wins those pixels. Retail re-fences with the exact portal POLYGON at true depth (and the AABB-vs-polygon shape mismatch is an additional acdream approximation the port would eliminate). Outdoor roots lack the mode-1 far punch entirely (ClearDepthSlice=null is a compromise, not an equivalent). Strengthening note: in retail the depth fence works IN CONCERT with screen-space portal clipping (Render::set_view pc:343750 + polyClipFinish planeMask=0xffffffff pc:427922); acdream currently draws indoor-root shells UNCLIPPED (clipShells only for outdoor roots, RetailPViewRenderer.cs:104-105 + :374-380 #114 scope) — so indoor roots have NEITHER protection, making the missing fence directly load-bearing for #109's oscillating far-door aperture and the §4 "indoor geometry paints over the doorway view" class.
Minor port-shape details surfaced (do not change the verdict): (a) retail's Z-clear is GATED on forceClear || prev-frame portalsDrawnCount≠0, not unconditional; (b) DrawPortalPolyInternal skips degenerate portal polys whose vertices all sit at ±12.0 cell-boundary extents and screen-clips the poly (needs ≥3 clipped verts) before drawing; (c) the fence loop in DrawCells covers EVERY visible cell's exit portals (other_cell_id==-1), not just the root cell's. One honest open sequencing question for the port plan: the building far punch is drawn during the building-mesh draw (LScape::draw path) which precedes DrawCells' conditional Z-clear — on frames where the clear fires (interior fences drawn last frame), the punch region is wiped to 1.0 anyway (same value), but exterior WALL depth is also wiped, implying retail leans on screen-space portal clipping as the primary aperture discipline with the depth fence as the in-aperture layering mechanism; the port should treat clipping+fence as a pair, not the fence alone.
- blastRadius: #109 (far exit door oscillates door-texture vs background): after our scissored depth clear, depth in the aperture region is 1.0 and NOTHING re-establishes the doorway's depth, so any interior/shell geometry drawn later — at ANY depth — wins the aperture; whether the outdoor view or the door-region geometry shows depends on per-frame flood/slice makeup → oscillation. Also a contributor to #108 and to the general 'indoor geometry paints over the doorway view' class the digest tracks under §4.
- retailEvidence: PView::DrawCells (Ghidra 0x005a4840, pc:432703): after LScape::draw + FlushAlphaList + Clear(D3DCLEAR_ZBUFFER, z=1.0) (0x5a48a9), it loops every cell (reverse) × every view (CEnvCell::setup_view) × every portal with other_cell_id==0xffffffff and calls D3DPolyRender::DrawPortalPolyInternal(portal_poly, 0) (0x5a49b7). DrawPortalPolyInternal (Ghidra 0x0059bc90) with maxZ2=6 (pc:1105964) draws the aperture polygon invisible (alpha 0), DEPTHTEST_ALWAYS, zwrite ON, at TRUE depth — re-fencing the doorway so interior geometry farther than the door fails depth there. The outdoor-side twin is the mode-1 far punch (maxZ1=7, z≈1.0) issued per building portal before the nested interior DrawCells (ConstructView(CBldPortal) Ghidra 0x005a59a0: `if (param_4 != 2) DrawPortalPolyInternal(poly, param_4==1)`; DrawMeshInternal 0x0059f360 calls build_draw_portals_only with modes 1 then 2).
- acdreamEvidence: RetailPViewRenderer.cs:325-343 (DrawExitPortalMasks) iterates and invokes ctx.DrawExitPortalMasks — but GameWindow.cs:7604-7663 (DrawInside ctx) and 7780-7798 (DrawPortal ctx) never set the callback; grep over src/ finds no other assignment. The only depth partition is the scissored AABB depth clear (GameWindow.cs:7644-7652), null for outdoor-node roots.
- portShape: Implement the two invisible depth-only portal-poly draws as a small dedicated pass: (1) indoor roots — after the per-slice landscape+depth-clear stage, draw each outside-leading portal polygon (the exact portal poly from the cell's CCellPortal, world-space) with depth-func ALWAYS, depth-write ON, color mask OFF, at true depth (retail maxZ2=6); (2) outdoor roots — before each per-building flood's interior shells draw, draw that building portal's polygon with depth forced to the far plane (gl_FragDepth=1.0 or glDepthRange trick; retail maxZ1=7), replacing the 'no clear outdoors' compromise. Wire both through the existing DrawExitPortalMasks hook (it already iterates reverse cells × slices). This removes the depth-clear-shape dependency entirely.
### [CRITICAL] approximate-portal-clip-for-landscape (adjusted) — Terrain/sky through portals is clipped by ≤8 GL planes + an NDC-AABB scissor instead of retail's exact software polygon clip per portal view
- correctedClaim: Retail does NOT software-clip terrain/sky polygons per portal view — that part of the claim is wrong. Retail accumulates EXACT clipped portal polygons into outside_view (ClipPortals 0x005a5520 → copy_view 0x0054dfc0, ≤31 verts/edges per view poly with per-edge world planes), then culls landscape per accumulated view at LANDBLOCK→LANDCELL (~24 m) granularity only (draw_check_blocks 0x00505f80, landcell_check 0x005050a0, get_clip_height 0x0054cff0 testing ALL edge planes), and draws in-view land cells WHOLE (DrawLandCell 0x0059f120 → landPolysDraw 0x006b7040 → DrawPrimitiveUP, no clip, no scissor; polyClipFinish 0x006b6d00 is called only by DrawPortalPolyInternal and GetClip). Pixel exactness in retail comes from compositing in DrawCells 0x005a4840: landscape first → depth clear → software-clipped exit-portal depth masks (DEPTHTEST_ALWAYS + z-write) → shells/objects overdraw the overspill. acdream instead confines the landscape pass at draw time with ≤8 gl_ClipDistance planes + per-slice NDC-AABB scissor (ClipPlaneSet.cs:54,132; ClipFrameAssembler.cs:134-169; GameWindow.cs:9477,9484-9496,9707-9724) — which is pixel-EXACT and equal-or-tighter than retail whenever the view poly has ≤8 edges (the common doorway case). The real, narrower divergences: (1) view polys with >8 edges (possible with nested portal chains; retail handles up to 31) degrade to AABB-only landscape over-coverage and pass-all shells (RetailPViewRenderer.cs:367-369); (2) particle passes are confined by AABB scissor only (no gl_ClipDistance in particle.vert; GameWindow.cs:9490-9492,9518-9530,9568-9578), where retail confines particles by depth against already-drawn shells; (3) acdream's exactness depends on the clip representation per slice, retail's on the z-clear/exit-mask/overdraw discipline — so any acdream pass that skips both planes and the mask discipline inherits the AABB slop. Severity: medium (not critical); the ≤8-edge equivalence and the zero-scissor-fallback pin at the Issue-113 site make it unlikely to be the primary cause of #108.
- verifier notes: RE-CHECKED RETAIL (all via Ghidra decompile, 127.0.0.1:8081 — not BN pseudo-C):
CONFIRMED parts of the retail claim:
1. PView::ClipPortals @ 0x005a5520: GetClip software-clips each portal poly against the current view; when other_cell_id==0xffffffff (outside sentinel) and draw_landscape!=0 && cliplandscape!=0, Render::copy_view(&this->outside_view, clip_view, n) accumulates the EXACT clipped polygon into outside_view (LAB_005a5711 path). Matches pc:433662-433682.
2. LScape::draw_check_blocks @ 0x00505f80: loops Render::PortalList->view_count, calling Render::set_view(&PortalList->view, i) per accumulated view poly, then Render::get_clip_height + Render::block_check per landblock. PView::DrawCells @ 0x005a4840 sets Render::PortalList = &this->outside_view before LScape::draw — so landscape culling is per accumulated outside_view poly. get_clip_height @ 0x0054cff0 iterates ALL portal_npnts per-edge world planes of the current view poly (planes built in copy_view @ 0x0054dfc0, last loop: cross of unprojected edge dirs through viewpoint) — no 8-plane budget.
3. "No scissor" — true, no scissor anywhere in the retail landscape path.
REFUTED parts of the retail claim:
4. "Every terrain poly is software-clipped through ACRender::landPolysDraw → polyClipFinish ... to the pixel" is FALSE. landPolysDraw @ 0x006b7040 only backface-culls (Plane::which_side2) and dispatches to landPolyDraw @ 0x006b6320 / 0x006b6760, both of which build D3D vertices and call RenderDeviceD3D::DrawPrimitiveUP directly — no view clip, no scissor, no D3D user clip planes (no SetClipPlane symbol exists). polyClipFinish @ 0x006b6d00 xrefs are ONLY D3DPolyRender::DrawPortalPolyInternal (call at 0x0059bdb0) and PView::GetClip (0x005a43b2, 0x005a4414) — portal polys only, never terrain. landPolysDraw's only caller is RenderDeviceD3D::DrawLandCell @ 0x0059f120.
5. "No plane-count cap" is FALSE: Render::copy_view @ 0x0054dfc0 clamps a stored view poly to 0x1f = 31 vertices/edges (and dedups verts within ~1px). 31 >> 8, but a cap exists.
6. Retail's terrain confinement granularity is the LANDCELL (~24 m): landcell_check @ 0x005050a0 refines block cull to per-cell in_view flags; in-view cells are drawn WHOLE. Pixel exactness comes from COMPOSITING, not clipping — DrawCells @ 0x005a4840 sequence: LScape::draw (terrain first, over-covering at cell granularity) → depth-buffer clear (Clear(4, black, 1.0f), gated on portalsDrawnCount/forceClear) → per cell per view, DrawPortalPolyInternal on every other_cell_id==-1 portal (software-clipped via polyClipFinish against the current view, drawn DEPTHTEST_ALWAYS + conditional z-write = the exit-portal depth mask) → cell shells → objects. Shells drawn after overwrite all terrain overspill; the masks preserve aperture pixels.
RE-CHECKED ACDREAM (all cited lines verified):
- ClipPlaneSet.cs:54 (MaxPlanes=8), :119-120 (multi-poly → union-AABB scissor), :132-133 (>8 edges → own-AABB scissor). ClipFrameAssembler.cs:134-169: outside_view slices built per polygon (ViewOf wraps ONE poly per slice, so unions become multiple slices — structurally matching retail's per-view loop, line 96-121 same for cells); TerrainClipMode.Scissor when any outside slice lacks planes (:167-169). RetailPViewRenderer.cs:367-369: shell-side slot-0 pass-all, >8-plane fallback unimplemented (comment also pins 0 such slices at the meeting hall via Issue113MeetingHallFloodTests). GameWindow.cs:9477 BeginDoorwayScissor(true, slice.NdcAabb) over the whole landscape slice; 9484-9496 EnableClipDistances around sky+terrain; 9490-9492/9518-9530/9568-9578 particle passes scissor-only (particle.vert has no gl_ClipDistance, per 9701-9706 comment); 9707-9724 NDC→pixel scissor impl. All as claimed.
JUDGMENT: the acdream description is accurate, but the divergence is mischaracterized in a way that flips the port recommendation. For the common doorway case (view poly ≤8 edges after collinear merge), acdream's gl_ClipDistance planes ARE the exact polygon — pixel-exact, equal-or-TIGHTER than retail's pre-composite terrain coverage (whole 24 m cells). The claim's "retail clips terrain exactly, acdream approximates" is inverted: retail approximates harder at draw time and relies on draw-order compositing (z-clear + exit-mask + shell overdraw) for exactness — a discipline acdream already partially ports (DrawExitPortalMasks at RetailPViewRenderer.cs:330-343, doorway depth-only z-clear per GameWindow.cs:9701-9706). The real residual gaps are: (a) >8-edge view polys degrade to AABB-only on the landscape pass and pass-all on shells; (b) particle passes are AABB-scissor-only; (c) retail's 31-edge cap vs our 8. "Critical / #108 primary" does not survive: the common case is behaviorally equivalent and the in-tree test pin reports zero scissor-fallback slices at the #113 site; no evidence ties >8-edge slices to the #108 repro. The proposed stencil port is not retail's mechanism (though it remains a defensible GPU-native option for the >8-edge residue).
- blastRadius: #108 primary; #114 region quality; doorway 'grey'/'sweep' family in the render digest §4.
- retailEvidence: Indoor outdoor-view: ClipPortals accumulates the EXACT clipped portal polygons into outside_view (0x005a5520 → Render::copy_view at 0x5a5711 path, gated by draw_landscape/cliplandscape pc:433662-433682); LScape::draw_check_blocks then culls blocks per accumulated view via Render::set_view(&PortalList->view,i) (Ghidra 0x00505f80), and every terrain poly is software-clipped through ACRender::landPolysDraw → polyClipFinish (DrawLandCell thunk pc:427860; same clipper the portal polys use at 0x59bc90). There is no scissor and no plane-count cap — the clip region is the exact polygon union, to the pixel.
- acdreamEvidence: ClipFrameAssembler.cs:136-169: a slice gets at most 8 half-space planes (ClipFrame.MaxPlanes); regions needing more fall back to scissor-only (TerrainClipMode.Scissor); RetailPViewRenderer.cs:368-369 records the shell-side >8-plane fallback as unimplemented (pass-all). DrawRetailPViewLandscapeSlice draws sky/terrain/scenery under BeginDoorwayScissor(slice.NdcAabb) (GameWindow.cs:9477, 9707-9724) + gl_ClipDistance planes (9484-9496). The AABB is axis-aligned and the plane set is convex — a rotated/concave doorway union is over-covered by construction.
- portShape: Faithful port = make the landscape slice's coverage exact: either (a) per-slice stencil mask — rasterize the exact OutsideView polygons into the stencil buffer once per frame and stencil-test the sky/terrain/scenery/weather slice draws (GPU-native equivalent of retail's software clip; removes the 8-plane cap and the AABB slop), or (b) triangulate the clip region and draw the landscape through a clipped viewport per polygon. Option (a) is the one-gate-shaped fix and also gives the cell shells their pixel-exact crop (#114).
### [HIGH] depth-clear-shape-and-order (adjusted) — Depth partition: retail clears the FULL depth buffer once (gated on portalsDrawnCount) between the outdoor stage and the interior stage; acdream clears per-slice scissored AABBs after all slices, and skips the clear entirely for outdoor-node roots
- correctedClaim: Depth partition: retail's DrawCells (0x5a4840) issues ONE full-buffer Z-only clear — RenderDeviceD3D::Clear(4→D3DCLEAR_ZBUFFER, Count=0/pRects=NULL, full-screen viewport) @ 0x59fd30 — between the landscape stage and the interior stage, gated read-and-clear on `forceClear || portalsDrawnCount` (the count increments only on fence-mode portal-poly draws, DrawPortalPolyInternal @ 0x59bc90 arg2=false), then re-fences each outside portal at its TRUE depth (maxZ2=6, DEPTHTEST_ALWAYS+write, color-invisible). Outdoors retail never reaches a top-level DrawCells (xrefs: only DrawInside/DrawPortal); instead each building portal gets a mode-1 far punch (maxZ1=7 → z≈0x3f7fffef) before the mode-2 DrawCells re-entry draws the interior with outside_view reset to 0. acdream (src/AcDream.App/Rendering/GameWindow.cs:7644-7652, RetailPViewRenderer.cs:234-235) instead clears per-slice scissored NDC-AABBs after all landscape slices for interior roots, has NO portal-depth fence, and skips the clear entirely for outdoor-node roots — flooded interiors fight terrain on raw depth. Blast radius correction: for interior roots the AABB clear DOES wipe terrain depth inside the AABB (the landscape slice is scissored to the same AABB, GameWindow.cs:9477) — the #108 artifact mechanism indoors is surviving terrain COLOR backed by far depth with no fence (protected region = AABB ≠ aperture), plus outdoors the no-clear/no-punch raw depth fight (terrain nearer than interior when the eye is below ground); #109 contributor framing (AABB ≠ aperture ≠ door-entity draw) stands. Both issue texts name the depth-clear as suspect; attribution plausible but uncaptured.
- verifier notes: RETAIL re-derived from Ghidra (not BN). (1) PView::DrawCells @ 0x5a4840: inside the `outside_view.view_count != 0` block, after LScape::draw + D3DPolyRender::FlushAlphaList, the decompile shows `if (forceClear || (portalsDrawnCount != 0)) { portalsDrawnCount = 0; render_device->vtbl[+0x2c](4, &RGBAColor_Black, 1.0f); }` — the claimed gate, at the claimed 0x5a4893-0x5a48a9 range (BN pc:432727-432728 shows check @ 0x5a489c, reset @ 0x5a489e), positioned between the landscape stage and the interior-cell stage. Note the count is read-and-clear at the check, and short-circuit means forceClear=true skips the reset. (2) The vtable slot resolves to RenderDeviceD3D::Clear @ 0x59fd30 (vtable entry 0x7e552c): engine flag 4 remaps to D3D bit 2 = D3DCLEAR_ZBUFFER; IDirect3DDevice9::Clear (slot 0xac) is issued with Count=0, pRects=NULL, bracketed by full-screen viewport set/restore — a genuine full-buffer Z-only clear, shape-unconditional. (3) Gate semantics: D3DPolyRender::DrawPortalPolyInternal @ 0x59bc90 increments portalsDrawnCount iff arg2==false (Ghidra: `if (!param_2) portalsDrawnCount++`), i.e. fence-mode draws. maxZ1=7 @ 0x820e18 / maxZ2=6 @ 0x820e14: bit 2 → depth-write on for both, DEPTHTEST_ALWAYS for both; bit 0 → vertex z forced to 0x3f7fffef (≈1.0 far) for maxZ1 (arg2=true, the per-aperture FAR PUNCH) vs the poly's true projected z for maxZ2 (arg2=false, the FENCE); alpha bit forced 0 → color-invisible. (4) Outdoor chain confirmed: RenderDeviceD3D::DrawMeshInternal @ 0x59f360 (building path; claimed 0x59f3cc is the build_draw_portals_only call region inside it) → BSPTREE::build_draw_portals_only @ 0x539860 modes 1 then 2 → BSPPORTAL::portal_draw_portals_only @ 0x53d870 → RenderDeviceD3D::DrawPortal @ 0x59f0e0 → PView::DrawPortal @ 0x5a5ab0: mode 1 = ConstructView success → DrawPortalPolyInternal(poly, true) = far punch only (no DrawCells); mode 2 = cell-side ConstructView @ 0x5a57b0 — whose FIRST statement resets outside_view.view_count=0 — then DrawCells re-entry. DrawCells xrefs show ONLY DrawInside (0x5a595b) and DrawPortal (0x5a5b53) as callers: there is NO outdoor-root DrawCells, so outdoors the gated full clear is structurally skipped and the mode-1 far punch is retail's only outdoor depth-isolation mechanism — this STRENGTHENS the claimed retail dichotomy (indoors: full clear + true-depth fence; outdoors: per-aperture far punch). ACDREAM verified at the cited lines (path nit: the file is src/AcDream.App/Rendering/GameWindow.cs, not src/AcDream.App/GameWindow.cs): GameWindow.cs:7644-7652 `ClearDepthSlice = clipRoot.IsOutdoorNode ? null : slice => { BeginDoorwayScissor(true, slice.NdcAabb); _gl.Clear(DepthBufferBit); ... }` with the full-screen-slice hazard comment at 7635-7643; BeginDoorwayScissor @ 9707-9724 converts the NDC AABB to a pixel scissor rect; RetailPViewRenderer.cs:234-235 (DrawLandscapeThroughOutsideView, 214-238) invokes the clears AFTER all slices drew; DrawInside stage order 44-108 (landscape+clears → exit-portal masks → shells → object lists). Grep confirms NO DepthFunc-Always fence or far-punch anywhere in RetailPViewRenderer.cs. The divergence is REAL and not behaviorally equivalent: indoors acdream clears an AABB (not full buffer) and never re-fences portal depth; outdoors acdream has neither clear nor punch, so flooded interiors fight terrain on raw depth — retail wins inside the aperture by construction. ONE BLAST-RADIUS SENTENCE IS WRONG AS WORDED: for interior roots, terrain DEPTH inside the AABB does NOT survive — the landscape slice draw is scissored to the SAME AABB the clear later wipes (GameWindow.cs:9477), so within slice AABBs depth is reset; what survives is terrain COLOR (now backed by far depth, with no fence to restore aperture depth for later passes). The outdoor half of the #108 reasoning (no clear, no punch → interiors lose raw depth fights when terrain is nearer, e.g. eye below ground) is correct as stated, and during the cellar ascent the root flips indoor/outdoor so both regimes plausibly contribute. #108/#109 attribution is consistent with the issue texts, which independently name the doorway depth-clear as a suspect (docs/ISSUES.md:3677-3690, 3694-3706; #108 explicitly 'needs its own capture' — attribution plausible, unproven). Severity HIGH stands; the port-shape coupling to the fence + exact-clip divergences is sound (the fence is what makes a full clear safe; the punch is what makes no-clear safe).
- blastRadius: #108 (with the eye below outdoor terrain, terrain depth deposited outside the exact aperture but inside the AABB survives wherever shells don't repaint, and outdoors — no clear, no punch — flooded interiors must win raw depth fights against terrain, which they lose when terrain is nearer than the interior, e.g. eye below ground); #109 contributor (the clear region is an AABB, not the aperture, so the protected region ≠ the drawn region).
- retailEvidence: PView::DrawCells 0x5a4893-0x5a48a9: `if (forceClear || portalsDrawnCount) render_device->Clear(4 /*Z only*/, 0x820fc0, 1.0f)` — one full-buffer depth clear, unconditional on shape, AFTER the complete landscape stage; correctness of the aperture is then delegated to the fence (see missing-portal-depth-fence) and to the software clip having confined outdoor COLOR. Outdoors the per-aperture far punch (mode 1, maxZ1) plays the clear's role per building portal (DrawMeshInternal 0x59f3cc).
- acdreamEvidence: GameWindow.cs:7644-7652: ClearDepthSlice = scissored Clear(DepthBufferBit) per slice for interior roots, null for the outdoor node (comment explains the full-screen-slice hazard); RetailPViewRenderer.cs:234-235 invokes it after ALL slices drew. The outdoor node relies on raw depth between terrain and flooded interiors.
- portShape: Once the fence + far-punch land (divergence 1) and the clip is exact (divergence 2), replace the scissored per-slice clear with retail's single full depth clear gated on 'any outside slice drew' for interior roots, and delete the outdoor-node no-clear special case in favor of the per-portal far punch. The three mechanisms are a set — porting them together is what makes each one safe.
### [HIGH] portal-poly-conditional-draw (UNVERIFIED (verifier hit token limit)) — Baked portal-filling polys (door/window quads, the e223325 finding) draw unconditionally as ordinary mesh geometry; retail routes them exclusively through the DrawPortal mode machinery (punch/fence/nothing), never as part of the shell pass
- blastRadius: #113 phantom staircase (portal polys to interior stair cells drawn as if solid geometry — confirmed same mechanism by e223325) and the e46d3d9 door regression (filtering them out also removed legitimately-visible fillings); #109 (the door-texture half of the oscillation is the unconditional filling quad fighting the outdoor slice — for outdoor roots the quad sits geometrically coincident with the clip planes derived from the same polygon, so the clipShells gl_ClipDistance crops it with boundary-epsilon flicker).
- retailEvidence: Portal polys live in BSPPORTAL::in_portals and are drawn ONLY by device->DrawPortal from the portals-only walk (BSPPORTAL::portal_draw_portals_only pc:326881, call sites 0x53d9a3/0x53d953 — the only emission path); the shell pass draws the constructed mesh via D3DPolyRender::DrawMesh (0x59f3f4 → 0x59d4a4) with no portal handling; DrawPortal's three outcomes for the poly are far-punch (mode 1, invisible), nothing (mode 2), or true-depth invisible fence on flood-fail (mode 3, no caller found in the built-mesh path) — all DEPTH-ONLY. (Where the TEXTURED filling draws is the open question below; what is certain is it is not unconditional shell geometry.)
- acdreamEvidence: Post-revert 124c6cb our GfxObj building meshes contain every dictionary poly including node.Portals fillings, drawn by the normal Wb mesh path; e223325 proved all 13 Holtburg building models' non-node.Polygons polys are portal polys. RetailPViewRenderer.cs:104-105 clips shells (incl. those quads) by slice planes for outdoor roots; indoor roots draw them unclipped (378-380).
- portShape: Separate the portal polys from the static mesh at build time (the e223325 classification makes this mechanical: node.Portals refs) into a per-portal side list, then drive them from the DrawPortal-equivalent: flood succeeded → do not draw the filling (draw the depth fence/punch instead); flood failed/not attempted → draw the filling textured (pending resolution of the open question on retail's exact textured-fill site). This is the '#113 and doors are the same mechanism with opposite signs' port.
### [HIGH] building-flood-seeding-48m-cutoff (adjusted) — Interior floods seed from a 48m per-building distance cutoff over Chebyshev≤1 landblocks; retail floods from the building's BSP portal walk during that building's draw with only plane-side + view-clip + cell-loaded gates (no distance cutoff)
- correctedClaim: Interior floods in acdream seed only from exit portals within a hard 48m camera-to-portal-vertex cutoff (RetailPViewRenderer.cs:30,141-142; PortalVisibilityBuilder.cs:426-428) over a Chebyshev<=1 landblock ring around the player (GameWindow.cs:7463-7477; legacy look-in 7759-7795) — while building exteriors draw out to the full near-tier streaming radius. Retail floods every drawn building: LScape::draw (0x506330, frustum in_view gate) -> DrawBlock (0x5a17c0, per-cell view gate) -> DrawSortCell (0x59f140) -> DrawBuilding (0x59f2a0) installs the building's CBldPortal list unconditionally, and PView::ConstructView (0x5a59a0) gates each portal purely on viewer plane-side vs F_EPSILON matched to portal_side, GetClip non-emptiness, CEnvCell::GetVisible loaded-cell lookup (0x52dc10), and copy_view success — no flood-specific distance constant; retail's only distance bounds (LScape block window, degrade-slot null check) remove the entire building from view, so flood eligibility always equals building visibility and a visible aperture can never pop. User-visible consequences: interiors missing (static filling quads instead of through-the-door views) at >48m through visible doors/windows, and a threshold pop for an outdoor viewer whose eye jitters across the 48m seed boundary. NOT a cause of #109 as filed — #109 is an indoor-root across-the-room draw-order/depth oscillation (~10m) where the 48m path never executes (RetailPViewRenderer.cs:60). Port shape stands: replace the 48m+ring predicate with retail's gates (seed every frustum-passed candidate building; per-portal plane-side + clip-non-empty + cell-loaded), noting the candidate gather must widen from the 1-LB ring in the same change since the ring becomes binding once 48m is lifted.
- verifier notes: RETAIL SIDE — re-derived from Ghidra decompiles (not BN pseudo-C), chain now VTABLE-VERIFIED, and it checks out:
(1) LScape::draw (0x506330): iterates the full block_draw_list (mid_width^2 blocks) and draws every block with in_view != OUTSIDE (set by draw_check_blocks 0x505f80); the per-block dispatch is render_device vtable+0x50 = 0x7e5550 = DrawBlock (xref-confirmed).
(2) RenderDeviceD3D::DrawBlock (0x5a17c0): per-cell loop gated only on the cell's own view check (cell vtable+0x68) or alwaysDrawObjects, then calls this vtable+0x58 = 0x7e5558 = DrawSortCell (xref-confirmed). No distance term.
(3) RenderDeviceD3D::DrawSortCell (0x59f140): if (cell->building) call vtable+0x68 = 0x7e5568 = DrawBuilding (xref-confirmed).
(4) RenderDeviceD3D::DrawBuilding (0x59f2a0): FIRST line installs outdoor_pview->outdoor_portal_list = building->portals (unconditional), then CPhysicsPart::UpdateViewerDistance (LOD/degrade selection) and a degrade-slot gfxobj null check before drawing the parts. The flood is triggered by portal polys encountered during the part draw: RenderDeviceD3D::DrawPortal (0x59f0e0) -> PView::DrawPortal (0x5a5ab0) -> PView::ConstructView(CBldPortal) (0x5a59a0).
(5) PView::ConstructView (0x5a59a0) gates, verified in the Ghidra decompile: (a) viewer-eye dot portal plane vs ::F_EPSILON producing Sidedness, matched against portal_side (POSITIVE required for side 0, NEGATIVE for side 1); (b) GetClip output non-empty; (c) CEnvCell::GetVisible(other_cell_id) non-null — decompiled 0x52dc10: a pure hash lookup in visible_cell_table, i.e. a loaded-cell check, NOT a distance check; (d) copy_view success. NO distance term anywhere in the flood path. Citation fix: the claim attributed 0x5a59a0 to CEnvCell::GetVisible — 0x5a59a0 is ConstructView itself; GetVisible is 0x52dc10/0x52ad40.
ACDREAM SIDE — all cited lines verified against the working tree:
- RetailPViewRenderer.cs:30 `OutdoorBuildingSeedDistance = 48f`; :60 MergeNearbyBuildingFloods runs only for IsOutdoorNode roots; :137-144 per-building ConstructViewBuilding(group, ..., 48f); PortalVisibilityBuilder.cs:548-554 ConstructViewBuilding is a passthrough to BuildFromExterior(maxSeedDistance); :426-428 `seedDistance > maxSeedDistance => continue` skips the exit-portal seed (seedDistance = camera-to-nearest-portal-vertex, :350/:426).
- Candidate gather: GameWindow.cs:7463-7477 (live R-A2 path, Chebyshev<=1 landblocks around the PLAYER landblock -> _outdoorNodeBuildingCells -> ctx.NearbyBuildingCells at GameWindow.cs:7610) and GameWindow.cs:7759-7776 + 7795 (legacy look-in path, same ring + MaxSeedDistance=48f, only runs when clipRoot is null). Grep over src confirms 48f is passed at BOTH production call sites and the PositiveInfinity defaults are never used in production.
- Building EXTERIORS draw via the normal entity path out to the near-tier streaming radius (N1=4 LBs), far beyond 48m — so acdream renders a building whose doorway aperture is visible at e.g. 100m but never floods its interior. Post-124cb6cc (DrawingBSP filter revert) the baked portal-filling quads draw unconditionally, so such doors show the static filling quad instead of retail's conditional through-the-aperture interior — partially masked, still not retail.
ADJUSTMENTS (why not 'confirmed'):
(A) The #109 blast-radius attribution is WRONG. #109 as filed (docs/ISSUES.md:3694-3706) is an INDOOR-root scenario: standing INSIDE a Holtburg house looking at the other exit door across the room (~10m), oscillating between door texture and background, explicitly suspected as OutsideView-slice / doorway depth-clear / door-entity draw-order interaction, and explicitly noted as distinct from the (fixed) flood strobe. On an indoor root MergeNearbyBuildingFloods never executes (RetailPViewRenderer.cs:60 gate) — the 48m predicate is not in the code path at all. The 48m-boundary jitter-pop mechanism is real but applies to an OUTDOOR viewer near 48m from a doorway; no filed issue currently matches it.
(B) Retail wording needs one nuance: retail's flood eligibility IS bounded by distance indirectly — (i) the LScape block window (block_draw_list spans the landscape draw radius) and (ii) DrawBuilding's degrade-slot null check after UpdateViewerDistance can suppress the whole building draw, not just mesh choice. But both bounds coincide exactly with "the building is drawn at all": a building too far to draw shows no aperture either, so no pop is ever user-visible. The correct invariant: flood eligibility == building visibility; retail has NO flood-specific distance constant.
(C) The Chebyshev<=1 ring is non-operative today: ring coverage is >=192m in every direction from the player while the 48m gate binds first (the GameWindow.cs:7755-7758 comment says exactly this). It becomes the binding constraint only once the 48m is lifted — the port shape must widen the gather to frustum-passed candidates in the same change (the claimed port shape already says this; correct).
The core divergence is REAL, not behaviorally-equivalent, and not handled elsewhere (grep confirms no other interior-flood path outdoors). Severity high stands on the artifact class (interiors absent through any visible aperture beyond 48m + threshold pop at the 48m boundary, both breaking the one-drawing-discipline invariant), but the headline user-bug tie to #109 must be dropped.
- blastRadius: #109's 'far' dimension: an exit door near the 48m seed boundary (or outside the 1-LB ring) flickers between flooded (interior/outdoor view through the aperture) and not flooded (filling quad / background) as the eye jitters — retail's gate is purely geometric visibility so a visible distant door never pops. Also explains interiors visibly missing through distant buildings' windows/doors at >48m.
- retailEvidence: DrawBuilding runs for every cell whose block passed block_check (LScape::draw 0x506330 → DrawBlock 0x5a17c0 → DrawSortCell 0x59f140 → DrawBuilding 0x59f2a0) — every in-view building gets the portals-only walk regardless of distance; ConstructView(CBldPortal) gates only on viewer plane-side (epsilon 2e-4), GetClip non-emptiness, and CEnvCell::GetVisible (Ghidra 0x005a59a0). The part-level LOD (UpdateViewerDistance/deg_level, 0x59f2bc-0x59f2d3) affects mesh choice, not flood eligibility.
- acdreamEvidence: RetailPViewRenderer.cs:30 (OutdoorBuildingSeedDistance=48f) + 137-144 (per-building ConstructViewBuilding) + GameWindow.cs:7472 (Chebyshev≤1 LB candidate gather) + 7795 (MaxSeedDistance=48f on the legacy look-in); PortalVisibilityBuilder.cs:426-427 (NearestPortalVertexDistance > maxSeedDistance ⇒ skip seed).
- portShape: Replace the distance cutoff with retail's gates: seed every candidate building whose landblock/cell passed the frustum cull, gate per portal on plane-side + clipped-view non-emptiness + cell-loadedness. The R-A2 per-building grouping itself is retail-faithful (one ConstructView per CBldPortal) and KEEP-listed — only the eligibility predicate diverges. Perf guard: the view-clip gate rejects far/off-screen portals cheaply, which is exactly retail's mechanism.
### [MEDIUM] entity-cull-no-portal-viewcone (confirmed) — Entities/objects are culled by frustum + cell membership only; retail additionally sphere-tests every drawn part against the CURRENT portal view's planes (viewconeCheck)
- correctedClaim: Confirmed as claimed, with three precision upgrades for the port plan: (1) the DrawObjCellForDummies call site is 0x005a4b0d (pc:432878), with Render::PortalList assigned in the same statement region; (2) retail's gate is a per-SLICE loop — DrawMesh (0x005a0860) iterates PortalList->view_count, set_view (0x0054d0e0) per slice, viewconeCheck (0x0054c250) per slice, and skips the part only when OUTSIDE in ALL slices — plus a once-per-frame part dedup (DrawMeshInternal 0x0059f360, GetDrawnThisFrame/SetDrawnThisFrame, player parts exempt) that a faithful port must reproduce; (3) acdream's indoor per-cell entity path is weaker than claimed: it bypasses even the frustum/AABB cull (RetailPViewRenderer.cs:465+474 pass the entry's own landblock as neverCullLandblockId into WbDrawDispatcher.cs:593-595/:662), so entities there are gated by cell membership alone.
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (127.0.0.1:8081), per the BN-invented-branch warning:
1. Render::viewconeCheck @ 0x0054c250 (Ghidra decompile): scales the part's drawing sphere by object_scale, transforms center to viewer space (Position::localtoglobal + Frame::globaltolocal vs viewer_pos), tests the signed distance against viewer_world_space.CY (returns OUTSIDE if dist < -radius), then loops `portal_npnts` planes at `Render::portal_vertex` (24-byte view_vertex stride; plane read as N.x/N.y/N.z/d), returning OUTSIDE on the first fully-behind plane, else PARTIALLY_INSIDE or ENTIRELY_INSIDE. Exactly as claimed it is a sphere-vs-plane-set BoundingType test, no geometry modification.
2. PView::DrawCells @ 0x005a4840 (Ghidra decompile) stage 3 (the loop that runs when the shell/portal stages are done): for each cell in cell_draw_list, `Render::PortalList = (cell->portal_view).data[cell->num_view - 1]` immediately before the render-device vtable call; the callee is RenderDeviceD3D::DrawObjCellForDummies — confirmed by pc:432878 (call at 0x005a4b0d; the claim's "0x5a4b07" is the same statement, off by 6 bytes) and the vtable slot assignment at pc:1037072.
3. Full chain to the per-part cull, every link decompiled: DrawObjCellForDummies @ 0x005a0760 (UpdateObjCell + CShadowPart::insertion_sort + vtbl) → DrawObjCell @ 0x005a1a40 → DrawPartCell @ 0x005a07a0 (iterates the cell's shadow_part_list) → CShadowPart::draw @ 0x006b50d0 → CPhysicsPart::Draw @ 0x0050d7a0 → vtbl+0x70 = RenderDeviceD3D::DrawMesh @ 0x005a0860.
4. DrawMesh @ 0x005a0860 (Ghidra decompile) is the gate: when Render::PortalList != null it loops i over PortalList->view_count, calls Render::set_view(&PortalList->view, i) — set_view @ 0x0054d0e0 installs that slice's portal_npnts/portal_vertex/inmask/xy-bounds — then viewconeCheck(gfxobj->drawing_sphere). A view returning OUTSIDE is skipped; if ALL views return OUTSIDE the function returns OUTSIDE_VIEWCONE_ODS without drawing. Slightly richer than the claim (a per-SLICE loop, plus a building_view filter), but fully consistent with "cell contents culled per portal-clipped view".
5. "Cull-only, never hard-clip" confirmed: DrawMeshInternal @ 0x0059f360 ignores the BoundingType argument on the built-mesh path and submits the whole constructed mesh (D3DPolyRender::DrawMesh). Bonus port-relevant detail: it dedups parts once-per-frame via CPhysicsPart Get/SetDrawnThisFrame with player-object parts EXEMPT — a part straddling cells draws under the first cell/view that passes, so a faithful port needs the frame-stamp semantics too.
ACDREAM SIDE — read the production code and call sites:
6. RetailPViewRenderer.cs:439-450 UseIndoorMembershipOnlyRouting: clears entity clip routing (`_entities.ClearClipRouting()`); the comment explicitly names Render::viewconeCheck as retail's mechanism and the character-slicing rationale for not hard-clipping — and no cull substitute exists anywhere (grep "viewcone" across src = comments only, RetailPViewRenderer.cs:371,442).
7. The divergence is actually slightly UNDERSTATED: in the indoor per-cell path, DrawCellObjectLists (RetailPViewRenderer.cs:401-426) → DrawEntityBucket (:460-477) builds the landblock entry with lbId = ctx.PlayerLandblockId (:465) AND passes neverCullLandblockId: ctx.PlayerLandblockId (:474) — so WbDrawDispatcher.WalkEntitiesInto's landblock frustum cull (WbDrawDispatcher.cs:593-595) and per-entity AABB frustum cull (:657-666, gated by `entry.LandblockId != neverCullLandblockId` at :662) are BOTH bypassed. The per-cell indoor entity path is gated by cell membership ONLY (EntityPassesVisibleCellGate, WbDrawDispatcher.cs:1816-1835). The claimed "frustum + 5m AABB" (PerEntityCullRadius=5.0f at :208-210) describes the general outdoor path.
8. Particles: GameWindow.cs:9553-9580 DrawRetailPViewCellParticles scissors to sliceCtx.Slice.NdcAabb via BeginDoorwayScissor (:9569) with clip distances explicitly disabled (:9568); the :9701-9706 comment confirms particle.vert has no gl_ClipDistance. AABB-of-slice scissor only — the AABB corners outside the actual portal polygon leak, feeding particles-through-walls.
9. Port-shape premise holds: the per-cell slice planes already exist at the entity draw site — GetCellSlicesOrNoClip (RetailPViewRenderer.cs:428-437) yields ClipViewSlice(Slot, NdcAabb, Vector4[] Planes) (ClipFrameAssembler.cs:40), currently consumed only by the particle scissor (:423-424), not by the entity draw.
REALITY OF THE DIVERGENCE: not behaviorally equivalent and not handled elsewhere. For the ROOT view retail's portal planes ≈ the screen frustum, so acdream's frustum cull is equivalent THERE; for any cell visible through a doorway the slice planes are strictly narrower than the frustum, and acdream has no test at all. User-visibility is amplified by acdream's own #113 shell clipping (RetailPViewRenderer.cs:374-380: outdoor-eye roots clip cell shells per slice via gl_ClipDistance) — a wall clipped away outside the slice no longer depth-occludes the unculled entity behind it, producing the statics/characters-visible-near-apertures class; particles leak at slice-AABB corners; and every non-culled entity is a wasted draw. Severity "medium" is fair today (depth test masks much of it for indoor-eye unclipped roots); it trends toward high once #114 lands pixel-exact indoor clip regions, because the more faithfully shells are clipped, the more the missing object cull shows.
- blastRadius: Objects in a visible cell draw at full screen extent even when the cell is visible only through a sliver of doorway — the 'statics/characters visible through walls near apertures' class, and wasted draws; particles inherit the same gap (AABB scissor only, no plane clip), feeding the particles-through-walls bug.
- retailEvidence: Render::viewconeCheck (Ghidra 0x0054c250) tests the object sphere against viewer_world_space.CY plus the active portal view's plane set (portal_npnts loop) and returns OUTSIDE/PARTIAL/INSIDE; DrawCells stage 3 installs Render::PortalList = the cell's portal_view before DrawObjCellForDummies (0x5a4b07), so cell contents are culled per portal-clipped view. Polygons are not hard-clipped — the test is cull-only.
- acdreamEvidence: RetailPViewRenderer.cs:439-450 UseIndoorMembershipOnlyRouting deliberately clears clip routing for entities (rationale: hard gl_ClipDistance slices characters, which retail does not do — correct observation, but it removed the CULL too); WbDrawDispatcher.cs:208-210/593-595 cull by frustum + 5m AABB only. Particle passes scissor to the slice AABB (GameWindow.cs:9569) with no plane clip (9701-9706 comment).
- portShape: Port viewconeCheck as a CPU sphere-vs-slice-planes test in the per-cell entity loop (the slice planes already exist in ClipViewSlice.Planes): skip the entity when fully outside every slice of its cell; never hard-clip. Apply the same test to per-cell particle emitters. Small, contained, and retail-faithful — it is a cull, not a clip.
### [MEDIUM] weather-gate-player-vs-viewer (adjusted) — Weather pass gating: retail draws weather only when the PLAYER is outside; acdream keys it on the viewer root's seen_outside, so rain draws through doorways while the player is inside
- correctedClaim: CONFIRMED divergence, corrected port shape. Divergence (as claimed, verified in Ghidra): retail gates the weather pass on the PLAYER being in an outdoor cell — GameSky::Draw @ 0x00506ff0 gate `is_player_outside() || pass==0`, with SmartBox::is_player_outside @ 0x00451e80 = `(player->m_position.objcell_id & 0xffff) < 0x100`; the gate is live on indoor frames because PView::DrawCells @ 0x005a4840 (pc:432719) calls LScape::draw whenever outside_view is non-empty. acdream gates both weather call sites (GameWindow.cs:9535 via renderSky param from 7423/7632, and 7881 via drawSkyThisFrame=renderSky at 7552) on the VIEWER root's seen_outside — so rain draws through the doorway slice (and on player-inside/camera-outside frames) while the player is indoors. CORRECTED port shape: gate the two RenderWeather calls on the retail predicate "player's cell id low word < 0x100" (player not in an EnvCell, false when no player exists) NOT on `playerRoot is null || playerSeenOutside` as originally proposed, because building interiors have SeenOutside=true, so that predicate stays true inside the inn and would leave the headline bug unfixed. The dome keeps the existing seen_outside-based renderSky gate (matches retail pass-0 behavior).
- verifier notes: RETAIL (all branch claims re-derived from Ghidra decompiles, not BN pseudo-C): (1) GameSky::Draw @ 0x00506ff0 — outer gate is literally `if ((SmartBox::is_player_outside(smartbox) != 0) || (param_1 == 0))`; the pass-1 body is `else if (LScape::weather_enabled) { render_device->vtbl[+100](this->after_sky_cell); }`. Weather (pass 1) therefore draws ONLY when is_player_outside; the dome/sky-object loop (pass 0) is exempt via `|| pass==0`. (2) SmartBox::is_player_outside @ 0x00451e80 — `return (this->player->m_position.objcell_id & 0xffff) < 0x100` (0 if player null): it keys off the PLAYER physics object's cell (indoor EnvCells have low word >= 0x100), NOT the viewer/camera — the load-bearing player-vs-viewer distinction is genuine, not a naming artifact. (3) LScape::draw @ 0x00506330 — GameSky::Draw(0) before the DrawBlock loop, GameSky::Draw(1) after, gated on weather_enabled. (4) PView::DrawCells @ 0x005a4840 (pc:432707-432719) — on indoor frames with outside_view.view_count > 0 retail calls LScape::draw, so GameSky::Draw(1)'s player gate is live precisely in the doorway-slice scenario: dome + landscape draw through the door, weather is suppressed while the player is in an EnvCell. ACDREAM (read at the cited lines + production call sites): GameWindow.cs:7423 `bool renderSky = viewerRoot is null || rootSeenOutside`, with rootSeenOutside = VIEWER cell SeenOutside (7320; viewer cell selected at 7301-7312 from RetailChaseCamera.ViewerCellId). The indoor doorway slice passes that same renderSky into DrawRetailPViewLandscapeSlice (call site 7624-7634; param at 9472), where it gates BOTH RenderSky (9485-9487) AND RenderWeather (9533-9536). The outdoor post-scene weather call (7874-7882) is gated on `clipRoot is null && drawSkyThisFrame` where drawSkyThisFrame = renderSky (7552) — also viewer-keyed. No player-outside predicate exists on any weather path: playerSeenOutside (7296) feeds only lighting (playerInsideCell 7337 → UpdateSunFromSky 7352), and SkyRenderer.RenderWeather (SkyRenderer.cs:136-144 → shared RenderPass:154) has no internal gate. DIVERGENCE IS REAL: player inside a building interior (seen_outside=true) with the doorway in view → retail draws no weather (is_player_outside=0) while acdream draws the rain/post-scene pass in the doorway slice; likewise player-inside/camera-outside frames draw full-screen weather in acdream but none in retail. Severity medium (cosmetic, weather-only) is fair. ADJUSTMENT: the claimed port shape is wrong in a load-bearing way — gating on `playerRoot is null || playerSeenOutside` (7296) would NOT fix the headline case, because building interiors have SeenOutside=true, so the predicate stays true inside the inn and weather keeps drawing. Retail's predicate is "player's objcell_id low word < 0x100" (player in an outdoor cell; false when player is null), which in acdream terms is the player's CurrCell NOT being an EnvCell (≈ `playerRoot is null` WITHOUT the `|| playerSeenOutside` disjunct), ideally derived from the player's cell id directly so the no-player case suppresses weather like retail. Keeping the dome on the existing renderSky/seen_outside gate matches retail pass-0 (dome ungated within LScape::draw; sealed dungeons never reach LScape::draw because outside_view is empty, which acdream's seen_outside=false reproduces).
- blastRadius: Cosmetic divergence at building doorways in rain/snow: retail shows no rain through the door while you are inside; we draw the weather cylinder through the slice. Also double-gates differently from the sky dome, which retail draws unconditionally in the landscape pass.
- retailEvidence: GameSky::Draw (Ghidra 0x00506ff0): pass gate `is_player_outside() || pass==0` (0x507009) — the dome (pass 0) draws even on indoor frames whose outside_view ran LScape::draw, the weather cell (pass 1, after_sky_cell at 0x5070da) requires is_player_outside.
- acdreamEvidence: DrawRetailPViewLandscapeSlice draws RenderWeather whenever renderSky (GameWindow.cs:9532-9541), and renderSky = viewerRoot null || rootSeenOutside (7423) — viewer-cell, not player-cell, and no is_player_outside equivalent on the weather half.
- portShape: Split the gate: keep the dome on the seen_outside/outside-view condition (matches retail pass-0), gate the RenderWeather calls (9535 and 7881) on the PLAYER-outside predicate (playerRoot is null || playerSeenOutside is already computed at 7296).
### [MEDIUM] unattached-particles-dropped-outdoors (adjusted) — On outdoor-node frames (normal outdoor play post-cutover) emitters with AttachedObjectId==0 are never drawn — the unattached-emitter pass only exists on the clipRoot==null safety path
- correctedClaim: Post-cutover, acdream's Scene-particle draws on clipRoot!=null frames (all normal in-world frames, outdoor and indoor) are gated by per-frame entity-membership sets AND a non-zero AttachedObjectId (GameWindow.cs:9528-9529, 9575-9576), whereas retail draws every shadow part in every in-view cell unconditionally with no attachment concept (PView::DrawCells 0x005a4840 → DrawObjCell 0x005a1a40 → DrawPartCell 0x005a07a0; emitters always have a parent physobj, makeParticleEmitter 0x0051cd80). However, the specific AttachedObjectId==0 population is EMPTY in production (every spawn path passes a non-zero key; sky emitters use non-Scene passes), so the ==0 exclusion is a LATENT trap (severity low — it will silently eat future world-positioned effects such as lightning #2), not an active regression; also the "legacy filtered branch" at GameWindow.cs:7851-7858 is unreachable (clipAssembly is non-null only when clipRoot!=null), the real safety path being the unfiltered draw at 7863-7867. The behaviorally ACTIVE divergence in the same predicates is the membership-set filter: emitters attached to partition.LiveDynamic entities (ParentCellId==null, InteriorEntityPartition.cs:39-40) belong to neither set and are never drawn on clipRoot!=null frames — that, not the ==0 clause, is the population retail would draw and acdream drops; port shape = route particle drawing off cell membership (the partition buckets) instead of attach-id set intersection, with an explicit bucket for LiveDynamic-attached and (if ever introduced) unattached emitters.
- verifier notes: RE-CHECKED ACDREAM: (1) Outdoor-node frames really are clipRoot!=null: _outdoorNode is built whenever viewerRoot is null && viewerCellId!=0 (GameWindow.cs:7458-7482), OutdoorCellNode.Build always returns a node (OutdoorCellNode.cs:23-30), clipRoot = viewerRoot ?? _outdoorNode (GameWindow.cs:7497); per the cutover comment (7488-7496) clipRoot==null only pre-spawn/login/legacy camera. (2) On clipRoot!=null frames the only Scene-pass particle draws are the landscape-slice pass (GameWindow.cs:9519-9529: AttachedObjectId != 0 && in _outdoorSceneParticleEntityIds, populated from sliceCtx.OutdoorEntities = partition.Outdoor per RetailPViewRenderer.cs:231+571-573) and the cell pass (GameWindow.cs:9558-9576: AttachedObjectId != 0 && in _visibleSceneParticleEntityIds from the per-cell bucket, RetailPViewRenderer.cs:424). ParticleRenderer.BuildDrawList applies pass+filter per emitter (ParticleRenderer.cs:182-187). So ==0 emitters are indeed never drawn on outdoor-node (and indoor) frames — the structural gate claim is CORRECT. (3) Mechanical correction: the cited "legacy filtered branch at 7856" is DEAD CODE — it requires clipRoot==null && clipAssembly!=null, but clipAssembly is only ever assigned non-null inside the clipRoot!=null branch (only assignments: GameWindow.cs:7501, 7532, 7665); on the real safety path the UNFILTERED global draw at 7863-7867 runs (also admits ==0). (4) BLAST RADIUS REFUTED: no production path spawns a Scene-pass emitter with AttachedObjectId==0. Sole factory ParticleSystem.SpawnEmitter (ParticleSystem.cs:32-64); sole production caller ParticleHookSink.SpawnFromHook always passes the entity key (ParticleHookSink.cs:226-232); key sources are EntityScriptActivator.OnCreate (guards key!=0, EntityScriptActivator.cs:97-98), the 0xF754 wire handler (GameWindow.cs:4974-4985 — guid addresses an object; PhysicsScriptRunner.Play has no zero-guard, PhysicsScriptRunner.cs:120-142, but guid==0 on the wire is not a known ACE behavior), and sky-PES synthetic ids which are SkyPre/PostScene pass (GameWindow.cs:5012-5016) and thus excluded from Scene draws by the pass check. The ==0 predicate filters an empty set today — latent trap (e.g. for future world-positioned lightning, issue #2), not an active invisible-particles regression. RE-CHECKED RETAIL via Ghidra (not BN pseudo-C): PView::DrawCells @ 0x005a4840 final stage iterates cell_draw_list and vtable-dispatches the per-cell object draw unconditionally; RenderDeviceD3D::DrawObjCell @ 0x005a1a40 forwards to DrawPartCell @ 0x005a07a0 which draws EVERY CShadowPart in the cell's shadow_part_list — no attachment filter. Stronger: retail cannot represent an unattached emitter at all — ParticleEmitter::makeParticleEmitter @ 0x0051cd80 null-guards the parent CPhysicsObj, ParticleManager::CreateParticleEmitter @ 0x0051b6c0 takes the parent physobj, and ParticleEmitter owns its own physobj living in cells (acclient.h:52469-52489, fields parent + physobj + parts). Retail draw is purely cell-membership-driven. SIBLING FINDING (the behaviorally active divergence hiding next to the claimed one): the same predicates require the attach id to be IN one of the two membership sets; emitters attached to partition.LiveDynamic entities (server entities with ParentCellId==null, InteriorEntityPartition.cs:35-49) are in neither set, so their effects (e.g. wire PlayScript on a moving player/NPC whose ParentCellId is unset) never draw on outdoor-node frames — that population is non-empty (GameWindow.cs:7813-7823 draws a real LiveDynamic bucket) and is what retail's unconditional cell draw would render. How often live dynamics have null ParentCellId outdoors was not fully settled (open question); entities with an outdoor ParentCellId DO land in partition.Outdoor (InteriorEntityPartition.cs:61-64) and their emitters draw.
- blastRadius: World-positioned particle effects (any emitter not attached to an entity) silently invisible during normal outdoor gameplay since the cutover; indoor too (per-cell filter also requires a non-zero attach id). A quiet regression class rather than a reported issue — worth a targeted visual check.
- retailEvidence: Retail draws cell contents (including particle-bearing objects) via DrawObjCell/DrawObjCellForDummies for every in-view cell unconditionally (DrawBlock 0x5a19e6, DrawCells stage 3 0x5a4b0d); there is no attachment-based filter — everything in a cell's shadow list draws.
- acdreamEvidence: GameWindow.cs:7846 gates the global Scene-particle pass on `clipRoot is null`; the clipRoot!=null replacements both require AttachedObjectId != 0 (slice pass 9528-9529, cell pass 9575-9576); only the legacy filtered branch at 7856 admits AttachedObjectId==0.
- portShape: Add the unattached-emitter draw to the clipRoot!=null frame (one extra ParticleRenderer.Draw with the `AttachedObjectId == 0` predicate after the slice/cell passes, scissored like the others), or fold unattached emitters into the outdoor bucket's slice pass. One-line predicate change once placed.
### [LOW] global-passes-vs-per-cell-interleave (confirmed) — Outdoor composition is global passes (terrain → interiors → entities) instead of retail's per-cell interleave (terrain cell → building+interior → objects → alpha flush) over far-to-near blocks
- verifier notes: RETAIL SIDE — re-derived from Ghidra decompiles (not BN pseudo-C) for every branch-sensitive element:
1. LScape::draw (0x506330, pc:267911-267951): GameSky::Draw(0) → iterate block_draw_list from index mid_width²-1 DOWN to 0, calling RenderDevice vtable DrawBlock per non-null block → weather sky pass. End-first iteration confirmed (esi_2 decrements from esi_1-1 to 0).
2. LScape::get_block_order (0x504c50, pc:266559-266655): block_draw_list[0] = the viewer's block (0x504c8a), then a ring loop (edi_1 = ring radius 1..max, four quadrant-symmetric writes per step) fills outward. Viewer-first rings confirmed → end-first iteration in draw = far→near at BLOCK granularity. (Within a block, cells iterate in plain array order, NOT distance-sorted — the claim's wording "over far-to-near blocks" is correct.)
3. RenderDeviceD3D::DrawBlock (0x5a17c0, pc:430021+, claimed pc:430027 — checks out; Ghidra decompile confirms): two per-cell loops. Loop 1: per cell, if in-view and has shadow objects → UpdateObjCell + CShadowPart::insertion_sort. Loop 2: per cell — SetSurfaceArray(terrain), landscape_detail_surface swap (ONLY when side_cell_count==8, i.e. full-LOD block; src_blend=5/dst_blend=6 at 0x5a199b-0x5a19b6) → vtable+0x54 DrawLandCell → vtable+0x58 DrawSortCell → FlushAlphaList(flush) gated on the global float `flush` vs 1.0 (Ghidra: `(flush < 1.0) != (flush == 1.0)`, i.e. flush ≤ 1.0; the global defaults to 0.75 at pc:1106050, so the per-cell flush IS active in the default config).
4. RenderDeviceD3D::DrawSortCell (0x59f140, Ghidra): if (cell->building) DrawBuilding(building); then DrawObjCell(cell). Exactly "building then objects".
5. RenderDeviceD3D::DrawBuilding (0x59f2a0, pc:427938-427961): sets outdoor_pview->outdoor_portal_list = building->portals; swaps Render::curr_detail_surface = building_detail_surface at 0x59f2eb (claimed address confirmed) with src_blend=9/dst_blend=6; calls FlushAlphaList(0f) at 0x59f30b BEFORE drawing the building (an extra flush boundary the claim didn't mention); CPhysicsPart::Draw(part,1) → DrawMeshInternal (0x59f360) → BSPTREE::build_draw_portals_only — the conditional portal-poly path; interiors recurse inline via RenderDeviceD3D::DrawPortal (0x59f0e0) → PView::DrawPortal(outdoor_pview) (0x59f109). So "building+interior" within the cell iteration is accurate. Interiors also swap their own environment_detail_surface (RenderDeviceD3D::DrawEnvCell 0x59f170 at 0x59f1c2) — the detail-state divergence covers interiors too, not just buildings.
ACDREAM SIDE — all cited lines verified against production code:
1. GameWindow.DrawRetailPViewLandscapeSlice (src/AcDream.App/Rendering/GameWindow.cs:9465-9551; note: GameWindow.cs lives under Rendering/): sky (9486) → ALL terrain in one call `_terrain?.Draw(...)` (9496) → ALL outdoor entities in ONE WbDrawDispatcher.Draw over sliceCtx.OutdoorEntities (9503-9511) → outdoor-entity particles (9523) → weather (9535). Global passes confirmed.
2. TerrainModernRenderer.Draw (src/AcDream.App/Rendering/TerrainModernRenderer.cs:206-295): builds one DEIC array over every frustum-visible slot and issues a single glMultiDrawElementsIndirect (288-292). Claim's ":206-299" checks out.
3. Interiors drawn AFTER the landscape slice: RetailPViewRenderer.DrawInside (src/AcDream.App/Rendering/RetailPViewRenderer.cs:93-106) — DrawLandscapeThroughOutsideView (93) then DrawEnvCellShells (104-105) then DrawCellObjectLists (106). Claimed :104-106 confirmed.
4. WbDrawDispatcher sorts opaque front-to-back / translucent back-to-front per invocation (src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1203-1204; comparators :1436-1446, cull-mode-major then distance asc/desc). Claimed :1199-1204 confirmed (1197-1202 is the comment).
5. No detail-surface analog exists anywhere in src/ — grep for detail_surface|DetailSurface|detail_tiling|DetailTiling over src/ returns zero hits. The building/landscape/environment micro-detail overlay system is wholesale absent, confirming that half of the blast radius.
DIVERGENCE IS REAL and not behaviorally equivalent in the abstract: retail = per-block far→near, per-cell terrain→building(+inline portal-recursed interiors)→objects→alpha-flush; acdream = global terrain MDI → global outdoor-entity dispatch → all interior shells → per-cell interior objects. Severity "low" is defensible: opaque ordering is fully masked by the depth buffer, no named issue (#99/#108/#109/#113/#114) is attributable to this divergence, and the KEEP-listed bindless-MDI architecture rules out a literal per-cell draw port anyway.
REFINEMENTS (recorded, not verdict-changing):
(a) The claim's concrete mis-order example ("alpha terrain/water vs building alpha at cell boundaries") is UNVERIFIED and likely moot — TerrainModernRenderer has no translucent/blend path at all (single opaque MDI). The verified concrete translucency consequence is sharper: translucent OUTDOOR entities are blended at GameWindow.cs:9508 BEFORE interior shells/objects exist in the framebuffer (RetailPViewRenderer.cs:104-106), inverting retail's order at doorways — a translucent outdoor object in front of a door aperture blends against sky/terrain instead of the interior behind it. Latent artifact class, not currently reported; consistent with severity low.
(b) Retail has an additional alpha-flush boundary the claim missed: FlushAlphaList(0f) inside DrawBuilding (0x59f30b) before every building draw, and the per-cell flush is conditional on the global flush ≤ 1.0 (default 0.75).
(c) acdream's INTERIOR side already does a per-cell far→near interleave (DrawEnvCellShells iterates IndoorDrawPlan.ShellPass per cell, RetailPViewRenderer.cs:382-394; DrawCellObjectLists iterates OrderedVisibleCells in reverse per cell, :408-425) — the global-pass divergence is specifically the OUTDOOR composition plus the outdoor-before-interiors pass ordering.
(d) The missing detail-surface system is real but is a standalone visual-fidelity gap (retail swaps it for terrain 0x5a199b, buildings 0x59f2eb, AND interiors 0x59f1c2) — it deserves its own low/polish line item independent of composition order, since it could be added to the MDI architecture without per-cell draws.
PORT SHAPE judgment: agreed — no correctness port needed; depth buffering substitutes for the opaque interleave, and a faithful translucency fallback (an alpha-flush/sort boundary keyed per building, or simply documenting the doorway translucent-entity case as the trigger to file) does not require abandoning the KEEP-listed MDI pipeline.
- blastRadius: Mostly masked by the depth buffer; visible only in translucent ordering (retail's per-cell FlushAlphaList sorts alpha against the just-drawn cell; our two-pass alpha-test + per-draw sort can mis-order alpha terrain/water vs building alpha at cell boundaries) and in the absence of the per-building detail-surface state retail swaps in (building_detail vs landscape_detail tiling, 0x59f2eb vs 0x5a199b).
- retailEvidence: DrawBlock (pc:430027): per cell DrawLandCell → DrawSortCell(building, objects) → FlushAlphaList(flush); LScape::draw iterates block_draw_list end-first (far→near, get_block_order 0x504c50 builds viewer-first rings).
- acdreamEvidence: GameWindow.cs:9494-9512 draws ALL terrain (TerrainModernRenderer.Draw, one MDI over every visible slot, TerrainModernRenderer.cs:206-299) then ALL outdoor entities; building interiors drawn afterward in DrawEnvCellShells/DrawCellObjectLists (RetailPViewRenderer.cs:104-106); alpha = two-pass alpha-test model (CLAUDE.md KEEP-list), opaque front-to-back / transparent back-to-front sort (WbDrawDispatcher.cs:1199-1204).
- portShape: No port needed for correctness given the KEEP-listed bindless MDI architecture — depth buffering substitutes for the interleave. File only if a concrete translucency mis-order is observed; the faithful fallback is a per-building alpha flush boundary (sort key extension), not a return to per-cell draws.
## OPEN QUESTIONS
- Where does retail draw the TEXTURED portal-filling quad (the visible door/window filling)? Verified: the shell pass draws the constructed mesh with no portal logic (DrawMeshInternal 0x59f3f4 → D3DPolyRender::DrawMesh 0x59d4a0), the portals-only pass uses only modes 1 (far punch) and 2 (flood, no poly) (0x59f3cc/0x59f3d9), and DrawPortal's mode-3 fail-fill draws the poly INVISIBLE (maxZ2 bit1 ⇒ alpha 0). If the built MeshBuffer excludes node.Portals polys (consistent with e223325's node.Polygons finding), nothing in the traced built-mesh path ever draws the filling textured — yet doors/windows are visibly filled in retail. Candidates not yet traced: MeshBuffer/constructed-mesh build including portal polys as subsets with a runtime skip I did not find; the legacy non-built-mesh BSP draw path (RenderDeviceD3D::DrawMesh 0x005a0860 / BSPNODE draw walk); or the maxZ1/maxZ2 globals being reconfigured at startup from the registry ('RenderD3D.*' strings near 0x7e5594) so the fail-fill is not invisible in practice. This is THE blocking question for the portal-poly-conditional-draw port and needs a dedicated trace (Ghidra xrefs on MeshBuffer construction + cdb on maxZ1/maxZ2 at runtime).
- Does CCellStruct.polygons (the EnvCell shell submitted with planeMask=0xffffffff at pc:427922) include the cell-side portal polygons, or are cell portals (CCellPortal) excluded from the shell the way building portals are excluded from node.Polygons? Determines whether our EnvCell meshes need the same portal-poly separation as building GfxObjs for #109's indoor case.
- CShadowPart::insertion_sort's exact key (assumed viewer distance from the UpdateViewerDistance calls at 0x5a17c0/0x59f2bc) and whether CShadowPart::draw itself calls Render::viewconeCheck per part or relies on a BoundingType computed earlier — I did not decompile CShadowPart::draw. Affects only the fidelity note on the entity-cull divergence, not its existence.
- The precise fragment source of #108's grass (which terrain triangles produce the sweeping fragments when the eye is below outdoor terrain): terrain inherits the frame's back-face cull (GameWindow.cs:7162-7163, TerrainModernRenderer sets no cull state of its own), so under-surface fragments should be culled; the structural divergences (AABB/8-plane clip slop + clear-after-slices ordering + no fence) are the named suspects, but a RenderDoc capture of one #108 frame is needed to pin which one paints the visible grass.
- DrawPortal modes: is there any runtime path that calls build_draw_portals_only / DrawPortal with mode 3 (the fail-fill mode) — e.g. a degrade/option-driven variant of CPhysicsPart::Draw — or is mode 3 dead code in the 2013 client? Ghidra xrefs on the thunk found no third call site, but virtual dispatch may hide one.
- Retail's CEnvCell 'GetDrawnThisFrame' guard in DrawEnvCell (0x59f17e) means a cell drawn through multiple views draws its shell only ONCE (first view's clip) — seemingly at odds with the per-view setup_view loop in DrawCells stage 2. Whether the guard is per-view-stamped (reset by setup_view) or genuinely once-per-frame changes how our per-slice shell loop (RetailPViewRenderer.cs:388-393, draws once PER SLICE) should be shaped; not yet traced into SetDrawnThisFrame/num_view interaction.
- forceClear (0x8ed824, init 0) and the portalsDrawnCount gate: confirm forceClear is debug/registry-only so the production behavior is exactly 'clear Z iff at least one fence/landscape portal poly drew this frame' — relevant to porting the clear gate faithfully.

View file

@ -0,0 +1,149 @@
# AREA 1 — GfxObj draw path (per-part mesh draw, drawing-BSP portal polys, clipping, degrades, viewcone)
## RETAIL
RETAIL'S GFXOBJ DRAW IS A FLAT MESH DRAW, NOT A PER-FRAME BSP WALK — WITH ONE EXCEPTION: PORTAL POLYS.
1. LOAD TIME (the big surprise). CGfxObj::InitLoad (Ghidra 0x005346b0, pc:318765-318789) does two things when running in the live client: (a) BSPTREE::RemoveNonPortalNodes(drawing_bsp) (Ghidra 0x0053a040) — it DELETES every non-portal node from the drawing BSP, leaving only a skeleton chain of portal-bearing nodes; (b) D3DPolyRender::ConstructMesh(this, &constructed_mesh) (wrapper Ghidra 0x0059ea90) which flattens the GfxObj into a D3DX mesh ("constructed_mesh", a MeshBuffer) from the FULL polygon dictionary: ConstructMesh(num_surfaces, m_rgSurfaces, &vertex_array, num_polygons, polygons, 1.0, false, out) (pc:427543). The inner ConstructMesh (Ghidra 0x0059dfa0) iterates ALL CPolygons (stride 0x30, struct acclient.h:31855-31869: vertices/vertex_ids/poly_id/num_pts/stippling/sides_type/pos+neg_uv_indices/pos_surface/neg_surface/plane), batching triangles per pos_surface/neg_surface (pc:426842-426871); I found NO portal-poly or stippling-based skip in it — portal fill quads land in the constructed mesh too. So retail itself is "flatten at load, draw the flat mesh per frame" — the same architecture acdream/WB uses. The runtime drawing BSP exists ONLY to drive portal polygons. EnvCells get the same treatment at CEnvCell::UnPack (ConstructMesh call at pc:311085, Ghidra 0x0052d875, detail-tiling 3.0).
2. PER-FRAME CHAIN FOR AN ORDINARY OBJECT. CPhysicsPart::Draw(part, mode) (Ghidra 0x0050D7A0, pc:274964): skip if hidden (draw_state & 1) or already drawn this render frame (m_current_render_frame_num == RenderDevice frame stamp; bypassed when mode!=0). Pick LOD: deg_level clamped to degrades->num_degrades else 0; mesh = gfxobj[deg_level]. Set part material, part surface array, object scale, then virtual RenderDevice::DrawMesh(mesh, &draw_pos, mode!=0) (vtable +0x70 = RenderDeviceD3D::DrawMesh, vtable dump pc:1037075). RenderDeviceD3D::DrawMesh (Ghidra 0x005a0860, pc:429245): if Render::PortalList == NULL (plain outdoor view) → one Render::viewconeCheck(gfxobj->drawing_sphere); skip if OUTSIDE. If PortalList != NULL (we are inside a portal-view context — PView::DrawCells sets PortalList=pview at pc:432718, and the per-cell object pass sets PortalList = that cell's view stack at pc:432877) → LOOP over every view: Render::set_view(view, i) (Ghidra 0x0054d0e0, pc:343750), viewconeCheck per view, draw per passing view (building_view filter). viewconeCheck (Ghidra 0x0054c250) is a SPHERE test of the object's drawing_sphere against the CURRENT VIEW's plane set (portal_vertex/portal_npnts installed by set_view): returns OUTSIDE / PARTIALLY_INSIDE / ENTIRELY_INSIDE. Then RenderDeviceD3D::DrawMeshInternal (Ghidra 0x0059f360, pc:427965-428002): non-player parts dedup via GetDrawnThisFrame/SetDrawnThisFrame; if gfxobj->use_built_mesh and mode==0 → D3DPolyRender::DrawMesh(gfxobj, constructed_mesh) (pc:427999 → 0x0059d790 → inner 0x0059d4a0). The inner draw iterates SURFACE BATCHES, routing alpha/translucent/clipmap batches to the deferred AlphaList per s_AlphaDelayMask (config string "Alpha=2, Translucent=4, ClipMap=8", pc:1037103) and hardware-drawing the rest via RenderMeshSubset. CRITICAL DETAIL: with global skipNoTexture (data-section default 1, pc:1105971 @0x00820e30), a surface batch whose CSurface type has neither Base1Image (0x2) nor Base1ClipMap (0x4) — i.e. an UNTEXTURED/SOLID-COLOR surface — is SKIPPED whenever RenderDeviceD3D::ObjBuildingOrBuildingPart==1 (building shell pass) or the cell flag arg is set (Ghidra 0x0059d4a0 at 0x0059d4f1; cell call site passes 1 at pc:427905); plain objects still draw solid batches. There is NO per-polygon geometric clipping anywhere on this mesh path — meshes are sphere-culled per view and hardware-frustum-clipped, exactly as claimed (Q5 CONFIRMED).
3. BUILDINGS — THE TWO-PASS PORTAL DISCIPLINE (Q2, the door-vanish answer). RenderDeviceD3D::DrawBuilding (Ghidra 0x0059f2a0, pc:427938-427961): set outdoor_pview->outdoor_portal_list = building->portals (CBldPortal list, struct acclient.h:32094); CPhysicsPart::UpdateViewerDistance(shell part) — buildings DO degrade; FlushAlphaList; then CPhysicsPart::Draw(part, 1) — the PORTAL-ONLY draw — then ObjBuildingOrBuildingPart=1; CPhysicsPart::Draw(part, 0) — the shell constructed mesh (with solid batches skipped). The mode-1 path (DrawMeshInternal pc:427988-427996) runs BSPTREE::build_draw_portals_only(drawing_bsp, 1) then (drawing_bsp, 2) (Ghidra 0x00539860; recursion BSPNODE 0x0053c100 / BSPPORTAL 0x0053d870). The walk classifies the VIEWER's eye against each node's splitting plane (dot(N, viewpoint)+d vs ±epsilon → front/back/on, node tags 'PORT'/'LEAF' 0x504f5254/0x4c454146) and, at each BSPPORTAL node (struct acclient.h:57768-57772: num_portals + CPortalPoly** in_portals; CPortalPoly = {portal_index, CPolygon* portal} acclient.h:39075-39079, wired to the SAME polygon dictionary at unpack, BSPPORTAL::UnPackPortal pc:327247), submits each portal poly via vtable DrawPortal (+0x4c, pc:1037066) = RenderDeviceD3D::DrawPortal (Ghidra 0x0059f0e0) → PView::DrawPortal (Ghidra 0x005a5ab0, pc:433895-433933).
THE GATE — PView::ConstructView(CBldPortal*, CPolygon*, int, pass) (Ghidra 0x005a59a0, pc:433827, Ghidra-confirmed): (a) SIDE TEST: compute which side of the portal polygon's plane the eye is on; CBldPortal::portal_side==0 requires POSITIVE, else NEGATIVE — wrong side ⇒ fail; (b) CLIP TEST: GetClip clips the portal polygon against the CURRENT view (screen frustum or accumulated portal view); fully clipped away ⇒ fail; (c) LOAD/VISIBILITY TEST: CEnvCell::GetVisible(other_cell_id) (Ghidra 0x0052dc10 — a lookup in the global visible_cell_table hash of loaded cells) must return the cell beyond ⇒ else fail; (d) Render::copy_view pushes the new through-portal view ⇒ else fail. ON SUCCESS with pass!=2 it calls D3DPolyRender::DrawPortalPolyInternal(poly, pass==1) and with pass!=1 recurses into the cell beyond. Back in PView::DrawPortal: success & pass!=1 ⇒ PView::DrawCells (draw the interior through the aperture); failure ⇒ draw NOTHING for passes 1/2 (the pass==3 fallback at pc:433914 has no live caller — the only DrawPortal call sites are the BSP walk with passes 1/2, pc:326951/326992).
WHAT "DRAWING THE PORTAL POLY" ACTUALLY IS: DrawPortalPolyInternal (Ghidra 0x0059bc90, pc:424490) is an INVISIBLE DEPTH-CONTROL DRAW, not a textured draw. With shipped defaults maxZ1=7 / maxZ2=6 (data section pc:1105964-1105965 @0x00820e14/18): vertex alpha computes to 0x00 (fully transparent under SRCALPHA/INVSRCALPHA), depth test ALWAYS, z-write ON; flag=true (pass 1, portal OPENS) forces Z to the far plane (0.99999988) — a "z-punch" that erases the depth buffer inside the aperture so the interior cells drawn in pass 2 land cleanly; flag=false writes the portal plane's OWN depth — a "z-seal" used by PView::DrawCells (Ghidra 0x005a4840, pc:432709-432889) on portals leading OUTSIDE (other_cell_id==0xFFFF, pc:432785-432786) so geometry beyond an unopened exit is depth-occluded. DrawCells also: draws the landscape through outside views (LScape::draw, pc:432719), conditionally CLEARS depth when seals were drawn (portalsDrawnCount, pc:432725-432732), draws cells BACK-TO-FRONT (reverse cell_draw_list) per view via DrawEnvCell, then per cell sets PortalList to the cell's views and draws cell objects (DrawObjCellForDummies, pc:432878). So: doors/windows/stair-apertures are all the SAME mechanism — an open portal punches depth and shows the room; an unopened one draws nothing (building passes) or a z-seal (cell exit portals); the visually-textured "fill" appearance comes only from the constructed mesh, where the skipNoTexture rule decides whether a fill batch (solid vs textured surface) draws at all.
4. PER-POLY CLIPPING / planeMask (Q3). Only the LEGACY non-built-mesh cell path does exact polygon clipping: RenderDeviceD3D::DrawEnvCell's slow path submits every cell polygon into Render::PolyList with planeMask=0xffffffff (pc:427913-427931). Render::set_view (Ghidra 0x0054d0e0) installs the active view's edge-plane array (portal_vertex, portal_npnts) and portal_inmask = (1<<(npnts+1))-1. ACRender::polyClipFinish (Ghidra 0x006b6d00, pc:702749) shifts the planeMask by (0x1e - npnts) and tests one bit per view plane as it walks them — bit set ⇒ geometrically clip the poly against that plane, bit clear ⇒ skip. planeMask=0xffffffff = "clip against every plane of the current portal view" (the conservative full clip); DrawPortalPolyInternal passes mask 0 (clip nothing — it draws depth-only with DEPTHTEST_ALWAYS, sloppy by design, because the shell mesh drawn afterwards repairs over-punched depth). Constructed-mesh draws never see planeMask.
5. DEGRADES (Q4). CPhysicsPart::UpdateViewerDistance (Ghidra 0x0050E030) computes eye distance to the part's sort_center and calls GfxObjDegradeInfo::get_degrade(dist/scale, &deg_level, &deg_mode) for EVERY part with a degrade table EXCEPT parts of the local player (player iid check ⇒ deg_level=0, full detail). Call sites: per-object draws via CPhysicsObj::UpdateViewerDistance (0x0051827a/0x005182b3), cell-object updates (UpdateObjCell 0x005a0712/0x005a073c), recursive part arrays (0x00510c53), AND DrawBuilding explicitly (0x0059f2bc). So distance LOD applies to building shells, statics, doors, NPCs — everything but the player. CPhysicsPart::LoadGfxObjArray (0x0050DCF0) loads the full gfxobj[] degrade array per part at setup; Draw then indexes gfxobj[deg_level].
## ACDREAM
ACDREAM'S EQUIVALENT CHAIN.
1. FLATTEN AT DECODE (matches retail's load-time ConstructMesh in shape). ObjectMeshManager.PrepareGfxObjMeshData (src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1012) iterates the FULL gfxObj.Polygons dictionary (line 1040) — portal fills included — batching by PosSurface/NegSurface with WB's stippling interpretation (NoPos suppresses the positive side at :1046; negative side per Negative/Both/NoNeg+CullMode at :1052-1058). DIVERGENCE INSIDE THE FLATTEN: solid surfaces are PROMOTED TO VISIBLE 32×32 solid-color textures and drawn (isSolid → TextureHelpers.CreateSolidColorTexture, :1075-1088) — retail SKIPS untextured batches on building shells and cells (skipNoTexture rule). The #113 filter experiment and its revert are memorialized in the comment block at :1020-1039. The EnvCell twin PrepareCellStructMeshData (:1370) flattens cellStruct.Polygons with NoPos/NoNeg suppression (:1393-1402) and the same solid-color promotion (:1430).
2. DRAW. WbDrawDispatcher.Draw (src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:702/:1403) walks landblock entries → entities → MeshRefs: per-landblock AABB frustum cull (:593-595), per-entity AABB frustum cull for statics (:657-665), per-entity classification cache, palette-hash memoization (:983), part-matrix composition (:1056+), then groups into indirect commands — opaque front-to-back sorted (camera position for sort :733, :1175, :1206), transparent after — and submits via glMultiDrawElementsIndirect in CullMode runs with uDrawIDOffset (:1478-1490). Visibility gating beyond the camera frustum is the clip-routing SLOT system (UseIndoorMembershipOnlyRouting / clip-slot resolution :314-:488): each instance is routed to the clip slice of its MEMBERSHIP cell or to OutsideView, or culled when its cell is not visible. There is NO per-portal-view loop, NO per-view sphere re-cull, and no equivalent of retail's PortalList iteration; entities draw at most once per frame against the single camera frustum.
3. PORTAL POLYS ARE NEVER DRAWN — only used as visibility math. PortalVisibilityBuilder/PortalProjection consume portal polygons to build clip regions and planes (src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:189-194/:413-416, PortalProjection.cs:54, CellVisibility.cs:56-74, GameWindow.cs:5708-5804). RetailPViewRenderer.DrawInside (src/AcDream.App/Rendering/RetailPViewRenderer.cs:44-110) orchestrates the flood (R-A2 per-building floods :118+), draws landscape through outside views, then shells (DrawEnvCellShells :346+, gl_ClipDistance per-slice clip enabled for OUTDOOR roots only — indoor roots draw unclipped pending #114, :399-410), then cell objects unclipped (comment :395-398 — deliberately matching retail's sphere-cull-not-hard-clip for meshes). The retail z-seal hook EXISTS but is DEAD: ctx.DrawExitPortalMasks is invoked (:95/:204/:325-341) yet declared nullable (:497/:534/:558) and never assigned by any production caller (repo-wide grep: no assignment). There is no z-punch (pass-1) equivalent anywhere, and no conditional portal-poly draw of any kind.
4. DEGRADES. GfxObjDegradeResolver.TryResolveCloseGfxObj (src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs:105-143) always returns Degrades[0] (close detail; the doc comment at :56-60 acknowledges no distance plumbing). Its ONLY production call site is GameWindow.cs:2610, gated by _options.RetailCloseDegrades && IsIssue47HumanoidSetup(setup) (GameWindow.cs:2605); the predicate (GameWindow.cs:302-313) matches only 34-part humanoid setups with null-part sentinels. Everything else — buildings, doors, statics, scenery, non-humanoid creatures — renders the BASE GfxObj id forever (WbDrawDispatcher trusts MeshRefs, comment :987-992; the scenery DIDDegrade reads at GameWindow.cs:5424-5428 are diagnostic-only). No per-frame deg_level selection exists.
5. BUILDING SHELLS are WorldEntities tagged IsBuildingShell hydrated from LandBlockInfo (GameWindow.cs:5258-5267), drawn through the same dispatcher path as any static; EnvCells render through EnvCellRenderer, not as entities (GameWindow.cs:5520-5530 comment, src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs).
## DIVERGENCES
### [CRITICAL] portal-poly-conditional-pass-missing (adjusted) — No per-frame portal-poly pass: retail's z-punch / z-seal / ConstructView gate on building+cell portal polys is entirely absent
- correctedClaim: CONFIRMED CORE (critical): retail runs a per-frame portal-poly depth pass that acdream entirely lacks. RenderDeviceD3D::DrawBuilding (Ghidra 0x0059f2a0) draws every building in two passes: first a portals-only walk of the load-time-stripped drawing BSP (RemoveNonPortalNodes 0x0053A040 in CGfxObj::InitLoad 0x005346B0; walk via build_draw_portals_only 0x00539860/0x0053c100 and BSPPORTAL::portal_draw_portals_only 0x0053d870, run twice — sub-pass 1 then 2 — from DrawMeshInternal 0x0059f360), submitting each BSPPORTAL.in_portals entry (acclient.h:57768) through RenderDeviceD3D::DrawPortal (0x0059F0E0) -> PView::DrawPortal (0x005A5AB0) -> the PView::ConstructView gate (0x005a59a0: eye-side vs CBldPortal::portal_side, GetClip vs current view, CEnvCell::GetVisible(other_cell_id), copy_view push). Gate success on sub-pass 1 ⇒ DrawPortalPolyInternal(poly,true) = invisible far-plane z-punch (mode maxZ1=7: alpha 0, DEPTHTEST_ALWAYS, z-write, z=0.99999988); success on sub-pass 2 ⇒ view recursion + DrawCells through the portal; failure ⇒ nothing. PView::DrawCells (0x005A4840) seals exit portals (other_cell_id==-1) with DrawPortalPolyInternal(poly,false) = invisible own-depth z-write (mode maxZ2=6). Acdream has zero of this machinery: no z-punch (only ColorMask in src/ is a state restore, GLStateScope.cs:208), DrawExitPortalMasks declared and invoked (RetailPViewRenderer.cs:325-341, :497/:534/:558) but never assigned at either production site (GameWindow.cs:7604-7663, :7781-7798). This is the missing depth discipline feeding #114 and the far-door residuals (#109). CORRECTION (the #113 half is reattributed): the per-frame pass is invisible-by-construction (alpha forced 0 in both default modes), so it does NOT explain doors/windows being visible — retail's visible portal-fill geometry comes from the unconditional flattened shell (ConstructMesh 0x0059ea90 consumes the full polygons array, no per-poly skip), same as acdream's flatten. The doors-visible vs phantom-stair-ramp-invisible split is instead produced by a STATIC retail mechanism the claim denied could exist: D3DPolyRender::DrawMesh (0x0059d4a0) skips untextured mesh subsets (CSurface.type & 6 == 0, i.e. no Base1Image/Base1ClipMap) when skipNoTexture=1 (default) and ObjBuildingOrBuildingPart=1 (set by DrawBuilding around the shell pass). Acdream instead draws those solid-color batches via synthesized 32x32 color textures (ObjectMeshManager.cs:1075-1088). So "no static poly filter can be correct" is wrong as stated: a static per-POLY BSP-reference filter (e46d3d9) is wrong, but retail's own visible-geometry filter is static — per-SURFACE texture presence, building-scoped. Port shape: (1) per-frame ConstructView-gated punch pass + exit-portal own-depth seal, preserving the punch-all-then-draw-interiors two-sub-pass order, never drawing fills visibly from that pass (correct as claimed); (2) ADD the building-scoped untextured-batch skip as the actual #113 visible-geometry fix; (3) pending dat verification that the stair-aperture fills are solid-color while door/window fills are image-textured.
- verifier notes: RETAIL SIDE — re-derived from Ghidra (127.0.0.1:8081), every load-bearing branch confirmed against the live decompile, not BN pseudo-C:
1. CONFIRMED two-pass DrawBuilding (Ghidra 0x0059f2a0): sets outdoor_pview->outdoor_portal_list = building->portals, FlushAlphaList, then CPhysicsPart::Draw(part,1) [portal pass] followed by ObjBuildingOrBuildingPart=1; CPhysicsPart::Draw(part,0) [shell pass].
2. CONFIRMED dispatch: CPhysicsPart::Draw (0x0050D7A0) -> render-device vtable+0x70 = RenderDeviceD3D::DrawMeshInternal (0x0059f360): portals_only=true runs BSPTREE::build_draw_portals_only(drawing_bsp,1) THEN (drawing_bsp,2) — TWO sub-passes — and returns without touching the mesh; portals_only=false draws constructed_mesh via D3DPolyRender::DrawMesh.
3. CONFIRMED walk: build_draw_portals_only 0x00539860 (BSPTREE) / 0x0053c100 (BSPNODE) / 0x0053d870 (actually named BSPPORTAL::portal_draw_portals_only, minor naming nit). Plane-side recursive walk submits in_portals[i] (BSPPORTAL {num_portals; CPortalPoly** in_portals} verified at acclient.h:57768-57772) via vtable+0x4c = RenderDeviceD3D::DrawPortal (0x0059F0E0) -> PView::DrawPortal(outdoor_pview,...).
4. CONFIRMED gate, PView::ConstructView(CBldPortal) (Ghidra 0x005a59a0): (a) eye-side-of-plane (dot(viewpoint,N)+d vs ±F_EPSILON) must match CBldPortal::portal_side (portal_side==0 requires POSITIVE, else NEGATIVE); (b) GetClip against clip_view, null clip ⇒ fail; (c) CEnvCell::GetVisible(other_cell_id) (0x0052DC10) must return a cell; (d) Render::copy_view push onto the cell's portal_view stack. Success: pass!=2 ⇒ DrawPortalPolyInternal(poly, pass==1); pass!=1 ⇒ recurse ConstructView(cell, other_portal_id). PView::DrawPortal (0x005A5AB0): success + pass!=1 ⇒ DrawCells(this,1); failure ⇒ nothing for passes 1/2 (pass 3 ⇒ DrawPortalPolyInternal(poly,false)). Claim's "DrawPortalPolyInternal(poly, pass==1) + DrawCells" is a compression: sub-pass 1 = punch only, sub-pass 2 = view push + recursion + DrawCells (all punches land BEFORE any through-portal interior drawing).
5. CONFIRMED z-punch/z-seal semantics, DrawPortalPolyInternal (0x0059bc90): mode word = maxZ1 (arg true) / maxZ2 (arg false); defaults maxZ1=7, maxZ2=6 verified in the data segment (0x00820e18/0x00820e14; pc:1105964-65 region). SetDepthBufferMode(DEPTHTEST_ALWAYS, zwrite=bit2 → on for both); vertex z forced to 0.99999988 (far plane) when bit0 (mode 7) else own z/w (mode 6); alpha bit = ~(mode<<30)&0x80000000 = 0 for BOTH default modes i.e. both draws are INVISIBLE (alpha-0 SRCALPHA/INVSRCALPHA), depth-only. (poly,true)=far-plane punch, (poly,false)=own-depth seal exactly as claimed.
6. CONFIRMED exit-portal seal, PView::DrawCells (0x005A4840): per cell view, portals with other_cell_id == -1 ⇒ DrawPortalPolyInternal(poly,false). Also clears Z (vtable+0x2c flag 4) when portalsDrawnCount!=0.
7. CONFIRMED load-time strip: BSPTREE::RemoveNonPortalNodes (0x0053A040) called from CGfxObj::InitLoad (0x005346B0, xref 0x005346c6), before ConstructMesh builds the flattened shell.
ACDREAM SIDE — all cited locations verified: ObjectMeshManager.cs:1041 iterates the FULL gfxObj.Polygons dictionary into the flattened batch mesh (e46d3d9 filter reverted, comment block :1020-1040); portal polys otherwise used only as clip math (PortalVisibilityBuilder.cs:186-199, PortalProjection.cs:54-62); DrawExitPortalMasks hook invoked (RetailPViewRenderer.cs:95, :204 -> :325-341) but null-guarded at :331 and NEVER assigned — both production context constructions (GameWindow.cs:7604-7663 DrawInside; :7781-7798 DrawPortal) omit it; repo-wide grep for `DrawExitPortalMasks\s*=` in src/ returns nothing. No z-punch exists: the only ColorMask reference in src/ is a state-restore (Wb/GLStateScope.cs:208); no DepthFunc(Always) draw anywhere.
THE CORE DIVERGENCE IS REAL AND CRITICAL: retail runs a per-frame, ConstructView-gated, depth-only portal-poly pass (punch + through-portal DrawCells + exit-portal seal) before every building shell draw, and acdream has none of it. The #114 / #109 / outside-looking-in depth-discipline blast radius stands.
WHY ADJUSTED — the #113 visible-geometry attribution does not survive: (a) DrawPortalPolyInternal NEVER draws visible color at default settings (alpha forced 0 in both modes 7 and 6), so the per-frame pass cannot be what makes doors/windows "usually visible" — it is depth-only by construction. (b) Retail's visible portal-fill geometry comes from the UNCONDITIONAL flattened shell: ConstructMesh(CGfxObj) (0x0059ea90) passes the FULL num_polygons/polygons array (loaded verbatim from the dat by CGfxObj::Serialize 0x00534970), and the low-level builder (0x0059dfa0) has no per-poly skip in its triangle-count loop — every dictionary poly is in the constructed mesh, same as acdream's flatten. (c) The doors-visible vs stair-ramp-invisible split is instead plausibly produced by a STATIC per-surface mechanism the claim missed: D3DPolyRender::DrawMesh (0x0059d4a0; BN pc:426064-426074 agrees) skips any mesh subset whose CSurface lacks the texture-image bits (type & 6 = Base1Image|Base1ClipMap) when skipNoTexture=1 (default, data 0x00820e30) — and the draw-anyway escape requires ObjBuildingOrBuildingPart==0, which DrawBuilding sets to 1 around the shell pass. So untextured (solid-color) batches of a BUILDING shell are never drawn in retail. acdream has no equivalent: ObjectMeshManager.cs:1075-1088 synthesizes a 32x32 solid-color texture for Base1Solid surfaces and draws them. This contradicts the claim's "no static poly filter can be correct": retail itself ships a static building-scoped per-SURFACE (texture-presence) filter for the visible-geometry side; what cannot be correct is e46d3d9's static per-POLY BSP-reference filter. The punch (depth-only, drawn BEFORE the shell) cannot hide an opaque textured fill drawn afterward, so the conditional pass mechanically CANNOT explain door/window visibility — only the surface-type skip can explain ramp invisibility.
PORT-SHAPE IMPACT: the claimed port (per-frame ConstructView-gated punch pass + DrawExitPortalMasks as own-depth seal, never drawing fills visibly from that pass) remains correct and necessary for depth discipline, with two amendments: preserve retail's two-sub-pass ordering (all punches, then all through-portal interior draws), and ADD the building-scoped untextured-batch skip (skipNoTexture / type&6 / ObjBuildingOrBuildingPart equivalent) as the actual #113 fix for visible geometry.
OPEN QUESTION (falsifiable test of the corrected #113 attribution): dat-verify that the meeting-hall stair-aperture fill polys {0,1} reference solid-color (no Base1Image/Base1ClipMap) surfaces while the Holtburg door/window fill quads reference image-textured surfaces — extend the e223325 audit with surface types. Also unresolved: precisely which polys e46d3d9's filter removed that the user perceived as "doors disappearing" (door-frame polys on building models vs door-panel polys on door-weenie GfxObjs) — the filter applied to ALL GfxObjs, not just buildings.
- blastRadius: This is the door-vanish ↔ phantom-staircase pair (#113): acdream draws ALL portal fill quads unconditionally as textured/solid-colored mesh geometry (phantom stair ramps visible; e46d3d9's static filter removed doors/windows too and had to be reverted 124c6cb — the dat side proved in e223325 that no static filter can be right). It is also the missing depth discipline for outside-looking-in: without the pass-1 z-punch and the pass-2 through-portal interior draw being fenced by ConstructView's gate, and without the z-seal on exit portals, interiors/exteriors rely on acdream's separate flood plumbing alone — feeding #114 (indoor clip-region quality) and the residual far-door artifacts (#109).
- retailEvidence: DrawBuilding two-pass: portal-only BSP walk then shell mesh (Ghidra 0x0059f2a0, pc:427938-427961). Walk: BSPTREE/BSPNODE/BSPPORTAL::build_draw_portals_only (Ghidra 0x00539860/0x0053c100/0x0053d870) submits BSPPORTAL.in_portals (acclient.h:57768-57772) via RenderDeviceD3D::DrawPortal (pc:1037066) → PView::DrawPortal (0x005a5ab0, pc:433895). GATE = PView::ConstructView(CBldPortal) (Ghidra 0x005a59a0, Ghidra-confirmed): viewer-side-of-plane vs CBldPortal::portal_side, GetClip against current view, CEnvCell::GetVisible(other_cell_id) (0x0052dc10), copy_view. Success ⇒ DrawPortalPolyInternal(poly, pass==1) + DrawCells; failure ⇒ nothing. DrawPortalPolyInternal (0x0059bc90) with defaults maxZ1=7/maxZ2=6 (pc:1105964-65) = alpha-0 DEPTHTEST_ALWAYS z-write draw: far-plane punch (open) or own-depth seal (exit portals, DrawCells pc:432785-432786). Drawing BSP is stripped to the portal skeleton at load (RemoveNonPortalNodes, 0x0053a040, in InitLoad pc:318775).
- acdreamEvidence: Portal polys flow into the flattened mesh and draw unconditionally (ObjectMeshManager.cs:1040-1058); portal polygons are otherwise used only as visibility-clip math (PortalVisibilityBuilder.cs:189-194, PortalProjection.cs:54); the z-seal hook DrawExitPortalMasks is invoked but never implemented (RetailPViewRenderer.cs:325-341, nullable at :497/:534/:558, no production assignment repo-wide); no z-punch exists at all.
- portShape: Keep the flatten (it is retail-faithful) but EXCLUDE nothing statically. Add a per-building per-frame portal pass before the shell entity draws: walk the dat DrawingBSPNode.Portals skeleton (or just the building's CBldPortal list — the BSP walk only orders submissions), and per portal poly run the ported ConstructView gate (eye-side vs portal_side, clip against the current view region, cell-beyond loaded/visible, view push). On success render the portal poly as an invisible far-plane depth punch (depth ALWAYS, depth-write on, color mask off) and credit the through-portal flood; never draw fills as visible geometry from this pass. Implement DrawExitPortalMasks as the own-depth z-seal for portals with other_cell_id==0xFFFF. This unifies doors, windows, and the hall stair apertures under one mechanism, matching the e223325 mandate.
### [HIGH] solid-surface-skip-missing (adjusted) — Building/cell mesh draw renders untextured (solid) surface batches that retail skips (skipNoTexture rule)
- correctedClaim: CONFIRMED MECHANISM, CONDITIONAL BLAST RADIUS: Retail's D3DPolyRender::DrawMesh (0x0059d4a0, gate asm 0x0059d4e0-0x0059d50c) skips any surface batch with (CSurface.type & 6)==0 (no BASE1_IMAGE/BASE1_CLIPMAP, acclient.h:5820) when skipNoTexture (global 0x00820e30, data default 1) is on AND the draw is a building shell (ObjBuildingOrBuildingPart=1, set only around DrawBuilding's second/mesh pass at 0x0059f322) or an EnvCell built-mesh draw (DrawEnvCell pushes 1 at 0x0059f20d); plain objects keep their solid batches (2-arg overload 0x0059d790 pushes 0). acdream has no such skip — ObjectMeshManager.cs:1075-1088 and :1429-1442 promote solid surfaces to visible 32x32 solid-color textures and draw them unconditionally. The divergence is REAL (severity: high as a mechanism, but its user-visible instances are unpinned). CORRECTIONS: (1) attributing the visible body of #113 stair ramps / door slabs to this rule is unverified — the e223325 audit never recorded whether those portal-fill polys are solid vs textured; settle that dat question first; (2) because ALL those fills are conditionally-drawn portal polys, porting the static skip ALONE would reproduce the e46d3d9 door-vanish regression if the fills are solid — sequence it with the conditional portal-poly pass, never as a standalone "drop from flatten"; (3) the port predicate must be absence-of-(IMAGE|CLIPMAP), not acdream's isSolid (which conflates NoPos stippling); (4) the skip is only proven for the built-mesh path — DrawEnvCell's raw-poly fallback (pc:427922) was not verified to skip, though acdream's pipeline corresponds to the built-mesh path anyway.
- verifier notes: RETAIL SIDE — verified at raw-assembly level via Ghidra (not BN pseudo-C), and it checks out exactly as claimed:
1. The gate in D3DPolyRender::DrawMesh (4-arg overload, 0x0059d4a0). Disassembly at 0x0059d4e00x0059d50c: MOV EAX,[0x00820e30] (skipNoTexture); JZ 0x0059d516 (skip disabled → draw); MOV EAX,[EBP+ESI*4]; TEST byte ptr [EAX+0x58],0x6; JNZ 0x0059d516 (surface textured → draw); MOV EAX,[0x008ed3c4] (ObjBuildingOrBuildingPart); JNZ 0x0059d58e (→ loop increment = SKIP the batch); MOV AL,[ESP+0x2c] (4th arg, cell flag); JNZ 0x0059d58e (SKIP); else MOV [0x00820e30],0x1 and fall into the draw path at 0x0059d516. So: skip iff skipNoTexture!=0 AND (CSurface.type & 6)==0 AND (building-flag OR cell-flag). The odd-looking re-assert `skipNoTexture=1` in the draw-anyway branch is real in the asm but semantically inert (the global was already nonzero to reach it). Both decompilers show the same structure — no invented branch here.
2. type&6 semantics: acclient.h:5820 SurfaceType enum — BASE1_SOLID=0x1, BASE1_IMAGE=0x2, BASE1_CLIPMAP=0x4. (type&6)==0 = neither image nor clipmap = solid-color-only. (CSurface is only forward-declared in acclient.h:342; offset 0x58=type comes from BN's typed pseudo-C at pc:426064-426074 — acceptable since the bit semantics are anchored by the enum.)
3. Default-on: data section at 0x00820e30 `int32_t skipNoTexture = 0x1` (pc:1105971 block, dumped).
4. Cell path: DrawEnvCell (0x0059f170) calls the 4-arg DrawMesh with PUSH 0x1 at 0x0059f20d → CALL 0x0059d4a0 at 0x0059f212 (pc:427905 region) — but only on the use_built_mesh path; the use_built_mesh==0 fallback submits raw polys with planeMask=0xffffffff through the poly list (pc:427922) and I did NOT verify an equivalent skip there. Built-mesh is the production path and is what acdream's batching corresponds to, so the comparison stands.
5. Building path: RenderDeviceD3D::DrawBuilding (0x0059f2a0, pc:427956 region) sets ObjBuildingOrBuildingPart=1 at 0x0059f322 BETWEEN CPhysicsPart::Draw(part,1) and Draw(part,0), clearing at 0x0059f33b. Important nuance the claim glossed: the first Draw (flag still 0) is the PORTALS-ONLY pass (DrawMeshInternal 0x0059f360 → BSPTREE::build_draw_portals_only(drawing_bsp,1/2) at 0x0059f3ca/0x0059f3d7); the second Draw (flag=1) is the constructed-mesh shell draw — that's where the skip bites.
6. Plain objects draw solid batches: the 2-arg DrawMesh overload (0x0059d790) forwards with PUSH 0x0 as the 4th arg (0x0059d7aa), and Ghidra xrefs show the ONLY callers of 0x0059d4a0 are the 2-arg overload (0x0059d7b2) and DrawEnvCell (0x0059f212). Outside DrawBuilding's second pass the global is 0, so ordinary GfxObjs keep their solid batches. Confirmed.
ACDREAM SIDE — confirmed: src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1075-1088 (PrepareGfxObjMeshData, used for building shells) and :1429-1442 (PrepareCellStructMeshData, the EnvCell path — call sites :519/:607/:1350) both compute `isSolid` and promote the surface to a visible 32×32 solid-color texture via TextureHelpers.CreateSolidColorTexture (src/AcDream.Core/Rendering/Wb/TextureHelpers.cs:6), batching it for unconditional draw. Repo-wide grep for Base1Solid/CreateSolidColorTexture/skipNoTexture confirms NO building- or cell-scoped untextured-skip exists anywhere: WbDrawDispatcher.cs uses IsBuildingShell only at :610/:639 (unrelated scoping), EnvCellRenderer.cs has no surface-type logic, and TextureCache.cs:394-400 does the same promote-to-solid-texture on its path. Anchor correction: IsBuildingShell is GameWindow.cs:5265 (claim said 5266), defined at WorldEntity.cs:65, tagged at LandblockLoader.cs:86.
WHY ADJUSTED, NOT CONFIRMED — two material corrections:
(a) The blast-radius attribution to #113 phantom stairs and door-aperture slabs is UNPROVEN. The e223325 audit (tests/.../Issue113DoorVanishDiagnosticTests.cs) proved every orphan poly is a DrawingBSPNode.Portals PortalRef but recorded NO surface-type data (grep for Solid/SurfaceType/Stippling in that test: zero hits; no session dump captures it either). Whether those fill quads are solid or textured is exactly the claim's own open question 1 — so "likely visible body of #113" is a hypothesis, not a finding. If the fills are textured, this rule has nothing to do with #113 and the blast radius shrinks to whatever genuinely-solid shell/cell surfaces exist (unquantified).
(b) The proposed "simpler" port shape (drop solid batches from the flatten for shell/cell meshes) is NOT safe in isolation — it can REPRODUCE the e46d3d9 door regression. Per e223325, ALL the orphan fills (doors AND stairs) are portal polys drawn conditionally by retail's portal pass (build_draw_portals_only, the flag==0 first pass of DrawBuilding). If the fills turn out solid, retail's shell-mesh pass skips them ALL and their retail visibility ("doors usually visible") comes solely from the conditional portal pass — so shipping the static skip before the portal pass exists would vanish doors again. The skip must land WITH or AFTER the conditional portal-poly pass.
(c) Predicate precision: a faithful port must skip on ABSENCE of (BASE1_IMAGE|BASE1_CLIPMAP) ((type&6)==0), not on presence of Base1Solid, and must not reuse acdream's `isSolid` (which conflates NoPos stippling at :1075/:1429 — a NoPos-stippled poly with a textured surface would be wrongly skipped).
- blastRadius: Any building-shell or EnvCell polygon whose surface is solid-color renders in acdream as an opaque colored quad where retail shows nothing — the likely visible body of the #113 phantom stair ramps and of door-aperture slabs (the fills users perceived as 'doors'); also the reason a faithful portal pass ALONE won't fix #113: even with the gate ported, the flattened solid fills would still draw.
- retailEvidence: D3DPolyRender::DrawMesh inner loop (Ghidra 0x0059d4a0 at 0x0059d4f1, pc:426064-426074): when skipNoTexture != 0 (data-section default 1, pc:1105971 @0x00820e30) a surface batch with (CSurface.type & 6)==0 (no Base1Image, no Base1ClipMap) is skipped if RenderDeviceD3D::ObjBuildingOrBuildingPart==1 (set during the shell pass, pc:427956) or the cell-draw flag is passed (DrawEnvCell passes 1, pc:427905); plain objects still draw solid batches.
- acdreamEvidence: PrepareGfxObjMeshData and PrepareCellStructMeshData promote solid surfaces into visible 32×32 solid-color textures and batch them for unconditional draw (ObjectMeshManager.cs:1075-1088 and :1430); no building/cell-scoped surface-type skip exists anywhere in the dispatcher or EnvCellRenderer.
- portShape: Tag batches at decode with 'is building shell / is cell geometry' (already known: IsBuildingShell GameWindow.cs:5266, cell path PrepareCellStructMeshData) and 'surface is untextured' (neither Image nor ClipMap), then skip those batches at draw — or simpler, drop them from the flatten for building-shell and cell-struct meshes only (equivalent because the retail rule is per-surface, static, and default-on). Must NOT apply to ordinary objects (retail draws their solid batches). Verify first against the dat which fills are solid vs textured (open question 1).
### [MEDIUM] degrade-lod-scoped-to-humanoids (confirmed) — Distance LOD (deg_level) exists only as a static Degrades[0] swap for 34-part humanoids; retail degrades every non-player part per frame
- correctedClaim: Claim stands as written, with one citation label tightened: the call sites 0x0051827a/0x005182b3 are inside the two CPartArray::UpdateViewerDistance overloads (0x00518260/0x00518290) — the per-part loop owned by CPhysicsObj — rather than functions literally named on CPhysicsObj. Substance unchanged. One strengthening addendum: retail's LoadGfxObjArray excludes the base GfxObj from the render array entirely when a degrade table exists, and INVALID_DID degrade entries become null slots that suppress drawing beyond a distance band (DrawBuilding gates the whole shell draw on gfxobj[deg_level] != NULL) — so the divergence also covers "objects/buildings that should vanish or simplify at distance never do," not just perf and close-detail silhouettes.
- verifier notes: RETAIL side re-derived entirely from Ghidra decompiles (port 8081), not BN pseudo-C. (1) CPhysicsPart::UpdateViewerDistance @ 0x0050E030: computes distance from Render::viewer_pos to the part's scaled sort_center every call, stores it in CYpt; if this->degrades != NULL and the owning physobj's IID != player_iid, calls GfxObjDegradeInfo::get_degrade(CYpt / gfxobj_scale.z, &deg_level, &deg_mode); otherwise pins deg_level=0/deg_mode=1; then runs calc_draw_frame only if gfxobj[deg_level] != NULL. Exactly as claimed, including the player_iid exemption. (2) Caller xrefs match the claim verbatim: 0x0051827a/0x005182b3 (inside the two CPartArray::UpdateViewerDistance overloads @ 0x00518260/0x00518290 — a per-part loop; the claim labeled these "CPhysicsObj update paths," a trivial naming imprecision since CPartArray is CPhysicsObj's part container), 0x005a0712/0x005a073c inside RenderDeviceD3D::UpdateObjCell @ 0x005a0690, 0x00510c53 in UpdateViewerDistanceRecursive, and 0x0059f2bc inside RenderDeviceD3D::DrawBuilding @ 0x0059f2a0. (3) UpdateObjCell is the per-frame proof: called from DrawObjCellForDummies and DrawBlock (the render walk), it iterates the cell's shadow_object_list calling CPhysicsObj::UpdateViewerDistance on EVERY object, with a near/far split at MAX_CELL_2D_DEGRADE_DISTANCE (near = exact per-object distance, far = shared cell distance via the float/Vector3 fast-path overload — a useful port detail). (4) DrawBuilding re-picks the shell LOD first thing each draw and gates the ENTIRE building draw on gfxobj[deg_level] != 0 — stronger than claimed: buildings can legitimately skip drawing at far LOD. (5) CPhysicsPart::Draw @ 0x0050D7A0 indexes gfxobj[deg_level] with clamp-to-0 when degrades==NULL || num_degrades <= deg_level, exactly as claimed. (6) LoadGfxObjArray @ 0x0050DCF0 preloads one CGfxObj* per degrade entry; notably, when a degrade table exists the BASE GfxObj id is NOT in the render array at all (it is loaded only to discover the degrade DID), and INVALID_DID entries become null slots = draw-nothing distance bands — retail objects can intentionally vanish past a distance, which acdream can never reproduce. ACDREAM side verified by reading production code: GfxObjDegradeResolver.cs:105-143 always returns Degrades[0] (explicit ack at :56-60 "always returns slot 0... far-distance LOD is a future concern"); the resolver's sole production call site is GameWindow.cs:2610 inside the `_options.RetailCloseDegrades && IsIssue47HumanoidSetup(setup)` gate at :2605 (predicate :302-312: 34 parts + >=8 null-sentinel 0x010001EC slots in 17-33; RetailCloseDegrades is default-on per RuntimeOptions.cs:76-79, so humanoids DO get the swap in production); WbDrawDispatcher.cs:987-993 trusts MeshRefs and never re-picks ("We trust MeshRefs as the source of truth here"); ObjectMeshManager.cs carries DIDDegrade only as write-only metadata (:700, :726, :1274, :1896) — the only reads anywhere in src/ are point-sprite-mode checks (Degrades[0].DegradeMode == 2) in ParticleEmitterRenderer.cs:81-86 and ParticleRenderer.cs:400-414, plus a [scenery-z] diagnostic dump at GameWindow.cs:5424-5459 — none constitute a distance-LOD mechanism. Door claim verified via commit e223325 message: doors (setup 0x020019FF) don't take the humanoid swap and render base ids; base + every degrade variant have full BSP coverage, so nothing in #113/#114 depends on this item, matching the port-shape claim. The divergence is REAL (no behaviorally-equivalent mechanism exists anywhere in acdream), the severity (medium) and blast radius (perf; conditional wrong silhouettes for non-humanoid degrade-table models whose base id is the low-detail variant; latent correctness gap for the holistic port since DrawBuilding re-picks shell LOD per frame) are accurate and if anything slightly understated: retail's null-degrade-slot "vanish at distance" behavior and the building-draw-skip gate are correctness consequences acdream's always-draw-base approach cannot reproduce. Port shape is sound, with two refinements from the decompiles: retail picks deg_level per PART (each part has its own pos/sort_center), not per entity — per-entity is an acceptable first approximation but should be stated as such; and retail's far-cell fast path (MAX_CELL_2D_DEGRADE_DISTANCE shared-distance overload) is the retail-faithful way to keep the per-frame cost bounded.
- blastRadius: No named open issue; consequences are (a) perf — full-detail meshes drawn at all distances for every static/building/creature, (b) wrong silhouettes for any non-humanoid model whose base GfxObj id is the LOW-detail variant (the #47 bulky-mesh class — fixed for humanoids only), (c) a latent correctness gap for the holistic port since retail's DrawBuilding explicitly re-picks the shell LOD per frame.
- retailEvidence: CPhysicsPart::UpdateViewerDistance (Ghidra 0x0050E030): per-frame GfxObjDegradeInfo::get_degrade(distance/scale) for every part with a degrade table EXCEPT the local player (player_iid check ⇒ deg_level=0); called from CPhysicsObj update paths (0x0051827a/0x005182b3), cell-object update (0x005a0712/0x005a073c), recursive parts (0x00510c53) and DrawBuilding itself (0x0059f2bc); CPhysicsPart::Draw indexes gfxobj[deg_level] with clamp (0x0050D7A0); LoadGfxObjArray (0x0050DCF0) preloads the array.
- acdreamEvidence: GfxObjDegradeResolver always returns Degrades[0] (GfxObjDegradeResolver.cs:105-143, ack at :56-60); sole production call gated by IsIssue47HumanoidSetup (GameWindow.cs:2605-2624, predicate :302-313); dispatcher trusts MeshRefs and never re-picks (WbDrawDispatcher.cs:987-992); doors/statics/buildings render base ids (confirmed for the door setup 0x020019FF in e223325).
- portShape: Move degrade resolution from spawn-time id swap to a per-entity-per-frame deg_level pick: preload all degrade slots' meshes into the global buffer at registration (retail LoadGfxObjArray analog), compute viewer distance per entity in the dispatcher walk (it already has camera position for the sort), select the MeshRefs slot via a ported get_degrade. Local player pinned to slot 0. Can ship after the critical items; nothing in #113/#114 depends on it (e223325: door degrade chains have full BSP coverage).
### [MEDIUM] no-per-view-entity-pass (adjusted) — Entities are culled once against the camera frustum + membership slot; retail re-culls each object's sphere against EVERY active portal view
- correctedClaim: CONFIRMED CORE, with corrections: acdream culls entities once against the camera frustum (AABB) plus cell-granular gates (visibleCellIds membership + U.4 clip-slot routing), and on the indoor cell-object path (RetailPViewRenderer.DrawCellObjectLists → DrawEntityBucket with neverCullLandblockId=player LB) even the frustum cull is bypassed — indoor cell objects draw with NO geometric cull. Retail re-culls each object part's drawing_sphere against EVERY active portal view: PView::DrawCells sets Render::PortalList = &pview->outside_view for the landscape pass and = cell->portal_view[num_view-1] per drawn cell before DrawObjCellForDummies → DrawPartCell (per-cell shadow_part_list); RenderDeviceD3D::DrawMesh (0x005a0860) loops view_count views (filtered by building_view), set_view + viewconeCheck(drawing_sphere) per view, returning OUTSIDE_VIEWCONE_ODS undrawn when all views fail; DrawMeshInternal (0x0059f360) dedups multi-view/multi-cell submission via CPhysicsPart::GetDrawnThisFrame (player parts exempt). BLAST-RADIUS CORRECTION: only the over-draw direction (membership cell visible, sphere outside every view) is explained by this divergence; the under-draw direction (object poking through an aperture from a non-visible membership cell) requires retail's per-cell shadow-part registration (multi-cell membership), which the sphere-vs-plane port alone does not provide — that half belongs to the A6.P4 per-cell shadow architecture gap. Citation fixes: the acdream "unclipped" comment is RetailPViewRenderer.cs:370-372/:441-450, not :395-398; GetDrawnThisFrame is 0x0050d4d0/0x0052c0c0 (0x0059f360 is DrawMeshInternal, its caller).
- verifier notes: RETAIL side — re-derived entirely from Ghidra decompiles (not BN pseudo-C):
1. RenderDeviceD3D::DrawMesh (Ghidra 0x005a0860): CONFIRMED verbatim. When `Render::PortalList == NULL` it does ONE `viewconeCheck(gfxobj->drawing_sphere)`; when `PortalList != NULL` it loops `PortalList->view_count` views, per view calling `Render::set_view(&PortalList->view, i)` then `Render::viewconeCheck(param_1->drawing_sphere)`. A view returning OUTSIDE increments a counter; if ALL views are OUTSIDE the function returns OUTSIDE_VIEWCONE_ODS without drawing. One nuance the claim omitted: a `(building_view == -1 || building_view == i)` filter restricts the loop to a single view index during building draws.
2. Render::viewconeCheck (Ghidra 0x0054c250): CONFIRMED — transforms the sphere to viewer space, tests against `viewer_world_space.CY` (near plane) then `portal_npnts` planes at `portal_vertex`, returning OUTSIDE on any plane with dist < -radius. Render::set_view (Ghidra 0x0054d0e0) is what loads `portal_npnts`/`portal_vertex` (plus xmin/xmax/ymin/ymax scissor bounds) from the active view's `view_poly` so viewconeCheck genuinely tests the per-view portal plane set.
3. PView::DrawCells (Ghidra 0x005a4840): CONFIRMED with one precision fix — for landscape it sets `Render::PortalList = &this->outside_view` (the portal_view_type at PView offset 0, acclient.h:45936; BN pc:432718 prints the raw `this` pointer because of the zero offset), then `LScape::draw`. The final loop (pc:432877 region, addr 0x005a4b07-0x005a4b0d) sets `Render::PortalList = cell->portal_view.data[cell->num_view-1]` per drawn cell and calls vtable DrawObjCellForDummies(cell). Traced onward: DrawObjCellForDummies (0x005a0760) → DrawObjCell (0x005a1a40) → DrawPartCell (0x005a07a0) iterates the cell's `shadow_part_list` calling CShadowPart::draw — so cell objects ARE drawn under the per-cell accumulated view list and re-culled per view in DrawMesh.
4. Dedup: CONFIRMED but mis-cited — 0x0059f360 is RenderDeviceD3D::DrawMeshInternal, which CONTAINS the dedup (`CPhysicsPart::GetDrawnThisFrame` early-return + `SetDrawnThisFrame`, player parts exempt via IsPartOfPlayerObj); GetDrawnThisFrame itself is at 0x0050d4d0/0x0052c0c0. Multi-view and multi-cell submission draws once. Substance correct.
ACDREAM side — read from production code:
5. WbDrawDispatcher.cs:657-666: CONFIRMED — single camera-frustum AABB cull per entity (`FrustumCuller.IsAabbVisible`), bypassed for animated entities and for `entry.LandblockId == neverCullLandblockId`. Cell-granular gates: `EntityPassesVisibleCellGate` (:1816-1835, ParentCellId ∈ visibleCellIds) and U.4 clip-slot routing `SetClipRouting`/`ResolveEntitySlot`/`ResolveSlotForFrame` (:324-331, :425-489). No sphere-vs-view-plane test exists anywhere on the entity path (grep for viewcone in src/ hits only comments describing retail).
6. RetailPViewRenderer.cs: citation drift — the "deliberately unclipped" rationale is at :370-372 and :441-447 (inside UseIndoorMembershipOnlyRouting, which calls `_entities.ClearClipRouting()` at :449), not :395-398 (:396-398 is the clip-disable loop). Substance correct: DrawCellObjectLists (:401-426) draws each visible cell's entity bucket with clip routing cleared.
7. STRENGTHENING finding the claim missed: DrawEntityBucket (:460-477) passes `neverCullLandblockId: ctx.PlayerLandblockId` while tagging the bucket with `lbId = ctx.PlayerLandblockId ?? 0u` — so indoor cell objects on the player's landblock skip even the camera-frustum AABB cull. The indoor cell-object path currently has NO geometric cull at all, only the cell-granular membership gate.
8. MISATTRIBUTION in the blast radius: the "vice versa" direction (object poking through an aperture from a non-visible membership cell still draws in retail) is produced by retail's per-cell shadow-PART registration (DrawPartCell iterates each cell's shadow_part_list; an object straddling a portal has parts registered in BOTH cells, deduped by GetDrawnThisFrame), NOT by the per-view sphere re-cull. acdream's single ParentCellId membership cannot replicate it, and the proposed sphere-vs-plane port alone will not close that half — it belongs to the per-cell shadow architecture gap (A6.P4 debt). The first direction (membership cell visible but sphere outside every view → drawn in acdream, culled in retail) is fully explained by the claimed mechanism and is real.
Port shape: plausible as stated (CPU sphere-vs-plane loop against per-cell accumulated view planes, draw once on first passing view); ClipFrameAssembly.CellIdToViewSlices (RetailPViewRenderer.cs:428-437) confirms per-cell view-plane data exists. Note retail additionally carries per-view 2D scissor bounds (set_view xmin/xmax/ymin/ymax) which the sphere-cull port does not replicate; and the acdream comment's rationale that retail does not hard-clip meshes per view is consistent with the decompile (DrawMeshInternal's BoundingType param is unused for built meshes). Severity medium is fair: visible artifact class at portal boundaries, contributes to #109, entity-side half of one-gate discipline.
- blastRadius: Object pop/visibility mismatches at portal boundaries seen through doorways (an object whose membership cell is visible but which lies outside every portal view draws in acdream, not in retail; and vice versa for objects poking through an aperture from a non-visible cell). Contributes to the residual far-door artifact class (#109) and is the entity-side half of the one-gate discipline; particles share the 'no view gate' hole (particles-through-walls is the named bug; ParticleRenderer is outside this area's scope).
- retailEvidence: RenderDeviceD3D::DrawMesh portal-list branch (Ghidra 0x005a0860): when Render::PortalList != NULL it loops view_count views, set_view + viewconeCheck(drawing_sphere) per view (viewconeCheck Ghidra 0x0054c250 tests the sphere against the active view's portal planes); PView::DrawCells sets PortalList=pview for landscape (pc:432718) and per-cell views for cell objects (pc:432877); per-frame dedup via GetDrawnThisFrame keeps multi-view submission single-draw (0x0059f360).
- acdreamEvidence: WbDrawDispatcher: single camera-frustum AABB cull per entity (WbDrawDispatcher.cs:657-665) plus membership-cell slot routing/culling (:314-488); no per-view sphere re-cull; cell objects deliberately drawn unclipped (RetailPViewRenderer.cs:395-398 comment).
- portShape: Per drawn cell, test each member entity's bounding sphere against that cell's accumulated view planes (the data already exists in ClipFrameAssembler slices) before adding its instances — a CPU sphere-vs-plane loop in the partition step, not a GPU change. Retail-faithful = test against the view's plane set, draw once on first pass.
### [MEDIUM] stippling-semantics-divergence (confirmed) — WB's NoPos/NoNeg = 'don't draw this side' interpretation is not visible in retail's ConstructMesh; retail treats stippling>0 as a batch FLAG and sides via sides_type
- verifier notes: RETAIL re-derived from Ghidra decompiles (BN pseudo-C used only for line citations, cross-checked): (1) CPolygon::UnPack (Ghidra 0x00538650, pc:322296) reads pos_uv_indices only when (stippling & 4)==0 and neg_uv_indices only when sides_type==2 && (stippling & 8)==0; pos_surface/neg_surface always read; sides_type==1 aliases neg_surface=pos_surface and neg_uv=pos_uv. NoPos/NoNeg are dat-stream UV-PRESENCE flags, not draw suppressors. (2) D3DPolyRender::ConstructMesh inner (Ghidra 0x0059dfa0; emit/count loop pc:426842-426909) counts and emits triangles purely from sides_type (0=pos once, 1=pos doubled same surface, 2=pos+neg into separate surface batches) with an explicit 'uv_indices==null -> uv index 0' fallback in the emit loop — so NoPos/NoNeg sides ARE still emitted; the ONLY stippling use is batchFlagByte[pos_surface] |= (stippling>0) (Ghidra 0x0059e1c3 = pc:426868), confirming 'stippling>0 is a batch flag'. (3) D3DPolyRender::SetSurface (Ghidra 0x0059d650, pc:426157-426189) maps bit0->POSITIVE-side stippled bool, bit1->NEGATIVE-side stippled bool, as claimed. (4) ConstructMesh is the real runtime path: CGfxObj::InitLoad (Ghidra 0x005346b0, pc:318781) builds constructed_mesh from the FULL polygon array via the wrapper at 0x0059ea90 (pc:427540-427543) and sets use_built_mesh=1; an EnvCell-side caller at 0x0052d87a (pc:311085) feeds cell geometry through the same inner function; the immediate fallback DrawPolyInternal (Ghidra 0x0059d7c0) also has zero bit-4/8 checks (bit0->stippled, cull from sides_type: ==1 -> CULLMODE_NONE else CULLMODE_CW). (5) Exhaustive grep of all 15 'stippling' hits in the 1.4M-line pseudo-C found no other draw-path consumer (remainder: ctor/Pack/UnPack init+presence bits, terrain CLandBlockStruct::ConstructUVs writing 3 at pc:316764, which is terrain-only and outside this claim). ACDREAM verified at src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs: :1046 skips pos side on NoPos; :1052-1058 gates neg side on Negative||Both||(!NoNeg && SidesType==Clockwise); cell path :1394-1404 suppresses both sides on NoPos/NoNeg with an in-comment retail assertion ('still suppress hidden portal/cap faces') that has no retail citation and is contradicted by the UnPack+ConstructMesh evidence above. Enum values pinned by reflection on the referenced package (Chorizite.DatReaderWriter 2.1.7, net48 dll): CullMode Landblock=0/None=1/Clockwise=2/CounterClockwise=3 maps onto retail sides_type 0/1/2, so the sides_type gates themselves (None->double, Clockwise->neg) are retail-correct; StipplingType NoPos=4/NoNeg=8 match retail bit positions. Behavioral-equivalence check I ran to try to refute: the GfxObj path's missing sides_type==1 pos-side doubling (cell path doubles at :1399-1401, GfxObj path does not) is MASKED at draw time — batches carry CullMode=poly.SidesType (:1251,:1596) and WbDrawDispatcher.ApplyCullMode disables face culling for CullMode.None (WbDrawDispatcher.cs:1495-1517, :1504-1505) — so one emission shows both faces; correctly NOT part of the divergence. What survives as REAL divergence: (a) NoPos suppression of the pos side (:1046, :1394/:1397) — retail emits it with UV fallback 0; (b) NoNeg suppression of the neg side on sides_type==2 (:1054, :1395/:1402) — retail emits it; (c) BONUS beyond the claim: stipple bits 0/1 misread as 'neg side exists' (:1052-1053) adds phantom negative-side faces on sides_type!=2 polys carrying those bits, and the per-side stippled-translucency render flag retail derives from bits 0/1 is entirely unimplemented (acdream instead maps NoPos->solid-color texture at :1075/:1429 — also not retail). Honest residual (data-level, could not settle from code): whether NoPos/NoNeg-flagged sides in the actual Holtburg model set are ever VISIBLE in retail (e.g. if they correlate with the e223325 conditional portal polys, suppression could be coincidentally invisible — but the conditional mechanism there is BSP-portal-based, not stippling-based, so WB's stippling interpretation is still mechanically wrong). The claim's proposed port shape — a per-surface triangle-count conformance diff against retail's ConstructMesh arithmetic on a fixed model set, landed BEFORE any BSP/portal poly filter so the two filters aren't conflated — is exactly the right instrument and I endorse it. Severity medium is appropriate: quiet correctness class, no named issue, but a hard precondition for pixel-wise validation of the flatten.
- blastRadius: Potential missing or extra faces on models/cells whose polys carry NoPos/NoNeg or stipple bits — a quiet correctness class with no named issue yet; matters for the port because the flatten must reproduce retail's triangle set exactly before the portal pass can be validated pixel-wise.
- retailEvidence: Inner ConstructMesh (Ghidra 0x0059dfa0, emit loop pc:426842-426909) counts/emits triangles purely from sides_type (1/2 double the count) with no stippling-based skip; stippling>0 only ORs a per-surface batch flag bit (Ghidra 0x0059dfa0 counting loop; pc:426868-426871); at draw, D3DPolyRender::SetSurface (0x0059d650, pc:426157-426189) maps stippling bit0/bit1 to a per-side 'stippled' render flag.
- acdreamEvidence: PrepareGfxObjMeshData skips the positive side on NoPos (ObjectMeshManager.cs:1046) and gates the negative side on Negative/Both/NoNeg+CullMode (:1052-1058); PrepareCellStructMeshData asserts in-comment that 'DAT-side NoPos/NoNeg flags still suppress hidden portal/cap faces' (:1388-1402) — an interpretation, not a retail citation.
- portShape: Conformance test: for a fixed model set, reproduce retail's ConstructMesh triangle count per surface (the counting loop is simple arithmetic over sides_type/num_pts) and diff against acdream's flatten; then either drop or keep the NoPos/NoNeg suppression to match. Do this BEFORE landing the solid-skip so the two filters aren't conflated.
### [LOW] no-frame-dedup (adjusted) — No per-frame part dedup (retail GetDrawnThisFrame / frame-stamp)
- correctedClaim: Retail dedups part draws with a per-pass frame stamp (CPhysicsPart::Draw @ 0x0050D7A0 early-outs when m_current_render_frame_num == m_nFrameStamp; DrawMeshInternal @ 0x0059f360 Get/SetDrawnThisFrame for non-player parts; stamp bumped at Flip pc:428642 and again mid-frame in DrawCells pc:432722, so the window is landscape-pass / cell-pass, with player parts exempt and the building portal-draw mode bypassing it). acdream has no per-part dedup — but the no-double-draw invariant is upheld not by the MDI design alone: each production frame issues multiple WbDrawDispatcher.Draw calls (one per visible cell, RetailPViewRenderer.cs:408-425/:470, plus Outdoor/LiveDynamic buckets), and the invariant rests on InteriorEntityPartition's one-bucket-per-entity assignment (InteriorEntityPartition.cs:29-73), the distinct flood list (PortalVisibilityBuilder.cs:170-172), and one explicit hand-rolled cross-pass guard (liveDynamicsDrawn, GameWindow.cs:7813) that is itself a bucket-granularity dedup. Severity low / no user-visible effect today stands. If divergence 4 (per-view or per-cell-shadow object lists, where one entity appears in multiple cells' lists) is ported, the frame-stamp dedup becomes load-bearing and must be ported with retail's exact shape: first-wins within the cell pass, player-part exemption, portal-draw-mode bypass.
- verifier notes: RETAIL (all re-derived from Ghidra decompiles via MCP, not BN pseudo-C): (1) CPhysicsPart::Draw @ 0x0050D7A0 — gate confirmed: `(draw_state & 1)==0 && (param_1 != 0 || this->m_current_render_frame_num != RenderDevice::render_device->m_nFrameStamp)`; the frame-stamp early-out is BYPASSED when the portal-mode arg (param_1) is nonzero. (2) RenderDeviceD3D::DrawMeshInternal @ 0x0059f360 — confirmed: for non-portal draws (!param_2) with s_current_physics_part set AND NOT IsPartOfPlayerObj, GetDrawnThisFrame ⇒ early return INSIDE_VIEWCONE_ODS, else SetDrawnThisFrame (pc:427965-427981, matches claimed pc:427972-427980 region). Helpers verified: GetDrawnThisFrame @ 0x0050d4d0 returns `m_current_render_frame_num == render_device->m_nFrameStamp`; SetDrawnThisFrame @ 0x0050d4f0 assigns it — same field CPhysicsPart::Draw checks, one mechanism at two levels. (3) PView::DrawCells — `m_nFrameStamp += 1` at exactly pc:432722 (addr 005a4886), after LScape::draw + FlushAlphaList, before the cell_draw_list loop. Grep of all m_nFrameStamp writes shows the only OTHER bump is RenderDeviceD3D::Flip (pc:428642, addr 0059fec8): the stamp advances twice per presented frame, so retail's dedup window is PER-PASS (within the landscape pass; within the cell pass) — a part may legally draw once in each. Two retail nuances the claim omitted: player parts are exempt (IsPartOfPlayerObj), and the building portal-draw mode (param_2/build_draw_portals_only path) bypasses the dedup entirely.
ACDREAM: WbDrawDispatcher.Draw is a self-contained build+upload+MDI cycle: WalkEntitiesInto clears scratch and emits each (entity, MeshRefIndex, landblockId) tuple exactly once (WbDrawDispatcher.cs:587-698 — each landblock entry's Entities iterated once; an entity lives in one entry); each instance written once (1182-1189); one indirect command per group (1206-1229); MDI over those commands (1465-1493). The cited :1206+/:1478-1490 are the build/dispatch, not themselves the dedup property, but support it. HOWEVER the "once per frame by construction of the MDI design" rationale is incomplete: production frames issue MULTIPLE Draw calls — one per visible cell in the PView flood (RetailPViewRenderer.cs:408-425 → DrawEntityBucket :460-477 → _entities.Draw :470), plus Outdoor and LiveDynamic buckets (GameWindow.cs:7813-7823 / InteriorRenderer.cs:141-163), or the legacy global pass only when clipRoot is null (GameWindow.cs:7825-7831). The once-per-frame invariant actually rests on three things: (a) InteriorEntityPartition assigns each entity to exactly ONE bucket via its single ParentCellId (InteriorEntityPartition.cs:29-73); (b) OrderedVisibleCells is distinct by construction (PortalVisibilityBuilder.cs:170-172), so each cell bucket draws once; (c) an EXPLICIT hand-rolled guard `if (!liveDynamicsDrawn && outdoorPartition.LiveDynamic.Count > 0)` (GameWindow.cs:7813) prevents the outdoor-portal pass and the main pass from both drawing LiveDynamic — i.e., acdream already needed one cross-pass dedup, implemented at bucket granularity. EntitySet enum has only `All` (WbDrawDispatcher.cs:75-80), so no overlapping set-partition passes exist today.
JUDGMENT: the divergence is REAL (retail has a per-part frame-stamp dedup; acdream has none) and the low severity / no-blast-radius-today assessment holds — acdream's bucket disjointness + the :7813 guard make per-instance double-submission impossible in current production paths. Adjusted because (i) the "by construction, no dedup needed" rationale understates how the invariant is maintained (it is partition-disjointness + one explicit guard across multiple per-frame Draw calls, not the MDI design alone — and the guard proves the double-draw class already bites this architecture), and (ii) the port shape needs retail's three qualifiers: first-wins applies within a pass (stamp bumps at Flip pc:428642 AND mid-frame in DrawCells pc:432722), player parts are exempt, and the building portal-draw mode bypasses the dedup.
- blastRadius: None today — the MDI design submits each instance once per frame by construction; becomes relevant only if the per-view entity pass (divergence 4) is ported naively as draw-per-view.
- retailEvidence: CPhysicsPart::Draw frame-stamp check (Ghidra 0x0050D7A0); DrawMeshInternal GetDrawnThisFrame/SetDrawnThisFrame for non-player parts (Ghidra 0x0059f360, pc:427972-427980); DrawCells bumps m_nFrameStamp before through-portal drawing (pc:432722).
- acdreamEvidence: WbDrawDispatcher submits one instance per (entity, batch) per frame (WbDrawDispatcher.cs:1206+, :1478-1490); no dedup needed.
- portShape: Only port if divergence 4 is implemented as multi-view submission: first-passing-view-wins, exactly retail's dedup.
## OPEN QUESTIONS
- Surface class of each portal fill poly in the dat (the deciding fact for divergence 2): are the door-fill and stair-ramp quads' PosSurface/NegSurface untextured Base1Solid (retail-skipped) while window fills are textured (retail-drawn)? The Issue113DoorVanishDiagnosticTests dump (tests/AcDream.Core.Tests/Conformance/Issue113DoorVanishDiagnosticTests.cs) records geometry but not Surface.Type/Stippling per orphan — extend it with PosSurface/NegSurface/Stippling + Surface.Type before implementing the solid-skip.
- Does retail's inner ConstructMesh (Ghidra 0x0059dfa0) really emit BOTH sides per sides_type with zero stippling-based suppression? The decompile is register-garbled in places; a cdb capture of a known model's constructed-mesh triangle count (or a careful Ghidra re-read of the emit loop) would settle WB's NoPos/NoNeg interpretation (divergence 5).
- DrawPortalPolyInternal's ±12.0 vertex filter (Ghidra 0x0059bc90: skips the draw when ALL vertices sit at x or y = ±12.0) — presumed to exclude synthetic full-cell-boundary portals from depth punching; not traced to a dat-side producer.
- maxZ1/maxZ2 are config-backed globals (defaults 7/6 at 0x00820e14/0x00820e18, registry strings nearby suggest they may be tunable); the z-punch/z-seal semantics asserted here assume shipped defaults — a retail-client registry dump would confirm no override ships.
- Frame-level ordering of DrawBuilding relative to terrain and the alpha flush (when exactly the punch can erase already-drawn terrain depth, and whether the shell mesh always repairs over-punch) belongs to the Area-2/3 frame-orchestration map; within DrawBuilding the order portal-pass-then-shell is proven (pc:427955-427957) but the surrounding LScape::draw sequence was not traced here.
- Child traversal order (pos vs neg first) in BSPNODE::build_draw_portals_only is unrecoverable from the _padding_-mangled decompile (Ghidra 0x0053c100); irrelevant for depth-only submissions but should be pinned via the binary's field offsets if the port reproduces the walk literally.
- Defaults of Render::m_RenderPrefs.MultiPassAlpha and D3DPolyRender::s_AlphaDelayMask (the alpha/translucent/clipmap deferred-list routing in 0x0059d4a0) — they shape translucent-fill parity but were not chased to their config initializers.
- Where exactly the #113 phantom staircase was observed from (indoor vs outdoor viewpoint) — determines whether the user-visible fix lands with divergence 1 (portal pass + flood discipline), divergence 2 (solid-skip), or both; the dat facts are consistent with both contributing.

View file

@ -0,0 +1,97 @@
# AREA 3 — Interior cell rendering and the draw-side portal clip (#114)
## RETAIL
THE DATA STRUCTURES. A "portal view" is NOT a set of clip planes handed to the rasterizer — it is a 2D screen polygon whose edges each carry a 3D plane through the eye, stored per cell. `portal_view_type` (acclient.h:32345-32355) = { DArray<portal_info> portal; view_type view; float max_indist; uint view_count; int cell_view_done; int view_timestamp; int update_count }. `view_type` (acclient.h:32337-32343) = { vertex_count_total; DArray<view_poly>; DArray<view_vertex> } — note the PLURAL: one cell accumulates a LIST of view polygons. `view_poly` (acclient.h:32465-32473) = { vertex_count, vertex_index, xmin/xmax/ymin/ymax } (a slice into the vertex array + 2D screen bbox). `view_vertex` (acclient.h:32483-32487) = { Vec2D pt; Plane plane } — a screen point PLUS the 3D eye-edge plane used for object culling. `portal_info` (acclient.h:32458-32462) = { int seen; int inflag }. `CCellPortal` (acclient.h:32300-32308) = { other_cell_id, other_cell_ptr, CPolygon* portal, portal_side, other_portal_id, exact_match }. Crucially, `CCellStruct::UnPack` (Ghidra 0x00533d00) shows `portals[i] = polygons + portal_poly_id` — portal-aperture polygons are ordinary entries in the SAME drawn-polygon array.
VIEW CONSTRUCTION (the flood). `PView::DrawInside` (Ghidra 0x005a5860, pc:433793): curr_view_push(cell), add_views over the cell's stab list, positionPush(cell), then `Render::copy_view(cell.top_view, nullptr, 4)` — a NULL source installs the FULL-SCREEN 4-vertex viewport quad as the root view (Ghidra 0x0054dfc0, also pc:345574) — then `ConstructView(cell, 0xffff)` and `DrawCells(this, 0)`. ConstructView(CEnvCell) (pc:433750-433792): master_timestamp++, InitCell(root, 0xffff), InsCellTodoList(root, 0f), then a worklist loop popping the NEAREST cell (cell_todo_list is sorted by InitCell's max/min portal-vertex distance), appending it to cell_draw_list, and running ClipPortals + AddViewToPortals on it (pc:433786-433787).
`PView::InitCell` (Ghidra 0x005a4b70, pc:432896): per portal of the cell, classify the EYE against the portal polygon's plane with F_EPSILON = 0.0002 (acclient.h-adjacent const at 0x7e32f8): dist > +eps → side 0, dist < -eps side 1; if side != portal_side the portal faces AWAY (inflag=1, not traversable); if side == portal_side OR |dist| <= eps (the knife-edge in-plane case) inflag=0, candidate. The portal you ENTERED through (index == arg3) is force-marked inflag=1/seen=1 so the flood never walks back. `PView::ClipPortals` (Ghidra/pc:433572): for each portal with seen && !inflag, resolve the neighbour (CEnvCell::GetVisible), then FOR EACH accumulated view i of this cell (`Render::set_view(&view, i)` then `PView::GetClip(portal_side, portal_poly, &clip_view, &n, 1)` pc:433651): project the portal polygon to homogeneous screen space and software-clip it against the INSTALLED view. If the portal leads outside (other_cell_id == 0xffffffff) and cliplandscape (default 1, 0x00820f4c) `Render::copy_view(&pview->outside_view, clip, n)` appends the clipped aperture to outside_view (pc:433668-433676); else after `PView::OtherPortalClip` (pc:433524, the neighbour-side reciprocal re-clip through the matching back-portal indexed DIRECTLY by other_portal_id, 0x005a54b2/0x005a54f6) `Render::copy_view(neighbour.top_view, clip, n)` APPENDS the polygon to the NEIGHBOUR's view list (pc:433674, target resolved via num_view/portal_view at 0x134/0x138).
MULTI-PORTAL ACCUMULATION: a cell visible through multiple portals accumulates MULTIPLE view polygons — copy_view (Ghidra 0x0054dfc0) perspective-divides the clipped verts, merges vertices closer than ~1 PIXEL (|dx|<=1 && |dy|<=1 screen units), appends a new view_poly + grows view_count. It is a UNION-AS-LIST; polygons are never merged geometrically. `PView::AddViewToPortals` (pc:433446, 0x005a52d0): for each portal whose clip produced something, if the neighbour was never seen this timestamp → InitCell + InsCellTodoList (enqueue ONCE); if already seen and its view GREW (0x44 watermark != 0x38 view_count) → `AddToCell` IN PLACE and, if the cell was already drawn-listed, `FixCellList` = AdjustCellPlace (re-sorts cell_draw_list so the grown cell draws in dependency order, pc:433247) + AdjustCellView (re-clips ONLY the new views: ClipPortals(cell, update_count), pc:433741-433745). There is NO re-enqueue and no iteration cap — growth propagates recursively in place, and the 1-px vertex dedup gives the fixpoint a hard floor.
WHAT set_view INSTALLS: `Render::set_view(view_type*, n)` (pc:343750, 0x0054d0e0) sets Render::portal_view/portal_view_num, portal_npnts = poly.vertex_count, portal_inmask = (1<<(npnts+1))-1, portal_vertex = &vertex.data[poly.vertex_index] (the screen points + eye-edge planes), and the 2D xmin/xmax/ymin/ymax bbox. This is GLOBAL clipper state consumed by polyClipFinish and viewconeCheck.
THE SOFTWARE CLIP: `ACRender::polyClipFinish` (Ghidra 0x006b6d00, pc:702749) is a Sutherland-Hodgman clipper in HOMOGENEOUS screen coordinates (Vec2Dscreen = xw,yw,zw,w): stage 1 clips against w = cdstW (the near/eye plane, interpolating all four homogeneous components — no divide, so eye-grazing portals never blow up); stage 2 clips against each of the installed view's portal_npnts edges using the perspective-correct 2D test (xw - x_edge*w)*dy - (yw - y_edge*w)*dx, gated per-edge by the planeMask shifted by (0x1e - npnts) — a SET bit means SKIP that edge. <3 surviving verts output count 0. It is pixel-exact because (a) it clips polygon-vs-polygon with no plane-count budget (views are DArrays, blocksize 0x80; the loop runs all npnts edges), and (b) the homogeneous interpolation is exactly the rasterizer's math.
THE DRAW — THE LOAD-BEARING SURPRISE: retail NEVER clips cell geometry. `RenderDeviceD3D::DrawEnvCell` (0x0059f170, pc:427880-427930): production path is the prebuilt D3D mesh — `D3DPolyRender::DrawMesh(num_surfaces, surfaces, constructed_mesh, 1)` (pc:427905) built once at `CEnvCell::UnPack` (Ghidra 0x0052d470, ConstructMesh of ALL structure->polygons, 3.0 detail, use_built_mesh=1). The legacy poly-list path submits every polygon with planeMask=0xffffffff (pc:427922) — and 0xffffffff after the (0x1e-npnts) shift has the sign bit set for every edge iteration, i.e. SKIP ALL VIEW EDGES; moreover `D3DPolyRender::polyListFinishInternal` (Ghidra 0x0059dba0) just calls `DrawPolyInternal` per poly, and `DrawPolyInternal` (Ghidra 0x0059d7c0) does NO view clipping whatsoever — it builds a triangle fan from the 3D verts and calls DrawPrimitiveUP, gated only by `(surface->type & 6) != 0` (BASE1_IMAGE|BASE1_CLIPMAP, acclient.h:5820-5824). The accumulated views gate ADMISSION, OBJECT CULLING and PUNCHES — never geometry pixels.
PIXEL-EXACTNESS = DEPTH PUNCH + ORDER + Z-BUFFER. `PView::DrawCells` (Ghidra 0x005a4840): pass 1 (only when outside_view.view_count != 0): PortalList=&outside_view, `LScape::draw` FIRST (landscape through the accumulated outside views), FlushAlphaList, m_nFrameStamp++, then for every cell far→near, FOR EACH VIEW (`CEnvCell::setup_view(cell, i)` = set_view of view i, Ghidra 0x0052c430), every portal with other_cell_id == -1 (to landscape) gets `D3DPolyRender::DrawPortalPolyInternal(poly, false)`. That function (Ghidra 0x0059bc90, pc:424490) projects the aperture polygon, SOFTWARE-CLIPS it against the installed view via polyClipFinish(mask=0 → clip ALL edges), and draws the clipped polygon as an INVISIBLE DEPTH-ONLY quad: DEPTHTEST_ALWAYS, z-write on, z = the portal's true projected depth zw/w when maxZ2=6 (bit0 clear; 0x00820e14) or z = 0.99999988 (far plane) when maxZ1=7 (bit0 set; 0x00820e18); alpha byte 0 so no color. Writing the DOOR-PLANE depth into the aperture after the landscape protects the landscape pixels: any interior geometry FARTHER than the door fails the z-test inside the aperture; nearer geometry draws normally. Pass 2 draws cells far→near, per view, via vtbl+0x5c = DrawEnvCell (vtable at pc:1037045, 0x7e555c) — the GetDrawnThisFrame guard (Ghidra 0x0052c0c0, == m_nFrameStamp) makes the per-view repeats no-ops, so cell GEOMETRY draws ONCE, unclipped; the per-view loop only matters for punches and object culling. Epilogue: per cell, PortalList = the cell's view, vtbl+0x64 = DrawObjCell — objects are tested per view by `Render::viewconeCheck` (Ghidra 0x0054c250): bounding sphere vs the camera plane AND each view_vertex.plane of the installed view (stride 6 floats), OUTSIDE → skipped; objects are CULLED, never clipped (RenderDeviceD3D::DrawMesh loops PortalList views with building_view filtering, pc:427940-428060 / 0x005a0860).
OUTSIDE-LOOKING-IN: `RenderDeviceD3D::DrawBuilding` (Ghidra 0x0059f2a0, pc:427938): set outdoor_pview->outdoor_portal_list = building->portals, then CPhysicsPart::Draw(part, 1) → DrawMeshInternal arg3=1 → TWO drawing-BSP walks `BSPTREE::build_draw_portals_only(drawing_bsp, 1)` then `(.., 2)` (pc:427993-427994, 0x0059f3cc/0x0059f3d9); each BSPPORTAL node fires RenderDevice::DrawPortal(portalPoly, 1, mode) (pc:326947-326992, 0x0053d870) → `PView::DrawPortal` (Ghidra 0x005a5ab0, pc:433895) → `ConstructView(CBldPortal)` (Ghidra 0x005a59a0, pc:433827). Mode 1 = eye-side gate (F_EPSILON, IN_PLANE → return 0 — building portals reject the knife-edge OUTRIGHT), GetClip vs the current view, copy_view into the interior cell, then PUNCH the door aperture to FAR-Z (param_4==1 → maxZ1) and stop. Mode 2 = no punch, RECURSE ConstructView(cell, other_portal_id) building the interior view graph, then DrawCells(this, 1) draws the interior cells into the punched aperture. THEN CPhysicsPart::Draw(part, 0) draws the building SHELL mesh last; interior pixels survive only inside the punched aperture (everywhere else the shell's nearer depth rejects them). Pixel-exact, again with zero geometry clipping.
KNIFE-EDGE (Q3): two distinct behaviors. Building portals (ConstructView(CBldPortal), Ghidra 0x005a59a0): |eye·N + d| <= 0.0002 → Sidedness IN_PLANE → return 0; the portal contributes nothing that frame; no degenerate view is ever built. Cell-to-cell portals (InitCell, Ghidra 0x005a4b70): the in-plane case leaves inflag=0 (candidate) and the degenerate projection dies naturally downstream — polyClipFinish's homogeneous near-W clip plus copy_view's 1-pixel vertex dedup collapse a sub-pixel sliver to <3 verts no view appended no propagation. Physically correct: an edge-on aperture subtends zero pixels.
>8 PLANES (Q4): confirmed unbounded. Views are DArray-backed (grow on demand, blocksize 0x80); polyClipFinish iterates all portal_npnts edges; the only fixed-width artifact is the 32-bit planeMask (shift by 0x1e-npnts) which the D3D path ignores entirely. There is no plane budget and no fallback tier.
CELL PORTAL POLYS (Q5): they live in the drawn polygon array (UnPack, 0x00533d00) and ARE emitted into the built mesh — D3DPolyRender::ConstructMesh (Ghidra 0x0059dfa0) has NO stippling or portal gate in either the counting or emission loop. Suppression is DATA + SURFACE-GATE: in the Holtburg cellar dat (a8-current-room-cellar-audit.txt / corner-cells-audit.txt, EnvCell 0xA9B40175), portal polys carry stippling NoPos and pos_surface → 0x080000DF (an untextured surface), and the draw gate skips untextured batches: DrawPolyInternal requires (type & 6) != 0 (0x0059d7c0), and DrawMesh (Ghidra 0x0059d4a0, pc:426064) skips a batch when skipNoTexture (default 1, 0x00820e30) && !(type & 6), with the bypass branch only for non-building/non-cell meshes. So cell apertures are never VISIBLY drawn; the only per-frame conditional draw of a cell portal poly is the invisible depth punch (DrawCells pass 1 for landscape portals; ConstructView/DrawPortal for building doors). Cell-to-cell apertures inside a dungeon get NO punch at all — plain z-buffer + far→near order suffices there.
## ACDREAM
FRAME ENTRY. GameWindow decides per frame on clipRoot (the VIEWER cell or the synthetic outdoor node): indoor/outdoor-node frames run ONLY RetailPViewRenderer.DrawInside (GameWindow.cs:7590-7604); the global terrain/sky block runs only when clipRoot is null (GameWindow.cs:7546-7589). The outside→in look (retail DrawPortal) exists as RetailPViewRenderer.DrawPortal driven from GameWindow.cs:7750-7780 via BuildFromExterior seeding.
VIEW CONSTRUCTION. PortalVisibilityBuilder.Build (PortalVisibilityBuilder.cs:63-381) is the ConstructView port: root view = full-screen NDC quad (PortalVisibilityBuilder.cs:77, 557-558), distance-priority CellTodoList (PortalVisibilityBuilder.cs:96-97), per-portal side test CameraOnInteriorSide with PortalSideEpsilon = 0.01 (PortalVisibilityBuilder.cs:38, 734-741), portal projection ProjectToClip = homogeneous transform + eye-plane-only clip at w >= 1e-4 (PortalProjection.cs:81-97), then ClipToRegion = homogeneous Sutherland-Hodgman against each active view polygon with w-multiplied edge tests (PortalProjection.cs:105-134) — a faithful polyClipFinish equivalent. Clipped regions append to the neighbour's CellView (union-as-list, AddRegion with dedup) and exit portals append to OutsideView (PortalVisibilityBuilder.cs:269-281, 334-336). Reciprocal OtherPortalClip by direct OtherPortalId index is ported (PortalVisibilityBuilder.cs:305-332, 755-764). Late view growth RE-ENQUEUES the cell, capped at MaxReprocessPerCell = 16 because ProjectToClip numerical drift otherwise never settles (PortalVisibilityBuilder.cs:40-51, 348-354); OrderedVisibleCells appends once on first pop and is never re-sorted (PortalVisibilityBuilder.cs:168-172). A clip-empty portal whose opening the eye stands inside (within 1.75 m) is RESCUED by substituting the whole current view (PortalVisibilityBuilder.cs:258-267). Per-building outdoor floods: ConstructViewBuilding == BuildFromExterior (PortalVisibilityBuilder.cs:548-554), merged by MergeBuildingFrame FIRST-WINS — a cell already present in the frame keeps its existing views and the building flood's views are dropped (RetailPViewRenderer.cs:151-160).
CLIP ASSEMBLY. ClipFrameAssembler.Assemble (ClipFrameAssembler.cs:78-196) packs ONE GPU clip slot per view polygon: ClipPlaneSet.From converts a single CCW NDC polygon of 3..8 edges (after ~0.5° collinear merge) into <=8 clip-space half-planes (nx,ny,0,d) (ClipPlaneSet.cs:135-149); >8 edges → scissor AABB fallback (ClipPlaneSet.cs:130-133) which the assembler maps to SLOT 0 = PASS-ALL with the renderer-side scissor documented as unimplemented (ClipFrameAssembler.cs:13-15, 114-119); degenerate/area<1e-7 IsNothingVisible slice omitted (ClipPlaneSet.cs:66-68, 241-242 + ClipFrameAssembler.cs:102-103). CellIdToSlot keeps only slices[0] for single-slot consumers (ClipFrameAssembler.cs:130).
DRAW. RetailPViewRenderer.DrawInside (RetailPViewRenderer.cs:44-109): build frame → (outdoor root) merge per-building floods → assemble clip slots → PrepareRenderBatches for all OrderedVisibleCells → DrawLandscapeThroughOutsideView: per outside slice, terrain UBO planes set + landscape drawn CLIPPED to the slice planes via gl_ClipDistance in terrain/sky shaders (RetailPViewRenderer.cs:214-238; sky.vert:153, terrain_modern.vert:47), then ClearDepthSlice per slice = scissored AABB depth CLEAR to far (indoor roots only; null for outdoor roots) (GameWindow.cs:7644-7652) → DrawExitPortalMasks — UNWIRED, GameWindow never sets the callback so it no-ops (RetailPViewRenderer.cs:331-332; absent from the ctx at GameWindow.cs:7604-7670) → DrawEnvCellShells far→near per IndoorDrawPlan.ShellPass (IndoorDrawPlan.cs:18-29), per view slice, with UseShellClipRouting routing the cell to its slice slot and the shell vertex shader writing gl_ClipDistance[i] = dot(planes[i], gl_Position) (mesh_modern.vert:120; EnvCellRenderer.cs:262, 1195-1230) — but GL clip distances are ENABLED only when clipShells == ctx.RootCell.IsOutdoorNode (9ce335e #114 scoping; RetailPViewRenderer.cs:96-105, 378-398): indoor roots draw shells UNCLIPPED → DrawCellObjectLists: entities drawn with membership cull only (UseIndoorMembershipOnlyRouting clears clip routing, RetailPViewRenderer.cs:439-450; the comment cites retail viewconeCheck but no per-view sphere-vs-plane test exists), and particles drawn per cell SCISSORED to the slice NDC AABB with clip distances disabled (GameWindow.cs:9553-9580). Cell-side portal polys are suppressed at mesh-extraction time by StipplingType.NoPos/NoNeg (ObjectMeshManager.cs:1385-1402 in PrepareCellStructMeshData, reached from the EnvCell path at ObjectMeshManager.cs:1343-1350) — a different criterion from retail's untextured-surface batch skip, agreeing on the audited cellar data.
## DIVERGENCES
### [CRITICAL] shell-chop-vs-depth-discipline (UNVERIFIED (verifier hit token limit)) — acdream clips cell-shell GEOMETRY to the view region; retail clips NOTHING — pixel exactness comes from aperture depth-punch + far→near order + z-buffer
- blastRadius: #114 in full (chopped interior stairs, vanished candle-holder area, neighbour-room barrel visible through a chopped wall — all are under-inclusive regions amputating real geometry), the e46d3d9→124c6cb door-regression cycle, and the structural reason 9ce335e had to scope the clip back out of indoor roots (leaving indoors with NO draw-side discipline at all). This is the one-drawing-discipline invariant breaker.
- retailEvidence: DrawEnvCell submits polys with planeMask=0xffffffff (pc:427922) which the shift in polyClipFinish (Ghidra 0x006b6d00: mask << (0x1e - npnts), set bit = SKIP edge) turns into skip-all; the production path is the prebuilt mesh DrawMesh(…, constructed_mesh, 1) (pc:427905) and DrawPolyInternal (Ghidra 0x0059d7c0) performs zero view clipping — a raw triangle fan to D3D. The accumulated view is consumed ONLY by: admission (ClipPortals/copy_view), object culling (viewconeCheck Ghidra 0x0054c250), and the depth punch (DrawPortalPolyInternal Ghidra 0x0059bc90: polyClipFinish-clipped aperture drawn DEPTHTEST_ALWAYS, z-write, alpha 0, z = portal depth (maxZ2=6 @0x00820e14) or far-z (maxZ1=7 @0x00820e18)). Cell geometry is always drawn whole; cells draw once (GetDrawnThisFrame guard Ghidra 0x0052c0c0) far→near (DrawCells Ghidra 0x005a4840).
- acdreamEvidence: DrawEnvCellShells enables GL_CLIP_DISTANCE0..7 and hard-clips shell vertices to the per-slice region planes (RetailPViewRenderer.cs:378-398; mesh_modern.vert:120; EnvCellRenderer.cs:1195-1230) — for outdoor roots only after 9ce335e (RetailPViewRenderer.cs:96-105); indoor roots draw shells unclipped with no compensating depth discipline. The region polygons themselves drift per frame (PortalVisibilityBuilder.cs:40-51 cap rationale), so any chop is also unstable.
- portShape: Remove the shell gl_ClipDistance chop as the enforcement mechanism (keep regions for admission). Port the retail discipline: draw every admitted cell's shell WHOLE, far→near per OrderedVisibleCells (already the order, IndoorDrawPlan.cs:21), and enforce aperture exactness with depth-only punches of the software-clipped portal polygons (see next divergence). gl_ClipDistance may survive only as the LANDSCAPE gate (terrain has no z-protection of its own until the punch exists).
### [CRITICAL] missing-aperture-depth-punch (UNVERIFIED (verifier hit token limit)) — The retail depth punch (DrawPortalPolyInternal) has no acdream equivalent — DrawExitPortalMasks is an unwired no-op and ClearDepthSlice clears an AABB to far instead of writing portal-plane depth on the exact clipped polygon
- blastRadius: Landscape-vs-interior compositing at every aperture: far interior cells can overpaint the terrain seen through a door (no door-plane z floor), the outdoor root has NO depth discipline at building doors at all (ClearDepthSlice=null), and the AABB-shaped clear over-includes around door frames — a direct candidate mechanism for #108 (grass sweeping at the aperture edge) and a contributor to the #114 see-through-to-neighbour-rooms class.
- retailEvidence: DrawCells pass 1 (Ghidra 0x005a4840): after LScape::draw, per cell per view, every other_cell_id==-1 portal is punched at its TRUE projected depth (maxZ2=6, bit0 clear → z=zw/w; Ghidra 0x0059bc90 tail) so geometry behind the door plane z-fails inside the aperture while the landscape keeps its pixels. Outside→in: ConstructView(CBldPortal) mode-1 walk punches the door to FAR-z (maxZ1=7) before the mode-2 walk draws interior cells into it, and the shell mesh draws LAST (DrawBuilding Ghidra 0x0059f2a0: Draw(part,1) then Draw(part,0); walks at pc:427993-427994). The punched polygon is the polyClipFinish-clipped aperture — pixel-exact.
- acdreamEvidence: RetailPViewRenderer.DrawExitPortalMasks exists but GameWindow never supplies the callback (RetailPViewRenderer.cs:331-332; ctx construction GameWindow.cs:7604-7670 sets only DrawLandscapeSlice/ClearDepthSlice/DrawCellParticles/EmitDiagnostics). The only depth management is ClearDepthSlice: glScissor on the slice NDC AABB + Clear(DepthBufferBit) — wrong shape (AABB ⊇ polygon), wrong value (far clear ≈ retail's maxZ1 special case, never the protective portal-depth maxZ2 write), and disabled outright for outdoor roots (GameWindow.cs:7644-7652).
- portShape: Wire DrawExitPortalMasks as a depth-only polygon draw: project + software-clip the aperture polygon against the slice view (ClipToRegion already does this math, PortalProjection.cs:105-134), rasterize it depth-always/z-write/color-masked at the portal's interpolated depth (indoor→out, retail maxZ2) or far-z (outside→in before interior cells, retail maxZ1), replacing ClearDepthSlice. ~80 lines of GL + the existing clipper.
### [HIGH] multiview-loss-first-wins (UNVERIFIED (verifier hit token limit)) — Multi-portal view accumulation is lossy: MergeBuildingFrame drops a building flood's views when the cell is already in the frame, and CellIdToSlot keeps only slices[0]
- blastRadius: A cell visible through TWO apertures (two doors, door+window) renders/punches/scissors with only the first view — missing second-door visibility, and per-frame winner flips drive oscillation: a named suspect for #109 (far-door oscillation) and the multi-aperture cases in #114 (meeting hall).
- retailEvidence: Render::copy_view APPENDS every clipped portal polygon as a new view_poly (Ghidra 0x0054dfc0: poly DArray grow + view_count++; called per portal per view from ClipPortals pc:433674 and ConstructView(CBldPortal) Ghidra 0x005a59a0). DrawCells iterates ALL views per cell for punches and object culling (Ghidra 0x005a4840 per-view loops; DrawMesh per-view viewconeCheck pc:427940-428060). AddViewToPortals propagates late growth in place via AddToCell/FixCellList/AdjustCellView (pc:433446, 433741-433745).
- acdreamEvidence: MergeBuildingFrame: `if (target.CellViews.ContainsKey(cellId)) continue;` — first-wins, src views discarded (RetailPViewRenderer.cs:151-160). ClipFrameAssembler.CellIdToSlot = sliceArray[0].Slot (ClipFrameAssembler.cs:130) feeds the single-slot consumers (entity routing RetailPViewRenderer.cs:227). The per-slice shell loop itself does handle multiple slices (RetailPViewRenderer.cs:388-393), so the loss is at merge/routing, not the draw loop.
- portShape: Merge = UNION the view lists (append src polygons through the existing AddRegion dedup) instead of skipping; once the shell chop is gone (divergence 1) multi-slice only matters for punches/scissors/object culling, where iterating all slices is already the code shape.
### [HIGH] eight-plane-budget-passall (UNVERIFIED (verifier hit token limit)) — The 8-plane GPU budget with an unimplemented scissor fallback (slot-0 = PASS-ALL) has no retail counterpart — retail's clip is polygon-vs-polygon software with no plane cap
- blastRadius: Any view polygon with >8 edges after collinear-merge silently degrades to pass-all: terrain slices flood the whole screen through a small aperture (grey/grass artifacts at complex doorways, #108 class), and under the current shell-chop model a per-frame flip between exact-planes and pass-all is a visible strobe. Issue113MeetingHallFloodTests pins 0 such slices at the hall, but the fallback is load-bearing wherever clipped polygons accrete vertices (every reciprocal clip adds edges).
- retailEvidence: polyClipFinish loops all portal_npnts view edges with DArray-backed vertex storage (Ghidra 0x006b6d00; view DArrays blocksize 0x80, acclient.h:32408-32445); the only 32-bit-mask artifact is ignored by the D3D draw path (DrawPolyInternal Ghidra 0x0059d7c0 takes no mask). copy_view's 1-pixel vertex dedup (Ghidra 0x0054dfc0) bounds vertex growth physically, not by a budget.
- acdreamEvidence: ClipPlaneSet: >8 edges → Scissor AABB (ClipPlaneSet.cs:130-133); assembler maps scissor fallbacks to slot 0 with 'the renderer uses scissor for passes that need that fallback' documented but no glScissor implemented in the slice consumers (ClipFrameAssembler.cs:13-15, 114-119; no scissor in RetailPViewRenderer draw paths — only particles use BeginDoorwayScissor, GameWindow.cs:9569).
- portShape: Once enforcement moves to the depth punch (CPU-rasterized clipped polygons), the 8-plane budget stops being load-bearing for shells; the landscape gate either keeps planes for the common ≤8 case with a real scissor (or stencil) fallback, or the punch protects terrain too and the budget disappears entirely. Do not extend gl_ClipDistance count.
### [HIGH] knife-edge-epsilon-and-rescue (UNVERIFIED (verifier hit token limit)) — Knife-edge handling diverges: retail uses ±0.0002 side classification (building portals reject IN_PLANE outright; cell portals degenerate naturally via the homogeneous near-W clip + 1-px dedup); acdream uses ±0.01 plus a non-retail 1.75 m eye-in-opening rescue that substitutes the ENTIRE current view
- blastRadius: #114's edge-on doorway grey (degenerate slice omitted → cell admitted by rescue but landscape/region slice missing → clear color through the aperture) and admission over-inclusion when grazing a doorway; the 50x-wider epsilon plus the rescue create a band where acdream and retail disagree about which portals contribute, feeding flap-class instability at thresholds.
- retailEvidence: ConstructView(CBldPortal) Ghidra 0x005a59a0: |dot| <= F_EPSILON (0.000199999995, const at 0x7e32f8) → IN_PLANE → return 0, no view, no punch. InitCell Ghidra 0x005a4b70: cell-portal in-plane falls through to inflag=0 (candidate) and the sliver dies in polyClipFinish stage-1 (w < cdstW homogeneous clip) or copy_view's |dx|<=1px && |dy|<=1px vertex merge <3 verts nothing appended. No rescue path exists anywhere.
- acdreamEvidence: PortalSideEpsilon = 0.01 (PortalVisibilityBuilder.cs:38, used at :734-741); clip-empty + EyeInsidePortalOpening(≤1.75 m) → clippedRegion := copy of the WHOLE current view (PortalVisibilityBuilder.cs:258-267, same in BuildFromExterior :501-507 and the reciprocal-empty rescue :324-332); ClipPlaneSet's MinPolygonArea=1e-7 gate turns surviving slivers into omitted slices (ClipPlaneSet.cs:66-68, 241-242) while the cell stays admitted.
- portShape: Adopt retail's constants and asymmetry: 0.0002 epsilon; building/exterior seeds reject in-plane; cell portals keep the candidate-then-degenerate path with a ~1-px screen-space vertex dedup in ClipToRegion output (the missing stabilizer that retail has and our drift cap compensates for). The rescue should shrink to what retail's geometry already implies — a portal the eye is truly inside projects near-full-screen through the homogeneous clip and needs no substitution.
### [MEDIUM] growth-requeue-vs-in-place (UNVERIFIED (verifier hit token limit)) — Late view growth: retail propagates in place (AddToCell + FixCellList/AdjustCellPlace re-sorts the draw list, AdjustCellView re-clips only new views via the update_count watermark); acdream re-enqueues with a drift cap and never re-sorts OrderedVisibleCells
- blastRadius: Draw-order staleness for late-grown cells (mostly masked by z-buffer) and the MaxReprocessPerCell=16 cap silently truncating legitimate propagation in portal-dense interiors — a churn/oscillation contributor (#109) and the source of the 2026-06-07 indoor-hang workaround complex.
- retailEvidence: AddViewToPortals pc:433446/0x005a52d0: first discovery → InitCell+InsCellTodoList; growth → AddToCell, and if cell_view_done, FixCellList = AdjustCellPlace (re-sort cell_draw_list, pc:433247) + AdjustCellView (ClipPortals(cell, update_count) → only NEW views re-clipped, pc:433741-433745; watermark fields 0x38/0x44 read at 0x005a5357-0x005a53b8). Termination comes from the 1-px dedup floor, not a cap.
- acdreamEvidence: Re-enqueue on grew with popCounts cap (PortalVisibilityBuilder.cs:348-354) and the cap's own comment attributing it to ProjectToClip drift (PortalVisibilityBuilder.cs:40-51); processedViewCounts is a faithful update_count port (PortalVisibilityBuilder.cs:174-184); OrderedVisibleCells append-once, never adjusted (PortalVisibilityBuilder.cs:168-172).
- portShape: With the dedup stabilizer (divergence 5) the cap becomes removable; the faithful shape is in-place propagation: on growth of an already-popped cell, immediately re-clip only the new view slice through its portals (recursive, like AdjustCellView) and re-position the cell in the ordered list (AdjustCellPlace) — or document why append-order + z-buffer makes re-positioning unnecessary in our GL pipeline.
### [MEDIUM] object-particle-gating (UNVERIFIED (verifier hit token limit)) — Objects/particles: retail culls per view by sphere-vs-view-edge-planes (viewconeCheck) and lets depth do the pixels; acdream culls by cell membership only and scissors particles to the slice NDC AABB
- blastRadius: particles-through-walls (AABB ⊇ aperture polygon → emitters visible outside the true opening), neighbour-room objects drawn whenever their cell is admitted even when their sphere is outside every view (the barrel half of the #114 barrel-through-wall once the wall chop is fixed, the object side remains over-inclusive), minor overdraw cost.
- retailEvidence: viewconeCheck Ghidra 0x0054c250: sphere vs viewer plane + each installed view_vertex.plane (stride 6 floats = Vec2D pt + Plane, acclient.h:32483-32487), OUTSIDE → skip; driven per view from RenderDeviceD3D::DrawMesh when PortalList is set (pc:427940-428060, building_view filter) and per cell from the DrawCells epilogue (PortalList = cell's view before vtbl+0x64 DrawObjCell, Ghidra 0x005a4840). No scissor, no geometry clip — depth + culling only.
- acdreamEvidence: UseIndoorMembershipOnlyRouting clears all clip routing for entities and the comment explicitly defers the viewconeCheck equivalent (RetailPViewRenderer.cs:439-450); entity draw passes visibleCellIds membership only (RetailPViewRenderer.cs:460-477); particles: DisableClipDistances + BeginDoorwayScissor(slice.NdcAabb) (GameWindow.cs:9553-9580).
- portShape: Port viewconeCheck: each ClipViewSlice already has the NDC edge set — lift the per-edge eye-planes (view_vertex.plane analog: the plane through the eye and the NDC edge, recoverable from the inverse view-projection) and test entity/emitter bounding spheres per slice before draw; drop the particle scissor once the punch protects apertures.
### [MEDIUM] portal-poly-suppression-criterion (UNVERIFIED (verifier hit token limit)) — Cell-side portal-poly suppression keys on the wrong property: acdream gates on Stippling NoPos/NoNeg at mesh build; retail includes them in the mesh and skips UNTEXTURED surface batches at draw (skipNoTexture + type&6)
- blastRadius: Anywhere stippling and surface-texturedness disagree the two renderers diverge: a TEXTURED portal poly without NoPos (window-filling between cell and outdoors, closed-door fillings) draws in retail but our criterion may pass or drop it for the wrong reason; this is the same mechanism family as the #113 phantom staircase / door-vanish on the GfxObj side (e223325) viewed from the cell side. On the audited cellar cells the criteria agree (both suppress), so blast radius today is latent, not pinned to an open issue.
- retailEvidence: ConstructMesh (Ghidra 0x0059dfa0) emits ALL polygons — no stippling/portal gate in counting or emission loops; CCellStruct::UnPack (Ghidra 0x00533d00) places portal polys inside the polygons array; the draw skip is per-surface: DrawPolyInternal requires (surface->type & 6) != 0 (Ghidra 0x0059d7c0; BASE1_IMAGE|BASE1_CLIPMAP acclient.h:5820-5824) and DrawMesh skips untextured batches when skipNoTexture (default 1 @0x00820e30) except for plain objects (Ghidra 0x0059d4a0 / pc:426064-426074). Dat evidence: cellar portal polys have stip=NoPos AND pos_surface→0x080000DF (untextured) — corner-cells-audit.txt EnvCell 0xA9B40175 polys 0x0004/0x0005.
- acdreamEvidence: PrepareCellStructMeshData: hasPos/hasNeg from StipplingType.NoPos/NoNeg decide emission entirely (ObjectMeshManager.cs:1394-1402); a NoPos poly is dropped from the mesh before any surface consideration; conversely a portal poly WITHOUT NoPos would be emitted and drawn textured with no draw-time portal awareness (ObjectMeshManager.cs:1343-1350 entry).
- portShape: Align the criterion with retail: emit all polys, classify batches by surface texturedness (we already read Surface.Type at ObjectMeshManager.cs:1430+), and skip untextured batches for cell/building meshes at draw — or prove from a dat sweep that NoPos ⇔ untextured-surface for all CellStructs (extend the e223325 conformance sweep to environments) and keep the cheaper build-time gate with the proof pinned.
## OPEN QUESTIONS
- LScape::draw internals: whether retail clips terrain POLYGONS against outside_view in software or only culls land blocks/cells per view (PortalList is installed before LScape::draw in DrawCells pass 1, Ghidra 0x005a4840, but I did not decompile LScape::draw). The punch+order discipline works either way, but a faithful landscape pass should know which; acdream currently plane-CLIPS terrain per slice, which retail may not do at all.
- PView::InitCell's second loop (Ghidra 0x005a4b70 tail): the decompile shows seen=1 set for every inflag==0 portal per view with no visible screen-bbox test — either a test was optimized out of the decompile or 'seen' is simply 'candidate'. The exact seen gate (and whether it uses the view xmin/xmax bbox installed by set_view) is unconfirmed.
- Who calls PView::DrawPortal with mode 3 (punch-on-ConstructView-failure, Ghidra 0x005a5ab0)? The BSP walks pass modes 1 and 2 (pc:427993-427994); mode 3 implies a 'seal this aperture in depth even though nothing is visible through it' caller I did not locate — possibly the unloaded-interior or option-disabled path. Matters for the port's behavior when an interior cell is not streamed in.
- GfxObj-side (building shell) portal-poly surfaces: the cell-side data proves the untextured-surface skip for the cellar; whether the Holtburg building models' door/window-filling polys split into textured (visible filling) vs untextured (open aperture) the way the skipNoTexture gate requires is AREA 1's question — the e223325 test dump should be extended to record each portal poly's pos_surface type bits before the holistic port relies on this gate.
- Retail DrawMesh's garbled else-branch (`skipNoTexture = 1` when ObjBuildingOrBuildingPart==0 && param_4==0, both BN pc:426074 and Ghidra agree on the assignment) — semantically it looks like a latch that should be an assignment to 0 or a one-shot draw-anyway; since skipNoTexture init is already 1 (0x00820e30) the net effect (skip untextured for buildings/cells, draw for plain objects) holds, but the branch's exact intent deserves a disassembly-level check before porting it verbatim.
- Spatially overlapping cells within one dungeon: retail punches ONLY landscape portals (other_cell_id==-1) and building doors — cell-to-cell apertures get no punch, so retail relies on z-buffer + non-overlapping cell volumes. Whether any shipped dungeon has same-dungeon overlapping cell volumes (which would bleed under the unclipped discipline and therefore under a faithful port too) is a dat question worth a one-off sweep before declaring the port's z-only indoor compositing sufficient.
- cdstW's exact value (the homogeneous near-W threshold in polyClipFinish, Ghidra 0x006b6d00): not extracted; acdream's EyePlaneW=1e-4/MinW=0.05 (PortalProjection.cs:182-188) should be pinned to retail's constant during the port.

View file

@ -0,0 +1,215 @@
# Area 6 — Interior collision: per-cell shadow lists (#99, A6.P4)
## RETAIL
RETAIL ARCHITECTURE (all branch/gate claims verified in Ghidra decompile; pc:LINE given as cross-reference only).
== Data structures ==
Every cell — indoor EnvCell or outdoor LandCell — owns a per-cell object index: CObjCell {num_objects + object_list (objects whose m_position is in this cell), num_shadow_objects + shadow_object_list (objects whose GEOMETRY overlaps this cell), num_stabs + stab_list, restriction_obj} (acclient.h:30916-30936). The link record is CShadowObj {physobj, cell_id, cell} (acclient.h:30940-30944): one per (object, overlapped-cell) pair, stored inline in the object's own shadow_objects DArray (stride 0x18 — Ghidra 0x00514ae0) AND pointer-referenced from each cell's shadow_object_list. So "where can this object be collided with?" is answered ONCE, at registration, by writing the object into every cell it geometrically touches.
== Registration: which cells does a shadow land in? (named question 1) ==
The cell set is built by CObjCell::find_cell_list(Position, num_spheres, spheres, CELLARRAY, outCell, SPHEREPATH) — Ghidra 0x0052b4e0, pc:308742. Verified structure:
1. Reset array. If m_position is OUTDOOR (objcell_id & 0xFFFF < 0x100): CLandCell::add_all_outside_cells(pos, n, spheres, array) the outdoor 24 m land cells the spheres overlap (crosses landblock borders). If INDOOR: add exactly THAT one cell (and set path->hits_interior_cell=1).
2. Then a single forward loop over the GROWING array: for each cell already in it, call its virtual find_transit_cells (vtable+0x80). Because newly appended cells are themselves visited, this is a recursive flood — but every hop is gated by ACTUAL SPHERE OVERLAP, not by topology and not by any visibility list:
- CEnvCell::find_transit_cells (Ghidra 0x0052c820 — the verified binary anchor): for each CCellPortal: (a) exterior portal (other_cell_id == 0xFFFFFFFF): test each sphere against the portal polygon plane; if |signed distance| < radius + F_EPSILON (the sphere STRADDLES the doorway plane) set a flag; after the portal loop the flag triggers add_all_outside_cells this is how an indoor-positioned object near a street door also lands in outdoor cells. (b) interior portal, neighbor LOADED: add the neighbor IFF some sphere intersects the neighbor's actual cell geometry CCellStruct::sphere_intersects_cell != OUTSIDE. (c) interior portal, neighbor UNLOADED: add by cell-id with a NULL cell pointer IFF the sphere crosses the portal plane to the far side (the portal_side sign test this is the only place portal_side appears in this function).
- CLandCell::find_transit_cells (Ghidra 0x00533800): add_all_outside_cells, then CSortCell::find_transit_cells (Ghidra 0x00534060) which forwards to CBuildingObj::find_building_transit_cells (Ghidra 0x006b5230): for each CBldPortal (acclient.h:32094) get the interior EnvCell behind it and call CEnvCell::check_building_transit (Ghidra 0x0052c5d0): add the interior cell IFF other_portal_id >= 0 AND some sphere intersects that interior cell's BSP. THIS is the outdoor→indoor bridge: an outdoor-positioned door whose sphere pokes into the vestibule is written into the vestibule's shadow_object_list at registration. No reverse portal map exists or is needed — the building's own portal list carries the direction.
3. Spheres used: the object's per-part CylSpheres globalized (overload Ghidra 0x0052b9f0: sphere center = each CylSphere's low_pt transformed local→global, radius = cyl radius, capped at 10 spheres), falling back to the part-array sorting sphere (calc_cross_cells, Ghidra 0x00515230). So the flood reach is the object's REAL collision footprint — a fireplace deep in a room lands in 1 cell; a door at a threshold lands in 2-3.
4. Static placement variant calc_cross_cells_static (Ghidra 0x00515160, pc:283340) sets cell_array.do_not_load_cells=1, which (back in find_cell_list, Ghidra 0x0052b4e0 tail) prunes flood results down to {start cell} start cell's stab_list (num_stabs/stab_list, acclient.h:30930-30931) — a don't-force-load restriction, NOT the placement rule itself.
The write: CPhysicsObj::add_shadows_to_cells (Ghidra 0x00514ae0, pc:282819): one CShadowObj per array cell; if the array entry's cell pointer is null (unloaded), the shadow records cell_id but joins no list. Each loaded cell gets CObjCell::add_shadow_object (Ghidra 0x0052b280, pc:308584: append + back-link shadow->cell) plus CPartArray::AddPartsShadow. Particle emitters (PARTICLE_EMITTER_PS=0x1000, acclient.h:2829) take a single-cell shortcut (add_particle_shadow_to_cell); HAS_PHYSICS_BSP_PS=0x10000 objects (acclient.h:2833) use find_bbox_cell_list (Ghidra 0x00510fc0: current cell + CPartArray::calc_cross_cells_static over the growing array). Children recurse.
== Query: who is consulted at collision time? (named question 2) ==
Primary cell: CTransition::transitional_insert (pc:273137, 0x0050b6f0) → CTransition::insert_into_cell(sphere_path.check_cell, attempts) (pc:271991, Ghidra-region 0x00509e70) → check_cell->vtable->find_collisions(this), retried up to `attempts`. Per cell type (verified Ghidra): CEnvCell::find_collisions (0x0052c100) = find_env_collisions (own cell BSP, vtable+0x8c) THEN CObjCell::find_obj_collisions(this). CLandCell::find_collisions (0x00532d60) = find_env_collisions (terrain) THEN CSortCell::find_collisions (0x005340a0: this->building → CBuildingObj::find_building_collisions) THEN find_obj_collisions(this). Two structural facts fall out: (a) CObjCell::find_obj_collisions (Ghidra 0x0052b750, pc:308916) iterates ONLY this->shadow_object_list — skip parented objects, skip self, call CPhysicsObj::FindObjCollisions per object, first non-OK halts; entire loop skipped when insert_type == INITIAL_PLACEMENT_INSERT; (b) the BUILDING collision channel exists only on LandCell — an indoor primary cell structurally cannot collide with a building shell.
Other cells: CTransition::check_other_cells (pc:272690-272798, 0x0050ae50): rebuilds this->cell_array via find_cell_list(&cell_array, &pick, &sphere_path) from the CURRENT sphere positions (the same flood machinery as registration), then for each array cell != the primary calls vtable+0x88 find_collisions — i.e. env AND shadow-objects per other cell. COLLIDED/ADJUSTED return immediately; SLID clears contact_plane_valid/contact_plane_is_water and returns; afterwards check_cell is retargeted to the containing-cell pick (adjust_check_pos).
Straddling-doorway object: covered twice — it was REGISTERED into both cells (sphere-overlap flood), and the moving player's own cell_array spans both cells at the threshold so both lists are iterated anyway. There is no spatial radius anywhere in the query; the only sets are per-cell lists.
== Removal / update cadence (named question 3) ==
Always remove-all-then-add-all, never an incremental diff: CPhysicsObj::remove_shadows_from_cells (Ghidra 0x00511230): per shadow, CObjCell::remove_shadow_object (Ghidra 0x0052b2d0: swap-remove + DArray shrink) + CPartArray::RemoveParts; recurse children. Triggers, verified by xref + decompile:
- EVERY successful movement step: CPhysicsObj::SetPositionInternal(CTransition*) (Ghidra 0x00515330, pc:283270-283545) ends with: if (cell) { if (state & HAS_PHYSICS_BSP_PS) calc_cross_cells(); else if (transit->cell_array.num_cells > 0) { remove_shadows_from_cells(); add_shadows_to_cells(&transit->cell_array); } } — the movement path REUSES the transition's already-computed CELLARRAY (the one check_other_cells just built), so per-tick re-registration costs no extra flood.
- Placement/teleport: calc_cross_cells (Ghidra 0x00515230 = fresh find_cell_list + remove + add) from the non-transition SetPositionInternal overload (xref 0x0051551b) and ForceIntoCell (xref 0x0051568b).
- Cell load: CObjCell::init_objects (Ghidra 0x0052b420): CObjectMaint::InitObjCell + recalc_cross_cells (Ghidra 0x00515a30) for each non-static, not-completely-visible object homed in the newly loaded cell. Also set_parent (xrefs 0x00515b15/0x00515bab).
## ACDREAM
== acdream call chain ==
REGISTRATION — src/AcDream.Core/Physics/ShadowObjectRegistry.cs. Register (line 41): if cellScope != 0 the entry goes into exactly that ONE cell (lines 62-72, the A1.5 interior-statics fix); otherwise the entry is written into the outdoor 24 m grid cells its XY bounding box overlaps, clamped to a SINGLE landblock (lines 77-105: minCx/maxCx clamp 0..7, one lbPrefix). RegisterMultiPart (line 124) does the same per shape (lines 162-186). No portal traversal, no sphere-vs-cell-BSP test, no building bridge — the cell set is an XY rectangle. UpdatePosition (lines 219-278) re-registers via Deregister + Register on the 5-10 Hz server UpdatePosition stream. Production call sites: GameWindow.cs:3264 RegisterMultiPart for server-spawned entities (doors included) passes NO cellScope → doors register into outdoor grid cells only, even when their geometry pokes through a doorway into a vestibule; GameWindow.cs:6176/6246/6282/6307/6506 Register statics with cellScope = entity.ParentCellId ?? 0u (interior EnvCell statics → one cell; landblock-baked building-shell GfxObjs → landblock-wide outdoor footprint).
QUERY — src/AcDream.Core/Physics/TransitionTypes.cs. TransitionalInsert (line 862) runs FindEnvCollisions (line 876, primary cell env only) then ONE flat FindObjCollisions (line 900) per attempt — the comment at lines 856-859 explicitly concedes "we don't have CellArray/CheckOtherCells iteration because our FindObjCollisions ... is already a flat per-landblock query". FindObjCollisions (line 2307): queryRadius = sphereRadius + movement.Length() + 5f (line 2340 — a 5 m pad with no retail analog); CellTransit.FindCellSet supplies portalReachableCells (line 2358); ShadowObjectRegistry.GetNearbyObjects is called once with primaryCellId = sp.CheckCellId and isViewer (lines 2376-2382); results iterated with self-skip (line 2398) + a broad-phase distance pre-check (lines 2401-2409). GetNearbyObjects (ShadowObjectRegistry.cs:430): first iterates portalReachableCells lists (lines 460-471), then the b3ce505 stopgap gate — `if ((primaryCellId & 0xFFFFu) >= 0x0100u && !isViewer) return;` (lines 494-495) — else a 9-landblock radial sweep over grid cells the query radius overlaps (lines 498-539). CheckOtherCells (TransitionTypes.cs:1632-1750, the A4 port of retail check_other_cells) iterates the FindCellSet set but runs ENV collision only (terrain ValidateWalkable for outdoor ids lines 1650-1684, cell BSP for indoor lines 1687-1750) — NO per-cell shadow-object query. RunCheckOtherCellsAndAdvance (lines 2158-2195) = FindCellSet (2181) → CheckOtherCells (2185) → containing-cell retarget (2192-2193).
CELL-SET MACHINERY — src/AcDream.Core/Physics/CellTransit.cs. FindTransitCellsSphere (line 107) is a faithful port of CEnvCell::find_transit_cells: exterior-portal straddle gate restored + live-binary verified (lines 130-176), loaded-neighbor sphere-vs-CellBSP test (lines 184-199); plus a NON-retail topology output hasExitPortal (lines 102-106, set at 132). BuildCellSetAndPickContaining (line 543): indoor seed = current cell at index 0 (line 580) + growing-array walk (lines 589-619) with the A6.P5 widening at lines 614-618 (any exit-portal cell → AddAllOutsideCells by TOPOLOGY, wider than retail's straddle gate, self-documented as a #99 stopgap); outdoor seed = AddAllOutsideCells + CheckBuildingTransit per building stab (lines 626-634 — the retail building bridge IS already ported here, just unused by registration).
== A6.P4 spec verdict (docs/superpowers/specs/2026-05-24-phase-a6-p4-retail-shadow-architecture.md) ==
CONFIRMED: §2.2 per-cell shadow_object_list + find_obj_collisions(this)-only iteration (Ghidra 0x0052b750); the decomp-anchor table's function identities all check out against Ghidra (308742→0x0052b4e0, 282819→0x00514ae0, 308584→0x0052b280, 308916→0x0052b750, 309560→0x0052c100, 316951→0x00532d60); §3.1's inversion (compute cell set at registration, strict per-cell query) is the right architecture; spec §7 Q2 answered yes — doors register outdoor-only (GameWindow.cs:3264, no cellScope).
REFUTED: §3.1/§3.3-slice-2's registration rule "indoor m_position: that cell + VisibleCellIds (forward portal traversal)" — retail does NOT use any visibility list for shadow placement. The recursion is sphere-overlap-gated portal flood (find_transit_cells: neighbor added only if the object's spheres intersect the neighbor's CCellStruct BSP, Ghidra 0x0052c820); stab_list appears only as the do_not_load_cells PRUNE in the static variant. Using VisibleCellIds would massively over-register (a fireplace would land in the whole room-chain PVS). Spec §7 Q1 is therefore moot — wrong list entirely.
REFUTED: §3.2's worry that outdoor→indoor needs a "reverse portal map" (option 3.2.a) — no reverse lookup exists in retail. The outdoor→indoor direction goes through the BUILDING: CLandCell::find_transit_cells → CSortCell.building → CBldPortal list → CEnvCell::check_building_transit (Ghidra 0x00533800/0x00534060/0x006b5230/0x0052c5d0). acdream already ports this exact bridge (CellTransit.cs:626-634) — slice 2 just has to invoke it from the registration-side cell-set builder.
ADJUSTED: §3.2.b ("query-side expansion ... matches retail behaviorally") shipped as the current portalReachableCells + A6.P5 widening, but it is NOT behaviorally equivalent to retail (topology-wide, plus the radial sweep persists for outdoor primaries); it is staging only, and slice 2/3 must land for retail parity, as the spec itself intends.
MISSING FROM SPEC (1): the movement-time trigger — retail re-registers every moved object each successful transition step by reusing the transition's CELLARRAY (SetPositionInternal, Ghidra 0x00515330 tail). The spec only discusses Register/UpdatePosition; the faithful port should refresh a moving entity's shadow set from its own transition cell set, answering spec §7 Q4 (cost ≈ zero — the set is already built).
MISSING FROM SPEC (2): buildings are not shadow objects in retail at all — the building channel hangs off LandCell::find_collisions only (CSortCell::find_collisions, Ghidra 0x005340a0/0x00532d60), so indoor cells can never see building shells. The spec keeps the cottage as an outdoor-registered shadow entry; the deeper port moves building shells out of the registry into a per-LandCell building reference (cache.GetBuilding exists, CellTransit.cs:631), which is what makes the #98 gate removable rather than merely relocated.
## DIVERGENCES
### [CRITICAL] registration-cell-set-not-portal-flood (confirmed) — Shadow registration uses an XY grid rectangle / single scoped cell instead of retail's sphere-overlap portal flood
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C), every claimed gate verified:
1. Registration chokepoint confirmed. CPhysicsObj::calc_cross_cells (Ghidra 0x00515230) and calc_cross_cells_static (0x00515160) both build a CELLARRAY via CObjCell::find_cell_list, then call remove_shadows_from_cells + add_shadows_to_cells. Xrefs confirm these are THE registration paths: calc_cross_cells_static is called from CObjCell::init_static_objs, CEnvCell::init_static_objects, and CPhysicsObj::add_obj_to_cell; calc_cross_cells from SetPositionInternal, ForceIntoCell, recalc_cross_cells (movement). add_shadows_to_cells (0x00514ae0) writes one CShadowObj per CELLARRAY cell via CObjCell::add_shadow_object (0x0052b280), which appends to the cell's shadow_object_list and back-points shadow->cell — so the per-cell shadow list IS the registration result, exactly as claimed.
2. CObjCell::find_cell_list (0x0052b4e0): verified verbatim — outdoor seed (cell id low16 < 0x100) CLandCell::add_all_outside_cells; indoor seed CELLARRAY::add_cell(that one cell); then a growing-array walk (`while (i < cell_array->num_cells)` with num_cells re-read each iteration) calling each cell's vtable+0x80 (find_transit_cells). The CylSphere wrapper (0x0052b9f0) converts up to 10 CCylSpheres (low_pt global center, cylinder radius) into the sphere set confirming the claimed "CylSphere-derived sphere set".
3. CEnvCell::find_transit_cells (0x0052c820): verified — per portal: other_cell_id == 0xFFFFFFFF (exterior) → straddle test per sphere (-(F_EPSILON+radius) < planeDist < F_EPSILON+radius) sets a flag, and after the loop CLandCell::add_all_outside_cells fires if any portal straddled; loaded neighbor added IFF CCellStruct::sphere_intersects_cell(neighbor->structure, sphere) != OUTSIDE. All as claimed. One branch the claim omitted (does not weaken it): when the neighbor cell is NOT loaded (GetOtherCell null), retail still adds the neighbor's cell id from a portal-plane distance + portal_side test — a port must preserve this for streaming-in cells.
4. Outdoor→indoor bridge verified: CLandCell::find_transit_cells (0x00533800) = add_all_outside_cells + CSortCell::find_transit_cells (0x00534060) → if cell has a building → CBuildingObj::find_building_transit_cells (0x006b5230) → per CBldPortal → CEnvCell::check_building_transit (0x0052c5d0), whose gate is exactly `(-1 < other_portal_id)` AND at least one sphere with sphere_intersects_cell != OUTSIDE → add_cell(interior cell). So a doorway-spanning door's spheres reach the vestibule's shadow_object_list at registration. Claim verified.
5. One genuine refinement (folded into notes, not a contradiction): both calc_cross_cells variants branch on `state & 0x10000` (HAS_PHYSICS_BSP) → CPhysicsObj::find_bbox_cell_list (0x00510fc0) instead of the sphere flood. That path is the SAME portal-graph growing-array architecture but geometry = part bounding boxes: CPartArray::calc_cross_cells_static (0x00518160) → vtable+0x7c part-based CEnvCell::find_transit_cells (0x0052cae0), gating neighbor add on Plane::intersect_box (portal plane) + CCellStruct::box_intersects_cell, with the same exterior → add_all_outside_cells (part variant 0x00533360). There is also a particle path (`state & 0x1000` → add_particle_shadow_to_cell). Whether ACE-sent door PhysicsState carries 0x10000 was not settled here; either way the registration is a portal flood with geometry-vs-cell-BSP gates, never an XY grid — the divergence is identical under both branches, and the sphere-flood port shape proposed matches what retail does for all non-HAS_PHYSICS_BSP objects.
ACDREAM SIDE — all cited lines verified by reading the code:
- ShadowObjectRegistry.Register: cellScope!=0 → exactly ONE cell (ShadowObjectRegistry.cs:62-72); else 24m XY-grid rect from position±radius, clamped to cx/cy 0..7 of ONE landblock (ShadowObjectRegistry.cs:77-105). RegisterMultiPart repeats the identical per-shape logic (162-186). UpdatePosition re-registers through the same paths (219-278). No portal traversal, no sphere-vs-cell-BSP test anywhere in registration — confirmed by reading the whole file; the only flood machinery (CellTransit.FindCellSet) is consumed at QUERY time (GetNearbyObjects portalReachableCells param, ShadowObjectRegistry.cs:460-471), not registration.
- Production call sites confirmed: server-spawned entities (doors included) register via RegisterMultiPart with NO cellScope argument (GameWindow.cs:3264-3273, in RegisterLiveEntityCollision) → outdoor XY-grid cells only. The five landblock-static sites pass cellScope: entity.ParentCellId ?? 0u (GameWindow.cs:6176-6197, 6246-6267, 6282-6303, 6307-6328, 6506-6527) → interior statics land in exactly one cell (retail would flood them into every cell their geometry overlaps).
- The claimed compensation stack is real and all query-side: the #98 indoor-primary gate (`(primaryCellId & 0xFFFF) >= 0x0100 && !isViewer → return` before the outdoor sweep, ShadowObjectRegistry.cs:494-495), the A6.P4-slice-1 portalReachableCells outdoor-id iteration explicitly documented as the #99 door-reachability patch (ShadowObjectRegistry.cs:413-428), and the hasExitPortal topology widening in CellTransit.cs (:102,:132,:614-616). The 3×3-landblock query sweep (ShadowObjectRegistry.cs:502-539) compensates the registration-side landblock clamp for the outdoor-outdoor case.
- Port-shape anchors exist as claimed: CellTransit.FindTransitCellsSphere (CellTransit.cs:53/66/107), AddAllOutsideCells (:257/:306), CheckBuildingTransit (:353), and the outdoor-seed AddAllOutsideCells + building bridge (:626-634).
JUDGMENT: the divergence is real and not behaviorally equivalent. Retail computes cell membership ONCE at registration via a geometric portal flood and queries only the current cell's (plus flooded cells') shadow lists; acdream registers into a grid/single-cell approximation and then patches reachability at every query with a stack the project's own physics digest labels workarounds (#98 stopgap b3ce505 → introduced #99, OPEN HIGH; A6.P4 per-cell shadow architecture named as the open debt). Severity "critical" is justified: it breaks retail's per-cell shadow-list invariant and is the root of #99. Two honest caveats for the port plan: (a) decide per-object between the sphere flood and the find_bbox_cell_list bbox flood based on HAS_PHYSICS_BSP (0x10000) state — or document choosing the sphere flood for all as a deliberate simplification; (b) preserve the unloaded-neighbor portal-plane fallback branch of CEnvCell::find_transit_cells (0x0052c820) and the 10-sphere cap of the CylSphere wrapper (0x0052b9f0).
- blastRadius: #99 (outdoor-registered doors invisible to indoor-side collision — walk-through at thresholds, OPEN HIGH); the entire compensation stack (b3ce505 indoor gate, A6.P5 topology widening, portalReachableCells query expansion) exists because of this one divergence; also the residual risk class behind #97-family phantom collisions at indoor/outdoor seams.
- retailEvidence: CObjCell::find_cell_list (Ghidra 0x0052b4e0, pc:308742): outdoor seed → add_all_outside_cells; indoor seed → that one cell; then a growing-array walk calling each cell's find_transit_cells (vtable+0x80). CEnvCell::find_transit_cells (Ghidra 0x0052c820): neighbor added IFF the object's spheres intersect the neighbor's CCellStruct BSP (sphere_intersects_cell != OUTSIDE); exterior portal → add_all_outside_cells IFF a sphere straddles the portal plane (|dist| < r+ε). Outdoorindoor via CLandCell::find_transit_cells CBuildingObj::find_building_transit_cells CEnvCell::check_building_transit (Ghidra 0x00533800/0x006b5230/0x0052c5d0): interior cell added IFF other_portal_id >= 0 AND a sphere intersects its BSP. Result written per cell by add_shadows_to_cells → add_shadow_object (Ghidra 0x00514ae0/0x0052b280); a doorway door lands in BOTH the outdoor cell's and the vestibule's shadow_object_list at registration.
- acdreamEvidence: ShadowObjectRegistry.Register: cellScope single cell (ShadowObjectRegistry.cs:62-72) else outdoor 24m XY-grid rectangle clamped to one landblock (ShadowObjectRegistry.cs:77-105); RegisterMultiPart same per shape (162-186). Server-spawned doors pass no cellScope (GameWindow.cs:3264-3273) → outdoor cells only. No portal traversal or sphere-vs-cell-BSP test anywhere in registration.
- portShape: BuildShadowCellSet(m_positionCellId, shapeSpheres) that reuses the ALREADY-PORTED CellTransit machinery: indoor seed → growing-array walk with FindTransitCellsSphere (CellTransit.cs:107, minus the hasExitPortal widening, minus the membership pick); outdoor seed → AddAllOutsideCells + CheckBuildingTransit (CellTransit.cs:626-634 — the retail building bridge, already in-tree). Write the entry into every returned cell's list. Spheres = the entity's real collision shapes (per-shape CylSphere/BSP bounding spheres), mirroring retail's CylSphere-derived sphere set (Ghidra 0x0052b9f0).
### [CRITICAL] flat-object-query-not-per-cell (confirmed) — Object collision is one flat radial+portal-set query per step instead of per-cell shadow-list iteration
- correctedClaim: Claim stands as written, with one refinement to the port shape: a flat union of the FindCellSet cells' shadow lists fixes the query SET but not retail's per-cell ordering — retail interleaves env+object collision per cell (find_collisions = env BSP → building → find_obj_collisions, primary cell first via insert_into_cell, then each other cell via check_other_cells, halting on the first non-OK). A fully faithful A6.P4 port should run find_obj_collisions per cell at those two retail call sites rather than as a single phased union query.
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C), all four load-bearing functions checked:
1. CTransition::transitional_insert @ 0x0050b6f0: per attempt calls insert_into_cell(this, sphere_path.check_cell, num_attempts), and on OK_TS immediately calls check_other_cells(this, check_cell). So the per-step object query is strictly cell-driven: primary cell first, then every other overlapped cell.
2. CTransition::insert_into_cell @ 0x00509e70: loops calling `(**(code**)(param_1->_padding_ + 0x88))(this)` — vtable slot +0x88 on the cell. The BN pseudo-C at pc:271984-272030 names this slot find_collisions; the Ghidra decompile of CEnvCell::find_collisions calls the ADJACENT slot +0x8c (find_env_collisions) internally, consistent with +0x88 = find_collisions. The claim's caller/callee composition ("insert_into_cell calls check_cell->find_collisions") is accurate: transitional_insert passes check_cell as param_1.
3. CTransition::check_other_cells @ 0x0050ae50: builds the overlap set via CObjCell::find_cell_list(&this->cell_array, ...), then for each non-null cell != primary calls the SAME vtable slot +0x88 (find_collisions); COLLIDED/ADJUSTED halt, SLID clears contact_plane_valid/is_water and returns. Matches pc:272735 as claimed.
4. The find_collisions implementations: CEnvCell::find_collisions @ 0x0052c100 = vtable+0x8c (env BSP) then CObjCell::find_obj_collisions; CLandCell::find_collisions @ 0x00532d60 = env → CSortCell::find_collisions (building, @ 0x005340a0) → CObjCell::find_obj_collisions. Both concrete cell types end in find_obj_collisions as claimed.
5. CObjCell::find_obj_collisions @ 0x0052b750: iterates ONLY this->shadow_object_list (num_shadow_objects), skipping parented objects and self, calling CPhysicsObj::FindObjCollisions per entry. No distance/radius filter of any kind. The only spatial operation in the whole chain is find_cell_list's sphere-to-cell overlap (cell membership, not a radial object gather). "No spatial radius exists anywhere in the retail query path" — confirmed.
ACDREAM SIDE — every cited line checked against the actual code:
- src/AcDream.Core/Physics/TransitionTypes.cs:862-925 (TransitionalInsert): exactly ONE FindObjCollisions(engine) per attempt at :900, after FindEnvCollisions at :876. Confirmed.
- TransitionTypes.cs:855-860: the concession comment is verbatim — "we don't have CellArray/CheckOtherCells iteration because our FindObjCollisions (via ShadowObjectRegistry) is already a flat per-landblock query."
- TransitionTypes.cs:2340: `float queryRadius = sphereRadius + movement.Length() + 5f;` — the +5m pad, confirmed.
- TransitionTypes.cs:2358-2359: `CellTransit.FindCellSet(...)` already computes portalReachableCells (containing-cell result discarded with `_ =`), passed into GetNearbyObjects at :2376-2382 with primaryCellId + isViewer. Confirmed.
- src/AcDream.Core/Physics/ShadowObjectRegistry.cs:430-540 (GetNearbyObjects): first unions the portal-reachable cells' shadow lists (:460-471), then the b3ce505 indoor gate at :494-495 (`if ((primaryCellId & 0xFFFFu) >= 0x0100u && !isViewer) return;`), then falls through to the 9-landblock radial 24m-cell grid sweep (:497-539) for outdoor primaries. Confirmed exactly as claimed, including the isViewer exemption (comment :482-493 admits "Retail's find_obj_collisions ... has NO indoor-cell gate — the gate is acdream-specific").
- #98 causal claim independently corroborated by the in-code comment at ShadowObjectRegistry.cs:473-480: "the landblock-wide cottage GfxObj was returned by the unconditional radial sweep."
NOT-HANDLED-ELSEWHERE CHECK (the conflation risk): acdream DOES have a CheckOtherCells port (A4), so I verified what it iterates — TransitionTypes.cs:1632-1769 runs ONLY environment collision per other cell (terrain ValidateWalkable :1650-1684 for outdoor ids, cell BSP via BSPQuery.FindCollisions :1728-1732 for indoor). No shadow-object iteration anywhere in it. So per-cell object collision is genuinely absent, not relocated.
BEHAVIORAL-EQUIVALENCE CHECK: not equivalent. (a) Outdoor primaries test the union of cellSet + every shadow list within queryRadius over a 24m cell grid — objects in cells the sphere never overlaps get tested (the +5m pad guarantees this); retail tests only overlapped cells' lists. (b) Indoors, the gate makes the SET roughly retail-shaped, but doors registered at outdoor cells (cellScope=0, GameWindow.cs:3139 per the comment at TransitionTypes.cs:2342-2354) are only visible via the AddAllOutsideCells straddle — the registration rule diverges from retail's register-into-every-overlapped-cell, which is exactly why #99 exists. (c) Halt ordering differs: retail halts per cell in cell_array order with the primary cell's env+obj interleaved in one find_collisions call; acdream phases env-all-cells then objects-flat, so which collision wins first can differ.
CAVEATS (noted, not verdict-changing): (1) The blast-radius attribution of #97/#100/#101 specifically to the +5m pad is plausible-but-not-independently-re-proven here; the digest lists them as the phantom-collision class and the mechanism (radial gather tests non-overlapped cells' objects) structurally produces that class. (2) The proposed port shape (flat union of FindCellSet cells' shadow lists) fixes the SET but would still not reproduce retail's per-cell halt ordering or the per-cell env/obj interleave; a fully faithful port iterates find_obj_collisions per cell inside the per-cell find_collisions sequence (primary via insert_into_cell, others via check_other_cells). The divergence as claimed — flat radial+portal-set query vs per-cell shadow-list iteration — is real, and the severity rating (critical: necessitated the b3ce505 stopgap which introduced OPEN-HIGH #99, blocks A6.P4 slice 3) is justified.
- blastRadius: #98's original cause (cellar sphere found the cottage via the radial sweep) and the reason the b3ce505 stopgap + isViewer exemption exist; the +5m pad finds objects retail would never test (phantom-collision class #97/#100/#101); blocks deleting the stopgap (A6.P4 slice 3).
- retailEvidence: insert_into_cell calls check_cell->find_collisions (pc:271991-272030); check_other_cells calls every other overlapped cell's find_collisions (vtable+0x88, pc:272735); each find_collisions ends in CObjCell::find_obj_collisions(this) which iterates ONLY this->shadow_object_list (Ghidra 0x0052b750). No spatial radius exists anywhere in the retail query path.
- acdreamEvidence: TransitionalInsert runs ONE FindObjCollisions per attempt (TransitionTypes.cs:900; concession comment at 856-859); queryRadius = sphereRadius + movement + 5f (TransitionTypes.cs:2340); GetNearbyObjects falls through to a 9-landblock radial grid sweep for outdoor primaries (ShadowObjectRegistry.cs:498-539) gated off for indoor primaries by the b3ce505 stopgap (ShadowObjectRegistry.cs:494-495).
- portShape: After registration-side flood ships: GetNearbyObjects(cellSet) = union of the shadow lists of exactly the transition's FindCellSet cells (already computed at TransitionTypes.cs:2358). Delete the radial sweep, the +5m pad, the primaryCellId gate, and the isViewer exemption (the viewer's own cell set reaches whatever its swept sphere overlaps). This is A6.P4 slices 1→3 with the corrected registration rule.
### [HIGH] building-shell-as-shadow-object (adjusted) — Building shells are landblock-wide shadow entries; retail buildings are a per-LandCell channel that indoor cells structurally cannot reach
- correctedClaim: Building shells in acdream are outdoor-grid footprint shadow entries (Register cellScope=0, ShadowObjectRegistry.cs:74-105) found by a membership-blind 3x3-landblock radial sweep (GetNearbyObjects:497-539) — reachable from any position pre-gate; retail instead stores at most ONE CBuildingObj pointer per outdoor CLandCell (CSortCell.building, acclient.h:31882, set once at the building's origin cell by init_buildings 0x0052fd80) and tests it ONLY inside CLandCell::find_collisions (0x00532d60 -> 0x005340a0 -> 0x006b5300). CORRECTION 1: the unreachability is per-CELL, not per-primary — an indoor-primary sphere straddling an exit portal DOES test the building in retail, because CTransition::check_other_cells (0x0050ae50) calls find_collisions on every cell in the sphere's cell array including outdoor CLandCells; only a fully-interior cell array (the #98 cellar case) structurally cannot reach a building. Retail additionally weakens (not skips) the shell BSP test when the path also hits interior cells (BSPTREE::find_collisions 0x0053a440, bldg_check && hits_interior_cell) — the port must keep the straddle-case building test or doorway collision regresses. CORRECTION 2: "landblock-wide" overstates registration (it is bounding-radius footprint over the owning landblock's outdoor 8x8 grid); the membership-blind reach comes from the query's radial sweep, which is what the #98 gate (ShadowObjectRegistry.cs:494-495) and the layered isViewer exemption compensate for. Both compensations dissolve under the retail shape, with one verification item: the camera probe must then be enclosed by interior cell-BSP collision (retail's find_env_collisions channel) for the isViewer exemption to be safely removable. Port shape stands: per-LandCell building reference consulted only in the outdoor-cell branch of the per-cell query (cache.GetBuilding already keyed this way, CellTransit.cs:631); doors stay shadow objects (retail's CObjCell::find_obj_collisions 0x0052b750 iterates shadow_object_list of dynamic CPhysicsObjs — buildings are not in it).
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C):
1. CLandCell::find_collisions (Ghidra 0x00532d60): vcall +0x8c (= find_env_collisions, CLandCell impl at 0x00532f20 — terrain/cell structure) -> CSortCell::find_collisions -> CObjCell::find_obj_collisions. CONFIRMED as claimed.
2. CSortCell::find_collisions (Ghidra 0x005340a0): `if (this->building != NULL) return CBuildingObj::find_building_collisions(building, trans); return OK_TS;`. CONFIRMED — the building channel is a single per-LandCell pointer (CSortCell.building, acclient.h:31882; CSortCell : CObjCell at acclient.h:31880, CLandCell : CSortCell at :31886).
3. CEnvCell::find_collisions (Ghidra 0x0052c100): vcall +0x8c (= find_env_collisions, CEnvCell impl at 0x0052c130 — the cell's own BSP) -> CObjCell::find_obj_collisions only. NO building call, and CEnvCell : CObjCell (acclient.h:32072) structurally has no building field. CONFIRMED.
4. CObjCell::find_obj_collisions (Ghidra 0x0052b750): iterates this->shadow_object_list (dynamic CPhysicsObjs) only — buildings are not shadow objects in retail. CONFIRMED the channels are disjoint.
5. CBuildingObj::find_building_collisions (Ghidra 0x006b5300): sets sphere_path.bldg_check=1, runs CPhysicsPart::find_obj_collisions on the building's physics part (shell BSP), sets collided_with_environment. CONFIRMED it is an environment-style BSP test, not a shadow-entry test.
6. Registration: CLandBlock::init_buildings (Ghidra 0x0052fd80) -> LandDefs::adjust_to_outside on the building origin -> get_landcell -> CBuildingObj::add_to_cell (0x006b5550) -> CSortCell::add_building (0x00534030, first-wins). Each building registers in EXACTLY ONE CLandCell (its origin cell). Confirms the proposed "per-LandCell building reference" port shape matches retail's actual data shape.
7. NUANCE THAT FORCES THE ADJUSTMENT: CTransition::check_other_cells (Ghidra 0x0050ae50) vcalls find_collisions (vtable +0x88) on EVERY cell in the sphere's cell array. When the sphere straddles an exit portal, the array contains outdoor CLandCells, whose find_collisions DOES reach the building. So "an indoor primary can never test a building shell" is overstated — the correct structural statement is per-CELL, not per-primary. The #98 blast radius survives: deep in the cellar (no exit-portal straddle) the cell array is all CEnvCells, so retail structurally never tests the cottage shell. Supporting design evidence: BSPTREE::find_collisions (Ghidra 0x0053a440) further WEAKENS the shell test when bldg_check && hits_interior_cell != 0 (placement/ethereal inserts pass centerSolid=false) — retail deliberately mutes shell collision for spheres engaged with interior cells even on the straddle path; a faithful port must preserve the straddle-case building test or doors/doorways regress.
ACDREAM SIDE — read the cited code:
1. src/AcDream.App/Rendering/GameWindow.cs:6176-6182 (note: path is Rendering/GameWindow.cs): per-part Register(...) with cellScope: entity.ParentCellId ?? 0u. Landblock-baked building stabs have ParentCellId = null (src/AcDream.Core/World/WorldEntity.cs:69-74; WbDrawDispatcher.cs:452 "building shell (no ParentCellId)") -> cellScope=0 path. CONFIRMED.
2. ShadowObjectRegistry.cs Register cellScope==0 path (lines 74-105): footprint registration into the owning landblock's outdoor 8x8 grid cells overlapped by world bounding radius. "Landblock-wide" is shorthand — registration is footprint-over-outdoor-grid; but the QUERY (GetNearbyObjects lines 497-539) radially sweeps the 3x3 landblock neighborhood, so pre-gate the shell is reachable from ANY position within queryRadius regardless of cell membership. Functional claim CONFIRMED.
3. Both compensations are inline-documented at ShadowObjectRegistry.cs:473-495: the #98 indoor gate `if ((primaryCellId & 0xFFFF) >= 0x0100 && !isViewer) return;` (494-495, comment 473-480 names #98/cellar Z-cap explicitly) and the Phase U isViewer exemption (comment 482-493). CONFIRMED.
4. Production call site (not just tests): Transition.FindObjCollisions at src/AcDream.Core/Physics/TransitionTypes.cs:2376-2382 passes primaryCellId: sp.CheckCellId, isViewer: oi.IsViewer, plus portalReachableCells from CellTransit.FindCellSet (:2358) — the gate is live and load-bearing. Also note the portal-reachable pass (ShadowObjectRegistry.cs:460-471) runs BEFORE the gate, so indoor-straddle spheres already reach outdoor-registered entries via A6.P5's AddAllOutsideCells (CellTransit.cs:614-618) — acdream's partial emulation of retail's straddle behavior.
5. cache.GetBuilding(landcellId) exists and is used for transit at src/AcDream.Core/Physics/CellTransit.cs:631 — port-shape claim CONFIRMED.
JUDGMENT: the structural divergence is REAL, not behaviorally-equivalent-elsewhere — acdream itself documents the two compensations as patches over exactly this shape, and the physics digest classifies the b3ce505 gate as a workaround. The only caveat on the blast-radius claim: whether the isViewer exemption fully dissolves under the retail port depends on acdream's interior cell-BSP collision actually enclosing the camera probe the way retail's CEnvCell find_env_collisions does — plausible (retail bounds the viewer with the EnvCell's own BSP, same channel) but not proven here; treat as a port-plan verification item, not a refutation.
- blastRadius: #98 cellar Z-cap root cause (cottage found from the cellar); keeps the b3ce505 gate load-bearing; the camera isViewer exemption (Phase U) is a second compensation layered on the first — both dissolve under the retail shape.
- retailEvidence: Building collision fires only from CLandCell::find_collisions → CSortCell::find_collisions → CBuildingObj::find_building_collisions (Ghidra 0x00532d60, 0x005340a0); CEnvCell::find_collisions (Ghidra 0x0052c100) has env + shadow objects only — no building channel. An indoor primary can never test a building shell.
- acdreamEvidence: Landblock-baked building GfxObjs register as shadow entries with cellScope=0 → outdoor-grid footprint (GameWindow.cs:6176-6182 family; ShadowObjectRegistry.cs:77-105), then are found (pre-gate) by any radial query within reach; ShadowObjectRegistry.cs:484-495 documents both compensations inline.
- portShape: Move building-shell collision out of ShadowObjectRegistry into a per-LandCell building reference consulted only in the outdoor-cell branch of the per-cell query (cache.GetBuilding already exists — CellTransit.cs:631 uses it for transit). Doors stay shadow objects (they are dynamic CPhysicsObjs in retail too).
### [HIGH] check-other-cells-env-only (confirmed) — CheckOtherCells iterates other cells for environment collision only; retail runs env AND shadow objects per other cell
- correctedClaim: Claim stands as stated, with three precision additions: (1) retail's per-other-cell find_collisions on land cells also interposes the building check (CSortCell::find_collisions → CBuildingObj::find_building_collisions @0x005340a0) between env and objects; (2) retail also runs the PRIMARY cell's objects inside insert_into_cell's own inner retry loop (acdream's retry wraps env+obj at the outer attempt level only); (3) acdream advances the carried cell BEFORE its flat object pass while retail advances it AFTER all per-cell object passes. acdream span is TransitionTypes.cs:1632-1769 (not :1750).
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra (not BN pseudo-C):
1. CTransition::check_other_cells @0x0050ae50 (Ghidra decompile): builds the cell array via CObjCell::find_cell_list, then for every cell != the check cell calls `(**(code **)(pCVar1->_padding_ + 0x88))(this)`. Switch on result: COLLIDED_TS/ADJUSTED_TS return; SLID_TS clears collision_info.contact_plane_valid AND contact_plane_is_water then returns — exactly as claimed (the claim's pc:272752-272760 matches; Ghidra confirms the BN listing did not invent this branch). After the loop it sets check_cell = the new containing cell and adjust_check_pos (cell advance AFTER all per-cell collision).
2. The vtable+0x88 slot identity was the refutation-critical claim, and it is PROVEN, not inferred: CEnvCell::CEnvCell @0x0052c240 disassembly contains `MOV dword ptr [ESI],0x7c8c98` (instruction at 0x0052c286) — primary CEnvCell vftable base = 0x007c8c98. Ghidra DATA xref to CEnvCell::find_collisions (0x0052c100) is from 0x007c8d20 = base + 0x88. DATA xref to CEnvCell::find_env_collisions (0x0052c130) is from 0x007c8d24 = base + 0x8c. Same adjacency in CLandCell's vtable (find_collisions 0x00532d60 ref'd from 0x007c9398; find_env_collisions 0x00532f20 from 0x007c939c). So vtable+0x88 IS find_collisions; +0x8c is find_env_collisions (which is what find_collisions itself calls internally — no recursion ambiguity).
3. What per-cell find_collisions does (Ghidra decompiles): CEnvCell::find_collisions @0x0052c100 = vtable+0x8c (find_env_collisions) then if OK_TS → CObjCell::find_obj_collisions. CLandCell::find_collisions @0x00532d60 = env, then CSortCell::find_collisions @0x005340a0 (= CBuildingObj::find_building_collisions when the sort cell has a building), then CObjCell::find_obj_collisions. CObjCell::find_obj_collisions @0x0052b750 iterates `this->shadow_object_list` (the PER-CELL shadow-object list), calling CPhysicsObj::FindObjCollisions per entry, skipping parented objects and self, gated off for INITIAL_PLACEMENT_INSERT. So retail's per-other-cell query is env AND building AND per-cell shadow objects — the claim said env+objects; the building interposition for land cells is an additional (claim-strengthening) detail.
4. Ordering context (Ghidra): CTransition::transitional_insert @0x0050b6f0 per attempt calls insert_into_cell(check_cell, num_attempts) — which @0x00509e70 runs the PRIMARY cell's full vtable+0x88 find_collisions (env→[bldg]→objects) in its own inner retry loop — and only on OK_TS calls check_other_cells. So retail interleaves object collision per cell: primary-cell objects are tested before any other cell's env, and each other cell tests its own objects immediately after its env.
ACDREAM SIDE — read at the cited locations:
5. Transition.CheckOtherCells, src/AcDream.Core/Physics/TransitionTypes.cs:1632-1769 (claim said :1632-1750 — trivially off, same function): per other cell, outdoor ids (low16 < 0x100) engine.SampleTerrainWalkable + ValidateWalkable (:1650-1684); indoor ids BSPQuery.FindCollisions on the cell's CellStruct BSP (:1687-1732). NO shadow-object / registry iteration anywhere in the loop. Confirmed env-only.
6. The flat object pass: TransitionTypes.cs:900 (`var objState = FindObjCollisions(engine);` inside TransitionalInsert Phase 2) is the sole FindObjCollisions call site in the insert path (grep over src/AcDream.Core confirms; :2307 is the definition, which queries the landblock-wide ShadowObjectRegistry). The stale comment at TransitionTypes.cs:856-859 explicitly documents the simplification: "we don't have CellArray/CheckOtherCells iteration because our FindObjCollisions (via ShadowObjectRegistry) is already a flat per-landblock query" — written pre-A4; A4 later gave the ENV half per-cell treatment (CheckOtherCells) but the object half never got it.
7. Resolution-order divergence confirmed real: acdream per attempt = primary env (FindEnvCollisions :1954, indoor BSP :2056 / terrain :2120) → CheckOtherCells env-only (:2185) → carried-cell advance (:2192-2193) → flat object pass (:900). Retail per attempt = primary [env→bldg→obj] (with inner retry) → per other cell [env→bldg→obj] → cell advance. Two extra ordering deltas beyond the claim: (a) retail runs primary-cell objects inside insert_into_cell's inner retry loop, acdream's retry wraps both phases at the outer level; (b) acdream advances the carried cell BEFORE its object pass, retail advances AFTER all per-cell object passes.
JUDGMENT: both sides check out from primary sources; the divergence is real and not behaviorally-equivalent-elsewhere. The "masked today" framing is accurate — the flat landblock-wide registry query is a superset of the per-cell lists, so objects are not LOST today; the live divergence is the interleaving/ordering (a plausible contributor to threshold deltas in the #99/#108/#109 doorway family, though not proven causal for any specific one) plus the structural hole that opens the moment the A6.P4 per-cell shadow port lands. The proposed port shape (fold per-cell shadow iteration into CheckOtherCells AND the primary-cell insert, retiring the flat pass) matches retail's actual call graph. Severity "high" is fair: no standalone user-visible artifact today, but it gates the A6.P4 architecture that closes #99.
- blastRadius: Masked today by the flat object query; becomes a correctness hole the moment the per-cell port lands (straddle coverage at doorways would be lost). Also a resolution-order divergence: retail interleaves obj collision per cell, acdream resolves all env then all objects — contributes to threshold-behavior deltas in the #99/#108/#109 doorway family.
- retailEvidence: check_other_cells calls each other cell's vtable+0x88 find_collisions (pc:272735), which is find_env_collisions THEN find_obj_collisions per cell (Ghidra 0x0052c100 / 0x00532d60); SLID clears contact-plane fields and returns (pc:272752-272760).
- acdreamEvidence: Transition.CheckOtherCells (TransitionTypes.cs:1632-1750) runs terrain ValidateWalkable (outdoor ids) or cell-BSP FindCollisions (indoor ids) only; no shadow-object iteration per cell. The flat FindObjCollisions at TransitionTypes.cs:900 is the sole object pass.
- portShape: Fold the per-cell shadow-list iteration into the same loop CheckOtherCells already walks (and into the primary-cell insert), making each cell's query env-then-objects like retail's find_collisions; retire the separate Phase-2 flat object pass in TransitionalInsert.
### [MEDIUM] a6p5-topology-widening (adjusted) — hasExitPortal topology widening adds outdoor cells to the collision set wider than retail's straddle gate
- correctedClaim: CONFIRMED divergence, CORRECTED port shape. Divergence (as claimed, medium severity): acdream widens the COLLISION cell set by topology — any sphere-overlapped indoor cell possessing an exterior (0xFFFF) portal triggers AddAllOutsideCells once per walk (CellTransit.cs:130-132, 614-618) — whereas retail adds outside cells only when a path sphere straddles the exterior portal plane, |dist| < radius + F_EPSILON (Ghidra 0x0052c820; caller 0x0052b4e0 has no topology branch either). Membership is unaffected (outdoorPickAllowed gate, CellTransit.cs:571/603/675). It is a deliberate, self-documented #99 stopgap keeping outdoor-registered (cellScope=0) doors findable from indoor cells via ShadowObjectRegistry.GetNearbyObjects(portalReachableCells). CORRECTED port shape: after A6.P4 registration-side flood places doors into indoor cells' shadow lists, do NOT delete lines 614-618 that would remove acdream's only indoor-path outside-add and break retail-faithful exit demotion (straddle fires on every real building exit; without outdoor candidates in the array the pick keeps the player indoor-classified, lines 675/693/732 + comment 705-709). Instead RE-GATE the AddAllOutsideCells call from hasExitPortal to the already-implemented retail straddle flag (exitOutsideStraddle, CellTransit.cs:160-175/597), then delete the hasExitPortal plumbing (out-param and line 130-132 assignment). Once-per-walk semantics (outdoorAdded) are already retail-faithful retail's CELLARRAY.added_outside guard at 0x00533630 does the same.
- verifier notes: RETAIL re-derived from Ghidra decompiles (not BN pseudo-C). (1) CEnvCell::find_transit_cells @ 0x0052c820: in the exterior-portal branch (other_cell_id == 0xffffffff) a local flag (bVar7) is set iff, for some path sphere, -(F_EPSILON + radius) < dist < +(F_EPSILON + radius) against the portal plane the straddle test exactly as claimed; CLandCell::add_all_outside_cells is called after the portal loop ONLY when that flag fired ('if (bVar7) CLandCell::add_all_outside_cells(...)'). No topology-only branch; no portal_side/exact_match test in this branch (portal_side appears only in the unloaded-interior-portal branch). (2) Caller CObjCell::find_cell_list @ 0x0052b4e0: indoor seed adds only the current cell then vtable-dispatches find_transit_cells over the growing CELLARRAY; add_all_outside_cells is called directly only for outdoor seeds ((id & 0xffff) < 0x100) so retail has no topology widening at the caller level either. (3) add_all_outside_cells @ 0x00533630 guards on CELLARRAY.added_outside (once per walk) acdream's outdoorAdded flag at CellTransit.cs:588/617 is retail-faithful on that sub-point. ACDREAM verified: hasExitPortal set purely on portal.OtherCellId == 0xFFFF topology (CellTransit.cs:130-132; doc 102-106 self-describes 'NOT retail'); the widening fires once per indoor walk on hasExitPortal regardless of straddle (CellTransit.cs:614-618, comment 608-613: 'by TOPOLOGY wider than retail'); the membership PICK is protected by outdoorPickAllowed (CellTransit.cs:571, 603, consumed at 675) so this diverges only in the collision cell SET. Blast radius confirmed real: the widened set feeds ShadowObjectRegistry.GetNearbyObjects which iterates portalReachableCells unconditionally (ShadowObjectRegistry.cs:460-471), and cellScope=0 entities (server-spawned doors per the comment at ShadowObjectRegistry.cs:422-423) are keyed under outdoor landcell ids (Register, ShadowObjectRegistry.cs:77-105); Transition.CheckOtherCells also consumes outdoor ids from the set (TransitionTypes.cs:1650-1684, limited to the under-foot terrain column). Strictly wider than retail: a sphere anywhere inside a cell bearing an exterior portal (doors AND outside-facing window portals are 0xFFFF) gets outdoor cells in its collision set; retail requires plane straddle. One precision note: the widening triggers from cells IN the sphere-overlapped candidate set that possess an exterior portal (the occupied cell or an overlapped neighbour) deep-interior cells without exterior portals never trigger it; the claim's 'ANY indoor cell that merely possesses an exit portal' is correct read that way. THE ADJUSTMENT the claimed port shape is materially wrong: CellTransit.cs:614-618 is the ONLY AddAllOutsideCells call on the indoor path; exitOutsideStraddle currently gates only the pick (line 603), there is no separate straddle-gated set-add. Deleting lines 614-618 outright (as the port shape instructs) would also delete the retail straddle-fired outside flood: on a real building exit the straddle fires but no outdoor candidate would enter the array, outdoorResult could never set (line 675 iterates candidates), and the pick would fall through to keep-curr (line 732) the player would stay indoor-classified after walking out, a membership regression the code's own comment (lines 705-709) documents depending on 'outside cells enter the candidate array the normal outdoorResult path demotes there, retail-faithfully'.
- blastRadius: Keeps #99 partially papered over today (named question 5): outdoor-registered doors stay findable from ANY indoor cell that merely possesses an exit portal, not just when a sphere straddles it. Over-wide set = extra false-positive collision candidates from deep interior cells near exits.
- retailEvidence: CEnvCell::find_transit_cells adds outside cells ONLY when a path sphere straddles an exterior portal plane — |dist| < radius + ε (Ghidra 0x0052c820; live-binary verified 2026-06-10 per CellTransit.cs:134-145 comment). No topology-only branch exists.
- acdreamEvidence: hasExitPortal output (CellTransit.cs:102-106, set at 130-132) drives AddAllOutsideCells once per indoor walk regardless of straddle (CellTransit.cs:614-618); self-documented as a non-retail #99 stopgap pending A6.P4. The membership PICK already ignores it (outdoorPickAllowed gate, CellTransit.cs:571/603).
- portShape: Once registration-side flood places doors into indoor cells' lists (divergence 1), delete hasExitPortal and the lines 614-618 widening; the collision cell set reverts to the pure straddle-gated retail flood. This is the retail answer to named question 5: the door is found in the indoor cell's OWN list, so the indoor query never needs outdoor cells it doesn't overlap.
### [MEDIUM] single-landblock-grid-clamp (confirmed) — Registration grid clamps to the entity's own landblock; retail's add_all_outside_cells crosses block borders
- correctedClaim: Confirmed as claimed, with one strengthening refinement: the divergence is not strictly invisible today. The 9-landblock query sweep compensates only on the outdoor radial path; the #98 indoor-primary gate (ShadowObjectRegistry.cs:494-495) skips that sweep, so an indoor sphere reaching outdoor cells through portalReachableCells (exact cell-id lookups, block-crossing per CellTransit.AddAllOutsideCells) can ALREADY miss an object whose footprint crosses a landblock seam but is registered only under its own block's prefix. The future A6.P4 slice-3 deletion of the radial sweep widens this from "doorway-at-a-seam" to all block-seam footprints.
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C), and it checks out end-to-end:
1. Registration path uses the same block-agnostic cell-set machinery as transit. CPhysicsObj::calc_cross_cells (Ghidra 0x00515230) builds a CELLARRAY via CObjCell::find_cell_list(&m_position, numSpheres, spheres, &cell_array, NULL) (or find_bbox_cell_list for state&0x10000 objects), then calls remove_shadows_from_cells + add_shadows_to_cells(this, &cell_array). add_shadows_to_cells (Ghidra 0x00514ae0) iterates the CELLARRAY and calls CObjCell::add_shadow_object per cell — so retail's shadow registration set IS the find_cell_list output, verbatim.
2. find_cell_list (Ghidra 0x0052b4e0, the citation in the claim — verified correct) calls CLandCell::add_all_outside_cells(position, numSpheres, spheres, cellArray) whenever the primary cell is outdoor ((objcell_id & 0xffff) < 0x100).
3. add_all_outside_cells sphere variant (Ghidra 0x00533630): per sphere, calls LandDefs::adjust_to_outside(&cellId, &center). adjust_to_outside (Ghidra 0x005a9bc0) re-homes the point across landblock borders: get_outside_lcoord → lcoord_to_gid replaces the cell id with whatever block the point actually falls in, then folds the local origin back into [0, block_length) via floor(x/block_length) subtraction. Then gid_to_lcoord yields GLOBAL landscape coordinates and add_outside_cell + check_add_cell_boundary add cells by global lcoord.
4. add_outside_cell (Ghidra 0x00532ec0): bounds-checks lcoords to [0, 0x7F8) (255×8 cells, whole map) and composes gid = (((x&~7)<<5)|(y>>3))<<16 | ((x&7)*8+(y&7)+1) the landblock prefix is RE-DERIVED from the global lcoord. check_add_cell_boundary (Ghidra 0x00533260) adds lx±1/ly±1 neighbors in global lcoords when the sphere center is within radius of a 24m cell edge crossing a multiple-of-8 lcoord lands in the adjacent landblock with the adjacent block's prefix. Block-agnostic, confirmed.
5. The bbox/parts variant (Ghidra 0x00533360, used via find_bbox_cell_list for state&0x10000 objects) computes a global-lcoord rectangle over all parts' bounding boxes and calls add_cell_block(xmin,ymin,xmax,ymax) — also block-agnostic. Both registration shapes cross block borders.
ACDREAM SIDE — cited lines verified exact:
- ShadowObjectRegistry.Register clamps minCx/maxCx/minCy/maxCy to [0,7] (src/AcDream.Core/Physics/ShadowObjectRegistry.cs:80-83) and composes cell ids under the single lbPrefix = landblockId & 0xFFFF0000 (:87, :93). RegisterMultiPart does the same per shape (:173-176, prefix at :140, compose at :182). Entries never land under a neighbor block's prefix (the cellScope path :62-72 is exact-cell and irrelevant to the outdoor grid).
- GetNearbyObjects compensates at query time with the 9-landblock sweep (:502-539), computing per-neighbor local coords and clamped ranges.
- Production registration sites pass the entity's own landblock (e.g. GameWindow.cs:6176-6182 passes origin.X, origin.Y, lb.LandblockId with worldRadius = local bounding-sphere radius × scale), so a building part near a seam genuinely loses its neighbor-block footprint cells.
- The transit-side port CellTransit.AddAllOutsideCells (src/AcDream.Core/Physics/CellTransit.cs:257-331) is a faithful block-crossing port of the same retail routine (AdjustToOutside + GidToLcoord + LcoordToGid, explicit "NO same-block filter" note at :324-328; boundary-neighbor signs at :284-297 match the Ghidra decompile of check_add_cell_boundary exactly: point > cellLenr → +1, point < r 1). So the proposed port shape (have registration reuse this) is sound.
ONE REFINEMENT (strengthens, doesn't weaken): the blast-radius statement "invisible today only because the query side sweeps 9 landblocks" is slightly understated. The #98 indoor-primary gate (ShadowObjectRegistry.cs:494-495) RETURNS before the 9-block sweep when the querying sphere's primary cell is indoor (and not viewer) — production call site TransitionTypes.cs:2376-2382 passes primaryCellId = sp.CheckCellId. On that path, outdoor-registered shadows are reachable ONLY via exact cell-id lookups from portalReachableCells (:460-471), whose outdoor ids come from the block-crossing CellTransit.AddAllOutsideCells. If a building/door footprint straddles a landblock seam and the indoor sphere's exit-portal expansion yields a NEIGHBOR-block outdoor cell, the clamped registration means that lookup misses the object TODAY — no sweep compensates. Geometrically narrow (doorway at a block seam), but the divergence is already live on the indoor→outdoor portal query path, not purely a future A6.P4-slice-3 exposure. Severity "medium" remains fair.
Minor caveat on the port shape: "BuildShadowCellSet" is the planned A6.P4 component named in docs/superpowers/specs/2026-05-24-phase-a6-p4-retail-shadow-architecture.md — it does not exist in src/ yet. The port shape should be read as "the A6.P4 registration cell-set builder must use CellTransit.AddAllOutsideCells (block-crossing) instead of inheriting the registry's private clamped grid math" — which this verification supports. Also note for the porter: RemoveLandblock (ShadowObjectRegistry.cs:337-361) removes cells by landblock prefix; once registration crosses blocks, an entity's entries can live under multiple prefixes and landblock unload must not strand or half-remove them (retail handles this via remove_shadows_from_cells per object, Ghidra 0x00515230 caller).
- blastRadius: Invisible today only because the query side sweeps 9 landblocks; the moment the radial sweep is deleted (A6.P4 slice 3), an object whose footprint crosses a landblock border silently vanishes from neighbor-block cells — missing collisions at block seams.
- retailEvidence: CLandCell::add_all_outside_cells operates on lcoords and adds whatever outdoor cells the spheres overlap, block-agnostic (called from find_cell_list Ghidra 0x0052b4e0; same routine the transit path uses — the 2026-05-25 AddAllOutsideCells coord fix in acdream's TRANSIT port already handles cross-block).
- acdreamEvidence: Register/RegisterMultiPart clamp minCx/maxCx/minCy/maxCy to 0..7 under a single lbPrefix (ShadowObjectRegistry.cs:80-83, 173-176) — entries never land in a neighboring landblock's cells. The 9-landblock loop in GetNearbyObjects (ShadowObjectRegistry.cs:502-539) compensates at query time.
- portShape: BuildShadowCellSet's outdoor branch should call the existing CellTransit.AddAllOutsideCells (block-crossing, already fixed) instead of the registry's private clamped grid; delete the private grid math.
### [LOW] movement-reregistration-source (confirmed) — Moved entities re-register from a fresh XY grid; retail reuses the transition's own cell array every tick
- verifier notes: RETAIL SIDE — re-derived from Ghidra (not BN), all three load-bearing pieces check out:
1. SetPositionInternal tail (Ghidra decompile 0x00515330): after handle_all_collisions, the function ends with exactly the claimed gate structure: `if (this->cell != 0) { if ((this->state & 0x10000) != 0) { calc_cross_cells(this); return 1; } if ((param_1->cell_array).num_cells != 0) { remove_shadows_from_cells(this); add_shadows_to_cells(this, &param_1->cell_array); } }`. The BN pseudo-C at pc:283536-283545 (addresses 0051550b-0051554c) matches the Ghidra decompile line-for-line — no invented branch here (BN's `num_cells > 0` vs Ghidra's `!= 0` is equivalent for an unsigned field). The 0x10000 flag is HAS_PHYSICS_BSP_PS per acclient.h:2833, so "BSP-bearing objects recompute via calc_cross_cells, everything else reuses the transition's cell_array" is accurate.
2. The "check_other_cells already built this step" attribution is accurate, which I specifically suspected of being a caller/callee conflation: the Ghidra decompile of CTransition::check_other_cells (0x0050ae50) BUILDS the array before consuming it — its first real act is `CObjCell::find_cell_list(&this->cell_array, &local_4c, &this->sphere_path)`, then it iterates `this->cell_array.cells[i].cell`. So the CELLARRAY that SetPositionInternal hands to add_shadows_to_cells is the portal-aware set populated during this transition step.
3. add_shadows_to_cells (Ghidra 0x00514ae0) does what the divergence implies: it sizes this->shadow_objects to cell_array.num_cells, stamps each CShadowObj with `cell_array.cells[i].cell_id`, and registers each into the corresponding CObjCell via CObjCell::add_shadow_object (plus CPartArray::AddPartsShadow), recursing into children. The targets are CObjCell pointers from the transition — indoor CEnvCells included — NOT an XY ground-grid sweep. (Nuance: a `state & 0x1000` particle-emitter branch routes to add_particle_shadow_to_cell instead; irrelevant for movers.)
4. Cadence claim ("retail per successful transition step") verified via xrefs: SetPositionInternal(CTransition*) is called from UpdateObjectInternal (call site 0x00515914 — the per-tick movement path) and from SetPosition (call site 0x0051614b — server-driven position sets), so both retail paths funnel shadow re-registration through the transition's cell_array.
ACDREAM SIDE — confirmed at the cited lines plus production call sites:
5. ShadowObjectRegistry.UpdatePosition (src/AcDream.Core/Physics/ShadowObjectRegistry.cs:219-278) re-runs RegisterMultiPart (line 245) for cached multi-part entities or Register (line 274) for the single-shape path. BOTH calls leave cellScope at its default 0u, so placement always goes through the outdoor 24m XY-grid loops (Register lines 77-103; RegisterMultiPart lines 169-186). The method signature takes no cell id and no cell set — the landblockId parameter is masked to the 0xFFFF0000 prefix for grid placement only. No transition cell set is consulted anywhere in the file.
6. Production call site: exactly one — GameWindow.cs:4492, on inbound server position updates for remote entities (`update.Guid != _playerServerGuid`), passing only (entityId, worldPos, rot, origin, p.LandblockId). The spawn-time registration for server-spawned entities (GameWindow.cs:3264 RegisterMultiPart) likewise passes no cellScope. So even an entity the server reports inside an EnvCell gets its shadow re-registered into outdoor landcells on every move — consistent with the known #99 family (doors/NPCs invisible to fully-indoor queries under the #98 indoor-primary gate at ShadowObjectRegistry.cs:494).
7. Real divergence, not behavioral equivalence: retail's re-registration target set is the portal-aware transition CELLARRAY (can contain indoor cells); acdream's is the outdoor XY grid, unconditionally. The blast-radius framing is honest and correct — cadence is comparable today, but after the per-cell shadow port (divergence-1 / A6.P4), UpdatePosition's XY-grid source would clobber correct per-cell registration on an entity's first move. Severity "low" is defensible as a port-ordering footnote (its present-day visible symptom is already accounted under #99).
One refinement worth carrying into the port shape (not a correction): retail only re-registers when the transition's cell_array is NON-EMPTY — `if (num_cells != 0)` — i.e., an empty array leaves the previous shadows in place rather than recomputing or clearing. The ported UpdatePosition should preserve that keep-when-empty behavior, and should keep the HAS_PHYSICS_BSP_PS (0x10000) → calc_cross_cells split for BSP-bearing objects.
- blastRadius: Cadence is comparable (acdream re-registers per server UpdatePosition; retail per successful transition step), but after the per-cell port the SOURCE matters: re-registering via the XY grid would undo divergence-1's fix for every moving door/NPC on its first move.
- retailEvidence: SetPositionInternal(CTransition*) tail (Ghidra 0x00515330; pc:283536-283545): non-BSP objects re-register via remove_shadows_from_cells + add_shadows_to_cells(&transit->cell_array) — the array check_other_cells already built this step; HAS_PHYSICS_BSP objects recompute via calc_cross_cells.
- acdreamEvidence: ShadowObjectRegistry.UpdatePosition (ShadowObjectRegistry.cs:219-278) re-runs Register/RegisterMultiPart → XY grid placement; no transition cell set is consulted.
- portShape: UpdatePosition takes the mover's current cell id (and ideally its transition cell set when one exists) and routes through BuildShadowCellSet, mirroring SetPositionInternal's reuse; remote entities without a local transition just run the flood from their reported cell.
## OPEN QUESTIONS
- find_bbox_cell_list's leaf (CPartArray::calc_cross_cells_static, called from Ghidra 0x00510fc0) was not decompiled — the exact bbox-driven cell rule for HAS_PHYSICS_BSP_PS statics (dungeon furniture class) is unverified; matters for which registration branch acdream's BSP-bearing statics should take.
- Unloaded-cell shadows: add_shadows_to_cells (Ghidra 0x00514ae0) stores cell=null for CELLARRAY entries whose cell pointer was null (unloaded neighbor added by id in find_transit_cells); what re-attaches those shadows when the cell later loads is inside CObjectMaint::InitObjCell (called from init_objects, Ghidra 0x0052b420), which was not decompiled. This is spec §7 Q3 (streaming order) — still open, and the acdream port needs an equivalent (re-run BuildShadowCellSet for objects homed in / overlapping a newly streamed cell).
- Registration spheres: the CylSphere overload (Ghidra 0x0052b9f0) builds each flood sphere at the cylinder's low_pt with the cyl radius — i.e. the BASE of the cylinder, not its center, and ignoring height. Verified in Ghidra but surprising (tall objects' flood reach is their base only); worth a second look (or a live-binary spot check) before porting verbatim.
- INITIAL_PLACEMENT_INSERT parity: retail's find_obj_collisions skips the whole shadow loop for initial-placement inserts (Ghidra 0x0052b750); whether acdream's placement path has an equivalent skip was not checked in this area pass.
- Is the local player itself registered in acdream's ShadowObjectRegistry (the SelfEntityId skip at TransitionTypes.cs:2398 implies live entities are)? Retail registers the player like any CPhysicsObj and relies on the self-skip in find_obj_collisions; parity of WHO is in the lists (not just how they're queried) wasn't fully audited.
- check_other_cells halt semantics: the BN pseudo-C shows SLID (case 4) clearing contact-plane fields and RETURNING (pc:272752-272760), while acdream's ApplyOtherCellResult path was ported from the same lines — given this function family's invented-branch history, the exact SLID-return-vs-continue behavior deserves a one-shot Ghidra confirm of 0x0050ae50 before the per-cell port hardens it (the Ghidra server decompiled neighbors of this function fine, but I did not pull this exact one).

View file

@ -0,0 +1,166 @@
# AREA 4 — Statics and dynamic objects in cells (incl. particles-through-walls)
## RETAIL
RETAIL'S OBJECT-IN-CELL DRAW MODEL — one registration mechanism, one draw pass, for everything.
1) REGISTRATION: every drawable thing in the world — an EnvCell's baked statics, a door, an NPC, the player, even a particle emitter — is a CPhysicsObj whose visual pieces are CPhysicsParts. When an object lands in cells, CPhysicsObj::add_shadows_to_cells (0x514ae0, pc:282819) creates one CShadowObj per overlapped cell (the collision-side record) AND calls CPartArray::AddPartsShadow (0x517e40, pc:285933) per overlapped cell, which registers each part into that cell's render-side list: CPartCell::shadow_part_list (a DArray<CShadowPart*>, acclient.h:30889-30894; CObjCell derives from CPartCell, acclient.h:30915). So a part that straddles two rooms is in BOTH rooms' lists. EnvCell statics are not special: CEnvCell::init_static_objects (0x52c350, pc:309690) makeObject()s each static_object_id and add_obj_to_cell()s it — after that it is an ordinary CPhysicsObj drawn through the same shadow parts (CEnvCell fields static_object_ids/static_objects at acclient.h:32080-32083 are only the spawn manifest, not a draw list).
2) THE DRAW PASS: PView::DrawCells (0x5a4840, pc:432709) runs three loops over cell_draw_list (the portal flood), all far-to-near (reverse list). Loop 1 (only when outside_view has slots): sets Render::PortalList=&outside_view, LScape::draw (landscape; per-landblock DrawBlock 0x5a17c0 gates each land cell on its cached cell->IsInView() BoundingType, then vtbl+0x54 DrawLandCell terrain + vtbl+0x58 DrawSortCell 0x59f140 = DrawBuilding for the cell's building + DrawObjCell for its objects), then per indoor cell draws the exit-portal polys (other_cell_id==-1) per view slot via DrawPortalPolyInternal (pc:432786). Loop 2: per cell, per portal_view slot, CEnvCell::setup_view (0x52c430) installs that slot's view planes then vtbl+0x5c RenderDeviceD3D::DrawEnvCell (0x59f170, pc:427885) draws the cell STRUCTURE — note the per-frame dedup at entry (GetDrawnThisFrame) and the use_built_mesh fast path (whole prebuilt vertex-buffer mesh; the per-poly planeMask=0xffffffff software-clip submit at pc:427922 is only the non-built fallback; CEnvCell::UnPack constructs the mesh at runtime, ConstructMesh call at pc:311085/0x52d87a, mirroring CGfxObj::InitLoad 0x5346b0 pc:318778-318784). Loop 3 — THE OBJECT PASS (pc:432883-432886, 0x5a4af3-0x5a4b0d): per cell, set Render::PortalList = the cell's top portal_view (the accumulated set of view cones looking into this cell), then vtbl+0x64 DrawObjCellForDummies(cell) (0x5a0760, pc:429177) = UpdateObjCell (0x5a0690, refreshes viewer distances/LOD off shadow_object_list) + CShadowPart::insertion_sort (depth-sort the cell's parts) + DrawObjCell (0x5a1a40) → DrawPartCell (0x5a07a0, pc:429198) which iterates shadow_part_list calling CShadowPart::draw (0x6b50d0, pc:701104) → CPhysicsPart::Draw(part, 0).
3) PER-PART VISIBILITY GATES (CPhysicsPart::Draw 0x50d7a0, pc:274964): (a) skip if draw_state&1 (the NoDraw flag); (b) skip if m_current_render_frame_num == device frame stamp — the dedup that makes multi-cell registration draw-once; then (c) RenderDeviceD3D::DrawMesh (0x5a0860, pc:429245): with PortalList set it LOOPS EVERY VIEW SLOT, Render::set_view(view, slot) + Render::viewconeCheck(gfxobj->drawing_sphere) (0x54c250, pc:342860 — the part's bounding sphere against the slot's portal-cone planes, returning OUTSIDE/PARTIALLY/ENTIRELY); any non-OUTSIDE slot draws via DrawMeshInternal (0x59f360, pc:427965) which has a second per-frame dedup (GetDrawnThisFrame, player parts exempt — the player redraws per slot) and then draws the WHOLE constructed mesh through D3DPolyRender::DrawMesh (0x59d4a0, pc:426048 — pure HW subset draws + alpha-list deferral, NO software poly clip, NO user clip planes). CONFIRMED: meshes are sphere-vs-cone checked per portal-view slot and never hard poly-clipped.
4) BUILDINGS (CBuildingObj is itself a CPhysicsObj): DrawBuilding (0x59f2a0, pc:427938) publishes building->portals into outdoor_pview->outdoor_portal_list, then CPhysicsPart::Draw(part, 1) → DrawMeshInternal's building branch runs BSPTREE::build_draw_portals_only(drawing_bsp, 1) then (…, 2) (pc:427993-427994) — the DrawingBSP is stripped to portal nodes at load (RemoveNonPortalNodes, pc:318775) so this walk visits only BSPPORTAL nodes (0x53d870, pc:326881), each calling vtbl DrawPortal(portalPoly, 1, pass) → PView::DrawPortal (0x5a5ab0, pc:433895) → ConstructView(CBldPortal…) (0x5a59a0, pc:433827): viewer-side test against the portal poly's plane (portal_side sign), GetClip against the current view, target cell loaded (CEnvCell::GetVisible). Pass-1 success → DrawPortalPolyInternal(poly, true) (0x59bc90, pc:424490 — a DEPTHTEST_ALWAYS screen fan, z forced ~far; a z-mask, and it bumps portalsDrawnCount which triggers the z-clear in DrawCells). Pass-2 success → recurse views into the interior + DrawCells(this,1) draws the interior cells through the aperture. FAILURE → the portal poly is NOT submitted at all (the param_3==3 "fill on failure" branch in DrawPortal has no reachable caller — only portal_draw_portals_only calls it, with pass∈{1,2}). So building portal polys (door/window fillers, the meeting-hall stair apertures) are drawn CONDITIONALLY, exactly per the fresh e223325 finding, and the building's main constructed mesh (node.Polygons only) draws unconditionally afterward via CPhysicsPart::Draw(part, 0).
5) PARTICLES: an emitter is a CPhysicsObj with state bit PARTICLE_EMITTER_PS=0x1000 (acclient.h:2829); its per-particle quads are the CPhysicsParts of its own part_array. add_shadows_to_cells routes such objects to CPhysicsObj::add_particle_shadow_to_cell (0x514a70, pc:282799, branch at pc:282875): ONE CShadowObj + AddPartsShadow into exactly ONE cell — this->cell. Therefore particle quads DRAW only inside loop-3 of the cell they live in, under that cell's portal_view, sphere-vs-cone checked per slot like any mesh. An emitter inside an unflooded building simply never reaches the renderer — its cell is not in cell_draw_list. A second, UPDATE-side gate: ParticleEmitter::UpdateParticles (0x51d180, pc:291770) calls CPhysicsObj::ShouldDrawParticles(physobj, degrade_distance) (0x50fe60, pc:277959): true iff examination-object OR (viewer distance CYpt <= degrade_distance AND cell != null AND cell->IsInView()). CLandCell::IsInView (0x532cb0, pc:316897) returns the in_view BoundingType cached by the landscape pass; CEnvCell::IsInView is an ICF-folded constant `return 1` (vftable slot pc:1019224 → 0x5269f0, pc:303646) — indoor emitters always pass the cell check and are distance-gated only. Failing → CPhysicsObj::SetNoDraw(1) (degraded_out) → parts skip via draw_state&1; far emitters stop simulating AND drawing.
6) LIST ROLES (Q4): the DRAW iterates CPartCell::shadow_part_list (CShadowPart→CPhysicsPart, acclient.h:30889-30894). The collision side iterates CObjCell::shadow_object_list (CShadowObj→CPhysicsObj, acclient.h:30923-30924). CObjCell::object_list (acclient.h:30919-30920) is the membership/enumeration list (get_object etc.). The only draw-path use of shadow_object_list is UpdateObjCell's viewer-distance/LOD refresh (0x5a0690, pc:429129).
## ACDREAM
ACDREAM'S EQUIVALENT — per-frame entity partition + single-cell buckets + a separate global particle system.
1) PARTITION (per frame, not a persistent registration): RetailPViewRenderer.DrawInside (src/AcDream.App/Rendering/RetailPViewRenderer.cs:44-109) builds the flood (PortalVisibilityBuilder.Build + R-A2 per-building MergeNearbyBuildingFloods :60-61,:115-145), then InteriorEntityPartition.Partition (src/AcDream.App/Rendering/InteriorEntityPartition.cs:22-79) walks all landblock entities and puts EACH entity in exactly ONE bucket keyed by its single ParentCellId: indoor cell id and cell ∈ flood → ByCell[cell]; indoor cell id but cell ∉ flood → DROPPED (:67-68); outdoor/no cell → Outdoor; ServerGuid!=0 with null ParentCellId → LiveDynamic.
2) DRAW ORDER (RetailPViewRenderer.DrawInside): landscape per outside-view slice (:93 → GameWindow.DrawRetailPViewLandscapeSlice, src/AcDream.App/Rendering/GameWindow.cs:9465-9551 — terrain + the WHOLE partition.Outdoor bucket per slice :9503-9512, scissored to the slice NDC AABB), exit-portal masks (:95), shells via EnvCellRenderer (:104-105, src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:1-19 — shell geometry only, gl_ClipDistance-cropped only for outdoor roots per #114 scope :96-105), then DrawCellObjectLists (:401-426): reverse OrderedVisibleCells (far→near :408), skip cells without buckets (:414), clear entity clip routing (UseIndoorMembershipOnlyRouting :439-450 — deliberate: entities are never gl_ClipDistance-clipped, comment cites retail's viewcone-not-clip behavior), draw the bucket through WbDrawDispatcher.Draw with visibleCellIds={cell} (:460-477), then invoke DrawCellParticles per clip slice (:423-424; GetCellSlicesOrNoClip falls back to a FULL-SCREEN NoClipSlice when the cell has no slot :428-437).
3) ENTITY VISIBILITY GATES (src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs WalkEntitiesInto :576-700): landblock AABB vs camera frustum (:593-595), EntityPassesVisibleCellGate = ParentCellId ∈ visibleCellIds when a set is passed, null-ParentCellId fails a cell filter (:1816-1835), per-entity AABB vs the one CAMERA frustum (:662-666). There is no per-portal-view-slot cone test and no multi-cell registration; LiveDynamic is drawn unclipped only for OUTDOOR roots (GameWindow.cs:7716-7724) — indoor roots never draw it.
4) PARTICLES: emitters live in a global AcDream.Core.Vfx.ParticleSystem keyed by AttachedObjectId = owning entity's guid/id (GameWindow.ParticleEntityKey :5072-5073); the renderer (src/AcDream.App/Rendering/ParticleRenderer.cs:119-171) draws camera-billboard instanced quads, depth test ON / depth write OFF (:141-143), no clip distances. Scene-pass gating is set-membership built per frame (sets cleared at GameWindow.cs:7521-7522): (a) per outside slice, emitters attached to partition.Outdoor entities (:9514-9530, scissor = slice NDC AABB); (b) per flooded cell, emitters attached to that cell's bucket (DrawRetailPViewCellParticles :9553-9580, scissor = slice AABB or full screen). Emitters with AttachedObjectId==0 never draw under a pview root (filters require !=0 at :9528 and :9575); when clipRoot is null (pre-spawn/legacy fallback, clipRoot=viewerRoot??_outdoorNode :7497) ALL Scene emitters draw globally unfiltered (:7860-7868). There is NO emitter-own-cell membership, NO sphere-vs-portal-cone test, and NO distance/degrade gate (no degrade logic in src/AcDream.Core/Vfx/ParticleSystem.cs). The WB-extracted Wb/ParticleEmitterRenderer.cs + Wb/ActiveParticleEmitter.cs are referenced nowhere in production — dead code.
5) BUILDINGS: exterior building GfxObjs are Outdoor-bucket entities drawn whole by WbDrawDispatcher; post-revert 124c6cb ALL dictionary polys draw unconditionally, including the baked door/window portal quads and the meeting-hall stair-aperture polys — there is no equivalent of the conditional DrawPortal/ConstructView portal-poly submission.
## DIVERGENCES
### [CRITICAL] building-portal-polys-unconditional (UNVERIFIED (verifier hit token limit)) — Building portal polys drawn unconditionally instead of retail's ConstructView-conditional submission
- blastRadius: #113 phantom staircase (stair-aperture portal polys always drawn), the door mystery (e46d3d9 filter removed doors because door quads are portal polys too), and outside-looking-in door/window appearance generally. Same mechanism, opposite signs, exactly as the e223325 fresh finding predicted.
- retailEvidence: Building DrawingBSP is stripped to portal nodes at load (RemoveNonPortalNodes, pc:318775); main mesh = ConstructMesh of node.Polygons only (CGfxObj::InitLoad 0x5346b0, pc:318778-318784). Portal polys submit ONLY via DrawBuilding (0x59f2a0, pc:427938) → CPhysicsPart::Draw(part,1) → build_draw_portals_only passes 1+2 (pc:427993-427994) → BSPPORTAL::portal_draw_portals_only (0x53d870, pc:326881) → PView::DrawPortal (0x5a5ab0, pc:433895) → ConstructView(CBldPortal) (0x5a59a0, pc:433827): viewer-side + clip-nonempty + target-cell-loaded. Success pass-1 → z-mask fan DrawPortalPolyInternal(poly,true) (0x59bc90, pc:424490); success pass-2 → recurse + DrawCells(interiors); failure → poly NOT drawn (param_3==3 fill branch unreachable — only callers pass 1/2).
- acdreamEvidence: All GfxObj dictionary polys drawn unconditionally post-revert (commit 124c6cb un-applied the e46d3d9 static filter); buildings are Outdoor-bucket entities drawn whole via WbDrawDispatcher (GameWindow.cs:9503-9512). No ConstructView-conditional path exists; R-A2 MergeNearbyBuildingFloods (RetailPViewRenderer.cs:115-145) decides which interiors flood but never gates the portal POLYS.
- portShape: Split building meshes at upload: unconditional slice (node.Polygons) + one indexed sub-range per portal poly (node.Portals PolyId/PortalIndex, helper already landed in e223325). Per frame per visible building, run the acdream ConstructViewBuilding result through the same decision retail makes: portal view constructed → draw the interior through it + (optionally) the z-mask; not constructed → submit nothing for that portal poly. The door weenie keeps drawing via its cell bucket.
### [CRITICAL] particles-not-cell-resident (adjusted) — Particle draw gated by owner-entity bucket + 2D scissor instead of emitter-own-cell residency + per-slot cone check
- correctedClaim: Particle draw is gated by OWNER-entity bucket membership + 2D scissor AABB (full-screen fallback for slot-less cells) + depth test, instead of retail's emitter-own-cell residency + per-view-slot sphere-vs-portal-plane cone check + degrade_distance freeze/hide. Retail (all Ghidra-verified): a particle emitter is a CPhysicsObj with PARTICLE_EMITTER_PS=0x1000 (acclient.h:2829) routed by add_shadows_to_cells (0x514ae0, state&0x1000 branch) into add_particle_shadow_to_cell (0x514a70) which registers parts into exactly ONE cell (this->cell); quads draw only in PView::DrawCells' per-cell object pass (0x5a4840, pc:432877-432886) under that cell's portal_view via DrawMesh's per-slot set_view+viewconeCheck plane test (0x5a0860/0x54c250); UpdateParticles (0x51d180) degrades far/out-of-view emitters via ShouldDrawParticles (0x50fe60: CYpt<=degrade_distance && cell->IsInView(), vptr+0x68; CLandCell cached in_view 0x532cb0, CEnvCell constant-true 0x5269f0 at vftable 0x7c8d00) -> SetNoDraw. acdream: global ParticleSystem keyed by owner id (GameWindow.cs:5072-5073), draw filters are owner-membership in per-cell/outdoor buckets built from the OWNER entity's ParentCellId (InteriorEntityPartition.cs:55-73) + slice NDC-AABB scissor with full-screen NoClipSlice fallback (RetailPViewRenderer.cs:22-23,428-437; GameWindow.cs:9707-9724) + depth test (ParticleRenderer.cs:141-143); emitters have no cell (VfxModel.cs:177-193), no cone test, no degrade (none in src/AcDream.Core/Vfx; Tick unconditional GameWindow.cs:7346). CORRECTIONS to the original: (1) on clipRoot-null frames only the clipAssembly==null sub-case draws all Scene emitters unfiltered (GameWindow.cs:7860-7867); with a clip assembly the outdoor pass is filtered and explicitly ADMITS AttachedObjectId==0 emitters (:7856) — so world-positioned emitters are dropped only under an indoor pview root (clipRoot non-null; filters :9528/:9575), not everywhere. (2) Far flames are not eternal — streaming Near->Far demotion removes them with their owner entity; the divergence is the absence of any retail degrade_distance gate within the near tier, where flames simulate and draw at any distance. Explains #114 re-test item 2 (flames visible through walls precisely where occluder statics are not drawn, since depth is then the only gate and the global/full-screen paths bypass the bucket filter); severity critical stands.
- verifier notes: RETAIL SIDE — re-derived from Ghidra decompiles (port 8081), not BN pseudo-C; every branch-sensitive claim CONFIRMED: (1) PARTICLE_EMITTER_PS = 0x1000 read directly from acclient.h:2829 region. (2) Ghidra 0x514ae0 CPhysicsObj::add_shadows_to_cells: `if ((this->state & 0x1000) == 0)` -> multi-cell CELLARRAY registration, ELSE add_particle_shadow_to_cell — particle emitters bypass multi-cell registration (BN pc:282815-282880 matches Ghidra; branch is real). (3) Ghidra 0x514a70 add_particle_shadow_to_cell: num_shadow_objects=1, registers into exactly this->cell via CObjCell::add_shadow_object (0x52b280, appends to cell shadow_object_list) + CPartArray::AddPartsShadow(part_array, this->cell, 1) (0x517e40: per-part cell->add_part virtual, null clip planes in single-cell case). shadow_part_list is a real cell field (acclient.h:30893). (4) Draw: PView::DrawCells = 0x5a4840 (pc:432709-432889); its final per-cell loop sets Render::PortalList = cell->portal_view[num_view-1] then RenderDevice->DrawObjCellForDummies(cell) (pc:432877-432886); DrawObjCellForDummies 0x5a0760 sorts the cell's shadow parts and dispatches the cell-part draw; DrawMesh 0x5a0860 (Ghidra) with PortalList non-null loops PortalList->view_count slots doing Render::set_view(slot) + Render::viewconeCheck(gfxobj->drawing_sphere), skipping OUTSIDE slots; viewconeCheck 0x54c250 is a sphere-vs-plane-set test (viewer CY plane + the current view's portal polygon planes loaded by set_view) — plane cone, NOT an AABB. (5) Degrade: Ghidra 0x50fe60 ShouldDrawParticles = m_bExaminationObject bypass, else (CYpt <= degrade_distance && cell != null && virtualcall(cell vptr+0x68) != 0). The +0x68 slot is IsInView: CEnvCell main vftable base = 0x7c8c98 (pc:1019182), +0x68 = 0x7c8d00 holding the ICF-folded constant-return function 0x5269f0 (pc:1019224 region) -> CEnvCell::IsInView constant-true; CLandCell::IsInView 0x532cb0 returns cached this->in_view; BN's own decompile names the call cell->vtable->IsInView() (pc:277990). Ghidra 0x51d180 ParticleEmitter::UpdateParticles: ShouldDrawParticles(physobj, this->degrade_distance) fail -> SetNoDraw(physobj,1) + degraded_out=1 (particles killed/frozen); recover -> SetNoDraw(0). All retail claims check out.
ACDREAM SIDE — all cited lines verified in src/AcDream.App/Rendering/: ParticleEntityKey = ServerGuid-or-Id OWNER key (GameWindow.cs:5072-5073); outdoor-bucket filter excluding AttachedObjectId==0 (GameWindow.cs:9514-9530, inside DrawRetailPViewLandscapeSlice :9465); per-cell-bucket filter + slice scissor excluding ==0 (DrawRetailPViewCellParticles GameWindow.cs:9553-9580); buckets keyed by the OWNER entity's ParentCellId gated on visibleCells (InteriorEntityPartition.cs:17,35-48,55-73) — the emitter itself has NO cell field (VfxModel.cs:177-193) and ParticleSystem.cs has zero degrade/distance/NoDraw logic (grep over src/AcDream.Core/Vfx: no matches; Tick unconditional at GameWindow.cs:7346); full-screen NoClipSlice fallback for slot-less cells (RetailPViewRenderer.cs:22-23 NdcAabb=(-1,-1,1,1), :428-437, invoked per cell at :423-424) feeding BeginDoorwayScissor's NDC->pixel rect (GameWindow.cs:9707-9724); depth-test-on/depth-write-off (ParticleRenderer.cs:141-143); DisableClipDistances() precedes every particle draw (GameWindow.cs:9518, 9568, 7839-7840) so portal clip planes are explicitly OFF for particles. #114 re-test item 2 text matches verbatim (docs/ISSUES.md:3800-3804: 'particle pass is not gated by the same flood').
TWO OVERSTATEMENTS in the original claim (the reason for 'adjusted'): (a) 'clipRoot-null frames draw all Scene emitters unfiltered (GameWindow.cs:7846-7868)' is wrong for half the cited range — when clipRoot==null AND clipAssembly!=null the draw IS filtered, and that filter explicitly ADMITS AttachedObjectId==0 emitters (:7856); only the clipAssembly==null sub-case (:7860-7867) is unfiltered-global. Consequently 'AttachedObjectId==0 emitters never draw under any pview root' is correct only as scoped (clipRoot non-null frames — verified: the 7846 block is skipped and both pview filters :9528/:9575 exclude ==0), NOT a general drop — they do draw outdoors. (b) 'far flames simulate + draw forever' — no degrade gate exists, but emitter lifetime is bounded by streaming: Near->Far demotion removes the owner entity and its VFX (C.1.5b GpuWorldState OnRemove hooks), so the real divergence is 'no retail degrade_distance equivalent at any range inside the near tier (N1=4 LBs)', not literally forever. CORE DIVERGENCE IS REAL AND NOT HANDLED ELSEWHERE: nothing in the codebase resolves an emitter to its own cell, no plane-cone test exists on any particle path (scissor AABB is overbroad + degenerates to full-screen for slot-less cells; depth test only occludes where occluder meshes are actually drawn — exactly the failure mode of #114 item 2 where non-visible cells' statics are not drawn), and the one-drawing-discipline invariant is broken for the particle pass. Severity 'critical' stands. Port shape as proposed is consistent with the verified retail mechanism (emitter-cell residency at spawn/anchor-update, draw inside the per-cell object pass keyed by EMITTER cell, sphere-vs-slice-plane-set test reusing slice planes, degrade_distance freeze+hide).
- blastRadius: particles-through-walls (#114 re-test item 2: candle flames inside other buildings visible while their statics' meshes are not drawn); also missing distance degrade (far flames simulate + draw forever) and dropped world-positioned emitters (AttachedObjectId==0 Scene emitters never draw under any pview root).
- retailEvidence: Emitter = CPhysicsObj with PARTICLE_EMITTER_PS (acclient.h:2829); registered into exactly ONE cell's shadow_part_list (add_particle_shadow_to_cell 0x514a70 pc:282799, branch pc:282875); quads draw only in that cell's loop-3 object pass (pc:432883-432886) under the cell's portal_view with per-slot viewconeCheck on the part sphere (DrawMesh 0x5a0860 pc:429245; viewconeCheck 0x54c250 pc:342860). Update/emission gated by ShouldDrawParticles (0x50fe60 pc:277959): CYpt <= degrade_distance AND cell->IsInView() (CLandCell cached in_view 0x532cb0 pc:316897; CEnvCell constant-true ICF 0x5269f0 pc:303646, vftable slot pc:1019224); fail → SetNoDraw degrade-out (ParticleEmitter::UpdateParticles 0x51d180 pc:291770).
- acdreamEvidence: Global ParticleSystem keyed by AttachedObjectId = OWNER entity id (GameWindow.cs:5072-5073); draw filters are set-membership of the owner in the per-cell bucket or Outdoor bucket (GameWindow.cs:9519-9530, 9553-9580) + scissor rectangle of the slice NDC AABB with FULL-SCREEN fallback for slot-less cells (RetailPViewRenderer.cs:428-437) + depth test (ParticleRenderer.cs:141-143). No emitter-own-cell, no cone test, no degrade (ParticleSystem.cs has none). clipRoot-null frames draw all Scene emitters unfiltered (GameWindow.cs:7846-7868); AttachedObjectId==0 emitters excluded by every pview filter (:9528, :9575).
- portShape: Give each emitter a cell residency (resolve the emitter anchor's cell on spawn/anchor-update — retail uses the particle physobj's single cell) and draw Scene particles inside DrawCellObjectLists' per-cell pass keyed by EMITTER cell, with a sphere-vs-portal-view-cone test per slice (reuse the slice's plane set, not its AABB). Add a degrade_distance gate that freezes + hides far emitters (retail SetNoDraw semantics). Route AttachedObjectId==0 emitters through their position's cell.
### [HIGH] single-cell-buckets-vs-shadow-parts (confirmed) — One ParentCellId bucket per entity vs retail's register-in-every-overlapped-cell with draw-once dedup
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (127.0.0.1:8081), not BN pseudo-C:
1. Multi-cell registration confirmed. CPhysicsObj::calc_cross_cells (Ghidra 0x515280) builds a CELLARRAY via CObjCell::find_cell_list over the object's cylspheres (or sorting sphere) — i.e. every cell the object's volume overlaps — then calls remove_shadows_from_cells + add_shadows_to_cells. CPhysicsObj::add_shadows_to_cells (Ghidra 0x514ae0, pc:282819) loops the CELLARRAY twice: first loop creates one CShadowObj per cell (set_physobj + cell_id), second loop calls CObjCell::add_shadow_object(cell, shadowObj, num_cells) AND CPartArray::AddPartsShadow(part_array, cell, num_shadow_objects) for EVERY non-null cell. Children recurse (0x514bf7/0x514c06). Callers: calc_cross_cells, calc_cross_cells_static, SetPositionInternal (Ghidra xrefs). Claim verified exactly.
2. Per-cell registration feeds the draw. CPartArray::AddPartsShadow (Ghidra 0x517e40) registers every CPhysicsPart with the cell via a CObjCell virtual — and notably passes the cell's clip_planes when num_shadows > 1, i.e. multi-cell-straddling parts carry per-cell clip info. Draw side: RenderDeviceD3D::DrawObjCell (Ghidra 0x5a1a40) → DrawPartCell (0x5a07a0) iterates the cell's shadow_part_list and calls CShadowPart::draw (0x6b50d0) → CPhysicsPart::Draw(part, 0).
3. Draw-once dedup confirmed with one mechanism refinement. CPhysicsPart::Draw (Ghidra 0x50d7a0, pc:274964-274971) skips when m_current_render_frame_num == render_device->m_nFrameStamp (active because the shadow path passes param=0). DrawMeshInternal (Ghidra 0x59f360) does GetDrawnThisFrame/SetDrawnThisFrame with an explicit IsPartOfPlayerObj exemption. Refinement: both gates read the SAME field — GetDrawnThisFrame/SetDrawnThisFrame (0x50d4d0/0x50d4f0, pc:274730-274743) compare/assign m_current_render_frame_num vs m_nFrameStamp, and the stamp is only ever SET in DrawMeshInternal for non-player parts (CPhysicsPart::Draw reads but never writes it). Net behavior is exactly as claimed: a part registered in N cells draws once per frame; player parts are never stamped so the player draws in every cell slot.
ACDREAM SIDE — all cited lines verified against the working tree:
4. WorldEntity.ParentCellId is a single uint? (src/AcDream.Core/World/WorldEntity.cs:46); every write site assigns one resolved membership cell (GameWindow.cs:4915, 6798, 8426, 8756, 11506). No overlapped-cell set exists on the entity.
5. InteriorEntityPartition.Partition buckets each entity under that single cell (src/AcDream.App/Rendering/InteriorEntityPartition.cs:37-44) and AddByCellOrOutdoor silently drops the entity when its cell is not in the flood (:67-68 `if (!visibleCells.Contains(cellId)) return;`) — added to NO list, not even LiveDynamic (LiveDynamic only takes ServerGuid!=0 entities with ParentCellId==null, :37-40).
6. The draw consumes only the single-cell buckets: RetailPViewRenderer.DrawCellObjectLists iterates visible cells back-to-front and draws partition.ByCell[cellId] only (src/AcDream.App/Rendering/RetailPViewRenderer.cs:408-421); WbDrawDispatcher.EntityPassesVisibleCellGate is `visibleCellIds.Contains(entity.ParentCellId.Value)` (src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1816-1835) — exact match of the claimed lines.
7. No compensating path exists. The outdoor-root fallback partition at GameWindow.cs:7732-7734 passes _outdoorRootNoCells (cleared = EMPTY set), so indoor-celled entities are dropped there too; the DrawPortal look-in path (RetailPViewRenderer.cs:193, 208) gates on the same single ParentCellId ∈ drawableCells. The only unfiltered draws (visibleCellIds:null at GameWindow.cs:7720-7722, 7816-7822) are restricted to the LiveDynamic bucket, which by construction excludes indoor-celled entities.
8. Port-shape premise verified: the physics side already computes the overlapped-cell set — CellTransit.FindCellSet returns the dedup'd CellArray ported from retail find_cell_list (src/AcDream.Core/Physics/CellTransit.cs:505-554), so bucketing into every overlapped visible cell + a per-frame drawn-stamp (player exempt) is a faithful and locally-scoped port.
JUDGMENT: the divergence is real, not behaviorally equivalent, and not handled elsewhere. Retail's contract is "an object exists in every cell it overlaps; dedup happens at draw time" — acdream's is "an object exists in exactly one cell; if that cell isn't flooded the object doesn't render." Any boundary-straddling object (door mid-swing, doorway NPC, multi-cell furniture) pops out whenever its membership cell leaves the flood while an overlapped cell stays visible. The #109 attribution is correctly hedged as a candidate contributor (a far door blinking as its single home cell enters/leaves the flood is consistent, but flood-set instability could also contribute — not proven here). Severity high (visible artifact class, not a one-drawing-discipline break) is appropriate. One strengthening detail found during verification: AddPartsShadow passes the cell's clip_planes to parts when the object spans >1 cell, so retail's multi-cell registration also supports per-cell portal-clipped drawing of straddlers — a faithful port should keep that in mind when wiring the dedup stamp.
- blastRadius: Pop-in/pop-out of boundary-straddling objects: a door mid-swing, an NPC standing in a doorway, multi-cell furniture — visible whenever the membership cell leaves the flood while an overlapped cell is still visible. Candidate contributor to #109 (far-door oscillation: the door object blinking as its single cell enters/leaves the flood) and to doorway-NPC flicker.
- retailEvidence: add_shadows_to_cells creates one CShadowObj AND registers parts per EVERY cell in the object's CELLARRAY (0x514ae0, pc:282819, AddPartsShadow per cell pc:282866); draw-once enforced at part level by the frame-stamp check in CPhysicsPart::Draw (0x50d7a0, pc:274964) + GetDrawnThisFrame in DrawMeshInternal (0x59f360, player parts exempt so the player draws in every slot).
- acdreamEvidence: InteriorEntityPartition.AddByCellOrOutdoor buckets each entity under its single ParentCellId and silently drops it when that cell is not in the flood (InteriorEntityPartition.cs:37-48, :67-68). WbDrawDispatcher's cell gate is ParentCellId ∈ visibleCellIds (WbDrawDispatcher.cs:1816-1835).
- portShape: Replace the single ParentCellId key with the entity's overlapped-cell set (the physics side already computes a CELLARRAY in CellTransit/Transition — AREA 6 territory); bucket the entity into every overlapped visible cell and dedup at draw with a per-frame drawn-stamp on the entity (player exempt). Small change to InteriorEntityPartition + a drawn-set in DrawCellObjectLists.
### [HIGH] shells-drawn-whole-in-retail-production (confirmed) — Retail production draws EnvCell shells as whole prebuilt meshes (use_built_mesh), not per-poly portal-clipped — acdream's #114 'pixel-exact indoor crop' target may be chasing the fallback path
- verifier notes: RETAIL SIDE — re-derived from Ghidra (not BN pseudo-C) at every load-bearing branch:
1. RenderDeviceD3D::DrawEnvCell (Ghidra decompile 0x59f170) is exactly as claimed: entry dedup `if (!CEnvCell::GetDrawnThisFrame(cell)) { SetDrawnThisFrame; ... }`, then `if (cell->use_built_mesh != 0) { D3DPolyRender::SetStaticLightingVertexColors(constructed_mesh, &pos); D3DPolyRender::DrawMesh(num_surfaces, surfaces, constructed_mesh, true); return; }`. The per-poly submit (`PolyNext->planeMask = -1` i.e. 0xffffffff, then polyListFinishInternal) is ONLY the else branch. pc:427922 indeed falls inside this function's else-branch poly loop (function spans pc:427885-427930; `use_built_mesh` gate at pc:427902/0059f1e9, planeMask line at 0059f24d). GetDrawnThisFrame is a true frame-epoch dedup: `m_current_render_frame_num == render_device->m_nFrameStamp` (0x52c0d2, pc:309546).
2. Production cells take the built-mesh branch: Ghidra decompile of CEnvCell::UnPack tail (function 0x52d470) shows `calc_clip_planes(this); if (DBCache::IsRunTime()) { this->use_built_mesh = 1; if (this->constructed_mesh == NULL) { if (D3DPolyRender::ConstructMesh(num_surfaces, surfaces, &structure->vertex_array, structure->num_polygons, structure->polygons, 3.0, true, &this->constructed_mesh)) return 1; } this->use_built_mesh = 0; }` — ConstructMesh call at 0x52d87a as cited. So use_built_mesh=0 only when ConstructMesh fails (or non-runtime tooling). The CGfxObj::InitLoad mirror exists at pc:318778/318784, and CEnvCell genuinely owns the fields (acclient.h:32086-32087 `MeshBuffer *constructed_mesh; int use_built_mesh;` inside CEnvCell struct 3405; the CGfxObj pair is acclient.h:31720-31721).
3. The caller IS the production path: PView::DrawCells (0x5a4840, pc:432709) loop 2 walks cell_draw_list and per view calls CEnvCell::setup_view then `render_device->vtable->DrawEnvCell(cell)` (pc:432852-432853, addr 005a4ab9-4abe); vtable slot at 007e555c resolves to RenderDeviceD3D::DrawEnvCell (pc:1037070).
4. Adversarial kill-shot attempt FAILED (which is what confirms the claim): I checked whether the per-view setup installs hardware clip planes that would also crop the built mesh. Render::set_view (Ghidra 0x54d0e0) only writes software-clipper globals (portal poly vertex pointer/count, inmask, 2D xmin/xmax/ymin/ymax) — no D3D state. D3DPolyRender::DrawMesh (Ghidra 0x59d4a0) never reads those globals — it sets FVF and per-subset either defers to the alpha list or calls RenderMeshSubset; no clip planes, no scissor. Grep for SetClipPlane/CLIPPLANE across the 1.4M-line pseudo-C hits only the D3D render-state NAME string table (pc:1044713+), no code. Structural second proof: the GetDrawnThisFrame dedup means a multi-view cell draws on the FIRST setup_view only — if per-view geometric clipping were load-bearing for the built-mesh branch, multi-view cells would render wrongly in retail; the dedup is only coherent if the whole-mesh draw is view-clip-independent (visibility = cell selection into cell_draw_list + portal z-masks + depth).
ACDREAM SIDE — read the production code, not docs: RetailPViewRenderer.cs:345-399 DrawEnvCellShells enables GL_CLIP_DISTANCE0..N around the shell pass only when clipShells (lines 378-380/396-398) and applies per-slice gl_ClipDistance crops via UseShellClipRouting (:390). Production call sites: DrawInside passes `clipShells: ctx.RootCell.IsOutdoorNode` (:104-105) with the #114 scope comment at :96-103; DrawPortal passes `clipShells: true` (:207). The in-code retail model at :357-360 cites "Render::set_view (:343750) installs the view polygon's edge planes and DrawEnvCell submits every cell polygon with planeMask=0xffffffff (:427922)" — i.e. it models the FALLBACK branch as "retail clips drawn CELL geometry," never mentioning use_built_mesh. ISSUES.md:3797-3798 states verbatim "Retail's reference: exact per-poly software clip against the accumulated portal view (planeMask=0xffffffff :427922)" as #114's target.
JUDGMENT — the divergence is real, not behaviorally-equivalent-elsewhere: acdream's #114 charter explicitly aims at pixel-exact geometric shell crops, modeled on retail's use_built_mesh==0 fallback; Ghidra proves production retail draws each cell shell ONCE per frame as a whole prebuilt hardware mesh with no geometric view clipping, the discipline being cell_draw_list admission + portal masking + depth. acdream does have a portal-mask pass (DrawExitPortalMasks, RetailPViewRenderer.cs:325-343), but the shells are still geometrically cropped on outdoor roots and #114 plans to extend cropping indoors — exactly the fallback-chasing the claim describes. Residual uncertainties (honest): (a) a raw by-value SetRenderState(CLIPPLANEENABLE) vtable call without a named symbol can't be fully excluded by grep — but DrawMesh's indifference to view state plus the dedup argument make it moot; (b) I did not verify what polyListFinishInternal does with planeMask=0xffffffff in the fallback branch (irrelevant to the verdict — it's the fallback either way); (c) the claim's proposed one-breakpoint live cdb check on use_built_mesh remains a cheap belt-and-suspenders confirmation but the static evidence (IsRunTime gate + construct-on-unpack) is strong. The port-shape reframing (cell-selection + exit-portal z-masks + depth instead of better crop regions; keep the outdoor crop only as the validated #113 mitigation) follows directly.
- blastRadius: #114 (indoor shell-clip regions not draw-quality: chopped stairs, vanishing walls, neighbour-room barrel). If production retail never geometrically crops shells, the faithful port is cell-selection + portal z-masks + depth — not better crop regions — which reframes the #114 work and the indoor half of #113.
- retailEvidence: RenderDeviceD3D::DrawEnvCell (0x59f170, Ghidra-confirmed): per-frame dedup at entry (GetDrawnThisFrame), then `if (use_built_mesh) { SetStaticLightingVertexColors; D3DPolyRender::DrawMesh(constructed_mesh); return; }` — the planeMask=0xffffffff per-poly submit (pc:427922) is only the else branch. CEnvCell::UnPack constructs the mesh at runtime (ConstructMesh call pc:311085/0x52d87a; pattern mirrors CGfxObj::InitLoad pc:318778-318784 where use_built_mesh=1 on fresh construct success). Cell shells therefore draw ONCE per frame epoch as whole HW meshes; visibility discipline = which cells are in cell_draw_list + portal z-fills + depth.
- acdreamEvidence: DrawEnvCellShells applies gl_ClipDistance crops per slice for outdoor roots and aspires to 'pixel-exact indoor regions' for #114 (RetailPViewRenderer.cs:345-399, scope note :374-377; ISSUES.md #114 cites 'retail's reference: exact per-poly software clip' as the target).
- portShape: Re-baseline #114: verify the use_built_mesh value live (one cdb breakpoint on DrawEnvCell reading [cell+offsetof(use_built_mesh)]), and if confirmed, port the z-mask + cell-selection discipline (exit-portal z-fans + interior draw through constructed views) instead of perfecting geometric crop regions. Keep the outdoor-root crop only if it remains the validated #113 phantom fix.
### [MEDIUM] no-per-slot-viewcone-for-meshes (confirmed) — Entities culled by one camera frustum instead of per-portal-view-slot sphere-vs-cone checks
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C), all claims check out:
1. RenderDeviceD3D::DrawMesh (Ghidra 0x005a0860; BN pc:429245, loop body pc:429272-429329 — the claimed range 429245-429271 covers only the header + non-portal branch, immaterial): when Render::PortalList != NULL it loops slot = 0..PortalList->view_count; per slot (gated by `building_view == -1 || building_view == slot`) it calls Render::set_view(&PortalList->view, slot) then Render::viewconeCheck(gfxobj->drawing_sphere). Non-OUTSIDE slots each get a DrawMeshInternal call under that slot's view state; OUTSIDE slots increment a counter (drawn anyway only when the force flag param_3 is set), and when the counter equals view_count the function returns OUTSIDE_VIEWCONE_ODS without drawing. Exactly the claimed "set_view per slot + viewconeCheck per slot, OUTSIDE in all slots → not drawn".
2. Render::viewconeCheck (Ghidra 0x0054c250; pc:342860): transforms the drawing sphere to viewer space and does sphere-vs-plane tests against viewer_world_space.CY plus the current slot's portal plane array (portal_vertex[i].plane, count portal_npnts); returns OUTSIDE / PARTIALLY_INSIDE / ENTIRELY_INSIDE. Render::set_view (0x0054d0e0; pc:343750-343764) is what installs portal_npnts/portal_vertex/portal_inmask + per-slot scissor (xmin..ymax), so the cone is genuinely per-slot.
3. The entity path really goes through this: CPhysicsPart::Draw (0x0050d7a0; pc:274964-275002) calls the virtual RenderDevice->DrawMesh, whose vtable slot (pc:1037075, 0x007e5570) is RenderDeviceD3D::DrawMesh 0x5a0860. So statics, dynamics, AND the player all pass the per-slot check.
4. Player nuance VERIFIED VERBATIM: DrawMeshInternal (Ghidra 0x0059f360) early-returns for parts where CPhysicsPart::GetDrawnThisFrame is set — but only when !CPhysicsPart::IsPartOfPlayerObj(s_current_physics_part). Non-player parts therefore draw in only the FIRST passing slot per frame; player parts are exempt from the dedup and draw once per passing slot (each under that slot's set_view scissor/planes). The claim's "player drawn once per slot, exempt from dedup" is exactly what the binary does.
ACDREAM SIDE — all cited lines check out, and the divergence is slightly WORSE than claimed on the indoor path:
1. WbDrawDispatcher.cs:660-666 (claim said 662-666): the only per-entity view test is FrustumCuller.IsAabbVisible(frustum, AabbMin, AabbMax) against the single camera frustum; animated entities bypass it (line 660-662). No per-slot/per-cone test exists anywhere in the dispatcher.
2. RetailPViewRenderer.cs:460-477 DrawEntityBucket: passes visibleCellIds = {cellId} (membership routing only) — confirmed. STRENGTHENING FINDING: it constructs the entry with LandblockId = ctx.PlayerLandblockId ?? 0u (line 465-466) while also passing neverCullLandblockId: ctx.PlayerLandblockId (line 474), so WbDrawDispatcher.cs:662's `entry.LandblockId != neverCullLandblockId` is false whenever PlayerLandblockId is non-null — the AABB-frustum cull is bypassed ENTIRELY for indoor per-cell buckets. The indoor flooded-cell entities get no view-based cull at all, only the cell-membership gate.
3. RetailPViewRenderer.cs:439-450 (UseIndoorMembershipOnlyRouting): comment is as claimed — it correctly cites retail's viewconeCheck-not-hard-clip behavior for meshes and clears entity clip routing, but no cone ACCEPT test was added in its place. The ClipViewSlice data the port shape needs exists: ClipFrameAssembler.cs:40 defines `record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes)`, and GetCellSlicesOrNoClip (RetailPViewRenderer.cs:428-437) already retrieves per-cell slices (currently used only for particles + shell clip routing).
4. Outdoor production site cross-checked: GameWindow.cs:7827-7830 and 9508-9511 pass the single camera frustum with neverCullLandblockId: playerLb — consistent with the claim's "one camera frustum" characterization for the non-PView path.
JUDGMENT: the divergence is real, not behaviorally equivalent. Retail skips any object whose drawing sphere is outside every portal-view slot's plane set; acdream draws every entity in every flooded drawable cell (and indoors even skips the camera-frustum test). When shells under-occlude (#114 family) those out-of-cone entities become visible artifacts; the player-per-slot multi-draw (with per-slot scissor) has no acdream equivalent. The claimed port shape (CPU-side sphere-vs-slice-planes accept test in DrawCellObjectLists using ClipViewSlice.Planes, skip when outside all slices) is a faithful analogue of viewconeCheck — retail's test is also a CPU-side sphere-vs-plane-set accept, not a GPU clip. Two refinements for the port: (a) retail's check also includes the viewer CY plane in addition to the portal planes; (b) full faithfulness would also reproduce the player's dedup exemption (player drawn per passing slot) and the non-player first-passing-slot draw, per DrawMeshInternal 0x0059f360. Severity "medium" is appropriate: over-draw plus artifact-class visibility contingent on shell under-occlusion, not a standalone top-bug breaker.
- blastRadius: Over-draw of objects in flooded cells that are outside every door cone — normally depth-hidden, but becomes visible artifact whenever shells under-occlude (the #114 family); also the player-in-multiple-views nuance (retail draws the player once per slot, exempt from dedup) has no equivalent.
- retailEvidence: RenderDeviceD3D::DrawMesh loops Render::PortalList->view_count slots, Render::set_view per slot + viewconeCheck(drawing_sphere) per slot, OUTSIDE in all slots → not drawn (0x5a0860, pc:429245-429271; viewconeCheck 0x54c250 pc:342860).
- acdreamEvidence: WbDrawDispatcher per-entity cull is a single AABB-vs-camera-frustum test (WbDrawDispatcher.cs:662-666); the per-cell call passes visibleCellIds={cell} but no per-slice cone (RetailPViewRenderer.cs:460-477). Comment at RetailPViewRenderer.cs:439-450 correctly chose not to hard-clip entities but did not add the cone CHECK retail uses instead.
- portShape: In DrawCellObjectLists, before dispatching a bucket entity, test its bounding sphere against each of the cell's clip slices' plane sets (the data already exists in ClipFrameAssembly); skip the entity when outside all slices. This is a CPU-side accept test, not a GPU clip.
### [MEDIUM] livedynamic-dropped-indoors (refuted) — ServerGuid entities with unresolved ParentCellId are not drawn under indoor roots
- correctedClaim: Not a real divergence. Acdream's LiveDynamic bucket (ServerGuid entity with null ParentCellId) is unpopulated in production: every server-spawned entity gets its full 32-bit wire cell id as ParentCellId at hydration (GameWindow.cs:2836), position-less spawns are dropped before entity creation (GameWindow.cs:2419-2427), and nothing ever nulls the field. Retail, per Ghidra (recalc_cross_cells 0x515a30), treats a cell-less object (objcell_id==0) as shadow-less and therefore undrawn EVERYWHERE — so even if the bucket were populated, the retail-faithful behavior would be to draw it nowhere, making the proposed 'draw LiveDynamic under indoor roots' port anti-retail. The only actionable residue is cleanup: the LiveDynamic bucket + its outdoor-root draw (GameWindow.cs:7716-7724) are dead code guarding an unreachable state, and the explanatory comment is stale.
- verifier notes: RETAIL re-derivation: Ghidra decompile of CPhysicsObj::recalc_cross_cells (0x515a30, via 127.0.0.1:8081) shows the OPPOSITE of the claimed retail evidence: `if (m_position.objcell_id == 0) { if (!m_bExaminationObject) return; if ((state & 0x1000)==0) return; add_particle_shadow_to_cell(this); } else calc_cross_cells(this);` — i.e. retail HAS a 'no cell yet' state, and in it the object registers NO shadows (except the examination-viewport special case). add_shadows_to_cells (0x514ae0, pc:282819) only runs from calc_cross_cells with a populated CELLARRAY. Since retail's world draw is a per-cell walk over each cell's shadow/object lists (DrawInside → cell object lists, pc:433793/:427922), a cell-less object is unreachable by the draw — invisible indoors AND outdoors. The claimed asymmetry ('retail draws them; acdream vanishes them indoors only') mischaracterizes retail: retail draws them nowhere.
ACDREAM re-derivation: the cited mechanism EXISTS — InteriorEntityPartition.cs:35-41 routes ServerGuid!=0 entities with null ParentCellId to LiveDynamic; GameWindow.cs:7716-7724 draws LiveDynamic only when clipRoot.IsOutdoorNode; RetailPViewRenderer.DrawInside consumes only partition.Outdoor (:93/:231) and partition.ByCell (:106/:414), never LiveDynamic. BUT the trigger population is empty by construction: (1) the ONLY ServerGuid!=0 creation site is GameWindow.cs:2826-2837, which always sets ParentCellId = spawn.Position!.Value.LandblockId — the full 32-bit wire ObjCellId (CreateObject.cs:293-294, parsed via ReadUInt32LittleEndian at :399-407), so indoor spawns carry their indoor cell immediately; (2) spawns with null Position are dropped before entity creation (GameWindow.cs:2419-2427 — inventory/held items, no world presence); (3) no code path ever assigns null to ParentCellId afterward (all writes non-null: GameWindow.cs:4481, 4915, 5629, 6798, 8426, 8756, 11506; the dead-reckoning sites guard `if (rm.CellId != 0)` to avoid clobbering); (4) the other new-WorldEntity sites (GameWindow.cs:5258/5463/5622, LandblockLoader.cs:63/79) all leave ServerGuid=0. So there is no 'just-spawned before cell resolve' window — cell assignment is synchronous with hydration from the wire. LiveDynamic is empty in production; the outdoor-root draw is a guard over an unreachable state and its comment (GameWindow.cs:7709-7715) is stale about reachability. No transient invisible NPCs/items, no user-visible asymmetry, severity is nil rather than medium.
- blastRadius: Transient invisible NPCs/items while the viewer is indoors (just-spawned entities before cell resolve); outdoors they draw unclipped, indoors they vanish — an asymmetry retail does not have.
- retailEvidence: Retail objects always occupy a cell and register shadows unconditionally on cell entry (add_shadows_to_cells 0x514ae0 pc:282819; enter_cell/recalc_cross_cells pc:283781) — there is no 'no cell yet, skip draw' state in the draw path.
- acdreamEvidence: GameWindow.cs:7716-7724 draws partition.LiveDynamic only when clipRoot.IsOutdoorNode; the indoor-root branch has no LiveDynamic draw. InteriorEntityPartition.cs:35-40 routes ServerGuid entities with null ParentCellId to LiveDynamic.
- portShape: Resolve a cell for every live entity at spawn/update (the membership machinery exists — P1 matches retail) so LiveDynamic is empty by construction; until then, draw LiveDynamic under indoor roots too (depth + frustum gated), matching the outdoor-root regression guard.
### [LOW] outdoor-objects-redrawn-per-slice (adjusted) — Outdoor bucket + its particles re-drawn once per outside-view slice instead of once under a multi-slot view
- correctedClaim: The headline divergence is not real: retail does NOT draw the outdoor bucket "once under a multi-slot view" — RenderDeviceD3D::DrawMesh (0x5a0860) re-draws each mesh once per outside-view slot that passes that slot's viewcone check, clipped to the slot's exact portal polygon (Render::set_view 0x0054d0e0); the frame-stamp (CPhysicsPart::Draw 0x0050d7a0) only dedups across cell draw lists, not slots. acdream's per-slice landscape loop (RetailPViewRenderer.cs:214-238 + GameWindow.cs:9465-9551) is the behaviorally equivalent loop inversion for all clip-routed geometry (sky/terrain/entities draw under per-slice clip distances + scissor). The surviving, narrower divergence (severity low): the particle sub-passes inside the per-slice callback (GameWindow.cs:9489-9492, 9518-9530, 9533-9541) ignore the slice clip planes and rely on the conservative NDC-AABB scissor alone, so additive/alpha particles can double-blend in the AABB-overlap-minus-portal-overlap region when 2+ exterior portals are on screen; plus a perf-only gap (no per-slice viewcone cull — full camera frustum passed at GameWindow.cs:9508). Correct port shape: KEEP the per-slice loop (it matches retail) and clip the per-slice particle passes to the slice planes (retail's per-slot alpha-poly clip), optionally adding a per-slice entity sphere-vs-slice-planes cull for perf — do NOT collapse to a single union-scissor draw, which would diverge from retail.
- verifier notes: RETAIL re-derived from Ghidra (not BN pseudo-C). (1) PView::DrawCells @ 0x005a4840: confirmed `Render::PortalList = &this->outside_view; LScape::draw(lscape);` runs exactly once when outside_view.view_count != 0, then one FlushAlphaList(0.0) and m_nFrameStamp++ (matches pc:432719/432722). (2) HOWEVER the claim's dedup mechanism is wrong: RenderDeviceD3D::DrawMesh @ 0x005a0860 (Ghidra decompile) iterates PortalList slots and calls DrawMeshInternal once PER slot whose viewconeCheck passes — retail deliberately RE-DRAWS each landscape mesh once per visible outside-view slot; correctness comes from Render::set_view @ 0x0054d0e0 installing each slot's exact portal polygon (vertex list, inmask, xmin/xmax/ymin/ymax) as the active clip region, so per-slot draws are pixel-disjoint up to true portal overlap. (3) The frame-stamp dedup actually lives in CPhysicsPart::Draw @ 0x0050d7a0 (pc:274971: `arg2 != 0 || m_current_render_frame_num != m_nFrameStamp`) and dedups a part across multiple CELL draw lists within a stamp epoch — it does NOT (and cannot) suppress per-slot draws, since the slot loop is below it in DrawMeshInternal's path. LScape::draw @ 0x00506330 confirms blocks are walked once; the per-slot fan-out happens at mesh level. ACDREAM verified: RetailPViewRenderer.cs:214-238 does iterate OutsideViewSlices invoking the full landscape callback per slice (as claimed), with SetTerrainClip(slice.Planes)+UploadClipFrame+SetClipRouting(slice.Slot) before each callback (lines 225-227). The callback DrawRetailPViewLandscapeSlice (GameWindow.cs:9465-9551, wired at 7624) scissors every slice draw to slice.NdcAabb (9477, disabled 9547-9548) and draws sky (9484-9487), terrain (9494-9496), and the outdoor entity bucket (9503-9512) with clip distances ENABLED against the slice planes — so the per-slice geometry redraw is clipped per slice, which is behaviorally EQUIVALENT to retail's per-slot DrawMeshInternal loop, merely loop-inverted (retail: per mesh, iterate slots; acdream: per slot, iterate meshes). No duplicated-pixel entity draws and no "draw once under multi-slot view" in retail to diverge from. Residual REAL gaps found: (a) the particle sub-passes inside the slice callback (SkyPreScene 9489-9492, outdoor-attached Scene 9518-9530, weather/SkyPostScene 9533-9541) run with clip distances DISABLED, confined only by the conservative NDC-AABB scissor — retail clips alpha-list polys to the exact per-slot portal polygon at transform time; where two slices' AABBs overlap but their portal polys do not, additive/alpha particles double-blend (requires interior viewer + 2+ exterior portals with overlapping screen AABBs; ClipFrameAssembler.cs:134-164 confirms one slice per outside-view polygon, so 2+ slices occur). (b) Perf-only: the dispatcher is invoked per slice with the FULL camera frustum (GameWindow.cs:9508), so entities invisible in a given slice still incur vertex work that gets clipped — retail's per-slot viewconeCheck skips those slots. The CLAIMED port shape (draw outdoor bucket once under a union scissor / any-slice accept) would be LESS retail-faithful: a single draw cannot apply per-slot exact plane sets, and retail's actual architecture IS the per-slot redraw.
- blastRadius: Double-blended (brighter) additive particles and duplicated outdoor entity draws when an interior viewer has 2+ exterior portals on screen; perf overdraw. No single-window artifact.
- retailEvidence: LScape::draw runs once with Render::PortalList=&outside_view (all slots); per-mesh slot iteration + frame-stamp dedup prevents duplicate draws (PView::DrawCells loop 1 pc:432709+; DrawMesh slot loop 0x5a0860).
- acdreamEvidence: DrawLandscapeThroughOutsideView iterates OutsideViewSlices invoking the full landscape callback per slice (RetailPViewRenderer.cs:214-238); the callback draws the whole Outdoor bucket and its attached particles each time (GameWindow.cs:9503-9530).
- portShape: Draw the outdoor bucket once with the union scissor (or a per-entity any-slice accept test) and draw outdoor-attached particles once, not per slice.
### [LOW] per-cell-depth-sort-missing (adjusted) — No per-cell viewer-distance sort of a cell's objects before draw
- correctedClaim: Acdream DOES per-cell viewer-distance sorting (the claim's headline is wrong): each indoor cell bucket gets its own WbDrawDispatcher.Draw call (RetailPViewRenderer.cs:408-477) which sorts translucent groups back-to-front by camera distance (WbDrawDispatcher.cs:1442-1446, :1203-1204) before the blended MDI pass. The REAL residual divergence is sort granularity: retail insertion-sorts every individual shadow part by its own per-part CYpt = 3D viewer distance to the part's scaled sort_center, descending/back-to-front (Ghidra 0x5a0760 DrawObjCellForDummies → 0x5a0690 UpdateObjCell → 0x510b30/0x50e030 UpdateViewerDistance → 0x6b5130 insertion_sort; called per visible cell from PView::DrawCells 0x5a4840, pc:432878), while acdream sorts per (mesh-slice,texture) GROUP keyed on the first instance's matrix origin only — instances within one translucent group are unsorted under blending, and particles render in a separate non-interleaved pass. Severity: low (translucent-within-batch ordering and translucent-vs-particle interleaving only); port shape if ever needed: per-instance distance keys (to part sort_center) within translucent groups rather than the proposed "sort each cell bucket" (already present).
- verifier notes: RETAIL SIDE — re-derived from Ghidra decompiles (not BN pseudo-C), every cited element confirmed:
(1) RenderDeviceD3D::DrawObjCellForDummies (Ghidra 0x5a0760): calls UpdateObjCell(cell); then, if the cell's CPartCell sub-object has num_shadow_parts > 1, calls CShadowPart::insertion_sort(shadow_part_list, num_shadow_parts); then calls vtable+0x60. CPartCell layout {vfptr, num_shadow_parts, DArray<CShadowPart*> shadow_part_list} at acclient.h:30889-30894 matches the decompile's piVar1[1]/piVar1+2 access. RenderDeviceD3D vtable base is 0x7e5500 (pc:1037045ff); base+0x60 = 0x7e5560 = DrawObjCell (pc:1037071) — so the sort happens immediately before DrawObjCell, as claimed.
(2) RenderDeviceD3D::UpdateObjCell (Ghidra 0x5a0690): iterates the cell's shadow_object_list calling CPhysicsObj::UpdateViewerDistance per shadow object (two variants split on MAX_CELL_2D_DEGRADE_DISTANCE), as claimed.
(3) CPhysicsObj::UpdateViewerDistance (Ghidra 0x510b30): writes this->CYpt = 3D Euclidean distance from Render::viewer_pos, then propagates to CPartArray → CPhysicsPart::UpdateViewerDistance (Ghidra 0x50e030), which writes per-PART CYpt = distance from viewer to the part's scale-adjusted gfxobj sort_center (CPhysicsPart::CYpt at acclient.h:31153).
(4) CShadowPart::insertion_sort (Ghidra 0x6b5130): sorts the CShadowPart* array on part->CYpt DESCENDING (2-element trace of the shift loop: an element with larger CYpt moves ahead of the pivot) — i.e. far-to-near, back-to-front painter's order. The original claim omitted the direction.
(5) Call chain: PView::DrawCells (0x5a4840, pc:432709) loop 3 walks cell_draw_list in reverse calling render_device->DrawObjCellForDummies per visible cell (0x5a4b0d, pc:432878); also fired for creature_cell (pc:91760) and LScape after_sky_cell (pc:268730). So yes: per visible cell, every frame, distances refreshed then parts sorted back-to-front before the cell's objects draw.
ACDREAM SIDE — the claim's characterization is WRONG in its load-bearing half:
(1) WbDrawDispatcher does NOT rely solely on two-pass alpha-test with unsorted buckets. It sorts BOTH sections every Draw() call: opaque front-to-back (CompareOpaqueSubmissionOrder, src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1436-1440) AND translucent BACK-TO-FRONT (CompareTransparentSubmissionOrder, WbDrawDispatcher.cs:1442-1446: b.SortDistance.CompareTo(a.SortDistance)), applied at :1203-1204. The transparent MDI section alpha-blends with depth-write off (:1346-1348), so this ordering is live, not vestigial.
(2) "Per-cell buckets dispatched as-is" is false. On the indoor path, DrawCellObjectLists walks OrderedVisibleCells in REVERSE (far→near, mirroring retail's reverse cell_draw_list walk) and calls DrawEntityBucket per cell (RetailPViewRenderer.cs:408-421, :460-477 → _entities.Draw at :470). Draw() clears _groups at the top of every call (WbDrawDispatcher.cs:741), so each cell's bucket is independently grouped AND viewer-distance-sorted within that call — i.e. acdream ALREADY HAS a per-cell viewer-distance sort of the cell's objects before draw. The proposed port shape ("sort each cell bucket by distance before dispatch") describes code that exists.
(3) Outdoors, GameWindow.cs:7827 issues one global Draw over all landblock entries — but a single global back-to-front translucent sort is behaviorally equivalent-or-better than retail's per-cell sorts concatenated in traversal order; not an absence.
WHAT SURVIVES (the real, narrower divergence): sort GRANULARITY, not sort existence. Retail sorts every individual CShadowPart by its own per-part CYpt (distance to that part's scaled sort_center, refreshed per frame). Acdream sorts per (mesh-slice, texture) GROUP (GroupKey.cs:14-22) keyed on the squared camera distance to the FIRST instance's matrix translation only — explicitly commented "cheap heuristic" (WbDrawDispatcher.cs:1174-1180). Two consequences: (a) multiple entities/parts sharing one batch collapse to a single sort key taken from whichever instance was appended first, and instances WITHIN a translucent group render in insertion order under blending, unsorted; (b) the distance reference is the part matrix origin, not the gfxobj sort_center. Additionally, particles draw in a separate pass after each cell's bucket (RetailPViewRenderer.cs:423-424; GameWindow.cs:7851/:9570 depth-write off) and never distance-interleave with translucent entity parts, whereas retail's UpdateViewerDistance has a particle-specific branch (state & 0x1000 → particle_distance_2dsq, Ghidra 0x510b30) feeding the same per-cell sorted structure. Severity stays LOW — opaque order is settled by the depth buffer in both engines; the residual affects only translucent-vs-translucent ordering within one batch group and translucent-vs-particle interleaving.
- blastRadius: Translucent statics/dynamics within one room can sort against each other and against particles differently than retail; subtle blending-order differences only.
- retailEvidence: DrawObjCellForDummies insertion-sorts the cell's shadow_part_list by viewer distance every frame before DrawObjCell (0x5a0760, pc:429177; CShadowPart::insertion_sort), after UpdateObjCell refreshes distances (0x5a0690, pc:429129).
- acdreamEvidence: WbDrawDispatcher sorts opaque front-to-back globally and handles translucency via the two-pass alpha-test model (CLAUDE.md N.5 design; per-cell buckets dispatched as-is at RetailPViewRenderer.cs:460-477).
- portShape: Low priority: if blending-order bugs surface, sort each cell bucket by distance before dispatch (cheap — buckets are small).
## OPEN QUESTIONS
- Pixel effect of DrawPortalPolyInternal (0x59bc90, pc:424490): it writes a DEPTHTEST_ALWAYS triangle fan with z forced ~far and a cycling portalColorVal palette with an alpha bit derived from the maxZ1/maxZ2 mode globals — almost certainly a z-mask (it drives portalsDrawnCount → the DrawCells z-clear), but whether any color is ever visible in production (and therefore what the 'closed door' aperture should look like when ConstructView fails) needs a live retail capture of maxZ1/maxZ2 or a RenderDoc-equivalent observation.
- PView::DrawPortal's param_3==3 branch (fill the portal poly when ConstructView FAILS) has no reachable caller I could find — only portal_draw_portals_only calls the vtable slot, passing pass∈{1,2}. If some other entry exists (tool mode? indoor exit portals?), the 'door fills when you can't see through' story changes; treat the failure→no-draw conclusion as production-path-only.
- Production truth of use_built_mesh==1 for EnvCells: the CGfxObj::InitLoad pattern is clean (pc:318778-318784) and CEnvCell::UnPack calls ConstructMesh at runtime (pc:311085), but BN's ADJ-garbled field writes around 0x52d780/0x52d882 make the EnvCell use_built_mesh assignment lower-confidence than the GfxObj one. One cdb read of a live CEnvCell settles it — load-bearing for the shells-drawn-whole divergence and #114's direction.
- Exact runtime mechanism of #114 re-test item 2 (foreign-building candle flames through walls): the structural gap is established (no emitter-own-cell + rectangle scissor + full-screen NoClipSlice fallback + clipRoot-null unfiltered fallback), but which specific admission path fires in the user's repro needs one capture frame — candidates: slot-less cell → full-screen scissor, owner ParentCellId vs actual flame-hook cell mismatch, or a clipRoot-null fallback frame during branch flicker.
- Retail CYpt semantics: assumed 'viewer distance' refreshed by CPhysicsObj::UpdateViewerDistance via UpdateObjCell (0x5a0690) — consistent with usage in ShouldDrawParticles, but the field's exact definition (eye vs object-center, world units) was not independently verified.
- DrawCells loop-1's per-slot DrawPortalPolyInternal for indoor portals with other_cell_id==-1 (pc:432786) — read as the exit-portal z-mask that lets landscape show through doorways; whether acdream's DrawExitPortalMasks (RetailPViewRenderer.cs:325-343) matches its draw state (DEPTHTEST_ALWAYS far-z fan, per view slot) 1:1 was not compared at the GL-state level.
- CBuildingObj leaf_cells (acclient.h:31913) + DrawBuildingLeaf (0x5a07e0; DrawPartCell call pc:429236) draw objects registered in a building's BSP leaf part-cells during the building draw — acdream has no equivalent; which Holtburg content (porch objects? hill-cottage steps?) depends on it is unverified.

View file

@ -0,0 +1,95 @@
# 2.1 Camera and viewer (issue #115 — camera feel in cramped interiors)
## RETAIL
RETAIL CALL CHAIN (one pass per render frame, two phases):
PHASE 1 — physics/update: Client::UseTime (pc:18670, 0x00411c40) → SmartBox::UseTime (pc listing at 0x00455410) → CPhysics::UseTime (call at 0x004554a7; body at Ghidra 0x00509950). CPhysics::UseTime iterates every physics object calling CPhysicsObj::update_object, and immediately after updating THE PLAYER object it calls SmartBox::PlayerPhysicsUpdatedCallback (Ghidra 0x005099ed-0x005099f2; fires once per frame whenever Timer::cur_time advanced — the gate at 0x0050996c only early-outs on zero elapsed). PlayerPhysicsUpdatedCallback (Ghidra-confirmed decompile of 0x00452d60; pc:91836-91844) is three lines: `sought = CameraManager::UpdateCamera(camera_manager, &ret, &this->viewer); viewer_sought_position = sought;`. THE LOAD-BEARING FACT: the third argument — the interpolation ORIGIN — is `&this->viewer`, the PUBLISHED, COLLIDED viewer from the previous frame's sweep. The collided eye feeds back into the damping every frame.
CameraManager::UpdateCamera (Ghidra 0x00456660, full decompile read): (a) dt = Timer::cur_time last_update_time (own clock, real seconds); (b) integrates held-key offset-movement flags into viewer_offset / pivot_offset scaled by dt; (c) builds the TARGET pose — pivot via QueryPivotPosition, heading via LOOK_AT_OBJECT / ALIGN_WITH_PLANE 5-sample velocity ring / LOOK_IN_DIRECTION, target origin = pivot + heading-frame-rotated viewer_offset (Frame::localtoglobal), target rotation = look frame; (d) translation alpha = t_stiffness * dt * 10.0 clamped to [0,1] (Ghidra tail: `fVar19 * (float)local_178 * ___real_4024000000000000`; t_stiffness ≥ 1F_EPSILON ⇒ instant), rotation alpha likewise from r_stiffness; (e) `Frame::interpolate_origin(&result, &param_1->frame /* = published viewer */, &target, t_alpha)` and `interpolate_rotation(..., r_alpha)` — an exponential lerp FROM the published collided viewer TOWARD the full-boom target; (f) convergence snap: when not instant and `Position::distance(result, param_1) < 2*F_EPSILON` and `Frame::close_rotation(result, param_1, F_EPSILON)` → return param_1 unchanged (exact fixed point; F_EPSILON = 0.000199999995). Constructor defaults t_stiffness = r_stiffness = 0.45 (pc:95963-95964, 0x004570b1-0x004570b4). There is NO explicit boom-distance lerp, NO hysteresis constants, NO per-frame max-rate clamp anywhere in update_viewer/set_viewer — the famous "shorten fast, lengthen slow" feel is EMERGENT: the sweep clamps the published eye instantly, and because the next frame's lerp origin IS that clamped eye, re-extension toward the full boom eases out exponentially (~alpha 7.5%/frame at 60fps), and while the player turns, the sought eye hugs the wall instead of orbiting at full radius behind it.
PHASE 2 — draw: the same per-frame SmartBox pass ends in SmartBox::DrawNoBlit (call at 0x0045557a; Ghidra labels the containing function Draw) → SmartBox::update_viewer (Ghidra xref: from 0x00454c34 in DrawNoBlit, UNCONDITIONAL — the sweep re-runs EVERY render frame regardless of whether anything moved). update_viewer (Ghidra-confirmed decompile of 0x00453ce0; pc:92675-92887): (1) player->cell null → reenter_visibility, else set_viewer(player_pos, 1) + viewer_cell = null; (2) pivot = part frame at camera_manager->pivot_part_index (else m_position) + rotated camera_manager->pivot_offset; (3) sweep START cell: outdoor ((objcell_id & 0xffff) < 0x100) player->cell; indoor → CPhysicsObj::AdjustPosition seats the cell at the PIVOT point, falling back to player->cell; (4) sweep target = viewer_sought_position's origin re-expressed in the start cell (Position::localtoglobal); (5) CTransition with init_object(player, 0x5c), init_sphere(1, &viewer_sphere /* 0.3 m, pc:93314 */, 1.0), init_path(startCell, pivotPos, soughtPos), find_valid_position; (6) SUCCESS → set_viewer(&sphere_path.curr_pos, 0) and `viewer_cell = sphere_path.curr_cell` — the published render position IS the raw collided sweep stop, and the viewer cell IS the transition's graph-tracked end cell; (7) fallback 1: AdjustPosition at the raw sought position (which carries viewer_sought_position's own objcell_id context) → set_viewer(sought, 0), viewer_cell = adjusted cell; (8) fallback 2: set_viewer(player->m_position, 1), viewer_cell = null. set_viewer (Ghidra 0x00452c40) copies the Position verbatim into this->viewer (param_2 != 0 additionally resets viewer_sought_position — failure-path re-seed), then re-anchors the viewer light, SoundManager::SetPlayerPosition, LScape::set_sky_position, and SceneTool::SetupCamera(&this->viewer) — there is NO separate smoothed render position; the renderer consumes the collided position raw, paired with the DAMPED rotation (the sought frame's interpolated rotation rides through the sweep unchanged — flags 0x5c include FreeRotate).
INPUT WHILE COLLIDED (Q3): held camera keys are polled per frame in CameraSet::UpdateCamera (Ghidra 0x00458ae0, pc:97625-97745; called per frame from the UI UseTime at 0x004d74b9) → CameraSet::Rotate (0x00458310, pc:97103-97230) rotates the viewer_offset vector around Z by angle = cm->m_rCameraAdjustmentSpeed × (cur_time m_ttLastRotate) (sin/cos at 0x00458609-0x00458629), then SetTargetDirection + SetTargetForOffset; mouse-look reaches the same Rotate with a scale argument (callers at 0x00458ef9). Rotation input ONLY moves the TARGET; no stiffness change during rotate (stiffness is forced to 1.0 only by mode switches: SetScale 0x004578fe, SetInHead 0x00458cfc, LookDown-family 0x00458097/0x00458204). The swing arc therefore collides CONTINUOUSLY: damping eases the sought eye from the published collided pose toward the rotated target each frame, and update_viewer re-sweeps pivot→sought every render frame.
PLAYER FADE (Q4): CameraSet::UpdateCamera (0x00458ae0): InHead → CPhysicsObj::SetTranslucencyHierarchical(player, 1f) (0x00458bb8, fully invisible); otherwise d = Position::distance(pivot, &sbox->viewer) — the PUBLISHED COLLIDED viewer (0x00458beb); d ≥ 0.449999988 → SetTranslucencyHierarchical(player, 0f) (0x00458ca1, opaque); d < 0.45 t = 1 (0.200000003 d)/(0.2 0.45), clamped to [0,1] (0x00458c19-0x00458c53), applied via SetTranslucencyHierarchical (0x00458c6d). So retail fades the player out over the 0.45 m 0.20 m approach band, keyed off the collided eye, applied to the actual player mesh hierarchy every frame.
## ACDREAM
ACDREAM CALL CHAIN (one pass per update tick, all in one phase):
GameWindow's player-mode update (src/AcDream.App/Rendering/GameWindow.cs:6728-6838) runs PlayerMovementController.Update (GameWindow.cs:6791), then updates BOTH chase cameras every frame — legacy ChaseCamera (GameWindow.cs:6821-6823) and RetailChaseCamera (GameWindow.cs:6832-6838) — passing RenderPosition, yaw, BodyVelocity, IsOnGround, ContactPlane.Normal, frame dt, the player CellId, and LocalEntityId. CameraController.Active picks per-read via CameraDiagnostics.UseRetailChaseCamera (CameraController.cs:20-33), default ON (CameraDiagnostics.cs:27-28); camera collision default ON (CameraDiagnostics.cs:48-49).
RetailChaseCamera.Update (src/AcDream.App/Rendering/RetailChaseCamera.cs:122-209): (1) 5-frame velocity ring + average (:133-134, mirrors retail old_velocities); (2) heading = facing projected on contact plane (ComputeHeading :140-145, :278-324); (3) target eye = pivot (player + 1.5 m, :151) forward·D·cosP + up·D·sinP with Distance default 2.61 / Pitch 0.291 (:57-60); (4) damping: `_dampedEye = Lerp(_dampedEye, targetEye, alpha)` with alpha = stiffness·dt·10 (:167-170, ComputeDampingAlpha :390-396), stiffness defaults 0.45/0.45 (CameraDiagnostics.cs:56-63 — matches retail pc:95963), plus the ported convergence snap (:172-176, :408-416, epsilons :97-98); (5) collision: `publishedEye` starts as `_dampedEye`; if CollideCamera and probe set, `swept = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId, playerPosition)`; publishedEye = swept.Eye; ViewerCellId = swept.ViewerCellId (:188-198). THE LOAD-BEARING DIVERGENCE: the comment block at :179-187 explicitly states the collided result "must NOT feed back into the damped state" and claims retail keeps two non-feeding states — `_dampedEye` (:104) only ever lerps from its own previous UNCOLLIDED value (:169); (6) publish Position = publishedEye, View = LookAt(publishedEye, publishedEye + _dampedForward) (:202-203 — position collided, rotation damped, same split as retail); (7) PlayerTranslucency = ComputeTranslucency(distance(publishedEye, pivot)) with Far 0.45 / Near 0.20 (:207-208, :454-463 — formula matches retail 0x00458c19) — but grep over src/ shows PlayerTranslucency has ZERO consumers outside RetailChaseCamera.cs itself (:82, :119, :208); no code applies it to the player mesh.
PhysicsCameraCollisionProbe.SweepEye (src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:24-103, KEEP-LISTED verbatim port): 0.3 m sphere (:18), indoor start-cell seated at the pivot via AdjustPosition (:36-40 = retail pc:92824-92844), ResolveWithTransition with retail's exact 0x5c flags (:68-69), success → swept eye + r.CellId (:92 = retail viewer_cell = sphere_path.curr_cell), fallback 1 AdjustPosition at the sought eye (:94-99 — seeds with the PLAYER cell rather than the sought position's own cell, a documented small divergence from retail's local_120 carrying viewer_sought_position.objcell_id), fallback 2 snap to player + cell 0 (:102).
Input: held zoom/raise keys integrate Distance/Pitch at CameraAdjustmentSpeed·dt (GameWindow.cs:6743-6754, default 40.0 CameraDiagnostics.cs:78); RMB mouse orbit writes YawOffset = filteredDx·0.004·sens through the ported low-pass FilterMouseDelta (GameWindow.cs:1063-1096, RetailChaseCamera.cs:231-244). Re-collide cadence MATCHES retail: the sweep runs every frame the camera updates, unconditionally (:193-198).
Consumption: GameWindow.OnRender roots the render at the VIEWER cell — viewerCellId = _retailChaseCamera.ViewerCellId when player mode + retail cam (GameWindow.cs:7301-7305), visibility = ComputeVisibilityFromRoot(viewerRoot, camPos) (:7313), while lighting keys on the PLAYER CurrCell (:7291-7296, :7337) — matching retail's player->cell vs SmartBox->viewer_cell split.
## DIVERGENCES
### [HIGH] boom-no-collided-feedback (confirmed) — Sought eye never re-anchors to the published collided viewer — retail's emergent boom easing is severed
- verifier notes: Re-derived the full retail camera loop from Ghidra decompiles (not BN pseudo-C) and checked every acdream citation against the actual code. All load-bearing elements of the claim survive.
RETAIL (Ghidra): (1) SmartBox::PlayerPhysicsUpdatedCallback 0x00452d60 — `UpdateCamera(this->camera_manager, &ret, &this->viewer)` with the result overwriting `viewer_sought_position`; the interpolation seed passed in is the PUBLISHED viewer, exactly as claimed. (2) CameraManager::UpdateCamera 0x00456660 tail — `local_158.objcell_id = param_1->objcell_id; Frame::interpolate_origin(&local_158.frame, &param_1->frame, target_frame, alpha)` where param_1 IS that published-viewer argument, alpha = t_stiffness·dt·10 clamped to 1 (constant ___real_4024000000000000 = 10.0), plus interpolate_rotation(r_stiffness·dt·10); convergence snap verified: when translation alpha unsaturated AND Position::distance(result, param_1) < F_EPSILON+F_EPSILON AND close_rotation(F_EPSILON), returns a copy of param_1 (published viewer) unchanged matches the claim's "distance < 2·F_EPSILON" wording. (3) SmartBox::update_viewer 0x00453ce0 reads viewer_sought_position, CTransition::init_path(cell, &pivot, &sought) (sweep starts at the PIVOT), init_sphere(viewer_sphere), and on success publishes the clamp RAW: set_viewer(&sphere_path.curr_pos, 0) + viewer_cell = sphere_path.curr_cell. (4) SmartBox::set_viewer 0x00452c40 straight copy into this->viewer, no lerp; param_2!=0 would also reseed sought but the normal publish passes 0. (5) Call sites: update_viewer called unconditionally from SmartBox::DrawNoBlit 0x00454c34 when player != null; PlayerPhysicsUpdatedCallback fired from the physics-object update loop at 0x005099f2 (pc:271646) when the updated object is the player. No hysteresis constants exist; shorten-instant/lengthen-damped is emergent from publish→re-anchor→sweep, as claimed.
ACDREAM: RetailChaseCamera.cs:169 — `Vector3.Lerp(_dampedEye, targetEye, tAlpha)` lerps from its own previous never-collided value; grep confirms _dampedEye is written ONLY at :161 (init) and :175-176 (convergence snap) — the swept result at :195-197 goes into local `publishedEye` + ViewerCellId only, so the collided eye never re-anchors the sought state anywhere in the codebase. The :179-187 comment explicitly forbids feedback and mis-attributes that design to retail — refuted by the 0x00452d60/0x00456660 decompiles. Production-active: CameraDiagnostics.UseRetailChaseCamera (CameraDiagnostics.cs:27-28) and CollideCamera (:48-49) both default ON, so this is the live path, not a flag-gated experiment.
Behavioral consequences check out mechanically: (a) while collided+turning, acdream's sweep target is the full-boom point orbiting behind the wall (publishedEye = fresh per-frame clamp of pivot→far-target → stair-steps along wall features), retail's sought hugs the published clamp (moves only alpha per update toward full boom → clamp point glides); (b) on obstruction clear, acdream's _dampedEye has already converged to full boom during the collision (the lerp toward targetEye is never impeded), so the eye pops the full clamped-to-boom delta in one frame (~2.3 m worst case at Distance 2.61, 0.3 m sphere), where retail eases out exponentially. The #109 render-root amplifier remains a labeled hypothesis (ViewerCellId = render root per GameWindow.cs:7303; a 1-frame eye jump crossing portal planes flipping it is plausible but unverified) — appropriately flagged as such in the original claim.
One framing correction (does not change the verdict): retail's re-anchoring fires per PLAYER-PHYSICS UPDATE (the 0x005099f2 loop), not per render frame, and UpdateCamera computes alpha from real elapsed time (Timer::cur_time last_update_time) — so "~7.5%/frame" is a 60 Hz illustration of a time-constant easing, not a fixed per-render-frame rate. The sweep (update_viewer) runs per render frame; the sought easing runs at physics-update cadence with time-based alpha. The proposed port doing both per render frame with dt-based alpha is behaviorally equivalent. Port shape is sound: retail's sweep starting at the PIVOT (not the previous eye) is what makes publish-feedback a stable fixed point, so the historical oscillation feared in the :183-187 comment (which arose under a different state arrangement) should not reproduce if the ordering (interpolate-from-published → sweep pivot→sought → publish) is ported exactly; the corner-seal replay + cramped-interior visual gate validation step is the right acceptance test.
- blastRadius: PRIMARY #115 suspect ("camera feels draggy/jittery vs retail when turning in cramped interiors — like dragging over walls instead of gliding"). Two symptom modes from one root cause: (a) while turning with the boom collided, acdream's sweep target is the FULL-distance eye orbiting behind walls, so the published eye is a fresh per-frame clamp of the pivot→far-target ray — it jumps discontinuously from wall feature to wall feature (stair-stepping/jitter = "dragging over walls"); retail's sought eye starts each frame AT the collided eye and moves only ~7.5%/frame toward the full boom, so the target hugs the wall and the clamp point glides; (b) the instant the sweep clears an obstruction, acdream's eye snaps out to full boom distance in ONE frame (up to ~2.3 m pop), where retail eases out exponentially over ~10-20 frames. Secondary: a 1-frame eye jump can cross multiple portal planes, flipping ViewerCellId (= the render root, GameWindow.cs:7301-7313) discontinuously — a plausible amplifier for #109 far-door render-root oscillation when the eye sits near a clamp boundary (hypothesis, not verified). Also makes #114-class shell-clip pops more noticeable since the eye teleports rather than glides between clip regimes.
- retailEvidence: SmartBox::PlayerPhysicsUpdatedCallback (Ghidra-confirmed decompile 0x00452d60; pc:91836-91844): `sought = CameraManager::UpdateCamera(cm, &ret, &this->viewer)` — the interpolation origin is the PUBLISHED COLLIDED viewer; result overwrites viewer_sought_position every frame. CameraManager::UpdateCamera (Ghidra 0x00456660 tail): `Frame::interpolate_origin(&result, &param_1->frame, &target, t_stiffness·dt·10)` + interpolate_rotation + convergence snap (distance < 2·F_EPSILON AND close_rotation(F_EPSILON) return param_1). SmartBox::update_viewer (Ghidra 0x00453ce0) then sweeps pivotsought per render frame (xref: unconditional call from DrawNoBlit 0x00454c34) and publishes the clamp RAW via set_viewer(&sphere_path.curr_pos, 0) (Ghidra 0x00452c40 straight copy, no lerp). No hysteresis constants exist; shorten-instant/lengthen-damped is emergent from this loop.
- acdreamEvidence: RetailChaseCamera.cs:169 — `_dampedEye = Lerp(_dampedEye, targetEye, alpha)` lerps from its own previous UNCOLLIDED value; :188-198 — collided result goes into a local `publishedEye` only; :179-187 — the comment explicitly forbids feedback ("must NOT feed back into the damped state") and mis-attributes that design to retail ("retail ... keeps TWO states ... collision ... must NOT feed back") — refuted by the Ghidra decompile of 0x00452d60. The collided distance is applied RAW per frame with no easing on re-extension and no wall-hugging of the sweep target.
- portShape: Replicate retail's two-phase loop: keep ONE published viewer state (Position: cell + origin + rotation). Per frame, FIRST compute sought = interpolate_origin/rotation FROM the published viewer toward the full-boom target (alpha = stiffness·dt·10, convergence snap vs the published viewer — the existing ApplyConvergenceSnap epsilons are already correct), THEN run the keep-listed sweep pivot→sought (probe unchanged), THEN publish the swept result (position + curr_cell) as the viewer that seeds the NEXT frame's interpolation. Delete the separate never-collided _dampedEye; _dampedForward re-anchors from the published frame the same way (rotation is never clamped so behavior is identical). Validate against the historical oscillation note (:183-187) with the corner-seal replay + a cramped-interior visual gate — retail's shape is a stable fixed point (sweep starts at the PIVOT, not the previous eye), so the old vibration should not reproduce if the ordering is ported exactly.
### [MEDIUM] player-fade-computed-not-applied (confirmed) — Player-mesh close-camera fade is computed but never applied to the player
- correctedClaim: Claim confirmed as stated, with one immaterial precision: in the d ≥ 0.45 branch retail calls SetTranslucencyHierarchical(player, 0) only when the current translucency is > 0 (a redundancy guard at the `if (0.0 < t)` check in 0x00458ae0), not unconditionally every frame — behaviorally identical to the claim. Everything else (thresholds 0.449999988/0.200000003, ramp formula, collided-viewer distance source, InHead → 1.0, per-frame application via gmSmartBoxUI::UseTime, hierarchical part-array application, and acdream computing-but-never-consuming PlayerTranslucency) checks out against Ghidra and the actual acdream call sites.
- verifier notes: RETAIL re-derived from Ghidra decompile (not BN pseudo-C): CameraSet::UpdateCamera @ 0x00458ae0 (header pc:97643) — InHead → CPhysicsObj::SetTranslucencyHierarchical(player, 1.0); else d = Position::distance(pivot-from-CameraManager::QueryPivotPosition, &sbox->viewer) [the published COLLIDED viewer]; if d < CAMERA_MIN_CHAR_DIST2 t = 1.0 (CAMERA_MIN_CHAR_TRANS_DIST d)/(CAMERA_MIN_CHAR_TRANS_DIST CAMERA_MIN_CHAR_DIST2) clamped to [0,1] SetTranslucencyHierarchical(player, t); else (d 0.45) SetTranslucencyHierarchical(player, 0) guarded by `if (0.0 < t)` (reset-on-transition only behaviorally equivalent to the claim's unconditional phrasing). Constants confirmed at pc:956784-956785: CAMERA_MIN_CHAR_DIST2 = 0.449999988, CAMERA_MIN_CHAR_TRANS_DIST = 0.200000003. Live per-frame caller confirmed: gmSmartBoxUI::UseTime (Ghidra xref from 004d74b9; pc:219786). SetTranslucencyHierarchical @ 0x005116c0 (Ghidra decompile) writes this->translucency, calls CPartArray::SetTranslucencyInternal, recurses CHILDLIST children — so retail's fade reaches the player's drawable part hierarchy. ACDREAM verified: RetailChaseCamera.cs:207-208 computes PlayerTranslucency = ComputeTranslucency(Distance(publishedEye, pivotWorld)) from the collided eye; ComputeTranslucency :454-463 matches retail exactly (Far=0.45, Near=0.20, same ramp, 0=opaque/1=invisible convention per :81 doc). Whole-src grep for PlayerTranslucency: ONLY RetailChaseCamera.cs:82 (declaration), :119 (doc), :208 (assignment) — zero consumers. All GameWindow.cs _retailChaseCamera sites (678, 1065-1092, 4921, 6743-6832, 7283-7304, 10442-11682) read ViewerCellId/Position/View or call Update/AdjustDistance/AdjustPitch — none reads PlayerTranslucency. Alternate-mechanism check: TranslucencyKind/SurfOpacity pipeline (GfxObjMesh.cs:201-227, TranslucencyKind.cs:80-121) is static per-SURFACE dat translucency, not per-entity dynamic; grep over src/AcDream.App/Rendering for per-entity/per-instance alpha override → no matches; CameraViewFirstPerson (InputAction.cs:216) has no App-layer consumer that hides the player. Blast radius accurate: DistanceMin = 2f (RetailChaseCamera.cs:85) means only the camera-collision pull-in can bring the eye inside 0.45 m — exactly the #115 cramped-interior scenario. The divergence is real: retail fades the player part hierarchy per frame; acdream computes the identical value and never applies it, leaving the player opaque against the back of the camera. Severity medium is fair (visible artifact class, limited to close-camera interiors).
- blastRadius: In exactly the #115 scenario (cramped interiors, eye pulled within 0.45 m of the head pivot) retail fades the player toward invisible; acdream leaves the player fully opaque, so the camera presses into the back of the player's head/torso and the model fills or clips the view. Contributes to "indoor world feels right" and the cramped-interior feel complaint; also makes the (correct) aggressive collision pull-in look worse than retail's.
- retailEvidence: CameraSet::UpdateCamera (Ghidra 0x00458ae0; pc:97625-97745): d = Position::distance(pivot, &sbox->viewer) at 0x00458beb (collided published viewer); d ≥ 0.449999988 → SetTranslucencyHierarchical(player, 0f) (0x00458ca1); else t = 1 (0.200000003 d)/(0.2 0.45) clamped (0x00458c19-0x00458c53) → CPhysicsObj::SetTranslucencyHierarchical(player, t) (0x00458c6d); InHead → t = 1f (0x00458bb8). Applied to the player part hierarchy every frame.
- acdreamEvidence: RetailChaseCamera.cs:207-208 computes PlayerTranslucency via ComputeTranslucency (:454-463, thresholds and formula match retail exactly, from the collided eye — correct). Grep over src/: the only references are RetailChaseCamera.cs:82 (declaration, doc says "Read by GameWindow"), :119, :208 — no GameWindow or renderer site consumes it; no per-entity translucency override reaches the player's draw.
- portShape: Wire the existing value through: per frame in GameWindow's camera block, push _retailChaseCamera.PlayerTranslucency into the local player entity's render path as an alpha/translucency override on its batches (the surface-metadata table already carries per-batch translucency for the two-pass alpha-test pipeline; a per-INSTANCE override needs the reserved InstanceData highlight/translucency hook or a per-entity skip-draw at t ≥ ~1). Smallest faithful first step: skip drawing the player when t == 1 and treat 0 < t < 1 via the transparent pass.
### [LOW] sought-position-lacks-cell-identity (confirmed) — The sought eye is a bare world-space Vector3; retail's viewer_sought_position is a cell-qualified Position
- verifier notes: RETAIL side — re-derived entirely from Ghidra decompiles (BN pseudo-C used only for navigation):
1. Struct claim checks out: acclient.h:35193 `Position viewer`, :35194 `CObjCell *viewer_cell`, :35196 `Position viewer_sought_position` — the sought eye IS a cell-qualified Position in SmartBox.
2. SmartBox::update_viewer (Ghidra 0x00453ce0): at function entry `local_120.objcell_id = (this->viewer_sought_position).objcell_id` + frame copy — local_120 is initialized from the sought INCLUDING its cell. After `CTransition::find_valid_position` fails, fallback-1 is `CPhysicsObj::AdjustPosition(&local_120, &viewer_sphere.center, &local_170, 0, 1)`; on success `set_viewer(this, &local_120, 0); this->viewer_cell = local_170`. Exactly as claimed. (One wrinkle the claim omits, not load-bearing: before the sweep, `Position::localtoglobal(&local_d8, &local_12c, &local_120)` re-expresses local_120's ORIGIN into the start cell's landblock frame while keeping the sought objcell_id — a no-op in the same landblock; the cell-context claim is unaffected.)
3. The sought really does carry the published viewer's cell: SmartBox::PlayerPhysicsUpdatedCallback (Ghidra 0x00452d60; raw disasm shows `CALL 0x00456660` at 0x00452d75, input `LEA EAX,[ESI+0x8]` = &this->viewer, result objcell_id `[EAX+4]` stored to `[ESI+0x5c]` = viewer_sought_position.objcell_id) does `viewer_sought_position = CameraManager::UpdateCamera(camera_manager, &local_48, &this->viewer)`. CameraManager::UpdateCamera (Ghidra 0x00456660) tail: `local_158.objcell_id = param_1->objcell_id` before interpolate_origin/interpolate_rotation; ALL three return paths (interpolated, convergence-snap `Position::Position(ret, param_1)`, degenerate) copy param_1's (published viewer's) objcell_id into the returned Position. The claimed citation is exact.
4. The cell context is genuinely load-bearing inside retail AdjustPosition (Ghidra 0x00511d80): `(objcell_id & 0xffff) < 0x100` selects the branch; indoor branch searches the SEED cell's stab list (`CEnvCell::find_visible_child_cell` on `CObjCell::GetVisible(objcell_id)`); outdoor branch interprets the (landblock-relative) origin in the seed's landblock via `LandDefs::adjust_to_outside`. So sought-cell vs player-cell context can change the fallback's answer.
ACDREAM side — all citations check out: RetailChaseCamera.cs:104 `private Vector3 _dampedEye;` (bare Vector3, no cell; the class's only cell state is ViewerCellId, the PUBLISHED viewer cell, which is never fed back into the sweep). PhysicsCameraCollisionProbe.cs:94-99: fallback-1 calls `_physics.AdjustPosition(cellId, desiredEye)` where `cellId` is the SweepEye parameter; the comment at :96-97 self-documents the substitution. Production call chain confirms `cellId` = the PLAYER's cell: GameWindow.cs:6837 passes `cellId: _playerController.CellId` → RetailChaseCamera.cs:195 forwards it to SweepEye. The probe is stateless — nothing elsewhere supplies the sought eye's own cell.
Divergence is REAL, not behaviorally equivalent: when the sweep fails with the eye in a cell different from the player's (camera trailing through a doorway / across a seam), retail seeds the re-seat with the eye's own cell (outdoor eye → adjust_to_outside succeeds; indoor eye in cell A → A's own stab list), while acdream seeds with the player's cell — and acdream's indoor branch returns (seed, false) outright when the player cell's stab-list miss combines with !SeenOutside (PhysicsEngine.cs:549-551), dropping to fallback-2 (snap viewer to player, viewer cell 0) where retail would have kept the sought eye → one-frame render-root blip, exactly the claimed blast radius. Partial mitigation narrows but doesn't erase it: acdream's AdjustPosition takes a WORLD point and its outdoor path scans all loaded landblocks by the point (PhysicsEngine.cs:557-566), so the retail outdoor landblock-frame dependence is moot by construction, and SeenOutside indoor seeds fall through to the correct outdoor answer. Severity "low" / fallback-only is accurate, and the "becomes load-bearing once boom-no-collided-feedback is ported" rider is supported by the verified sought-derivation chain (0x00452d60 → 0x00456660). Port shape as claimed is consistent with the verified retail flow.
- blastRadius: Fallback paths only: when the sweep fails outright, retail re-seats the sphere at the sought position using the sought position's OWN objcell_id as context; acdream's probe seeds AdjustPosition with the player cell instead (self-documented at PhysicsCameraCollisionProbe.cs:96-97). Wrong cell context across a landblock/indoor seam could pick a wrong fallback cell for one frame (render-root blip). Becomes load-bearing once boom-no-collided-feedback is ported, since the sought state then persists across frames and carries the published viewer's cell.
- retailEvidence: SmartBox::update_viewer (Ghidra 0x00453ce0): local_120 is initialized from viewer_sought_position with its objcell_id and used as the fallback AdjustPosition input; CameraManager::UpdateCamera returns a Position whose objcell_id = param_1->objcell_id (published viewer's cell, Ghidra 0x00456660 tail local_158.objcell_id assignment); acclient.h SmartBox holds viewer / viewer_sought_position as Position (cell + frame) and viewer_cell as CObjCell*.
- acdreamEvidence: RetailChaseCamera.cs:104 `_dampedEye` is a Vector3 with no cell; PhysicsCameraCollisionProbe.cs:94-99 fallback 1 seeds AdjustPosition with `cellId` (the player's cell) and the comment admits "acdream's camera doesn't track the sought-eye's cell separately".
- portShape: Falls out of the boom-no-collided-feedback port for free: once the published viewer (cell + position) is the persistent state and the sought is derived from it each frame, carry the published cell with the sought eye and pass it as the fallback-1 AdjustPosition context.
### [LOW] camera-input-scalars-unverified (adjusted) — Mouse-orbit and held-key input scalars are invented constants, not retail's
- correctedClaim: Mouse-orbit and held-key camera scalars in acdream are partly invented and the integration shape diverges from retail — but one claimed-uncited constant is actually retail-correct, and the retail formula needed two corrections. Retail (Ghidra-verified): CameraSet::Rotate @ 0x00458310 computes value = m_rCameraAdjustmentSpeed × (cur_time m_ttLastRotate), REPLACED (not multiplied) by the caller's scale when scale ≠ 1.0, then rotates viewer_offset around Z by value × angle, where angle is a global = π/(180/8) ≈ 0.13963 rad (8°), data @ 0x0083d034, init $E123 @ 0x006eabf0; gate is F_EPSILON = 0.000199999995 s; mouse-look (MouseLookHandler, call @ 0x00458ef9) passes scale = FilterMouseInput(delta) × ICIDM[0x20] × 1/15 after a 5-sample debounce. m_rCameraAdjustmentSpeed = 40.0 IS extracted (CameraManager ctor, 0x0045710a / pc:95986) — acdream's 40.0 (CameraDiagnostics.cs:78) is retail-correct and only missing a citation, as are its 0.45 stiffness defaults. The REAL divergences: (1) acdream's RMB-orbit scalars 0.004 yaw / 0.003 pitch per count (GameWindow.cs:1091-1092) are invented; retail's per-count yaw = filtered × sensitivity × (1/15) × 0.13963 rad via offset rotation. (2) acdream's held-key pitch ×0.02 (GameWindow.cs:6751-6753) is invented; retail Raise @ 0x00457b00 uses ×0.13963 rad (≈7× faster, modulo untraced caller scale). (3) acdream's held-key zoom is additive meters at 40 m/s clamped 2-40 (GameWindow.cs:6746-6749 + RetailChaseCamera.cs:216-217); retail Closer @ 0x004586d0 is a multiplicative shrink viewer_offset ×= (1 40·dt·0.2) (CAMERA_MOUSELOOK_INC = 0.2 @ 0x0079bc04) — a structural shape difference, not just a scalar. Severity remains low (feel-polish, #115 class). Port shape: replace the 0.004/0.003/0.02 scalars and the additive zoom with retail's value×angle offset-rotation/shrink forms; extract ICIDM[0x20]'s default before finalizing the mouse path.
- verifier notes: RE-CHECKED RETAIL (Ghidra decompile + disassembly, not BN-only):
(1) CameraSet::Rotate @ 0x00458310 — confirmed: F_EPSILON minimum-elapsed gate (the claim's "0.0002 s" is exactly retail F_EPSILON = 0.000199999995, the same constant acdream already cites at src/AcDream.Core/Physics/CellTransit.cs:36); m_ttLastRotate seeded to cur_time 1/SceneTool::m_FramesPerSecond when zero; rotation applied as a sin/cos Z-rotation of cm->viewer_offset (fsin/fcos at 0x004585cb-0x004585cf left branch, 0x00458609-0x0045860d right branch — the claimed 0x00458609-0x00458629 range is the right-branch math). TWO FORMULA CORRECTIONS: (a) param_3 (scale) REPLACES the speed×elapsed product when ≠ 1.0 (`if (param_3 != 1.0) fVar8 = param_3`), it is NOT a multiplicative term as claimed; (b) the resulting value is multiplied by a global `angle` the claim missed — instruction-level verified: static initializer $E123 @ 0x006eabf0 does FSTP [0x0083d034] with value π/(180/8) = 8° in radians ≈ 0.13963 (pc:763151), and Rotate FMULs the SAME address 0x0083d034 (0x004585bd, 0x004585fb).
(2) Mouse-look caller — confirmed: CameraSet::MouseLookHandler (call site 0x00458ef9) passes |FilterMouseInput(raw) × ICIDM[0x20] × 0x3d888889(=1/15)| as Rotate's scale, gated by a 5-sample mouselook_x_extent debounce. So retail per-count yaw = filtered × sensitivity × (1/15) × 0.13963 rad.
(3) m_rCameraAdjustmentSpeed — the claim said "not extracted"; NOW EXTRACTED: CameraManager::CameraManager (Ghidra decompile containing 0x0045710a; pc:95986) sets m_rCameraAdjustmentSpeed = 40.0, and pc:96077 registers it as console var "Camera_AdjustmentSpeed". So acdream's CameraDiagnostics.CameraAdjustmentSpeed = 40.0f is RETAIL-CORRECT, merely uncited — the "invented constant" framing is refuted for this one. (Bonus: same ctor sets t_stiffness = r_stiffness = 0.45, so acdream's 0.45 stiffness defaults at CameraDiagnostics.cs:56/63 are retail-correct too.)
(4) Held-key paths — CameraSet::Raise @ 0x00457b00 (Ghidra): pitch delta = (elapsed × 40) × angle(0.13963) rad, with an extra ×0.25 when ICIDM[0x22].field_0x1 is set; CameraSet::Closer @ 0x004586d0 (Ghidra): zoom is MULTIPLICATIVE — viewer_offset *= (1 elapsed×40×CAMERA_MOUSELOOK_INC), CAMERA_MOUSELOOK_INC = 0.2 (static const @ 0x0079bc04, pc:956772), floored at CAMERA_MIN_CHAR_DIST.
RE-CHECKED ACDREAM: all three citations accurate. GameWindow.cs:1086-1098 — RMB orbit: YawOffset = filteredDx × 0.004f × sens (line 1091) and AdjustPitch(filteredDy × 0.003f × sens) (line 1092); both scalars uncited. GameWindow.cs:6745-6753 — adj = CameraAdjustmentSpeed × dt; CameraZoomIn/Out → AdjustDistance(±adj) which per RetailChaseCamera.cs:216-217 is ADDITIVE METERS clamped 2..40 (i.e. 40 m/s linear); CameraRaise/Lower → AdjustPitch(±adj × 0.02f) = 0.8 rad/s, uncited. CameraDiagnostics.cs:73-78 — "Retail default 40.0" comment with no decomp citation (value now proven right). Acdream's FilterMouseDelta (RetailChaseCamera.cs:231-244) faithfully mirrors retail FilterMouseInput, so the divergence is confined to the post-filter scalars and integration shape.
JUDGMENT: the core divergence is REAL — acdream's 0.004 (yaw/count), 0.003 (pitch/count), and 0.02 (held-key pitch) scalars have no retail provenance, and the held-key shapes diverge structurally: retail pitch rate ≈ 40 × 0.13963 ≈ 5.6 rad/s vs acdream 0.8 rad/s (~7× slower, modulo unverified caller scale params), and retail zoom is an exponential offset shrink vs acdream's linear m/s — both squarely in the #115 "feel" class. Severity low stands (feel-polish only, no rendering/correctness impact). OPEN QUESTIONS: (a) what scale param retail's per-frame held-key callers (UpdateCamera @ 0x00458b27 / OnAction @ 0x0045603b) actually pass — 1.0 assumed, not traced; (b) ICIDM[0x20] default mouse-look sensitivity value not extracted, so an exact retail-equivalent per-count yaw constant cannot be computed yet; (c) semantic of ICIDM[0x22] flag (routes rotate to character-turn motion commands 0x6500000d/0x6500000e and ×0.25 on raise) not pinned.
- blastRadius: Feel-polish only: turn-rate of the RMB orbit and zoom/raise key speed may differ from retail, compounding the #115 "draggy" perception even after the boom fix. Not a correctness or rendering issue.
- retailEvidence: CameraSet::Rotate (Ghidra 0x00458310; pc:97103-97230): rotation angle = cm->m_rCameraAdjustmentSpeed × (Timer::cur_time m_ttLastRotate) × scale, applied as a Z-rotation of viewer_offset (sin/cos at 0x00458609-0x00458629), with a 0.0002 s minimum-elapsed gate and m_ttLastRotate seeded to cur_time 1/FPS; mouse-look callers pass a delta-derived scale (0x00458ef9). The retail value of m_rCameraAdjustmentSpeed and the mouse-delta→scale mapping were not extracted in this sweep.
- acdreamEvidence: GameWindow.cs:1089-1091 — YawOffset = filteredDx × 0.004 × sens (0.004 is uncited); GameWindow.cs:6745-6753 — Distance/Pitch integrate CameraAdjustmentSpeed·dt with an uncited ×0.02 pitch scalar; CameraDiagnostics.cs:78 claims "Retail default 40.0" for CameraAdjustmentSpeed without a decomp citation.
- portShape: Extract cm->m_rCameraAdjustmentSpeed's initialization (CameraManager constructor at 0x004570b1 region / config read) and the mouse-look caller at 0x00458ef9; replace the 0.004 / 0.02 / 40.0 constants with the retail values and route mouse-look through the same offset-rotation shape (rotate viewer_offset by speed×elapsed×scale) instead of direct YawOffset integration.
## OPEN QUESTIONS
- Why acdream's earlier feedback experiment oscillated (the warning at RetailChaseCamera.cs:183-187 says writing the clamped result into _dampedEye caused visible vibration against walls). Retail's exact shape — damp FROM the published viewer in the physics phase, sweep pivot→sought in the draw phase, publish raw — is a stable fixed point on paper (the sweep restarts at the pivot every frame), so the historical vibration likely came from a different feedback wiring (e.g., lerping the clamped eye toward the full target inside the same call, or the InitPath sphere-center Z-offset interacting with the clamp). The port should reproduce retail's ordering exactly and re-run the cramped-interior visual gate rather than assume the old failure generalizes.
- Retail's mouse-look delta→CameraSet::Rotate scale mapping (callers around 0x00458ef9) and the initialization value of CameraManager::m_rCameraAdjustmentSpeed were not extracted; acdream's 0.004·sens yaw scalar, ×0.02 pitch scalar, and CameraAdjustmentSpeed=40.0 are therefore unverified against retail.
- Ghidra labels the function containing the DrawNoBlit call at 0x0045557a as 'Draw' while the BN pseudo-C listing shows that address at the tail of the block starting with SmartBox::UseTime (0x00455410) — the two tools disagree on the function boundary. Either way both run once per frame with CPhysics::UseTime (the damping callback) executing BEFORE DrawNoBlit (the sweep), which is the ordering that matters for the port.
- Retail's default camera pivot: update_viewer composes the pivot from camera_manager->pivot_part_index's part frame plus camera_manager->pivot_offset (Ghidra 0x00453ce0, LAB_00453da5 region); acdream hardcodes pivot = player position + 1.5 m (RetailChaseCamera.cs:71,151). Whether retail's default pivot_offset is exactly (0,0,1.5) with pivot_part_index = -1 for players was not verified in this sweep.
- Whether the hypothesized #109 link (1-frame eye snap-out flipping ViewerCellId across portal planes → render-root oscillation at far doors) actually matches the #109 reproduction — needs a capture correlating the eye-position delta per frame with the ViewerCellId flip sequence before crediting the boom fix with any #109 improvement.

View file

@ -0,0 +1,76 @@
# 2.2 — Lighting discipline for buildings/interiors (sun gating, per-cell lights, fog, shells, dynamic objects)
## RETAIL
RETAIL LIGHTING — data structures first. A light source in retail is a LIGHTINFO: { type (0=point, 1=directional), Frame offset, viewerspace_location, RGBColor color, intensity, falloff (= hard range in meters), cone_angle } (acclient.h:31688-31697). Lights do NOT live in the EnvCell dat: CEnvCell::UnPack (Ghidra 0x0052d470) reads flags/surfaces/portals/stab-list/static-objects/restriction and reads NO lights; its `RGBColor *light_array` field (acclient.h:32084) is allocated at load as `new RGBColor[structure->vertex_array.num_vertices]` — one color PER VERTEX, computed, not dat-sourced (Ghidra 0x0052d470 end). The actual light sources come from SETUP files: CSetup has `num_lights` + `LIGHTINFO *lights` (acclient.h:31137-31138) — torches, braziers, lamps are Setup objects whose dat carries the lights. At object init, CPartArray::InitLights (pc:287036 @0x518c00) wraps them in a LIGHTLIST and CPartArray::AddLightsToCell (pc:285959 @0x517ea0) registers each LIGHTOBJ {lightinfo, Frame global_offset, int state} (acclient.h ~31265) into the owning CELL's light_list via CObjCell::add_light (pc:308535 @0x52b1d0). So lights are cell-resident.
Per-frame global list: each cell pushes its lights into a global, distance-sorted pool — CObjCell::add_static_to_global_lights (pc:308636 @0x52b350; LIGHTOBJ.state&1 → Render::add_static_light(lightinfo, cell m_DID.id, frame)) and add_dynamic_to_global_lights (pc:308656 @0x52b390). Render::add_static_light/add_dynamic_light (pc:343907/343915 @0x54d3e0/0x54d420) call insert_light which keeps `world_lights.sorted_static_lights` / `sorted_dynamic_lights` sorted by distancesq; capacity defaults: max_static_lights=40 (pc:1101871 @0x81ec94), max_dynamic_lights=7 (pc:1101872 @0x81ec98). Each pooled entry is a RenderLight {_D3DLIGHT9, d3dLightIndex, cellID, LIGHTINFO info, distancesq} (acclient.h:38944-38951). The pool is rebuilt around the player: CellManager::ChangePosition (pc:94660-94670 @0x455a98) zeroes num_static/num_dynamic on every player cell change, and SmartBox::set_viewer calls CObjCell::add_dynamic_lights() each frame (pc:91828 @0x452d30).
Per-DRAW selection — the heart of the discipline. The hardware has 8 fixed-function light slots (Render::curLightUsage[0..7], reset_active_lights_state pc:342626 @0x54be00). Three selectors: (a) Render::useSunlightSet(1) (pc:343923 @0x54d450) makes the SUN the sole active light (special index 0xffffffff, lightClass 0); the sun's D3D light is rebuilt lazily in PrimD3DRender::config_hardware_light (@0x59ad30; rebuild at pc:424103-424120 @0x59b4ed: Diffuse = sunlight_color × |sunlight vector|, Direction = sunlight, gated by m_bSunlightValid). (b) Render::minimize_object_lighting (pc:343939 @0x54d480) — per OBJECT: fills ≤8 slots with dynamic lights first (filtered by remove_object_light's sphere test: light falloff sphere vs the object's bounding sphere, pc:342820 @0x54c1b0), then static lights whose falloff sphere intersects the object (local_object_center/radius test pc:343985-344000). (c) Render::minimize_envcell_lighting (pc:342794 @0x54c170) — per CELL: enables ALL dynamic lights only (statics excluded — see burn-in below). enable_active_lights (pc:342746 @0x54c080) then issues device SetFFLight/SetFFLightEnable per slot.
Q1 — how interior geometry is lit: RenderDeviceD3D::DrawEnvCell (pc:427877-427910 @0x59f170) calls minimize_envcell_lighting(cell pos, drawing-BSP sphere radius) then, on the built-mesh path (use_built_mesh=1 at runtime, set in UnPack), calls D3DPolyRender::SetStaticLightingVertexColors(constructed_mesh, &cell->pos) (pc:425771-425935 @0x59cfe0). That function LOCKS the cell's vertex buffer and, for EVERY vertex, accumulates the contribution of EVERY static light in world_lights.sorted_static_lights (no 8-light cap!) — each light converted into cell-local space (LIGHTINFO::convert_to_local) and evaluated as a point light (calc_point_light) or directional, clamped to [0,1] per channel, and WRITTEN INTO THE VERTEX DIFFUSE COLORS. It is cached via MeshBuffer.burnedInStaticLights (pc:425933 @0x59d2ca) and re-burned only when num_static_lights changes. At draw, D3DPolyRender::DrawMesh switches the fixed-function ambient source to FromVertex for burned meshes (pc:425691 @0x59cea2) vs FromMaterial (pc:425535 @0x59cbc4) — i.e. the burned per-vertex static lighting acts as the ambient term, with the ≤7 dynamic FF lights added in hardware on top. So: interior static lighting = CPU per-vertex burn-in of ALL static lights; dynamic = real-time FF lights; sun = NEVER (below).
Q2 — sun/ambient gating, two independent gates. GATE A (per-draw-class, every frame): PView::DrawCells (@0x5a4840, Ghidra-verified decompile) — if the flood reached outside (outside_view.view_count != 0): useSunlightSet(1) → LScape::draw (landscape+buildings through the portals, sun ON); then unconditionally useSunlightSet(0) + restore_all_lighting; loop DrawEnvCell (interior geometry, sun OFF); loop DrawObjCellForDummies (objects per cell, sun OFF → each object goes through minimize_object_lighting per DrawMeshInternal pc:427983 @0x59f398 `if (useSunlight == 0) minimize_object_lighting()`); finally useSunlightSet(1) restore. SmartBox::RenderNormalMode (pc:92635-92686 @0x453aa0): outdoor viewer → set_default_view + useSunlightSet(1) + LScape::draw; indoor viewer → DrawInside(viewer_cell) with no sun-set (interiors get the DrawCells discipline). NET: interior cell geometry and interior objects are NEVER sun-lit, even when the player stands outside looking in; landscape seen through a doorway from inside IS sun-lit, in the same frame. GATE B (per player-cell change): CellManager::ChangePosition (pc:94600-94720 @0x4559b0) — if the new player cell is outdoor OR seen_outside: copy LScape::sunlight(+color) into world_lights and SetWorldAmbientLight(LScape::calc_object_light(), LScape::ambient_color) where calc_object_light = sqrt(|sunlight|)×0.2 + region ambient_level (pc:94400-94406 @0x455730); if the cell is SEALED (seen_outside==0): SetWorldAmbientLight(0.2f, 0xffffffff) — flat 0.2 white ambient (pc:94711 @0x455af4). SetWorldAmbientLight stores ambient_color = color×level into world_lights (pc:91995-92011 @0x4530a0).
Q3 — fog indoors: distance fog is a single global fixed-function state (GraphicsStatesType.DistanceFogColor/Near/Far, acclient.h:38975-38990). Parameters come from the region + weather in LScape::UseTime (@0x505880; override fog color/levels pc:267378-267447) and the enable is the player option (PlayerModule::DisableDistanceFog → LScape::m_fFogEnabled pc:423368-423372 @0x59a6c2; SetFFFogEnable pc:267436 @0x505ada). NOTHING in the interior path toggles fog — the Ghidra decompiles of DrawCells/DrawEnvCell/DrawInside contain no fog calls — so the SAME outdoor distance fog applies to interior fragments; interiors are simply too close to the camera for it to matter. One per-surface exception: surface-type bit 0x10000 disables fog-alpha for that surface (pc:425295 @0x59c882).
Q4 — building shells from outside: drawn during the sun-ON pass — RenderDeviceD3D::DrawBuilding (pc:427930-427960 @0x59f2a0) → CPhysicsPart::Draw → DrawMeshInternal with useSunlight==1, so minimize_object_lighting is SKIPPED and the shell is lit by the directional sun FF light + the global ambient. No baked lighting on shells. Additionally every surface's diffuse scalar is tinted by the sun color when the sun is on: Render::diffuse = surface.diffuse × sunlight_color, and Render::luminosity = surface.luminosity (emissive) — pc:425150-425175 @0x59c64c-0x59c699. (Terrain, for contrast, IS precomputed per-vertex: CLandBlockStruct::calc_lighting @0x531700, pc:315648+, sun+ambient.) Day/night on shells = the sun light's diffuse magnitude (|sunlight| changes over the day) + ambient.
Q5 — dynamic objects inside: drawn via DrawObjCellForDummies inside DrawCells (sun OFF) → per-part DrawMeshInternal → minimize_object_lighting: up to 8 FF lights chosen vs the OBJECT's bounding sphere — all reaching dynamic lights first, then reaching static (cell torch) lights. Plus the global flat/region ambient from Gate B. Retail ALSO adds a per-frame "viewer light": SmartBox::set_viewer (pc:91781-91835 @0x452c40) adds a white point light (init pc:93342-93356 @0x4547e8: type=point, color 0xffffffff, cone 360; falloff default 10 m pc:1088428 @0x818610; intensity from the registry option "SmartBox.ViewerLightIntensity", set to 0.5×4.5=2.25 at pc:761995 @0x6e9a7c) positioned 2 m above the player, registered as a dynamic light EVERY frame — the classic "personal light bubble" that keeps dungeons readable.
## ACDREAM
ACDREAM LIGHTING — one global 576-byte UBO per frame, one lighting state for everything. Data: LightSource {Kind, WorldPosition, ColorLinear, Intensity, Range (hard cutoff), ConeAngle, OwnerId, IsLit} (src/AcDream.Core/Lighting/LightSource.cs:39-53). LightManager (src/AcDream.Core/Lighting/LightManager.cs:37-137) is a flat global registry — NO cell association — whose Tick(viewerWorldPos) filters point/spot lights by Range²×1.1², sorts by distance-to-VIEWER, reserves slot 0 for the Sun, and takes the next 7. SceneLightingUbo.Build (src/AcDream.Core/Lighting/SceneLightingUbo.cs:103-134) packs those ≤8 lights + CellAmbient + FogParams/FogColor + camera into a std140 block uploaded once per frame by SceneLightingUboBinding at binding=1 (src/AcDream.App/Rendering/SceneLightingUboBinding.cs:23-59; upload at GameWindow.cs:7378).
Light SOURCES are read from the dat correctly in shape: LightInfoLoader.Load (src/AcDream.Core/Lighting/LightInfoLoader.cs:35-91) converts Setup.Lights (LightInfo: color/intensity/falloff/cone) into LightSources at the entity root (no per-part chain yet). Registration happens once per landblock load in GameWindow.ApplyLoadedTerrainLocked over lb.Entities (GameWindow.cs:6082-6108) — and the merged entity list includes interior EnvCell statics (merged.AddRange(BuildInteriorEntitiesForStreaming) at GameWindow.cs:5272-5275), so inn torches/fireplaces DO register. LightingHookSink (src/AcDream.Core/Lighting/LightingHookSink.cs:41-77) tracks owner→lights and flips IsLit on SetLightHook. Server-spawned entities (EntitySpawnAdapter path) and held items never register lights — LightInfoLoader has exactly one call site (GameWindow.cs:6099).
Indoor/outdoor switch (the GameWindow write site): the lighting root is the PLAYER cell (GameWindow.cs:7291-7296 — playerRoot from CellGraph.CurrCell, playerSeenOutside = playerRoot?.SeenOutside ?? true); playerInsideCell = playerRoot != null && !playerSeenOutside (GameWindow.cs:7337). UpdateSunFromSky(kf, playerInsideCell) (GameWindow.cs:9741-9785): if playerInsideCell — Sun zeroed (ColorLinear=0, Intensity=0) and CurrentAmbient = flat (0.2, 0.2, 0.2) (GameWindow.cs:9752-9763, explicitly citing retail ChangePosition @0x4559B0); else — Sun = sky-keyframe SunColor and CurrentAmbient = kf.AmbientColor (GameWindow.cs:9772-9783). Then Lighting.Tick(camPos) (GameWindow.cs:7353) and the single UBO build/upload (7354-7378). FogParams are overridden with streaming-radius-derived distances (N₁×192×0.7 / N₂×192×0.95, GameWindow.cs:7364-7376, deliberate A.5 T22).
Shading: ONE lighting model for every mesh — building shells, interior cell geometry, statics, NPCs, the player — all through mesh_modern.frag's accumulateLights (src/AcDream.App/Rendering/Shaders/mesh_modern.frag:26-63): lit = uCellAmbient + Σ active lights (directional N·L for kind 0 — the sun; hard-range point/spot otherwise), clamp 1.0, × texture, then distance fog (65-74, 109-120). The EnvCellRenderer (interior cell geometry) explicitly SHARES this shader (src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:50-55, wired at GameWindow.cs:1835-1841), and neither RetailPViewRenderer.cs nor InteriorRenderer.cs touches any lighting state (grep: zero light/ambient/sun references). Terrain uses per-vertex sun N·L + ambient from the same UBO (terrain_modern.vert:146-149). Surface Luminosity/Diffuse scalars are extracted into AcSurfaceMetadata (src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:277) but consumed ONLY by SkyRenderer (src/AcDream.App/Rendering/Sky/SkyRenderer.cs:264, 339-340) — the world mesh path ignores them. There is no per-vertex static burn-in, no per-cell or per-object light selection, no viewer light, and no per-draw sun gating: whatever the player-cell switch decided applies to every fragment drawn that frame.
## DIVERGENCES
### [CRITICAL] interior-sun-bleed (UNVERIFIED (verifier hit token limit)) — Interior cell geometry is sun-lit whenever the player-cell gate says 'outside' — retail NEVER sun-lights interiors
- blastRadius: The single biggest 'indoor world feels right' (M1.5 mandate) lighting gap. In acdream, standing outdoors or inside any seen_outside interior (ALL surface-building interiors: Holtburg inn, cottages), interior walls/floors receive full directional sun N·L through the roof — sun-facing interior walls bright, others dark, and the whole interior re-shades as the day passes. It also flattens the retail outside-looking-in depth cue (interiors should read distinctly darker/flatter than the sun-lit exterior through a doorway — feeds the #114 doorway-quality complaint). In retail the same wall is lit only by burned-in torch light + ambient, regardless of where the player stands.
- retailEvidence: PView::DrawCells @0x5a4840 (Ghidra-verified): useSunlightSet(1) ONLY around LScape::draw for the outside view, then unconditionally useSunlightSet(0) before the DrawEnvCell loop and the DrawObjCellForDummies loop, useSunlightSet(1) restored at the end — interior geometry and interior objects are never drawn with the sun active, even in the same frame where the outdoors is. SmartBox::RenderNormalMode pc:92635-92686 @0x453aa0 shows the outdoor branch is the only place sun is set ON at top level. The player-cell switch (CellManager::ChangePosition pc:94600-94720 @0x4559b0) gates only the AMBIENT level and sun VALIDITY, not whether interiors receive sun — that is gated per draw-class by Render::useSunlight (DrawMeshInternal pc:427983 @0x59f398).
- acdreamEvidence: One global UBO per frame: UpdateSunFromSky keys the sun on playerInsideCell = player cell !seenOutside (GameWindow.cs:7337, 9741-9785) — seen_outside interiors keep FULL sun (comment at GameWindow.cs:7334 says so explicitly). EnvCellRenderer shares mesh_modern.frag (EnvCellRenderer.cs:50-55, GameWindow.cs:1835-1841) whose accumulateLights applies the slot-0 directional sun to every fragment (mesh_modern.frag:34-44). No per-pass lighting state exists (RetailPViewRenderer.cs / InteriorRenderer.cs: zero lighting references).
- portShape: Split the lighting state per draw-class, not per frame — the modern equivalent of useSunlightSet. Keep ONE UBO but upload it (or a second UBO/uniform toggle) twice per frame: outdoor pass = sun + outdoor ambient; interior pass (EnvCellRenderer + interior-partition entities) = sun slot zeroed + Gate-B ambient (outdoor-formula ambient for seen_outside player cells, 0.2 flat for sealed). The flood already knows which draws are interior (EnvCellRenderer + InteriorEntityPartition), so the seam is small: re-upload the 576-byte UBO before/after the interior block in the frame, exactly mirroring DrawCells' useSunlightSet(1)→LScape / useSunlightSet(0)→cells+objects ordering.
### [HIGH] no-static-light-burnin (UNVERIFIED (verifier hit token limit)) — No per-vertex static-light burn-in for interior cells — interiors capped at the 8 viewer-nearest lights instead of ALL static lights
- blastRadius: Interior light quality/coverage: retail interiors accumulate EVERY reaching static light (torches, fireplaces, lamps — no 8-light cap) into vertex colors, so a many-torch inn is evenly lit. acdream funnels ALL lighting through 8 global slots ranked by distance-to-camera: in a room with >7 point lights some torches simply don't light; lights also pop in/out as the camera moves and re-ranks the global list. Directly the 'interiors look uneven/flat/wrong' component of M1.5.
- retailEvidence: D3DPolyRender::SetStaticLightingVertexColors pc:425771-425935 @0x59cfe0: locks the cell mesh vertex buffer and per-vertex accumulates ALL world_lights.sorted_static_lights (loop bound = num_static_lights, capacity 40 per pc:1101871 @0x81ec94) via LIGHTINFO::convert_to_local + calc_point_light, clamped [0,1], written as vertex diffuse; cached by burnedInStaticLights (pc:425933 @0x59d2ca). Called from RenderDeviceD3D::DrawEnvCell pc:427904 @0x59f1f6. At draw the FF ambient source switches to FromVertex (pc:425691 @0x59cea2). Dynamic lights ride on top via minimize_envcell_lighting (pc:342794 @0x54c170, dynamic-only).
- acdreamEvidence: Interior cells draw with the same global-8 UBO as everything else (EnvCellRenderer.cs:50-55 sharing mesh_modern; accumulateLights mesh_modern.frag:34-63 reads uLights[8] only). LightManager.Tick picks 7 points by distance-to-viewer (LightManager.cs:95-137). No burn-in, no per-cell color buffer anywhere in the pipeline.
- portShape: Port the burn-in: at cell registration (EnvCellRenderer.RegisterCell / mesh build) compute per-vertex RGB from all registered static lights reaching the cell (same point-light falloff math as calc_point_light), store as a per-vertex color attribute on the cell mesh, and have the cell shader use it as the ambient/base term (retail FromVertex). Re-burn when the static light set changes (retail's burnedInStaticLights == num_static_lights cache key; acdream equivalent: a registry generation counter). Dynamic lights stay in the UBO path. This also removes interiors' dependence on the global 8-slot budget.
### [MEDIUM] no-per-object-light-selection (UNVERIFIED (verifier hit token limit)) — Light selection is per-FRAME from the camera, not per-OBJECT against object bounds
- blastRadius: Objects/NPCs away from the camera get the camera's lights, not their own: an NPC at the dark end of a long hall is lit by the torches near the camera (or by none), and lights pop as the camera's nearest-8 ranking churns. Retail picks lights per object via falloff-sphere vs object-bounds tests, so each object is lit by what actually reaches it. Visible in any interior with more than ~7 lights or large rooms; part of the M1.5 feel gap (no numbered issue yet).
- retailEvidence: Render::minimize_object_lighting pc:343939-344012 @0x54d480: per object, ≤8 slots, dynamic lights first (filtered by remove_object_light's sphere-overlap test vs local_object_center/local_object_radius pc:342820 @0x54c1b0), then static lights whose falloff sphere intersects the object (pc:343975-344000). Invoked per mesh draw when sun is off: DrawMeshInternal pc:427983 @0x59f398.
- acdreamEvidence: LightManager.Tick(camPos) runs once per frame from the camera position (GameWindow.cs:7353; LightManager.cs:95-137); every draw call reads the same uLights[8] (mesh_modern.frag:26-32). No per-object or per-cell selection exists.
- portShape: Per-instance light indices: keep the global pool (sorted, ~40 statics + 7 dynamics like retail), and per entity (or per cell for the cell-object partition) select ≤8 reaching lights by the retail sphere tests on the CPU, writing 8 light indices into the per-instance SSBO (InstanceData has a documented extension hook). Shader indexes a larger light array (e.g. 64-entry UBO/SSBO) by those per-instance indices. The burn-in divergence above removes most of the pressure; this one covers objects/NPCs.
### [MEDIUM] no-viewer-light (UNVERIFIED (verifier hit token limit)) — Retail's per-frame viewer light (white point light above the player) is missing
- blastRadius: Dungeon/dark-interior readability: retail always adds a white point light (falloff 10 m, intensity from the 'ViewerLightIntensity' option, ~2.25 at default slider) 2 m above the player, re-registered as a dynamic light every frame — the personal light bubble. acdream sealed interiors get only flat 0.2 ambient + whatever cell torches exist; dark dungeons will read much darker/deader than retail. Directly an 'indoor world feels right' item for dungeon milestones.
- retailEvidence: SmartBox::set_viewer pc:91781-91835 @0x452c40: viewer_light intensity/falloff loaded from SmartBox::s_fViewerLightIntensity/Falloff, offset z=+2 m above the player (pc:91817-91819), Render::add_dynamic_light(&viewer_light, player objcell_id, player frame) + CObjCell::add_dynamic_lights() every frame (pc:91827-91828). Init: type=point, color 0xffffffff, cone 360 (pc:93342-93356 @0x4547e8); falloff default 10 (pc:1088428 @0x818610); intensity set 0.5×4.5=2.25 via the registry option (pc:761995 @0x6e9a7c).
- acdreamEvidence: No viewer/player light anywhere: LightManager has only Sun + registered Setup lights (LightManager.cs:37-137); the only LightSource creation sites are LightInfoLoader.Load (GameWindow.cs:6099) and UpdateSunFromSky's sun (GameWindow.cs:9752, 9772).
- portShape: One LightSource (Point, white, Range 10 m, Intensity 2.25 default — future user option) owned by GameWindow, repositioned to player position +2 m Z every frame before Lighting.Tick, registered as a permanent dynamic light. ~20 lines; pairs naturally with the per-object selection work but is independently shippable.
### [MEDIUM] surface-luminosity-diffuse-ignored (UNVERIFIED (verifier hit token limit)) — Per-surface luminosity (emissive) and diffuse scalars ignored in the world mesh path; sun-tint of surface diffuse missing
- blastRadius: Glowing surfaces — lamp panes, lava, light-fixture textures, glowing runes — render dark indoors because their dat Luminosity never becomes emissive light; interior light FIXTURES look off even where the light they cast is right. Outdoors, retail also tints every surface's diffuse by the current sun color (warm dawn/dusk cast on buildings) which acdream approximates only via the sun light's color in N·L, losing the flat diffuse-scalar modulation.
- retailEvidence: Surface setup @0x59c64c-0x59c699 (pc:425150-425175): Render::luminosity = surface.luminosity (r=g=b, the emissive term), and Render::diffuse = surface.diffuse × sunlight_color when useSunlight==1, else surface.diffuse flat. Same pattern confirmed in the sky path (SkyRenderer.cs:340 cites retail FUN_0059da60 'surface.Luminosity → D3DMATERIAL.Emissive').
- acdreamEvidence: WbMeshAdapter extracts Translucency/Luminosity/Diffuse into AcSurfaceMetadata (src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:277, AcSurfaceMetadata.cs:17) but the only consumer is SkyRenderer (SkyRenderer.cs:264, 339-340); mesh_modern.frag has no luminosity/diffuse-scalar input (whole file — lighting is texture × accumulateLights only).
- portShape: Extend the per-batch SSBO (uvec2 handle, layer, flags) with luminosity+diffuse floats from the already-populated AcSurfaceMetadataTable; in mesh_modern.frag, lit = max(lit, luminosity) or lit += luminosity (match retail: emissive floors the lighting term), and multiply the diffuse scalar (×sun color in the outdoor pass) into the diffuse contribution. Localized to WbDrawDispatcher/EnvCellRenderer batch upload + one shader edit.
### [LOW] dynamic-entity-lights-unregistered (UNVERIFIED (verifier hit token limit)) — Server-spawned and held entities never register Setup lights (and lights don't follow animated parts)
- blastRadius: A server-spawned lamp/brazier weenie casts no light; a player- or NPC-held torch casts no light (and when LightInfoLoader is used, lights sit at the entity root, not the hand part — acknowledged in-code). Limited M1.5 impact since inn/dungeon lighting is mostly dat statics, but it diverges from retail where ANY physics object with Setup lights lights its cell.
- retailEvidence: CPartArray::InitLights pc:287036 @0x518c00 runs for any part array (creatures included — note SmartArray<LIGHTINFO*> creature_mode_lights acclient.h:52564); CPartArray::AddLightsToCell pc:285959 @0x517ea0 registers into whatever cell the object occupies; LIGHTLIST::set_frame pc:285756 @0x517c60 re-frames lights as the object moves, add/remove per cell crossing (pc:285976/286005).
- acdreamEvidence: LightInfoLoader.Load has exactly one call site — the landblock-load loop over lb.Entities (GameWindow.cs:6082-6108); the server-spawn path (EntitySpawnAdapter) and equip/hold paths register nothing. LightInfoLoader.cs:30-33 documents the missing per-part transform.
- portShape: Call LightInfoLoader.Load + LightingHookSink.RegisterOwnedLight at server-entity spawn (the EntitySpawnAdapter / GpuWorldState add sites) and UnregisterOwner on despawn (sink already supports it, LightingHookSink.cs:54-59); reposition owned lights from the entity tick (GetOwnedLights exists for exactly this, LightingHookSink.cs:65-68). Per-part placement waits for the animation part-chain work.
## OPEN QUESTIONS
- Who WRITES CEnvCell::light_array (the per-vertex RGBColor buffer)? Ghidra confirms allocation in CEnvCell::UnPack (0x0052d470: new RGBColor[num_vertices]) and the destructor frees it (pc:311207-311213 @0x52d9e2), but I could not locate the writer/consumer by name — likely the legacy non-built-mesh (immediate poly) path's equivalent of the burn-in. At EoR runtime use_built_mesh=1 (set in UnPack under DBCache::IsRunTime), so SetStaticLightingVertexColors on the constructed mesh is the live path and light_array appears vestigial — but a faithful-port plan should confirm before declaring it dead.
- Exact data flow from world_lights.ambient_color to the D3D ambient render state per draw: SetFFAmbientColor32 is called at 0x0059b165 (pc:423997) inside the PrimD3DRender lighting region with a packed ARGB I did not trace to its source registers; the shape (global ambient applied per draw from world_lights) is solid but the precise call chain (and whether ambient differs between the sun-on and sun-off passes beyond Gate B) is unverified.
- Render::restore_all_lighting (called in DrawCells between the sun-off switch and the DrawEnvCell loop, Ghidra decompile) was not decompiled — assumed to restore the FF slot state cleared by useSunlightSet(0); worth one decompile during the port to make sure it doesn't re-enable lights the port should replicate.
- Default/typical value of SmartBox.ViewerLightIntensity in practice: the static initializer is 0 (pc:1144644 @0x83cc10) and 0x006e9a7c sets 0.5×4.5=2.25 — I did not identify whether that site is the options-default path (always runs) or only when the user touches a slider; affects how bright the ported viewer light should default.
- Whether retail's Gate-B outdoor ambient formula (sqrt(|sunlight|)×0.2 + region ambient_level, × region ambient color) materially differs from acdream's sky-keyframe kf.AmbientColor at representative times of day — acdream's outdoor ambient was visual-verified, but a side-by-side at dawn/noon/midnight would settle whether the formula needs porting for interior seen_outside cells (where it becomes the dominant light after the sun-bleed fix).
- LightSource.cs:58's comment claims 'for indoor cells the EnvCell dat carries a per-cell ambient override (r13 §3)' — CEnvCell::UnPack shows no such field; the r13 research doc appears wrong on this point and the comment should be corrected during the port (no per-cell ambient exists in retail; ambient is the global Gate-B switch).

View file

@ -0,0 +1,51 @@
# 2.6 — Picking and selection vs the building-render discipline
## RETAIL
Retail picking is a DEFERRED, DRAW-RESOLVED hit test: the click arms a global "selection cursor", and the actual hit testing happens INSIDE the very same render traversal that draws the world — same cell flood, same portal views, same viewcone gates. There is no separate ray-through-the-world trace.
CHAIN. (1) Mouse click reaches a UIElement_SmartBoxWrapper handler (0x004e5430, pc:233355-233375): if a 3D-icon UI element is under the mouse it short-circuits via SmartBox::set_found_object; otherwise it calls SmartBox::find_object(x,y) (0x00451C60, Ghidra decompile): stores the click pixel, zeroes click_object_id/click_object_index, validates the pixel is inside the 3D viewport, then calls Render::set_selection_cursor(x-vpX, y-vpY, polys=true) (0x0054B750, Ghidra: check_selection=1, check_curr_object_polys=1, clears m_MouseSelectData.bFoundPolygon/bFoundSphere), sets CPhysicsPart::selected_object_in_view=0 and SmartBox::lookingForObject=1. The accumulator struct Render::MouseSelectData (acclient.h:46610-46620) carries {bFoundPolygon, fClosestPolygon, PolygonID, PolygonIndex, bFoundSphere, fClosestSphere, SphereID, SphereIndex}. (2) During the next world draw, SmartBox::DrawNoBlit (0x00454c20, pc:93700-93740) runs SetNormalMode → update_viewer → RenderNormalMode. Inside Render::update_viewpoint (0x0054cdd0, pc:343689-343696), if check_selection, the click pixel is converted ONCE into a world ray direction: Render::pick_ray (0x0054B610, Ghidra: pixel × xinvscale/yinvscale tx/ty, composed through the camera Xaxis/Yaxis/Zaxis, normalized) → stored in Render::selection_ray. (3) Every object part draw goes through CPhysicsPart::Draw (0x0050d7a0, Ghidra-confirmed): it sets the load-bearing gate Render::check_curr_object = (physobj != null && physobj->id != 0) || CPhysicsPart::creature_mode != 0, sets RenderDeviceD3D::s_current_physics_part = this, and calls the device DrawMesh. CPhysicsPart::creature_mode (static, 0x00843bf4) has NO writer anywhere in the named pseudo-C (the creature_mode written by CreatureMode::Render 0x004529d0 / cleared by SmartBox::SetNormalMode 0x00453120 is a different field, SmartBox's instance field for char-gen rendering) — so in-world the gate is exactly "this part belongs to a CPhysicsObj with a non-zero server object id". (4) RenderDeviceD3D::DrawMesh (0x005a0860, pc:429180-429340) is where pick meets visibility: with no portal list it runs Render::viewconeCheck(gfxobj->drawing_sphere) and only if not OUTSIDE calls Render::GfxObjUnderSelectionRay(gfxobj) (pc:429262, 0x005a09bb) before DrawMeshInternal; with a portal list it loops the per-cell portal views (Render::set_view(view,i) 0x0054d0e0, honoring RenderDeviceD3D::building_view), runs viewconeCheck per view, and calls GfxObjUnderSelectionRay exactly once on the first view that passes (pc:429318-429325, 0x005a08f8-0x005a090c, var_8_1 guard). An object OUTSIDE in every portal view is never selection-tested at all. (5) Render::GfxObjUnderSelectionRay (0x0054c740, Ghidra-confirmed): requires check_selection && check_curr_object && s_current_physics_part != null. It transforms selection_ray + viewpoint into part-local space (Frame::globaltolocalvec, direction scaled by 1/|gfxobj_scale|, ray length = RAY_INFINITE_DISTANCE — no max distance). Coarse test: CSphere::sphere_intersects_ray(gfxobj->drawing_sphere); a sphere hit farther than the globally-closest polygon hit so far is discarded entirely. Otherwise it records the nearest sphere hit (SphereID = CPhysicsPart::get_physobj_id = physobj->id, SphereIndex = physobj_index), then — because check_curr_object_polys — scans the object's FULL FLAT polygon array gfxobj->polygons[0..num_polygons] (stride 0x30; Ghidra-confirmed loop), NOT the DrawingBSP-filtered subset, breaking on the FIRST polygon the ray hits in array order; that hit's distance is then compared against the global fClosestPolygon and recorded if nearer. CPolygon::polygon_hits_ray (0x005395E0, Ghidra: if sides_type==0 (single-sided) and dot(ray.dir, plane.N) > 0 → miss; else Plane::compute_time_of_intersection + point_in_polygon). (6) Harvest, end of the SAME DrawNoBlit frame (pc:93722-93733): Render::GetMouseSelectionObjectID (0x0054C950): bFoundPolygon → PolygonID, else bFoundSphere → SphereID, else 0 — any polygon hit categorically beats any sphere hit. The id lands in SmartBox::click_object_id and fires ECM_UI::SendNotice_SmartBoxObjectFound(id); lookingForObject=0; Render::clear_selection_cursor (0x0054B790).
WHAT IS NOT TESTED. EnvCell structure polys: RenderDeviceD3D::DrawEnvCell (0x0059f170, pc:427898-427935) pushes structure->polygons straight onto Render::PolyList (planeMask=0xffffffff) or D3DPolyRender::DrawMesh for built meshes — s_current_physics_part is never set, GfxObjUnderSelectionRay is never reached: CELL GEOMETRY IS INVISIBLE TO THE PICK. Same for land cells (DrawLandCell 0x0059f120 → ACRender::landPolysDraw). Buildings DO route through CPhysicsPart::Draw (DrawBuilding 0x0059f2a0, pc:429282-429295), BUT CBuildingObj::makeBuilding (0x006b53a0, pc:701302) calls CPhysicsObj::InitObjectBegin(result, 0, 0) and InitObjectBegin (0x0050ff80, pc:278065-278073) does this->id = arg2 → buildings have id 0 → check_curr_object=0 → building shells (including their baked door/window portal quads) are NEVER pick candidates. Dat-baked statics likewise: EnvCell statics CPhysicsObj::makeObject(setupId, 0, 0) pc:309713 (0x0052c3e7), landblock statics pc:314697 (0x00530acb) — object_iid 0.
ANSWERS. Q1: retail hit-tests ONLY CPhysicsObj parts whose physobj->id != 0 (server-identified weenies), per-poly with sphere fallback, at draw time. It never picks against cell geometry; walls never "swallow" a click by being hit — they are invisible to the pick; missing everything yields id 0 (deselect). A baked closed-door/window portal poly on a building shell can NOT swallow a click (id 0 gate). Q2: yes — enforced by the SAME PView, by construction: objects only reach CPhysicsPart::Draw via the flood's drawn cells, and the selection test only fires in portal views where the drawing_sphere passes viewconeCheck (DrawMesh 0x005a0860). Granularity is sphere-vs-portal-view-cone, not per-pixel — an object whose sphere pokes through a doorway view can be picked via a polygon that is pixel-wise clipped; retail accepts that slop. Q3: the door ENTITY (server weenie, id != 0) takes the click categorically; the shell's baked portal quad never competes because the building is not a candidate. The only place portal polys touch picking at all is on id!=0 objects, where the FULL flat poly array is tested whether or not the DrawingBSP drew those polys this frame. Bonus: retail also tracks "is the selected object still visible" through the same draw — SmartBox::set_selected_object_id (0x00451BA0, pc:90768) sets CPhysicsPart::viewcone_check_object_id; CPhysicsPart::Draw flips selected_object_in_view=1 when DrawMesh returns 2 (drawn) for that id (Ghidra); CPlayerSystem::OnObjectRangeExit (0x00560870, pc:365073-365090) deselects via ACCWeenieObject::SetSelectedObject(0,0) when range-exited AND not in view, else re-registers a range handler at 25 m indoors / 75 m outdoors (0x005608a1).
## ACDREAM
acdream picks SYNCHRONOUSLY at input time against a server-entity dictionary, with a screen-rect sphere test plus a parallel ad-hoc ray-vs-cell-polygon occluder; it has zero coupling to the PView flood that decides what draws.
CHAIN. (1) InputAction.SelectLeft / SelectDblLeft → GameWindow.PickAndStoreSelection (src/AcDream.App/Rendering/GameWindow.cs:10414-10419 → 10515). (2) Candidates = _entitiesByServerGuid.Values (GameWindow.cs:10546) — the dict is populated ONLY from server spawns (GameWindow.cs:2855, `_entitiesByServerGuid[spawn.Guid] = entity`), so building shells, dat-baked EnvCell statics (0x40xxxxxx ids), and atlas scenery are structurally absent. WorldPicker additionally skips ServerGuid==0 and the player (src/AcDream.Core/Selection/WorldPicker.cs:253-254) — note retail does NOT skip the player. (3) Test = the screen-rect overload WorldPicker.Pick (WorldPicker.cs:210-285): per candidate, the Setup-level SelectionSphere (resolved by GameWindow.TryGetEntitySelectionSphere, GameWindow.cs:11010-11045: dat Setup.SelectionSphere scaled by entity scale, rotated to world; null → skipped) is projected via ScreenProjection.TryProjectSphereToScreenRect to a screen rect, inflated 8 px to match the target-indicator brackets, hit-tested against the mouse pixel; among containing rects the nearest clip.W depth wins (WorldPicker.cs:261-282). There is NO polygon stage — Stage B (CPolygon::polygon_hits_ray port) is explicitly deferred per issue #71 (WorldPicker.cs:199-204), so retail's "any polygon hit beats any sphere hit" arbitration does not exist. (4) Occlusion (#86, closed 2026-05-19): PickAndStoreSelection snapshots every loaded EnvCell's physics (`_physicsDataCache.CellStructIds`, GameWindow.cs:10535-10540) and passes cellOccluder = CellBspRayOccluder.NearestWallT (GameWindow.cs:10565-10568). NearestWallT (src/AcDream.Core/Selection/CellBspRayOccluder.cs:42-79) Möller-Trumbore-tests the click ray against EVERY polygon in every loaded cell's `Resolved` dict — which is cellStruct.PhysicsPolygons (src/AcDream.Core/Physics/PhysicsDataCache.cs:176,208), the collision set, NOT the drawn set — two-sided, no BSP, no portal awareness; the nearest wall t becomes a clip.W depth and candidates deeper than it are skipped (WorldPicker.cs:229-248, 276). Portal-opening polys live in a separate dict (CellPhysics.PortalPolygons, PhysicsDataCache.cs:178-179, 212, 571) that the occluder does not consult. (5) Visibility coupling: NONE. RetailPViewRenderer.DrawInside produces a per-frame RetailPViewFrameResult with OrderedVisibleCells (src/AcDream.App/Rendering/RetailPViewRenderer.cs:44, 71, 153) but the picker never reads it; an entity in a cell the flood rejected is still a candidate as long as no occluder polygon happens to lie along the ray, and conversely the occluder can block entities the flood draws. (6) Selected-object tracking: _selectedGuid persists until the next pick; TargetIndicatorPanel projects the same SelectionSphere every frame (GameWindow.cs:1278-1322) with no "was it drawn this frame" gate and no range-based deselect — no analog of retail's viewcone_check_object_id / selected_object_in_view / OnObjectRangeExit machinery.
## DIVERGENCES
### [MEDIUM] pick-outside-draw-traversal (UNVERIFIED (verifier hit token limit)) — Pick is resolved outside the draw traversal — visibility enforced by a parallel mechanism, not the flood
- blastRadius: The pick can select entities the flood does not draw (entity in a cell the PView rejected — e.g. a cellar NPC below the floor, or an entity around a corner the flood culled — is pickable whenever no EnvCell physics polygon happens to lie along the ray, including whenever the cell physics simply is not streamed in), and can refuse entities the flood draws (see occluder-stricter-than-retail). No single filed issue maps to it, but it is the structural risk for the holistic port: every flood fix in the #108/#109/#114 family silently leaves picking behind, because what-you-can-click and what-you-can-see are computed by two unrelated mechanisms. Contributes to the 'indoor world feels right' mandate at doorways and multi-story interiors.
- retailEvidence: Pick is resolved inside the same render traversal that draws: arming via SmartBox::find_object 0x00451C60 → Render::set_selection_cursor 0x0054B750; ray built once in Render::update_viewpoint (pc:343689, 0x0054cfab); per-part test Render::GfxObjUnderSelectionRay 0x0054c740 fires only from RenderDeviceD3D::DrawMesh 0x005a0860 after viewconeCheck(drawing_sphere) passes for at least one portal view (pc:429262 / 429318-429325); objects in non-flooded cells never reach CPhysicsPart::Draw 0x0050d7a0 at all; harvest at end of SmartBox::DrawNoBlit 0x00454c20 (pc:93722-93733) via Render::GetMouseSelectionObjectID 0x0054C950.
- acdreamEvidence: GameWindow.PickAndStoreSelection (GameWindow.cs:10515) runs synchronously at input time against _entitiesByServerGuid.Values (GameWindow.cs:10546) with no reference to RetailPViewRenderer's per-frame OrderedVisibleCells (RetailPViewRenderer.cs:44,71); occlusion is the separate CellBspRayOccluder ray test (GameWindow.cs:10565-10568, CellBspRayOccluder.cs:42-79).
- portShape: Adopt retail's arm-then-resolve-in-draw shape: a click arms a selection ray (Render::pick_ray equivalent already exists as WorldPicker.BuildRay); during the unified DrawInside traversal, each entity that passes the same per-cell / per-portal-view gates the draw uses gets a sphere test (and later the Stage-B first-poly-hit test) into a MouseSelectData-style accumulator; harvest after the frame. CellBspRayOccluder and the per-pick cell-physics snapshot get deleted — occlusion falls out of 'not submitted by the flood → not testable', the one-gate discipline.
### [LOW] occluder-stricter-and-looser-than-retail (UNVERIFIED (verifier hit token limit)) — Cell-polygon ray occluder blocks picks retail allows and allows picks retail blocks
- blastRadius: Stricter: an entity visually behind a baked column / interior wall of a cell that IS in the drawn flood is pickable in retail (cell geometry is invisible to the pick; only the sphere-vs-portal-view gate applies) but blocked in acdream by the wall polygon's smaller ray-t; outdoors, an NPC standing behind a building is retail-pickable (drawn, z-buffer notwithstanding) but acdream's ray may cross the building's loaded interior EnvCells and block. Looser: beyond the streamed cell-physics window there is no occlusion at all. Both directions are rare in practice and arguably acdream's strict side feels nicer — but it is not retail, and it hard-codes a second visibility oracle. Possible doorway-blocking variant is an open question (see openQuestions).
- retailEvidence: DrawEnvCell 0x0059f170 (pc:427898-427935) submits cell polys with no selection hook; GfxObjUnderSelectionRay 0x0054c740 requires s_current_physics_part, set only in CPhysicsPart::Draw 0x0050d7a0 — cell geometry never participates; occlusion granularity is drawing_sphere vs portal view cone in DrawMesh 0x005a0860.
- acdreamEvidence: CellBspRayOccluder.NearestWallT (CellBspRayOccluder.cs:42-79) tests every Resolved physics polygon of every loaded EnvCell (snapshot at GameWindow.cs:10535-10540); WorldPicker.Pick skips candidates deeper than the wall depth (WorldPicker.cs:276).
- portShape: Subsumed by pick-outside-draw-traversal: once the pick rides the draw traversal, delete the occluder rather than tuning it. No standalone fix recommended.
### [LOW] no-poly-stage-no-poly-beats-sphere (UNVERIFIED (verifier hit token limit)) — No polygon stage: screen-rect sphere test replaces retail's first-poly-hit + poly-beats-sphere arbitration
- blastRadius: Over-pick on entities whose visible mesh is much smaller than their SelectionSphere rect (the documented #71 deferral), and no per-part precision on multi-part setups (retail tests each CPhysicsPart's gfxobj drawing_sphere + flat poly array; acdream tests one Setup-level SelectionSphere). Deliberate Stage-A design that intentionally matches the drawn indicator rect — only worth revisiting if visual testing surfaces an over-pick, exactly as WorldPicker.cs:199-204 already states.
- retailEvidence: GfxObjUnderSelectionRay 0x0054c740 (Ghidra): per-part CSphere::sphere_intersects_ray(drawing_sphere) coarse gate, then first-hit-in-array-order scan of gfxobj->polygons[0..num_polygons] via CPolygon::polygon_hits_ray 0x005395E0; GetMouseSelectionObjectID 0x0054C950 returns PolygonID whenever any polygon hit exists, else SphereID — polygon hits categorically beat sphere hits.
- acdreamEvidence: WorldPicker.Pick screen-rect overload (WorldPicker.cs:210-285): Setup.SelectionSphere → screen rect + 8 px inflate → nearest clip.W; Stage B deferred per issue #71 (WorldPicker.cs:199-204); sphere source is the Setup-level SelectionSphere (GameWindow.cs:11010-11045), not per-part drawing_spheres.
- portShape: When the pick moves into the draw traversal, Stage B becomes natural: per drawn part, sphere gate on the part's mesh bounding sphere then first-poly-hit against the part's flat poly list (the mesh data WbMeshAdapter already decodes), with the retail accumulator arbitration. Keep #71's trigger condition — only do it when an over-pick is actually observed.
### [LOW] no-selected-in-view-tracking (UNVERIFIED (verifier hit token limit)) — No selected_object_in_view analog — indicator persists and selection never auto-deselects on occlusion/range
- blastRadius: Target indicator brackets keep drawing on a selected entity that walks behind a wall or that the flood stops drawing, and selection never expires by range; retail hides/deselects via drawn-this-frame tracking + 25 m indoor / 75 m outdoor range handlers. Pure polish; no filed issue.
- retailEvidence: SmartBox::set_selected_object_id 0x00451BA0 (pc:90768) sets CPhysicsPart::viewcone_check_object_id; CPhysicsPart::Draw 0x0050d7a0 (Ghidra) sets selected_object_in_view=1 when DrawMesh returns 2 (drawn) for the matching physobj->id; CPlayerSystem::OnObjectRangeExit 0x00560870 (pc:365073-365090) deselects via ACCWeenieObject::SetSelectedObject(0,0) when range-exited and not in view, else re-registers at 25 m indoors / 75 m outdoors (0x005608a1).
- acdreamEvidence: _selectedGuid persists until the next pick (GameWindow.cs:10570-10572); TargetIndicatorPanel resolver (GameWindow.cs:1278-1322) projects the SelectionSphere unconditionally — no drawn-this-frame flag, no range/visibility deselect.
- portShape: One flag on the draw traversal: when the per-entity draw submission processes the currently-selected guid, mark selectedDrawnThisFrame; indicator reads it; a range-exit hook (the interaction layer already tracks distances for UseRadius) deselects when out-of-range AND not drawn. Small, fully parasitic on the holistic port's unified entity walk.
## OPEN QUESTIONS
- Does Chorizite DatReaderWriter's CellStruct.PhysicsPolygons (the occluder's polygon set, PhysicsDataCache.cs:176/208) include polygons spanning OPEN doorway portals? Retail-side I confirmed CCellStruct::UnPack (Ghidra 0x00533D00) resolves the portal index list into the VISIBLE polygons array (`portals[i] = polygons + idx`), matching acdream's PhysicsDataCache.cs:178 comment — but that says nothing about whether physics_polygons ALSO contains a coplanar quad at the opening. If it does, CellBspRayOccluder blocks clicks on entities visible through an open doorway (user-visible: 'can't select the NPC in the next room'). Testable in 2 minutes in-client at the Holtburg inn vestibule, or by dumping a doorway cell via the existing CellDump apparatus and ray-testing through the portal. Becomes moot if the pick moves into the draw traversal.
- CPhysicsPart::creature_mode (static, 0x00843bf4) has no writer anywhere in the 1.4M-line named pseudo-C — either dead (gate is purely physobj->id != 0) or written by raw address from the obfuscated/packed minority. Does not change in-world conclusions, but a writer hiding in packed code could widen the pick gate in some mode I haven't identified.
- Retail does not exclude the local player from the pick (CPhysicsPart::Draw gates only on id != 0; the player has an id and is drawn in third person) — I did not trace whether ECM_UI::SendNotice_SmartBoxObjectFound consumers accept a self-click as a selection. acdream explicitly skips the player (WorldPicker.cs:254, skipServerGuid). Worth one retail-client test before porting the harvest path.
- The consumer chain of ECM_UI::SendNotice_SmartBoxObjectFound (what the UI layer does when click_object_id is 0 or resolves to no weenie) was not traced — I assumed deselect-on-zero. Affects only the harvest port's edge cases, not the geometry/visibility conclusions.
- Whether retail's sphere-hit fallback (GetMouseSelectionObjectID returns SphereID only when NO polygon hit exists anywhere in the frame) matters in practice for tiny/poly-less objects — determines whether acdream's Stage-A rect test is closer to retail's fallback than to its primary path, i.e. how urgent #71 (Stage B poly refine) really is.

View file

@ -0,0 +1,81 @@
# 2.3 — Sky, weather, and procedural scenery vs the portal-view discipline
## RETAIL
FRAME ENTRY — SmartBox::RenderNormalMode (Ghidra 0x453aa0). The branch key is the VIEWER (collided camera) cell: `(viewer.objcell_id & 0xffff) < 0x100` = viewer outdoors. Outdoors: LScape::update_viewpoint(viewer cell) → Render::set_default_view (no portal clip list) → useSunlightSet(1) → LScape::draw. Indoors: if viewer_cell->seen_outside, LScape::update_viewpoint(Position::get_outside_cell_id(viewer)) — this only re-centers the 2D landblock draw list under the indoor viewer, it draws nothing — then RenderDevice vtable+0x48 = RenderDeviceD3D::DrawInside (vtable map pc:1037065; impl 0x0059f0d0) which tail-calls PView::DrawInside(indoor_pview, viewer_cell).
SKY/WEATHER DRAW SITE — LScape::draw (Ghidra 0x00506330): (1) GameSky::Draw(sky, 0) FIRST, (2) back-to-front DrawBlock per landblock with in_view != OUTSIDE, (3) if LScape::weather_enabled: GameSky::Draw(sky, 1) LAST. GameSky::Draw (Ghidra 0x00506ff0) gates its whole body on `is_player_outside() || pass==0`: the sky pass is unconditional (whenever LScape::draw runs at all), the weather pass additionally requires the PLAYER to be outdoors. SmartBox::is_player_outside (Ghidra 0x00451e80) = (player cell & 0xffff) < 0x100. Depth state for BOTH passes: SetDepthBufferMode(DEPTHTEST_ALWAYS, z-write OFF), zfar ×4, fog forced per LScape::m_override_enabled; restored to (LESSEQUAL, write on) after (0x00507063/0x005070fc). Sky pass iterates sky_obj skipping property bit 0x01 (after-cell members), bit 0x04 objects when !weather_enabled, bit 0x02 under the admin fog override, calling CPhysicsObj::DrawRecursive each (pc:268704-268760). Weather pass = RenderDevice::DrawObjCellForDummies(after_sky_cell) (0x005070da; impl 0x005a0760 = UpdateObjCell + shadow-part sort + DrawObjCell). GameSky owns two dummy CEnvCells, before_sky_cell/after_sky_cell (acclient.h:35426): MakeObject (0x00506ee0) puts props&1 objects in after_sky_cell, the rest in before_sky_cell, and refuses to create props&4 (weather) objects while weather is disabled (also created/deleted on toggle by CreateDeletePhysicsObjects 0x005073c0, pc:268912-269036).
SKY POSITION — SmartBox::set_viewer (0x00452c40) calls LScape::set_sky_position(this->lscape, &this->viewer) (pc:91830 / 0x00452d45; impl 0x00504c30) → GameSky::UpdatePosition (0x00506dd0, BN pc:268569-268618): both dummy cells snap to the VIEWER's cell id + frame; weather objects (bit 0x04) snap x,y to the viewer origin and, when bit 0x08 is clear, pin z := 120.0f (constant 0xc2f00000 stored at 0x00506e96-e98 — absolute world height, not camera-relative). So the rain cylinders (GfxObj 0x01004C42/0x01004C44, ~815 m tall) ride the camera in x,y but hang at fixed world z 120.
INDOOR LOOKING OUT — PView::DrawInside (0x005a5860, pc:433793) → ConstructView (0x005a57b0: zero outside_view.view_count, BFS flood via InitCell/ClipPortals/AddViewToPortals) → PView::DrawCells (0x005a4840, pc:432709). ClipPortals (0x005a5520): a portal whose other_cell_id == 0xFFFFFFFF is the OUTSIDE; if this->draw_landscape, its accumulated clip view is merged into the pview's outside_view via Render::copy_view (clipped when global `cliplandscape` != 0, unclipped otherwise; 0x005a566c-5711). PView::DrawCells then runs FOUR stages: (a) if outside_view.view_count > 0 → useSunlightSet(1), Render::PortalList = pview, **LScape::draw(lscape)** — the ENTIRE outdoor world draws through the doorway: sky pass, terrain blocks, buildings, scenery, outdoor statics/creatures, and the weather pass which self-gates on is_player_outside (player indoors → no rain even through the door). Every mesh in this slice is portal-clipped: DrawMesh (0x005a0860) loops PortalList views, viewconeCheck per view, and draws per intersecting view under that view's clip planes. (b) Clear(4 /*depth*/) — a FULL depth-buffer clear, conditional on portalsDrawnCount/forceClear (0x005a48a9) — then per cell (reverse cell_draw_list) per view, the OUTSIDE portal polys (other_cell == 1) are re-drawn via D3DPolyRender::DrawPortalPolyInternal (0x005a49af-49b7): a "z-stamp" that writes the doorway aperture's true depth back so indoor geometry lying beyond the doorway can't paint over the outside image. (c) useSunlightSet(0) + restore_all_lighting; shells per cell per view via DrawEnvCell (0x0059f170; planeMask=0xffffffff submit pc:427922). (d) per cell: Render::PortalList = the cell's last portal_view; DrawObjCellForDummies(cell) — cell contents portal-clipped.
OUTDOOR CONTENT (what the through-door slice contains) — RenderDeviceD3D::DrawBlock (0x005a17c0): per LandCell, DrawLandCell (0x0059f120 — terrain polys) then DrawSortCell (0x0059f140): if the cell has a building → DrawBuilding, then DrawObjCell (the cell's object list). DrawBuilding (0x0059f2a0, pc:429282-429295) installs outdoor_pview->outdoor_portal_list = building->portals before CPhysicsPart::Draw; portal polys inside the building's DrawingBSP dispatch RenderDeviceD3D::DrawPortal (0x0059f0e0) → PView::DrawPortal (0x005a5ab0, pc:433895) → ConstructView(CBldPortal) (0x005a59a0) → DrawCells of that building's interior. I.e. NESTED recursion: standing inside looking out, another building's interior renders through its open door/window because DrawBuilding runs inside the through-door LScape::draw.
SCENERY — CLandBlock::get_land_scenes (0x00530460, pc:314322): pseudo-random ObjectDesc::Place per scene-type slot; skips cells holding buildings (CSortCell::has_building 0x00530865), road cells (on_road 0x005307ce), too-steep terrain (CheckSlope 0x0053089a); then CPhysicsObj::makeObject → set_initial_frame → **add_obj_to_cell(landcell)** (0x00530923) + CLandBlock::add_static_object. Scenery is therefore an ordinary per-LandCell object — drawn through DrawSortCell/DrawObjCell and portal-clipped through doorways exactly like every other static. There is no separate scenery draw path.
## ACDREAM
ROOT PICK — GameWindow.OnRender: `clipRoot = viewerRoot ?? _outdoorNode` (src/AcDream.App/Rendering/GameWindow.cs:7497). Steady-state frames (eye indoors OR outdoors) ALL go through RetailPViewRenderer.DrawInside; the `clipRoot is null` "Outdoor LScape entry" (GameWindow.cs:7546-7587 sky+terrain, 7874-7889 weather) survives only as the pre-spawn/login safety path. Sky policy gate: `renderSky = viewerRoot is null || rootSeenOutside` (GameWindow.cs:7423).
DRAWINSIDE — src/AcDream.App/Rendering/RetailPViewRenderer.cs:44-109: PortalVisibilityBuilder.Build floods from the root; exit portals (OtherCellId==0xFFFF) union their clipped screen regions into OutsideView (src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:279, 87); the synthetic outdoor node seeds a FULL-SCREEN OutsideView quad (PortalVisibilityBuilder.cs:80-89). Outdoor-node roots additionally merge per-building interior floods within 48 m (RetailPViewRenderer.cs:60-61, 115-145) — interior roots do NOT. ClipFrameAssembler produces ≤8-plane slices + an NDC AABB each; InteriorEntityPartition (src/AcDream.App/Rendering/InteriorEntityPartition.cs:22-79) buckets every landblock entity: indoor ParentCellId→ByCell, outdoor/none→Outdoor, server-guid-without-cell→LiveDynamic.
OUTSIDE SLICE — DrawLandscapeThroughOutsideView (RetailPViewRenderer.cs:214-238) loops slices: SetTerrainClip(slice.Planes) + entity clip routing, then GameWindow.DrawRetailPViewLandscapeSlice (GameWindow.cs:9465-9551): scissor to the slice NDC AABB → SkyRenderer.RenderSky (9486, clip distances ON) → SkyPreScene particles → terrain Draw (9496) → the WHOLE Outdoor bucket (scenery + dat stabs + building exteriors + outdoor-parented live entities) via WbDrawDispatcher, frustum cull + slice clip routing (9503-9512) → outdoor-filtered Scene particles with clip distances DISABLED, scissor only (9518-9530) → SkyRenderer.RenderWeather (9535) + SkyPostScene particles, gated ONLY on renderSky. After all slices: a SCISSORED depth clear per slice, interior roots only (GameWindow.cs:7644-7652; explicitly null for outdoor-node roots); then DrawExitPortalMasks — declared on the context (RetailPViewRenderer.cs:534) but NEVER assigned by GameWindow, so the pass no-ops (RetailPViewRenderer.cs:331-332); then DrawEnvCellShells (GL-clipped only for outdoor roots per #114 scope, :104-105, 378-398); then DrawCellObjectLists + per-cell particles (scissor only, clip off; GameWindow.cs:9553-9580).
SKYRENDERER — src/AcDream.App/Rendering/Sky/SkyRenderer.cs (WB SkyboxRenderManager port): RenderSky draws the pre-scene partition (Properties bit 0x01 clear), RenderWeather the post-scene partition (bit set) (:106-144, 219-235 — correct retail bit semantics, citing MakeObject 0x00506ee0). GL state: depth test OFF + depth mask OFF + cull off + per-submesh additive/alpha blend (:194-210) — equivalent to retail's DEPTHTEST_ALWAYS/no-write. View translation zeroed = camera-centred sky (:175-178). Weather 120 offset applied as a CAMERA-RELATIVE model translation for bit4 && !bit8 objects (:307-308). Fog-override bit 0x02 skip implemented (:240-241). sky.vert writes gl_ClipDistance from the terrain-clip UBO (src/AcDream.App/Rendering/Shaders/sky.vert:153) so the doorway slices genuinely clip sky and rain.
SCENERY — SceneryGenerator.Generate (src/AcDream.Core/World/SceneryGenerator.cs:86 via WbSceneryAdapter) → GameWindow.BuildSceneryEntitiesForStreaming (GameWindow.cs:5290-5473): scenery becomes WorldEntity ids 0x80XXYYII with MeshRefs and NO ParentCellId (5463-5472) → lands in the Outdoor partition bucket → drawn once per outside slice with per-entity frustum cull + slice clip planes. Building suppression at generation uses a 9×9 vertex-grid set derived from building origins (5310-5316). There are no per-LandCell object lists outdoors — the bucket is flat.
## DIVERGENCES
### [CRITICAL] outside-portal-zstamp-missing (UNVERIFIED (verifier hit token limit)) — No depth re-stamp of outside portal polys after the outside-view draw (and the depth clear is scissored, not full)
- blastRadius: #108 (grass texture sweeping across the upstairs door opening during the cellar ascent — the outside image / terrain drawn through the doorway region is never re-fenced in depth) and #109 (far exit door oscillating between door texture and background — a per-frame depth race in the doorway rectangle between the slice's cleared depth, the door entity, and shell geometry). Both are top entries in the 2026-06-11 holistic mandate.
- retailEvidence: PView::DrawCells (0x005a4840, pc:432709): after LScape::draw through outside_view it issues Clear(4 /*depth, FULL buffer*/) conditional on portalsDrawnCount (0x005a48a9), then per cell per view re-draws every OUTSIDE portal poly (other_cell == 0xFFFFFFFF) via D3DPolyRender::DrawPortalPolyInternal (0x005a49a0-49b7) — writing the doorway aperture's real depth back so indoor geometry beyond the doorway cannot overpaint the outside image, while geometry in front of it still occludes normally.
- acdreamEvidence: Depth clear is scissored to each slice's NDC AABB and only for interior roots (GameWindow.cs:7644-7652). The z-stamp stage exists as RetailPViewRenderer.DrawExitPortalMasks (RetailPViewRenderer.cs:325-343) but the callback is null — GameWindow's RetailPViewDrawContext initializer (GameWindow.cs:7604-7663) never assigns DrawExitPortalMasks, so the pass no-ops every frame.
- portShape: Wire the masks stage: after the slice depth-clears, draw each visible cell's exit-portal polygons depth-only (color mask off) per slice — the portal quads already exist as PortalRef polys per the e223325 dat finding. Match retail's full-buffer Clear(depth) gated on any-slice-drawn rather than per-slice scissored clears (or prove the scissored equivalent identical and document it). Order: outside slices → depth clear → portal z-stamp → shells → objects, exactly DrawCells.
### [HIGH] weather-indoor-gate (UNVERIFIED (verifier hit token limit)) — Weather pass not gated on is_player_outside — rain draws through doorways while the player is inside
- blastRadius: Rain/snow visibly composites in the doorway slice (depth-test off) when standing inside a building looking out; retail shows zero weather in that situation. Part of the 'indoor world feels right' gap; no dedicated issue number yet.
- retailEvidence: GameSky::Draw (0x00506ff0) gates its body on `is_player_outside() || pass==0` (0x00507009) — the weather pass (pass 1, DrawObjCellForDummies(after_sky_cell) at 0x005070da) requires the PLAYER cell to be outdoor (SmartBox::is_player_outside 0x00451e80: (cell & 0xffff) < 0x100), independently of the viewer/outside_view. LScape::draw additionally requires LScape::weather_enabled (0x0050638b-96).
- acdreamEvidence: DrawRetailPViewLandscapeSlice calls _skyRenderer.RenderWeather inside every OutsideView slice gated only on renderSky (GameWindow.cs:9533-9536), and renderSky is the viewer-root seen_outside policy (GameWindow.cs:7423) — the player's indoor/outdoor state is never consulted.
- portShape: Pass a playerIsOutside bool (player CellId & 0xFFFF < 0x100 already computed for lighting at GameWindow.cs:7291-7320) into the slice context; skip RenderWeather + the SkyPostScene weather particles when false. RenderSky stays ungated (retail pass-0 is unconditional).
### [HIGH] particles-not-portal-clipped (UNVERIFIED (verifier hit token limit)) — Particles draw with clip distances disabled — scissor rectangle only, no portal-plane clipping
- blastRadius: The reported particles-through-walls bug: an emitter whose particles fall inside the doorway's bounding RECTANGLE but outside the portal WEDGE paints across interior walls; same for per-cell particles bleeding across neighbor cells inside the AABB.
- retailEvidence: Retail particles are cell objects drawn through DrawObjCellForDummies under the active Render::PortalList — DrawMesh (0x005a0860) loops the portal views and draws only per intersecting view with that view's clip planes installed (Render::set_view), i.e. polygon-level portal clipping identical to statics.
- acdreamEvidence: Both particle draw sites explicitly DisableClipDistances() before drawing and rely on the slice/cell NDC-AABB scissor alone: outdoor slice particles at GameWindow.cs:9518-9530, per-cell particles at GameWindow.cs:9568-9579 (DrawRetailPViewCellParticles).
- portShape: Give the particle pipeline the same slice clip the sky already has: write gl_ClipDistance in the particle vertex/billboard shader from the slice planes (the terrain-clip UBO is already bound), enable clip distances around the draw, keep the scissor as a cheap pre-cull.
### [MEDIUM] no-nested-building-flood-through-outside-view (UNVERIFIED (verifier hit token limit)) — Interior roots never flood neighbor buildings — their interiors are absent from the through-door outside view
- blastRadius: Standing inside looking out a doorway at another building with an open door or window: retail renders that building's interior through its aperture; acdream shows the aperture as background/unsealed (same artifact family the outdoor look-in had pre-R-A2). Contributes to 'indoor world feels right'; not yet a numbered issue.
- retailEvidence: The through-door slice is the full LScape::draw, whose DrawSortCell (0x0059f140) calls DrawBuilding (0x0059f2a0, pc:429282-429295); DrawBuilding installs outdoor_pview->outdoor_portal_list and the building DrawingBSP's portal polys dispatch RenderDeviceD3D::DrawPortal (0x0059f0e0) → PView::DrawPortal (0x005a5ab0, pc:433895) → ConstructView(CBldPortal) (0x005a59a0) → DrawCells of the neighbor interior — nested recursion reachable from inside-looking-out.
- acdreamEvidence: MergeNearbyBuildingFloods runs only when ctx.RootCell.IsOutdoorNode (RetailPViewRenderer.cs:60-61); for interior roots NearbyBuildingCells is null by construction (GameWindow.cs:7610). The Outdoor bucket draws neighbor buildings' EXTERIOR entities through the slice (GameWindow.cs:9503-9512) but no interior cells flood.
- portShape: When an interior root has OutsideView slices, run the existing BuildFromExterior per-building seeding (the R-A2 machinery, keep-listed) for buildings whose entrance portals intersect a slice, intersect the resulting views with the slice planes, and append to the frame — the renderer already merges per-building frames (MergeBuildingFrame, RetailPViewRenderer.cs:151-160).
### [LOW] outdoor-objects-flat-bucket (UNVERIFIED (verifier hit token limit)) — Outdoor scenery/statics drawn as one flat bucket per slice instead of per-LandCell object lists
- blastRadius: No single named bug; costs are structural: the full outdoor entity set is re-dispatched once per doorway slice (perf when multiple doorways visible), alpha-blended outdoor objects have no near-to-far cell ordering, and per-cell semantics retail relies on (cell in_view, building-cell suppression at draw time) have no home. Mostly masked today by clip planes + scissor + depth buffer.
- retailEvidence: Scenery is placed INTO land cells (CPhysicsObj::add_obj_to_cell, 0x00530923 in CLandBlock::get_land_scenes 0x00530460) and drawn per cell in block draw order: DrawBlock (0x005a17c0) → per-LandCell DrawLandCell (0x0059f120) + DrawSortCell (0x0059f140) → DrawObjCell, with per-view viewcone checks in DrawMesh (0x005a0860).
- acdreamEvidence: Scenery entities carry no ParentCellId (GameWindow.cs:5463-5472) → InteriorEntityPartition.Outdoor (InteriorEntityPartition.cs:42-49, 61-64); the whole bucket is drawn in one WbDrawDispatcher call per OutsideView slice with frustum cull only (GameWindow.cs:9503-9512).
- portShape: Long-term: per-LandCell object lists (the render twin of the A6.P4 per-cell shadow architecture), letting the slice walk cells in draw order like DrawBlock. Near-term acceptable as-is; do not re-fix under this area alone.
### [LOW] rain-anchor-z-relative (UNVERIFIED (verifier hit token limit)) — Rain cylinder z pinned camera-relative (120 below camera) instead of world-absolute z = 120
- blastRadius: At high terrain/camera altitude the rain volume rides up with the camera (acdream span camZ120..camZ+695 vs retail fixed 120..+695 world) — subtle density/coverage differences on mountains; nothing user-reported.
- retailEvidence: GameSky::UpdatePosition (0x00506dd0, BN pc:268596-268618): weather objects (props bit 4) snap x,y to the viewer origin; when bit 8 is clear the frame z slot is OVERWRITTEN with the constant 0xc2f00000 = 120.0f (0x00506e96-e98) — an absolute height, not an offset.
- acdreamEvidence: SkyRenderer.RenderPass applies Matrix4x4.CreateTranslation(0,0,120) to the model in a sky view whose translation is zeroed — i.e. 120 relative to the camera (SkyRenderer.cs:175-178, 307-308; the doc comment at 284-306 itself reads the decomp as an offset).
- portShape: In the weather branch, translate by (0,0,120 cameraWorldPos.Z) so the cylinder's base sits at world z 120 while x,y stay camera-snapped — a two-line change in RenderPass.
### [LOW] weather-enabled-toggle-absent (UNVERIFIED (verifier hit token limit)) — No weather_enabled client toggle (weather objects always instantiated)
- blastRadius: None vs retail defaults (retail ships weather on, GameSky::s_weatherEnabled init 0x1 at pc:1098001); only matters for the user-options parity pass.
- retailEvidence: LScape::weather_enabled gates the weather draw (0x0050638b-96, 0x005070d8) and GameSky::CreateDeletePhysicsObjects (0x005073c0, pc:268912-268917) creates/destroys the weather physics objects when the flag flips.
- acdreamEvidence: SkyDescLoader.cs:46-51 documents the missing toggle ('we don't honor a weather_enabled toggle yet'); SkyRenderer partitions purely on the dat property bits.
- portShape: A RuntimeOptions/settings bool consulted at both the object-build site (SkyDescLoader/DayGroup hydration) and the RenderWeather call sites — mirroring retail's create/delete + draw double gate.
## OPEN QUESTIONS
- Is D3DPolyRender::DrawPortalPolyInternal in DrawCells stage (b) (0x005a49b7, second arg 0) strictly a depth-only write? The position in the sequence (after the full z-clear, before shells) and its use with arg 0 vs the ConstructView call site (arg = arg5==1, 0x005a5a7b) strongly imply a z-stamp with color off, but I did not decompile DrawPortalPolyInternal itself to confirm the write mask — confirm before porting the masks stage.
- Default and source of the retail global `cliplandscape` (branch at 0x005a5681 in PView::ClipPortals): when 0 the outside view merges UNCLIPPED (Render::copy_view(this, nullptr, 0) at 0x005a5699). Presumably a registry/debug toggle defaulting to clipped, but I could not find its initializer.
- Does retail's weather pass really composite rain over near buildings outdoors? GameSky::Draw sets DEPTHTEST_ALWAYS around DrawObjCellForDummies(after_sky_cell), but the poly pipeline below DrawObjCell (CShadowPart::draw / D3DPolyRender surface setup) may re-set depth state per surface — not traced to the draw-call level.
- Exact draw-time role of before_sky_cell: GameSky::Draw pass 0 iterates sky_obj directly (skipping bit-0x01 objects) rather than drawing before_sky_cell as a cell, so the cell looks like a positioning/lighting container only — no draw site for it was found, but I did not exhaustively xref it.
- Retail re-centers the landscape block draw list on get_outside_cell_id(viewer) while the viewer is indoors (RenderNormalMode 0x453aa0, gated on viewer_cell->seen_outside); acdream's streamed terrain window is centered on the player landblock. Whether this matters at landblock edges (viewer inside a building near a block boundary, doorway facing the un-streamed direction) is untested.
- #108's final mechanism still needs its own capture: this map identifies the unwired portal z-stamp and the scissored-vs-full depth clear as the faithful-port gaps sitting exactly in that code path, but does not prove which of the two produces the grass sweep during the cellar ascent.
- DrawMesh's per-view loop draws a mesh once per intersecting portal view (0x005a08ae-096f) — when two doorways show the same outdoor object, retail composites it twice under disjoint clips, same as acdream's per-slice redraw; I treated this as a match, but alpha-blended objects at overlapping view edges were not verified pixel-level.

View file

@ -0,0 +1,170 @@
# 2.5 — acdream-internal audit: every visibility/culling/clipping gate in today's frame; one-gate-rule violations and legacy remnants
## RETAIL
Retail has exactly ONE visibility product per frame and enforces it once for every geometry class. RenderNormalMode (0x453aa0) calls DrawInside(viewer_cell) unconditionally (pc:92675); PView::DrawInside (pc:433793) runs ConstructView (pc:433750) -> ClipPortals (pc:433572) -> AddViewToPortals (pc:433446), producing per-cell portal_view polygon lists + outside_view. PView::DrawCells (Ghidra 0x005a4840, decompiled this session) then enforces that single product in four stages: (0) if outside_view.view_count != 0, set Render::PortalList = &outside_view and call LScape::draw ONCE — the landscape is drawn one time under the whole outside_view region, followed by a z-buffer clear (vtbl+0x2c with flag 4, RGBAColor_Black, 1.0f) gated on portalsDrawnCount/forceClear; (1) reverse cell_draw_list, per view slice (CEnvCell::setup_view(cell, i)): DrawPortalPolyInternal for every portal with other_cell_id == -1 — the exit-portal mask/z pass; (2) reverse cell_draw_list, per setup_view slice: vtbl+0x5c (DrawEnvCell) — the shell pass, where every cell polygon is submitted with planeMask=0xffffffff (pc:427922) through the view planes Render::set_view installed (pc:343750, 0x0054d0e0); (3) reverse cell_draw_list: Render::PortalList = cell->portal_view.data[num_view-1], then vtbl+0x64 (DrawObjCell) — the object-list pass. Objects are NOT hard-clipped per slice; instead the mesh path gates each drawing sphere against the active viewcone: Render::viewconeCheck (Ghidra 0x0054c250) tests the sphere against the near plane (viewer_world_space.CY) plus portal_npnts view-edge planes, returning OUTSIDE / PARTIALLY_INSIDE / inside, and is called unconditionally from DrawMesh (xrefs 0x005a08e4, 0x005a09a4). So: one flood, one draw order, two enforcement mechanisms (hard poly clip for cell shells, viewcone sphere check for meshes) — but both read the SAME portal_view product. is_player_outside gates only sky/lighting, not the draw path.
## ACDREAM
FRAME ANATOMY (GameWindow.OnRender, src/AcDream.App/Rendering/GameWindow.cs:7124). Per frame: clear (DepthMask asserted true, :7155-7156), WbMeshAdapter.Tick (:7178), FrustumPlanes built from camera VP (:7221), lighting root = player CurrCell (:7291-7296), render root = RetailChaseCamera.ViewerCellId -> CellVisibility.TryGetCell (:7301-7312). THEN the dual-visibility surface: (A) _cellVisibility.ComputeVisibilityFromRoot(viewerRoot, viewerEyePos) at :7313 runs the full ACME-ported BFS (CellVisibility.cs:348-363 -> GetVisibleCellsFromRoot :455-515, eye-side portal test :493-506) every indoor frame, but its result is consumed ONLY as `bool cameraInsideCell = visibility?.CameraCell is not null` (:7314) — equivalent to `viewerRoot is not null`; VisibleCellIds and HasExitPortalVisible are produced and never read anywhere in src (grep: HasExitPortalVisible only at CellVisibility.cs:190/479). cameraInsideCell feeds only the debug-only UpdateSkyPes (:7343-7344, gated on EnableSkyPesDebug) and the [render-sig] probe (:7899). (B) The REAL gate: PortalVisibilityBuilder.Build inside RetailPViewRenderer.DrawInside (RetailPViewRenderer.cs:48-52) + per-building BuildFromExterior merges for the outdoor node (:60-61, 115-160). So yes — TWO live visibility computations run per indoor frame; only (B) gates pixels; they cannot disagree visibly today because (A)'s set is discarded, but (A) is live CPU + a drift trap (its doc says pass the player position, the call site passes the chase eye — CellVisibility.cs:343-347 vs GameWindow.cs:7313).
UNIFIED PATH (clipRoot != null = viewerRoot or OutdoorCellNode.Build, :7458-7482, :7497). Gates in order: [1] streaming window N1/N2 (what exists in _worldState.LandblockEntries + EnvCellRenderer registry); [2] PortalVisibilityBuilder flood (side test PortalVisibilityBuilder.cs:226-233, homogeneous portal clip via PortalProjection.ProjectToClip/ClipToRegion :81-134, reciprocal clip :779-823, eye-inside-opening rescues :258-267/:869 EyeStandingPerpDist=1.75m, MaxReprocessPerCell=16 cap :51/:348, CanonicalKey 1e-3 NDC snap-dedup PortalView.cs:115-164); [3] ClipFrameAssembler.Assemble — one slot (<=8 planes, ClipPlaneSet.cs:112-150) per view polygon; multi-polygon or >8 edges degrade to slot 0 + AABB scissor (ClipPlaneSet.cs:119-133, ClipFrameAssembler.cs:115-119); [4] drawableCells = ALL OrderedVisibleCells (RetailPViewRenderer.cs:71); [5] EnvCellRenderer.PrepareRenderBatches: cameraZ>4000 early-out (EnvCellRenderer.cs:555), camera-centred _nearRadius LB ring (:581-588), GpuReady (:590), WbFrustum.TestBox per LB (:612) + per-cell Intersects (:642), filter=drawableCells (:624/:630/:641); [6] landscape per OutsideView slice (RetailPViewRenderer.cs:223-232): scissor to slice NDC AABB (GameWindow.cs:9477, BeginDoorwayScissor :9707-9724), terrain UBO planes per slice (SetTerrainClip RetailPViewRenderer.cs:225, ClipFrame.cs:208-215), gl_ClipDistance enables (GameWindow.cs:9484/9689-9699), terrain per-slot FrustumCuller + neverCullLandblockId (TerrainModernRenderer.cs:206-218), outdoor entities clip-routed to the slice slot (SetClipRouting RetailPViewRenderer.cs:227 -> WbDrawDispatcher ResolveEntitySlot :425-455, SSBO binding=2/3 -> mesh_modern.vert:120), sky/weather clipped by the same terrain UBO (sky.vert:153); [7] depth-clear per slice scissored, indoor roots only — suppressed for the outdoor node (GameWindow.cs:7644-7652); [8] exit-portal masks: DORMANT — RetailPViewRenderer.DrawExitPortalMasks no-ops because neither production context sets the callback (RetailPViewRenderer.cs:331-332; the ctx initializers at GameWindow.cs:7604-7663 and :7780-7798 never assign DrawExitPortalMasks; no production StencilTest enable exists); [9] shells: IndoorDrawPlan.ShellPass (every flooded cell with non-empty view, far->near, IndoorDrawPlan.cs:18-29) x per slice, UseShellClipRouting (RetailPViewRenderer.cs:452-458), GL clip planes enabled ONLY when clipShells == RootCell.IsOutdoorNode (:104-105, :378-380) — indoor roots draw shells UNCLIPPED (#114 interim), cells without slices fall through to a full-screen NoClipSlice (:428-437); [10] object lists: per flooded cell far->near, InteriorEntityPartition.ByCell (InteriorEntityPartition.cs:22-79), UseIndoorMembershipOnlyRouting — clip routing explicitly cleared (:420, :439-450), so objects are gated ONLY by cell membership (WbDrawDispatcher.EntityPassesVisibleCellGate :1816-1835) + per-LB/per-entity FrustumCuller (:593-595, :662-666); [11] cell particles per cell per slice: clip distances OFF, scissor to slice AABB only, emitter filter AttachedObjectId != 0 && in-cell (GameWindow.cs:9553-9580); [12] LiveDynamic bucket (ServerGuid != 0, no ParentCellId) drawn unclipped+unfiltered ONLY for outdoor-node roots (:7716-7724); [13] global Scene-particle pass and post-scene weather run ONLY when clipRoot is null (:7846, :7874) — i.e. effectively never in normal play.
LEGACY PATH (clipRoot == null — pre-spawn / non-chase debug cameras, :7726-7831): sky+terrain ungated (no-clip ClipFrame, :7546-7587), global outdoor entity bucket via InteriorRenderer.DrawEntityBucket with an EMPTY visibleCells partition (:7732-7746 — all indoor-parented entities dropped), look-in via RetailPViewRenderer.DrawPortal over 1-ring candidate cells (:7748-7811) which uses a DIFFERENT membership rule (drawableCells = clipAssembly.CellIdToSlot.Keys, RetailPViewRenderer.cs:182 — the documented grey-walls under-include the DrawInside path fixed at :66-71), LiveDynamic fallback (:7813-7823), global scene particles INCLUDING AttachedObjectId==0 emitters (:7846-7868).
FILE CLASSIFICATION. CellVisibility.cs: PARTIALLY-LIVE — the cell REGISTRY half (AddCell/TryGetCell/GetCellsForLandblock/RemoveLandblock, :252-305) is the live backbone (root resolve GameWindow.cs:7294/7311, outdoor-node gather :7475, exterior candidates :7774, CellLookup :7613/:7786); the BFS half runs per frame (:7313) but its set is unconsumed; ComputeVisibility/GetVisibleCells test-only (:318-328 doc); IsInsideAnyCell dead in production (:414-419). InteriorRenderer.cs: PARTIALLY-LIVE — DrawInside (:63-101) has ZERO callers (dead since the RetailPViewRenderer cutover); DrawEntityBucket (:141-163) live at GameWindow.cs:7720/7739/7816. IndoorDrawPlan.cs: LIVE (RetailPViewRenderer.cs:382). PortalView.cs (ViewPolygon/CellView): LIVE (flood + assembler data model). PortalProjection.cs: PARTIALLY-LIVE — ProjectToClip/ClipToRegion live; ProjectToNdc (:32-71) has no production callers (tests only). OutdoorCellNode.cs: LIVE (GameWindow.cs:7478). FrustumCuller.cs (+FrustumPlanes): LIVE (terrain :216-218, dispatcher :593/:665, perf counter GameWindow.cs:8001). ScreenPolygonClip.cs: LEGACY-DEAD — referenced only in comments (PortalVisibilityBuilder.cs:801) and its own test file. Wb/EnvCellVisibilitySnapshot.cs: LIVE (EnvCellRenderer._activeSnapshot, EnvCellRenderer.cs:47/:718). Wb/WbFrustum.cs: LIVE (EnvCellRenderer ctor :188, updated GameWindow.cs:7396). ClipPlaneSet.cs: LIVE (ClipFrameAssembler.cs:101/:140). ClipFrame.cs/ClipFrameAssembler.cs: LIVE. RenderingDiagnostics.ShouldRenderIndoor: probe-only — playerIndoorGate no longer selects the path (GameWindow.cs:7485-7496).
NET: visibility is computed twice (one result discarded), and the ONE live flood product is enforced at FIVE different strengths — exact planes (outdoor-root shells), nothing (indoor-root shells), membership+frustum only (all object lists), planes+scissor (terrain/sky), scissor-rectangle only (particles) — where retail enforces one product through exactly two mechanisms (poly clip for shells, viewcone sphere check for meshes) that read the same view.
## DIVERGENCES
### [HIGH] object-lists-skip-portal-view-gate (confirmed) — Object lists are never gated by the portal view — no viewconeCheck equivalent exists
- correctedClaim: Confirmed as claimed, with two citation refinements: (1) DrawCells loop 3 calls vtbl+0x64 = RenderDeviceD3D::DrawObjCellForDummies (0x005a0760: UpdateObjCell + shadow-part insertion sort), which forwards to vtbl+0x60 DrawObjCell → DrawPartCell → CShadowPart::draw → CPhysicsPart::Draw → vtbl+0x70 DrawMesh; (2) viewconeCheck is called once per portal view inside DrawMesh's PortalList loop (gated by building_view == -1 || building_view == i), with set_view installing each view's edge planes first; the OUTSIDE-skip is absolute on the cell-object path because CShadowPart::draw passes force=0. Additionally, acdream's situation is worse than stated: the one geometric gate it does have (global frustum AABB, WbDrawDispatcher.cs:662-666) is bypassed for indoor buckets because DrawEntityBucket passes neverCullLandblockId equal to the bucket's LandblockId (RetailPViewRenderer.cs:465-474) — indoor cell objects are drawn with no geometric culling whatsoever.
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C):
1. PView::DrawCells (Ghidra 0x005a4840): loop 3 (the object pass after the env-cell pass) sets `Render::PortalList = (cell->portal_view).data[cell->num_view - 1]` per cell, then calls render_device vtbl+0x64 with the cell. Verified verbatim in the decompile.
2. Vtable slot correction (minor): the RenderDeviceD3D vtable base is 0x007e5500 (pc:1037039-1037075 vtable dump), so vtbl+0x64 = 0x007e5564 = RenderDeviceD3D::DrawObjCellForDummies, NOT DrawObjCell (+0x60 = 0x007e5560). DrawObjCellForDummies (Ghidra 0x005a0760) does UpdateObjCell + CShadowPart::insertion_sort of the cell's shadow_part_list, then forwards to vtbl+0x60 = DrawObjCell (Ghidra 0x005a1a40). Substance unchanged. BN pc:432878 independently resolves the loop-3 call as DrawObjCellForDummies.
3. Full chain to the mesh gate, every link decompiled: DrawObjCell 0x005a1a40 → DrawPartCell 0x005a07a0 (iterates cell->shadow_part_list) → CShadowPart::draw 0x006b50d0 (calls CPhysicsPart::Draw(part, 0) — note force flag = 0) → CPhysicsPart::Draw 0x0050d7a0 → vtbl+0x70 = 0x007e5570 = RenderDeviceD3D::DrawMesh 0x005a0860 (vtable dump pc:1037075).
4. DrawMesh 0x005a0860: with Render::PortalList non-null (always true in the cell-object pass, set by loop 3), it loops over PortalList->view_count; for each view i (subject to `building_view == -1 || building_view == i`), calls Render::set_view(&PortalList->view, i) then Render::viewconeCheck(gfxobj->drawing_sphere). If OUTSIDE in every view and the force flag is false, returns OUTSIDE_VIEWCONE_ODS WITHOUT calling DrawMeshInternal — the mesh is skipped. The claimed xref addresses verify exactly: function_xrefs on viewconeCheck returns 0x005a08e4 (PortalList==null path) and 0x005a09a4 (per-view loop), both in DrawMesh. Wording correction (minor): "unconditionally" is loose — the call is per-portal-view inside a loop with the building_view filter, and an OUTSIDE result can still draw when the caller passes force=true; but the cell-object-list path passes force=false (CShadowPart::draw → Draw(part, 0)), so OUTSIDE ⇒ skip holds for exactly the statics/doors/NPCs path the claim is about.
5. Render::viewconeCheck (Ghidra 0x0054c250): transforms the drawing sphere's center to viewer space, tests signed distance against the viewer_world_space.CY forward plane, then against portal_npnts planes at portal_vertex; any distance < -radius OUTSIDE; else INSIDE/PARTIALLY_INSIDE. Render::set_view (Ghidra 0x0054d0e0, pc:343750) is what installs portal_npnts/portal_vertex/portal_inmask from view->poly.data[i] — confirming the planes tested ARE the per-cell accumulated portal-view edge planes, not just the global frustum.
ACDREAM SIDE — all citations verified against the code:
6. RetailPViewRenderer.DrawCellObjectLists (src/AcDream.App/Rendering/RetailPViewRenderer.cs:401-426) calls UseIndoorMembershipOnlyRouting() at :420 before every cell bucket; UseIndoorMembershipOnlyRouting (:439-450) clears clip routing for entities and env-cells, with the comment at :441-447 explicitly acknowledging retail's Render::viewconeCheck while implementing nothing in its place.
7. WbDrawDispatcher.EntityPassesVisibleCellGate (src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1816-1835) is pure ParentCellId-vs-visibleCellIds membership. The AABB frustum cull at :662-666 exists BUT is bypassed for these buckets: the production call site DrawEntityBucket (RetailPViewRenderer.cs:460-477) passes neverCullLandblockId: ctx.PlayerLandblockId while setting the entry's LandblockId to the same value (:465-466), so `entry.LandblockId != neverCullLandblockId` is false and the frustum test is skipped. The divergence is therefore slightly STRONGER than claimed: indoor cell-bucket entities receive no geometric culling at all — only set membership + depth test.
8. No viewcone equivalent anywhere: grep for viewcone/ViewCone/view_cone across src/ hits only the two comments (RetailPViewRenderer.cs:371, :442). PortalVisibilityFrame.CellViews (the data a port would need) is consumed only by ClipFrameAssembler.cs:93, IndoorDrawPlan.cs:24, and a GameWindow debug dump (GameWindow.cs:9416) — never by an entity-sphere test. InteriorEntityPartition (src/AcDream.App/Rendering/InteriorEntityPartition.cs:22-79) is also pure membership.
9. Blast-radius premise verified: clipShells is true only for outdoor-eye roots (RetailPViewRenderer.cs:374-378 comment + gate at :378-380), so at indoor roots the shells draw unclipped AND the objects are ungated — depth test is the only mechanism hiding far-room objects, exactly as the claim states.
JUDGMENT: the divergence is real, not behaviorally-equivalent-elsewhere, and not already handled. Retail enforces the portal view on object lists twice (per-view sphere gate at draw time + BoundingType handed into DrawMeshInternal); acdream enforces it zero times for objects. Severity 'high' is fair: it is a visible artifact class (objects painting through walls wherever depth doesn't cover — unclipped indoor-root shells, anything drawn after a depth clear) plus unbounded interior overdraw, but not 'critical' since depth test masks the common case. The proposed port shape (CPU sphere-vs-CellView-edge-planes pre-draw test; defer PARTIALLY_INSIDE semantics until DrawMeshInternal's BoundingType handling is decompiled) matches the verified retail mechanism. Two citation-level corrections folded into the claim text: vtbl+0x64 is DrawObjCellForDummies (sort + forward to DrawObjCell at +0x60), and the viewconeCheck call is per-portal-view with a building_view filter and a force-flag override that is always false on this path.
- blastRadius: Statics/doors/NPCs in any flooded cell draw whole, relying solely on depth test to hide them. Wherever the shell is unclipped (all indoor roots, see indoor-shell-clip-disabled) or depth was cleared per slice, far-room objects paint through walls — the phantom-staircase artifact class and part of #114's 'see-through to neighbour rooms'. Also pure overdraw cost in dense interiors.
- retailEvidence: DrawCells Loop 3 (Ghidra 0x005a4840) sets Render::PortalList = cell->portal_view.data[num_view-1] then calls DrawObjCell (vtbl+0x64); the mesh path calls Render::viewconeCheck (Ghidra 0x0054c250) unconditionally from DrawMesh (xrefs 0x005a08e4 / 0x005a09a4), testing the drawing sphere against the near plane + portal_npnts view-edge planes installed by Render::set_view (pc:343750) and returning OUTSIDE to skip the mesh.
- acdreamEvidence: RetailPViewRenderer.DrawCellObjectLists calls UseIndoorMembershipOnlyRouting() before every bucket (RetailPViewRenderer.cs:420, :439-450 — clip routing explicitly cleared with a comment asserting retail uses viewcone checks); WbDrawDispatcher then gates only on cell membership (EntityPassesVisibleCellGate, WbDrawDispatcher.cs:1816-1835) + global frustum AABB (:662-666). No code anywhere evaluates an object sphere against the cell's view polygons.
- portShape: Port Render::viewconeCheck as a CPU pre-draw test: for each entity in a cell bucket, test its bounding sphere against the cell's CellView polygons' edge planes (the data already exists in PortalVisibilityFrame.CellViews); OUTSIDE -> skip the entity. PARTIALLY_INSIDE handling (retail per-poly clip vs draw-whole) needs the DrawMesh decompile first. This restores retail's second enforcement mechanism without hard-clipping characters (the doorway-slicing problem the comment correctly avoids).
### [HIGH] indoor-shell-clip-disabled (confirmed) — Shell clipping enabled only for outdoor-eye roots — indoor roots draw flooded shells whole
- correctedClaim: Claim stands as written. One precision refinement for the gap map: retail's DrawEnvCell carries a DrawnThisFrame stamp guard (Ghidra 0x0052c0c0/0x0052c0e0 vs m_nFrameStamp), so a cell reachable through multiple view slices draws its shell once, clipped to the FIRST slice's view polygon — not re-drawn per slice. acdream's per-slice re-render (RetailPViewRenderer.cs:388-393) is a slightly-more-generous union; the asymmetry divergence itself is unaffected.
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C):
1. PView::DrawCells (Ghidra 0x005a4840, decompiled): the shell-draw loop (the bottom do/while over cell_draw_list in reverse, far-to-near) runs UNCONDITIONALLY for every cell with a drawing_bsp: per view slice it calls CEnvCell::setup_view(cell, sliceIndex) then the render-device virtual draw (vtbl+0x5c, one CEnvCell* arg = DrawEnvCell's exact signature). There is NO inside/outside branch anywhere in that loop. The only outside-gated block in DrawCells is the landscape + portal-mask block gated on outside_view.view_count != 0 — which is landscape drawing, not shell clipping. So "no inside/outside asymmetry in retail [shell clipping]" is confirmed at the decompile level.
2. CEnvCell::setup_view (Ghidra 0x0052c430): one-liner — Render::set_view(&this->portal_view[num_view-1]->view, sliceIndex). Confirms the per-slice accumulated portal view is installed before the cell draw.
3. Render::set_view (Ghidra 0x0054d0e0): installs the slice polygon globally — portal_vertex (slice poly vertices, each carrying an edge plane), portal_npnts, portal_inmask = (1<<(npnts+1))-1, plus the slice screen bbox xmin/xmax/ymin/ymax. Matches the claim's pc:343750 citation.
4. RenderDeviceD3D::DrawEnvCell (Ghidra 0x0059f170): non-built-mesh branch submits every cell-structure polygon with planeMask = -1 (0xffffffff) to Render::PolyList then flushes via vtbl+0x40 — confirming the pc:427922 anchor. The flush pipeline's clipper ACRender::polyClipFinish (Ghidra 0x006b6d00) reads Render::portal_npnts/portal_vertex (the set_view globals) and SutherlandHodgman-clips each submitted poly against the slice polygon's edge planes. The use_built_mesh branch is also covered: DrawEnvCell first calls Render::obj_view_set (Ghidra 0x0054b9b0), which transforms every edge plane of the CURRENT slice polygon into object space (portal_obj_plane[]) for the mesh path — the per-slice view is prepared and enforced on both branches. Retail clips shells to the per-slice aperture region for every root type.
One mechanical nuance (does not change the verdict): DrawEnvCell early-outs on CEnvCell::GetDrawnThisFrame (Ghidra 0x0052c0c0: m_current_render_frame_num == render_device->m_nFrameStamp; stamp bumped once in DrawCells before the shell loop), so a multi-slice cell's shell effectively draws ONCE, clipped to its FIRST view slice, rather than once per slice. The claim's wording "setup_view per view slice before each DrawEnvCell" is call-level accurate (the early-out is inside DrawEnvCell). acdream draws the shell once per slice (union of regions) — a minor, more-generous difference irrelevant to the claimed asymmetry.
ACDREAM SIDE — all four citations verified against the code:
1. RetailPViewRenderer.cs:104-105 — DrawEnvCellShells(..., clipShells: ctx.RootCell.IsOutdoorNode). Confirmed; comment at :96-103 explicitly documents the #114 scope-down (indoor roots stay unclipped after the first user gate's chopped-stairs/vanishing-walls/see-through findings).
2. RetailPViewRenderer.cs:378-380 and :396-398 — GL_CLIP_DISTANCE0..MaxPlanes enabled/disabled ONLY when clipShells is true. Confirmed.
3. RetailPViewRenderer.cs:452-458 — UseShellClipRouting still writes the per-cell slot routing unconditionally, but with the enables off the gl_ClipDistance writes in mesh_modern.vert:120 are ignored (GL semantics, documented at :361-363), so the routing is inert for indoor roots. Verified no enclosing enable leaks at the production call site: GameWindow.cs:7599-7663 calls DrawInside with RootCell = clipRoot (indoor roots have IsOutdoorNode=false; the outdoor-node root is built by OutdoorCellNode.cs:27); the outdoor terrain block (GameWindow.cs:7546-7587, EnableClipDistances at :7577) is skipped when clipRoot is non-null; and DrawRetailPViewLandscapeSlice — which runs inside DrawInside BEFORE the shell pass (RetailPViewRenderer.cs:93 vs :104) — ends with DisableClipDistances() (GameWindow.cs:9550). So indoor shell draws genuinely run with all clip planes disabled.
4. RetailPViewRenderer.cs:428-437 + :22-23 — GetCellSlicesOrNoClip falls through to the full-screen NoClipSlice (slot 0, planes empty = shader pass-all per mesh_modern.vert:122) when the assembler produced no slice. Confirmed, including the :367-369 note that the >8-plane scissor fallback is unimplemented.
DIVERGENCE REALITY: confirmed real, not behaviorally equivalent, not compensated elsewhere. The flood (PortalVisibilityBuilder + ClipFrameAssembler, run unconditionally at RetailPViewRenderer.cs:48-63) computes per-cell aperture regions for indoor roots too, but the indoor shell draw ignores them — exactly the "two gates disagree about the same shell" framing. DrawExitPortalMasks (:95) is a depth trick on exit apertures and does not constrain a flooded neighbor cell's geometry to its entry aperture; DrawCellObjectLists is unclipped by design in both modes (matching retail's mesh path, per :439-447). The blast-radius mapping to #114 is exact — the #114 charter and the user-gate findings are quoted in the code comment itself (:96-103), and #114 was filed in commit 6c9bbce as "indoor shell-clip region quality". The port shape in the claim matches the code reality: the mechanism exists and is live for outdoor roots; the gap is indoor region quality, after which the clipShells parameter can be deleted to restore retail's single unconditional rule. Severity "high" is appropriate.
- blastRadius: #114 directly (chopped stairs / vanishing inner walls / see-through at the meeting hall were the user-gate findings that forced the scope-down). Two gates disagree about the same shell: the flood says 'visible through THIS aperture region', the indoor draw ignores the region. Combined with object-lists-skip-portal-view-gate it is the core of 'indoor world feels right'.
- retailEvidence: Retail always clips shells: DrawCells Loop 2 (Ghidra 0x005a4840) runs CEnvCell::setup_view(cell, i) per view slice before each DrawEnvCell, and DrawEnvCell submits every cell polygon with planeMask=0xffffffff (pc:427922) through the set_view planes (pc:343750). There is no inside/outside asymmetry in retail.
- acdreamEvidence: RetailPViewRenderer.cs:104-105 passes clipShells: ctx.RootCell.IsOutdoorNode into DrawEnvCellShells; the GL_CLIP_DISTANCEi enables at :378-380 are skipped for interior roots, making the UseShellClipRouting slot writes (:452-458) inert (gl_ClipDistance writes are ignored when the enables are off, per the #113 comment :360-369). Cells without an assembler slice additionally fall through to a full-screen NoClipSlice (:428-437).
- portShape: Not a new mechanism — the machinery exists and is validated for outdoor roots. The port work is making indoor clip REGIONS pixel-exact (the #114 charter): fix the per-slice region quality (assembler slot fidelity, >8-plane fallback, slice ordering) until the indoor enables can be flipped on, then delete the clipShells parameter so one rule covers both roots.
### [HIGH] particles-third-gate-tier (UNVERIFIED (verifier hit token limit)) — Particles are gated by a third, weaker mechanism (scissor rectangle only) and several emitter classes are dropped entirely on the unified path
- blastRadius: 'Particles-through-walls': a cell's particles draw anywhere inside the slice's NDC AABB rectangle (a superset of the aperture), and cells without a slice get a FULL-SCREEN NoClipSlice scissor. Additionally on every unified-path frame (the normal in-world case): Scene-pass emitters with AttachedObjectId==0 are never drawn (both production filters require !=0), and LiveDynamic entities' particles are never drawn — silent VFX loss vs the legacy branch which drew both.
- retailEvidence: Retail draws particles through the same single view product as everything else — particle emitters hang off objects in the cell's object list, drawn inside DrawObjCell under Render::PortalList = the cell's portal_view (DrawCells Loop 3, Ghidra 0x005a4840), gated by the same viewconeCheck mesh path (0x0054c250). There is no separate scissor-rectangle tier.
- acdreamEvidence: DrawRetailPViewCellParticles: clip distances disabled, BeginDoorwayScissor(slice.NdcAabb) only, filter AttachedObjectId != 0 (GameWindow.cs:9568-9576); NoClipSlice fallback for slot-less cells (RetailPViewRenderer.cs:428-437) makes that scissor full-screen. Outdoor attached particles same pattern (:9519-9530). The only path drawing AttachedObjectId==0 Scene emitters is the clipRoot==null block (:7846-7868) which is unreachable in normal play; LiveDynamic particles have no draw site on either branch.
- portShape: Route particles through the object-list gate once viewconeCheck lands: an emitter draws iff its owning entity passed the viewcone test for its cell (no scissor tier needed; particle.vert has no gl_ClipDistance so a sphere-level CPU gate is the faithful shape). Re-home unattached and LiveDynamic emitters into the partition buckets so they draw under the same rule.
### [MEDIUM] dual-live-visibility-computations (confirmed) — Two visibility computations run per frame: the ACME BFS (CellVisibility) and the retail flood (PortalVisibilityBuilder)
- correctedClaim: Confirmed as stated, with one refinement: the doc-vs-callsite contradiction (CellVisibility.cs:343-347 vs GameWindow.cs:7313) is real but the doc comment is the likely-stale half (it predates the Phase-W move of the render root to the VIEWER cell, where viewer-eye + viewer-root is the consistent pairing); either way the position argument is behaviorally inert because it only affects the unread VisibleCellIds/HasExitPortalVisible fields. The proposed one-liner replacement (`bool cameraInsideCell = viewerRoot is not null;`) is proven exactly equivalent: TryGetCell success implies non-empty _cellLookup, and GetVisibleCellsFromRoot unconditionally returns CameraCell = root.
- verifier notes: RETAIL SIDE (re-derived from Ghidra decompile, not BN pseudo-C): (1) SmartBox::RenderNormalMode decompiled at 0x00453aa0 — contains NO visibility graph traversal: outdoor viewer (objcell_id & 0xffff < 0x100) goes straight to LScape::draw; indoor viewer dispatches vtable +0x48 with this->viewer_cell (= RenderDeviceD3D::DrawInside); seen_outside only gates the LScape::update_viewpoint(get_outside_cell_id) sky/lighting viewpoint. (2) PView::DrawCells decompiled at 0x005a4840 — pure consumer: reads this->outside_view.view_count, iterates this->cell_draw_list/cell_draw_num (filled by ConstructView), draws; zero portal recursion, zero ConstructView calls. (3) PView::ConstructView confirmed at pc:433750 (0x005a57b0, CEnvCell variant) with the CBldPortal variant at pc:433827; DrawInside calls ConstructView(this, cell, 0xffff) once at pc:433817. So retail runs exactly ONE per-frame visibility walk (ConstructView flood), consumed by DrawCells — the claimed retail picture is accurate.
ACDREAM SIDE (all citations re-read): GameWindow.cs:7313 calls _cellVisibility.ComputeVisibilityFromRoot(viewerRoot, viewerEyePos); :7314 `bool cameraInsideCell = visibility?.CameraCell is not null` is the ONLY read of `visibility` (grep of \bvisibility\b in GameWindow.cs: no other code reads). The BFS body GetVisibleCellsFromRoot is CellVisibility.cs:455-515 (class doc :207 'Ported faithfully from ACME's EnvCellManager.cs'), allocating per call: VisibilityResult (:457, whose VisibleCellIds HashSet inits at :184), visited HashSet (:458), Queue (:459) — runs only on indoor frames (root==null returns null immediately at :350-351). VisibilityResult.VisibleCellIds (:184, written :462/:509) and HasExitPortalVisible (:190, written :479) have ZERO readers in src (the src hits for 'VisibleCellIds' are the unrelated Core physics CellPhysics.VisibleCellIds in CellDump/CellTransit/PhysicsDataCache; PortalVisibilityBuilder.cs:73 is a comment). LastVisibilityResult (:234) also has no src reader outside CellVisibility.cs. cameraInsideCell feeds exactly two places: UpdateSkyPes at :7344 gated on _options.EnableSkyPesDebug (:7343, debug-only), and the EmitRenderSignatureIfChanged probe arg at :7899 (formatted 'camIn=' at :9293). Crucially I checked the sky gate at :7423 — it is `viewerRoot is null || rootSeenOutside`, NOT cameraInsideCell (the :7419-7422 mentions are comments only), so no hidden draw-decision consumer. The real retail flood runs separately: RetailPViewRenderer.DrawInside → PortalVisibilityBuilder.Build at RetailPViewRenderer.cs:48, invoked per frame from GameWindow.cs:7604 — so on indoor frames TWO independent portal-graph traversals execute, vs retail's one.
PORT SHAPE VERIFIED zero-behavior-change: viewerRoot comes from _cellVisibility.TryGetCell (:7311), which reads the same _cellLookup the BFS guards on (CellVisibility.cs:286-287), so viewerRoot non-null ⇒ _cellLookup non-empty ⇒ ComputeVisibilityFromRoot returns non-null with CameraCell=root unconditionally (:457). Hence cameraInsideCell ≡ (viewerRoot is not null) exactly. Only production caller of any BFS entry point is GameWindow.cs:7313 (ComputeVisibility/GetVisibleCells have no src callers — test-compat only per :311-316/:425-431).
CONTRACT-CONTRADICTION sub-claim: literally true — the param doc (CellVisibility.cs:343-347) says 'Should be the player/physics position (stable inside the cell), not the chase-camera eye'; the site passes viewerEyePos = camPos (:7306, :7313). One nuance the claim doesn't note: the doc is arguably the stale half (written in the Stage-3 player-root era; post-W-V1 the root IS the viewer cell, so viewer-eye + viewer-root is the internally consistent pairing). Moot either way: cameraPos only influences the portal-side test (:493-506) which affects only the unread VisibleCellIds/HasExitPortalVisible — never CameraCell. Severity 'medium' (one-gate violation + per-indoor-frame CPU/alloc + drift trap, no pixel disagreement today) is fair.
- blastRadius: No pixel disagreement TODAY — the BFS result is consumed only as a non-null bool. But it is a literal one-gate violation (the rule that cost the 2026-05-25 week), live CPU + per-frame allocation (HashSet/Queue/VisibilityResult per indoor frame), and a drift trap: a future reader wiring VisibleCellIds back into a draw decision reintroduces the three-gate era. The call also contradicts its own contract (doc says pass the stable player position; the site passes the jittering chase eye).
- retailEvidence: Retail computes visibility once: ConstructView (pc:433750) is the only per-frame visibility walk; DrawCells (Ghidra 0x005a4840) only consumes it. Nothing in RenderNormalMode runs a second graph traversal.
- acdreamEvidence: GameWindow.cs:7313 var visibility = _cellVisibility.ComputeVisibilityFromRoot(viewerRoot, viewerEyePos) -> full BFS (CellVisibility.cs:455-515); :7314 is the sole consumer (bool). VisibleCellIds/HasExitPortalVisible unread in src (CellVisibility.cs:184/190 produced :462/:479/:509). cameraInsideCell feeds only debug UpdateSkyPes (:7343-7344) and the render-sig probe (:7899). The real flood runs separately at RetailPViewRenderer.cs:48.
- portShape: Replace :7313-7314 with `bool cameraInsideCell = viewerRoot is not null;` and demote CellVisibility's BFS methods (ComputeVisibility/ComputeVisibilityFromRoot/GetVisibleCells*) to the test assembly or delete; keep the class as the cell registry (rename candidate: CellRegistry). Zero behavior change by construction.
### [MEDIUM] landscape-redrawn-per-outside-slice (adjusted) — The whole landscape (sky + terrain + scenery + weather) is re-drawn once per OutsideView slice; retail draws it once
- correctedClaim: acdream re-runs the ENTIRE landscape pipeline (sky + SkyPre/Post particles + full terrain dispatch + outdoor entities + scene particles + weather) once per OutsideView slice (RetailPViewRenderer.cs:223-232 -> GameWindow.cs:9465-9551), where retail runs the landscape PASS exactly once (PView::DrawCells 0x005a4840: PortalList=&outside_view; LScape::draw once; sky/terrain/weather bundled at 0x00506330) and handles multi-polygon outside_view per PART: the visibility pass loops set_view over views to mark blocks (draw_check_blocks via 0x0050603e), and individual parts visible in multiple views ARE re-drawn once per visible view, clipped to that view (RenderDeviceD3D::DrawMesh 0x005a0860 calls DrawMeshInternal per non-OUTSIDE view). The faithful port is therefore NOT "draw each part exactly once" but "one landscape pass whose fixed costs run once, with a per-part view loop (test per view, draw clipped per visible view)" — behaviorally approximable by uploading all OutsideView polygons as one multi-region clip set tested in-shader. The user-visible divergence is (1) N x full-pipeline fixed cost per frame indoors (terrain dispatch, sky dome, weather re-run per slice — retail re-touches only parts spanning multiple views), and (2) alpha double-composite confined to acdream's slice-overlap regions, which exist mainly because particle passes draw with clip distances disabled (AABB scissor only, GameWindow.cs:9489/9518/9537) and because >8-plane slices fall back to AABB-only clipping (ClipFrameAssembler.cs:114-119) — exact-plane-clipped terrain/sky slices produce the same image as a single draw where slices are disjoint.
- verifier notes: RETAIL side re-derived from Ghidra (not BN pseudo-C): (1) PView::DrawCells @ 0x005a4840 — gated on `(this->outside_view).view_count != 0`, sets `Render::PortalList = &this->outside_view` and calls `LScape::draw(this->lscape)` EXACTLY ONCE; no loop over view polygons surrounds it. (2) LScape::draw @ 0x00506330 — the single call bundles sky (`GameSky::Draw(sky,0)`), per-landblock terrain draw (`block_draw_list` loop, each block drawn once if `in_view != OUTSIDE` via render-device vtable+0x50), and weather (`GameSky::Draw(sky,1)` gated on `weather_enabled`). (3) LScape::draw_check_blocks (decompiled via 0x0050603e) — the VISIBILITY pass loops `Render::set_view(&PortalList->view, i)` over all view_count polygons to mark blocks in-view (OR across views); the block DRAW after it runs once per block. (4) CRITICAL NUANCE the claim missed: RenderDeviceD3D::DrawMesh @ 0x005a0860 — when PortalList != null it loops view_count, `set_view` per view, `viewconeCheck(drawing_sphere)`, and calls `DrawMeshInternal` once PER non-OUTSIDE view. So retail DOES multi-draw an individual part once per view polygon it is visible in (each draw clipped to that view); "retail draws it once" is true at the PASS level only. ACDREAM side verified: RetailPViewRenderer.cs:219-232 `DrawLandscapeThroughOutsideView` loops `foreach (var slice in clipAssembly.OutsideViewSlices)` calling `SetTerrainClip(slice.Planes)` + `ctx.DrawLandscapeSlice(...)` per slice. The callback (wired at GameWindow.cs:7624-7634 with a per-frame-constant renderSky) is GameWindow.DrawRetailPViewLandscapeSlice :9465-9551, which per invocation runs: sky :9486, SkyPreScene particles :9490-9492, FULL terrain dispatch :9496, outdoor entities :9503-9512, scene particles :9519-9530, weather :9533-9536, SkyPostScene particles :9538-9540 — i.e., the entire landscape pipeline re-runs per slice. N>1 is real in production: ClipFrameAssembler.cs:134-164 emits one ClipViewSlice per `pvFrame.OutsideView.Polygons` entry with no union step, and PortalVisibilityBuilder.cs:279 appends each exit portal's clipped region into OutsideView (one polygon — or several, since clipping can split — per visible exit aperture). Two acdream-specific aggravators beyond the claim: (a) the <=8-plane budget fallback (ClipFrameAssembler.cs:114-119, 153-158) gives a slice slot 0 with EMPTY planes — that slice clips only to its NDC-AABB scissor, inflating overlap with neighbouring slices; (b) all particle passes within the slice draw with clip distances DISABLED (GameWindow.cs:9489, 9518, 9537), so particles are clipped only by the slice's AABB scissor (:9477) — blended particle content genuinely composites N times in AABB-overlap regions. Tempering of the blast radius: for plane-clipped slices, terrain/sky fragments are clipped to the exact slice polygon, so where slices do not overlap the N draws produce the same image as one draw (the cost is N x dispatch, not double-bright); retail's per-part view loop has the same overlap exposure in principle, but clips to the exact view polygon, whereas acdream's AABB fallbacks and unclipped particles create overlap retail would not have. The double-bright-rain-through-two-doorways framing therefore holds for particles/AABB-fallback slices, but is overstated for the plane-clipped terrain/sky case. Severity medium is fair; the dominant real effect is N x full-pipeline fixed cost plus particle/fallback double-composite; the #108 contribution (scenery re-drawn per slice under frame-to-frame-changing clips) remains plausible but unproven.
- blastRadius: With N visible exit apertures the terrain/sky/scenery/weather pipeline runs N times (N x full terrain dispatch, N x sky). Alpha-blended passes (weather rain cylinder, sky cloud layers) composite N times where slices overlap -> double-bright rain/sky through two doorways. Plausible contributor to #108 (grass-sweep: scenery re-drawn per slice under different clip planes/scissor as slices change shape frame to frame) and to indoor-frame FPS dips.
- retailEvidence: DrawCells Loop 0 (Ghidra 0x005a4840): Render::PortalList = &this->outside_view; LScape::draw(this->lscape) is called exactly ONCE — the multi-polygon outside_view is handled by the viewcone machinery per drawn part, not by re-running the landscape per polygon.
- acdreamEvidence: RetailPViewRenderer.DrawLandscapeThroughOutsideView loops `foreach (var slice in clipAssembly.OutsideViewSlices)` calling ctx.DrawLandscapeSlice per slice (RetailPViewRenderer.cs:223-232); each callback runs the FULL sky + terrain + outdoor-entity + weather sequence (GameWindow.DrawRetailPViewLandscapeSlice :9465-9551).
- portShape: Draw the landscape once under the union region: either upload all OutsideView polygons as the multi-region the shaders test (requires >1 region per pass in the UBO/SSBO scheme), or scissor to the union AABB + plane-clip per geometry against its best-fit slice. The faithful shape is retail's: one landscape pass, per-part viewcone test against outside_view.
### [MEDIUM] flood-convergence-heuristics (UNVERIFIED (verifier hit token limit)) — The flood carries acdream-only convergence heuristics (re-enqueue cap, NDC snap-dedup, eye-inside-opening rescue) with frame-to-frame membership effects at edges
- blastRadius: #109 far-door oscillation is the natural symptom: at grazing/far apertures the heuristics (MaxReprocessPerCell cap binding, CanonicalKey snap collapsing or admitting a drifted region, the 1.75 m eye-rescue toggling) flip a far cell in/out of OrderedVisibleCells across frames -> its shell/objects strobe. NOT proposing to revert the keep-listed flood port — this is the residual divergence inventory inside it.
- retailEvidence: ConstructView's termination is watermark-based: AddViewToPortals (pc:433446) compares the last-incorporated view watermark vs current view_count and handles growth in place; cells append to cell_draw_list once per pop (pc:433783). Retail has no per-cell pop cap, no NDC grid snap-dedup, and no eye-distance rescue constant — its 3D homogeneous clip (GetClip finish=1 -> polyClipFinish, pc:702749 per PortalProjection.cs header) makes those unnecessary.
- acdreamEvidence: PortalVisibilityBuilder.cs:51 MaxReprocessPerCell=16 (re-enqueue allowed when a popped cell's view grows, :348-354 — coexisting with the enqueue-once `queued` set :109); PortalView.cs:97 DedupGridNdc=1e-3 snap + collinear canonicalization :115-164; EyeStandingPerpDist=1.75 m rescue PortalVisibilityBuilder.cs:258-267/:869; MinW=0.05 / EyePlaneW=1e-4 PortalProjection.cs:182-188.
- portShape: Confirm retail's exact re-enqueue semantics in Ghidra (open question below), then converge: if retail never re-enqueues, drop the cap+re-enqueue and propagate late growth in place exactly as AddViewToPortals does; keep the snap-dedup only as an assertion (it should become unnecessary once the region pipeline is drift-free). Each heuristic removed shrinks the #109 oscillation surface.
### [MEDIUM] exit-portal-mask-pass-dormant (confirmed) — Retail's exit-portal poly pass (DrawCells Loop 1) is wired as a callback that no production caller sets
- correctedClaim: Claim stands as written, with two refinements: (1) retail's exit-portal poly pass (and the LScape draw + portalsDrawnCount-gated z-clear it pairs with) only executes when outside_view.view_count != 0 — i.e. on frames where any outside view exists; (2) the pass writes each exit-portal polygon's REAL projected depth (maxZ2=6: z-write on, DEPTHTEST_ALWAYS, alpha=0 color-invisible) and is also what arms the next frame's z-clear via portalsDrawnCount++ — so acdream is missing a coupled pair (mask + armed clear), not just the mask. acdream's callback is dead in the entire worktree (not even tests assign it).
- verifier notes: RETAIL SIDE — re-derived from Ghidra (not BN pseudo-C):
1. PView::DrawCells (Ghidra decompile @ 0x005a4840) matches the claim exactly. Inside `if (outside_view.view_count != 0)`: Render::useSunlightSet(1) → LScape::draw → D3DPolyRender::FlushAlphaList → frameStamp++ → conditional z-clear `if (forceClear || portalsDrawnCount != 0) { portalsDrawnCount = 0; vtbl+0x2c(4, &RGBAColor_Black, 1.0f); }` → Loop 1: for each cell in cell_draw_list (reverse), if structure->drawing_bsp != null, for each setup_view slice (`CEnvCell::setup_view(cell, i)`), for each portal with `other_cell_id == -1` (stride 0x18): `D3DPolyRender::DrawPortalPolyInternal(portal->portal, false)`. Then useSunlightSet(0)/restore_all_lighting → Loop 2 shell pass (vtbl+0x5c per slice) → Loop 3 object lists (vtbl+0x64). So the exit-portal poly pass is AFTER LScape + the portalsDrawnCount-gated z-clear and BEFORE the shell pass, as claimed.
2. D3DPolyRender::DrawPortalPolyInternal (Ghidra @ 0x0059bc90), param_2=false path selects global `maxZ2`, whose static initializer is 6 (pc:1105964 `00820e14 int32_t maxZ2 = 0x6`; maxZ1 = 7 at pc:1105965); no runtime writes found. Decoding flags=6 (0b110) against the decompile: bit0=0 → vertex z = real projected z/w (not the 0.99999994 far-plane constant); bit1=1 → vertex alpha forced to 0 (`~(flags<<30) & 0x80000000` = 0) under SetBlendFunction(SRCALPHA, INVSRCALPHA) → color-invisible; bit2=1 → z-write ENABLED; depth mode = DEPTHTEST_ALWAYS; SetStageTexture(0,null); SetCullMode(NONE); DrawPrimitiveUP(TRIANGLEFAN). So Loop 1 is precisely a color-invisible, depth-always, z-writing draw of each exit-portal polygon at its REAL depth — "draw-the-portal-poly z machinery" as claimed. Additionally `portalsDrawnCount++` fires only on the param_2=false path, i.e. this pass is what ARMS the next frame's z-clear — the clear and the poly pass are a coupled pair; acdream reproduces neither half. Xrefs (Ghidra function_xrefs): callers are DrawCells (005a49b7), PView::DrawPortal (005a5b7c), PView::ConstructView (005a5a7b) — consistent with this being PView-internal machinery.
ACDREAM SIDE — all citations check out:
3. RetailPViewRenderer.cs:325-343 — DrawExitPortalMasks orchestration exists (reverse OrderedVisibleCells, per GetCellSlicesOrNoClip slice — mirrors retail's reverse cell_draw_list per-slice loop) but no-ops at :331-332 when ctx.DrawExitPortalMasks is null. It is invoked at the retail-faithful position in both flows: DrawInside at :95 (after DrawLandscapeThroughOutsideView at :93, before DrawEnvCellShells at :104) and DrawPortal at :204.
4. No production caller sets the callback — verified STRONGER than claimed: `grep 'DrawExitPortalMasks\s*='` across the entire worktree returns ZERO matches (not even tests assign it; only the nullable declarations at RetailPViewRenderer.cs:497/534/558). The DrawInside production context (GameWindow.cs:7604-7663) assigns RootCell/NearbyBuildingCells/DrawLandscapeSlice/ClearDepthSlice/DrawCellParticles/EmitDiagnostics etc. but never DrawExitPortalMasks; the DrawPortal context (GameWindow.cs:7780-7798) likewise omits it. Dead plumbing confirmed.
5. The substitute machinery is as claimed: ClearDepthSlice at GameWindow.cs:7644-7652 — for indoor roots a SCISSORED full depth clear over each OutsideView slice's NDC AABB (invoked per slice at RetailPViewRenderer.cs:234-235, after all landscape slices draw); for the outdoor node `null` (no depth op at all), with the comment at GameWindow.cs:7635-7643 explicitly documenting the full-buffer-wipe hazard the suppression works around. Stencil claim verified: grep for Stencil in src/ hits only the frame-start global Clear (GameWindow.cs:7156), a diagnostics readout (:9651), save/restore helpers (GLStateScope.cs:112-181, GLHelpers.cs:239), and framebuffer attachment plumbing (ManagedGLFrameBuffer.cs) — no production stencil pass.
IS THE DIVERGENCE REAL? Yes. Retail's depth story at exit portals is ONE path regardless of viewer location: (armed) full z-clear + per-exit-portal real-z color-invisible poly write. acdream substitutes two different non-equivalent behaviors branched on IsOutdoorNode: an AABB-footprint clear-to-FAR (wrong polarity — retail writes the doorway's real z, acdream erases to far; and an axis-aligned superset of the portal poly) indoors, and nothing outdoors. No other acdream mechanism is equivalent (the gl_ClipDistance shell clip is the Render::set_view geometric-plane mechanism, not the z-mask). The claim's port-shape note is also correct: GameWindow.cs:7644 is itself an indoor/outdoor branch retail does not have, so the faithful port (depth-only exit-portal quads per slice, plus the portalsDrawnCount-armed z-clear) would unify it.
REFINEMENTS (not refutations): (a) retail's whole Loop-1 block — including LScape::draw and the z-clear — is gated on `outside_view.view_count != 0`; in a fully sealed interior with no outside view none of it runs, so the pass only matters on frames where outside is visible (which is exactly the doorway/#109 regime). (b) DrawPortalPolyInternal also skips degenerate portal polys whose vertices all sit at x or y = ±12.0 (cell-boundary extent quads). (c) Severity "medium" is fair — it is a depth-correctness artifact class at door apertures plus a workaround-shaped branch, not a top-level invariant break on its own.
- blastRadius: acdream substitutes a scissored full depth-clear per slice (indoor roots) / no clear (outdoor node) for retail's draw-the-portal-poly z machinery. Depth relationships at doorways therefore differ from retail by construction — candidate contributor to far-door artifacts (#109's visual component) and to the outdoor-node decision to suppress the clear entirely (GameWindow.cs:7635-7644 comment documents the wipe hazard the suppression works around).
- retailEvidence: DrawCells Loop 1 (Ghidra 0x005a4840): per cell, per setup_view slice, D3DPolyRender::DrawPortalPolyInternal(portal->portal, false) for every portal with other_cell_id == -1, executed AFTER the LScape draw + the portalsDrawnCount-gated z-clear (vtbl+0x2c flag 4) and BEFORE the shell pass.
- acdreamEvidence: RetailPViewRenderer.DrawExitPortalMasks no-ops when ctx.DrawExitPortalMasks is null (:331-332); neither production context initializer assigns it (GameWindow.cs:7604-7663 DrawInside ctx, :7780-7798 DrawPortal ctx). No production stencil use exists (only save/restore helpers in GLStateScope.cs/GLHelpers.cs/ParticleBatcher.cs).
- portShape: Either implement the retail pass (draw exit-portal quads depth-only per slice, replacing the scissored glClear trick and the outdoor-node suppression) or delete the dead callback plumbing. The faithful port is the former; it also unifies the indoor/outdoor depth story that currently branches at GameWindow.cs:7644.
### [MEDIUM] legacy-outdoor-branch-remnant (adjusted) — A second full render path (clipRoot == null block) survives with different gates than the unified path
- correctedClaim: A second, separately-coded render path (the clipRoot == null block, GameWindow.cs:7726-7831) survives with gates that differ from the unified DrawInside path — CONFIRMED on the acdream side in every cited respect. The retail premise must be corrected, however: retail does NOT route every in-world frame through DrawInside(viewer_cell). Ghidra 0x453aa0 (SmartBox::RenderNormalMode) shows an explicit top-level branch on (viewer.objcell_id & 0xffff) < 0x100: outdoor viewers go straight to LScape::draw (after Render::set_default_view + useSunlightSet(1)), and only indoor viewers go through vtable+0x48 RenderDeviceD3D::DrawInside PView::DrawInside PView::DrawCells (0x005a4840), whose outside_view block (gated on outside_view.view_count != 0, with Render::PortalList = &outside_view) is where LScape::draw appears for indoor frames. The invariant that actually supports this divergence is narrower but still decisive: retail's two entries funnel into ONE shared drawing pipeline (the same LScape::draw building DrawPortal DrawCells machinery) with identical gates the outdoor entry is the degenerate full-screen-view case and retail's "viewer cell unknown" input (objcell_id == 0 satisfies < 0x100) lands in that SAME pipeline. acdream's analog of cell-unknown (viewerCellId == 0 clipRoot null) instead lands in a hand-written second pipeline whose gates diverge from the unified path in at least three verified ways: (1) indoor-parented entities dat statics AND live dynamics with an indoor ParentCellId are dropped wholesale by Partition over an empty visible set (GameWindow.cs:7732-7734 + InteriorEntityPartition.cs:35-44,67-68), recovered only partially by the 1-ring DrawPortal look-in (:7748-7811); (2) unattached scene particles (AttachedObjectId == 0) DO draw via the unfiltered global Scene-pass call (:7862-7867, reachable because clipAssembly is always null in this branch), whereas the unified path's only Scene-pass particle draws both filter to AttachedObjectId != 0 (:9523-9529, :9570-9576) and never draw unattached emitters; (3) a raw _wbDrawDispatcher.Draw fallback with no cell gating at all when _interiorRenderer is null (:7827-7831). Reachability as claimed: clipRoot == null viewerRoot == null AND viewerCellId == 0 (OutdoorCellNode.Build never returns null, OutdoorCellNode.cs:23-30; _outdoorNode built whenever viewerCellId != 0, GameWindow.cs:7458-7482), and viewerCellId falls back to playerRoot?.CellId ?? 0u under legacy/debug cameras (:7301-7305) so pre-spawn and legacy-camera-outdoors frames land here, and any future regression that zeroes the viewer cell silently lands here too. The port shape stands: shrink the null branch to the login/pre-spawn sky-only minimum (K-fix1) and route every in-world frame through DrawInside, after relocating the unattached-particle draw into the unified path.
- verifier notes: RETAIL re-derivation (Ghidra MCP, 127.0.0.1:8081, per the prefer-Ghidra rule): (1) Decompiled SmartBox::RenderNormalMode @ 0x453aa0 — it contains an explicit if/else on bVar4 = ((viewer.objcell_id & 0xffff) < 0x100). True (outdoor) branch: LScape::update_viewpoint, Render::update_viewpoint, Render::set_default_view, Render::useSunlightSet(1), LScape::draw no PView, no DrawInside. False (indoor) branch: optional LScape::update_viewpoint(Position::get_outside_cell_id) when seen_outside, then (*(render_device->vtbl+0x48))(this->viewer_cell). (2) Decompiled the vtable target's body: RenderDeviceD3D::DrawInside(CEnvCell*) @ containing 0x0059f0d6 is a one-line wrapper calling PView::DrawInside(indoor_pview, cell); its CEnvCell* parameter type itself shows the indoor-only dispatch. (3) Decompiled DrawCells @ 0x005a4840 — it is PView::DrawCells; first block gated on (this->outside_view).view_count != 0 does Render::useSunlightSet(1); Render::PortalList = &this->outside_view; LScape::draw(this->lscape) — confirming the claimed 'LScape::draw is INSIDE DrawCells Loop 0' for the indoor flow. (4) function_xrefs?name=DrawCells: called only from PView::DrawInside (0x005a595b) and PView::DrawPortal (0x005a5b53). Conclusion: the claim's retail sentence 'RenderNormalMode -> DrawInside(viewer_cell) every in-world frame; there is no alternate outdoor pipeline' is an overstatement — exactly the branch-flattening error class this project has been burned by — but the corrected retail invariant (one shared pipeline, identical gates, unknown-cell input degrades into it) still supports the divergence, arguably more sharply: retail's fallback cannot diverge in gates because it IS the normal pipeline; acdream's can and does. ACDREAM verification (all by reading production code): GameWindow.cs:7497 (clipRoot = viewerRoot ?? _outdoorNode); :7458-7482 (_outdoorNode rebuilt per frame, only when viewerRoot is null && viewerCellId != 0); OutdoorCellNode.cs:23-30 (Build always returns a LoadedCell — never null); GameWindow.cs:7301-7305 (viewerCellId = chase camera ViewerCellId only when _playerMode && _retailChaseCamera != null && CameraDiagnostics.UseRetailChaseCamera, else playerRoot?.CellId ?? 0u — legacy/debug camera with player outdoors gives 0); :7726-7831 (the else block: Partition over cleared _outdoorRootNoCells at :7732-7734; sigOutdoorRootObjectCount/outdoor bucket draw :7735-7746; 1-ring candidate gather + DrawPortal look-in :7748-7811; LiveDynamic fallback :7813-7823; raw _wbDrawDispatcher!.Draw fallback :7827-7831). InteriorEntityPartition.cs:61-72 — AddByCellOrOutdoor silently returns (drops the entity from ALL buckets) when the cell id is indoor (low word >= 0x100, != 0xFFFF) and not in the visible set; with the empty set every indoor-parented entity is dropped, including ServerGuid != 0 live dynamics with indoor ParentCellId (:35-38), which are then also absent from the LiveDynamic fallback (it only holds null-ParentCellId entities, :39-40). Particles: the Scene-pass draw at :7846 is gated clipRoot is null; in that branch clipAssembly is provably always null (declared null :7501, only assigned :7665 inside the clipRoot != null branch), so the unfiltered global draw :7862-7867 runs — drawing AttachedObjectId == 0 emitters; the unified path's only Scene-pass particle sites are DrawRetailPViewLandscapeSlice :9523-9529 (filter AttachedObjectId != 0 && in _outdoorSceneParticleEntityIds) and DrawRetailPViewCellParticles :9570-9576 (filter AttachedObjectId != 0 && in _visibleSceneParticleEntityIds) — unattached scene emitters never draw there. Post-scene weather :7874-7889 likewise gated clipRoot is null; unified-path weather lives in the landscape slice :9533-9541. Side finding (not load-bearing): the filtered particle sub-branch at :7848-7858 (clipAssembly is not null inside clipRoot is null) is dead code by the same clipAssembly argument. Not verified here: the 'stricter membership rule' of the look-in — the claim explicitly defers it to a separate divergence entry. Severity medium and the proposed port shape are consistent with the evidence; no workaround is being proposed (the shape is delete-and-unify, matching the keep-listed Option A direction).
- blastRadius: Reachable pre-spawn and under legacy/debug cameras; any future regression that nulls the outdoor node silently lands here. Its gates differ from the unified path in at least three ways: indoor-parented entities are dropped wholesale (empty partition set), unattached scene particles DO draw (unlike the unified path), and its look-in uses a stricter membership rule (next entry). One-path violations of exactly this shape caused the original FLAP.
- retailEvidence: Retail has one render path: RenderNormalMode (0x453aa0) -> DrawInside(viewer_cell) every in-world frame; there is no alternate outdoor pipeline (LScape::draw is INSIDE DrawCells Loop 0, Ghidra 0x005a4840).
- acdreamEvidence: GameWindow.cs:7726-7831: global outdoor bucket via Partition(_outdoorRootNoCells empty set, …) (:7732-7746), 1-ring DrawPortal look-in (:7748-7811), LiveDynamic fallback (:7813-7823), raw _wbDrawDispatcher.Draw fallback when _interiorRenderer is null (:7827-7831); plus the only live sites of global scene particles (:7846-7868) and post-scene weather (:7874-7889).
- portShape: Shrink the null-clipRoot case to the login/pre-spawn minimum (sky only — the K-fix1 requirement) and route every in-world frame through DrawInside; delete the DrawPortal look-in and the global-bucket draw once the outdoor node is guaranteed non-null whenever a world exists. Move the unattached-particle draw into the unified path first (see particles entry) so deleting this branch loses nothing.
### [MEDIUM] drawportal-membership-rule-mismatch (UNVERIFIED (verifier hit token limit)) — DrawPortal and DrawInside use different drawable-cell membership rules in the same file
- blastRadius: The slot-keys rule silently drops cells whose view reduced to IsNothingVisible/slot-less — the documented grey-walls bug class (unsealed shells showing clear color), still live on the look-in path. Any frame that transitions between the two paths can flash a cell in/out.
- retailEvidence: Retail has one membership rule: every cell in cell_draw_list draws (DrawCells iterates cell_draw_num unconditionally, Ghidra 0x005a4840); a cell entered the list exactly by being popped in ConstructView (pc:433783).
- acdreamEvidence: DrawInside: drawableCells = new HashSet(pvFrame.OrderedVisibleCells) with the R1 comment naming the old slot-keys filter as the grey-walls bug (RetailPViewRenderer.cs:66-71). DrawPortal: drawableCells = new HashSet(clipAssembly.CellIdToSlot.Keys) (:182) — the exact filter the R1 fix removed.
- portShape: One-line alignment: DrawPortal adopts OrderedVisibleCells. Folds into deleting the legacy branch if that lands first.
### [MEDIUM] livedynamic-invisible-under-interior-roots (UNVERIFIED (verifier hit token limit)) — LiveDynamic entities (ServerGuid != 0, unresolved ParentCellId) draw only when the root is the outdoor node
- blastRadius: A just-spawned or not-yet-cell-resolved server entity is invisible for as long as the viewer is indoors (player standing in the inn when something spawns). The dispatcher's ResolveEntitySlot would also CULL them under active routing (by design), but here they are never even submitted — two layers agree to hide them with no retail basis.
- retailEvidence: Retail draws every object out of its cell's object list (DrawObjCell per cell, DrawCells Loop 3, Ghidra 0x005a4840) — an object always has a cell in retail (physics owns placement), so there is no 'unresolved' class to drop; the faithful behavior is resolve-then-draw, not drop.
- acdreamEvidence: The LiveDynamic draw is gated on clipRoot.IsOutdoorNode (GameWindow.cs:7716-7724); interior roots have no LiveDynamic submission site. ResolveEntitySlot returns ClipSlotCull for serverGuid != 0 with null ParentCellId while routing is active (WbDrawDispatcher.cs:449-450).
- portShape: Make cell resolution the fix, not the draw site: entities should carry a resolved ParentCellId by the time they render (membership pipeline), making the bucket empty by construction; until then, draw LiveDynamic under interior roots gated by the player-cell slice the way the outdoor node does, so nothing blinks out.
### [LOW] dual-frustum-implementations (UNVERIFIED (verifier hit token limit)) — Two frustum-cull implementations and two center/radius windows gate shells vs the objects inside them
- blastRadius: Margin disagreements only: a cell's shell (WbFrustum on the cell AABB at Prepare time, camera-LB-centred _nearRadius ring) and the SAME cell's statics (FrustumCuller per entity AABB, player-LB streaming window) can disagree for one frame at screen edges or at ring boundaries when camera and player straddle different landblocks -> shell-without-statics or statics-without-shell popping. Cheap consolidation; low urgency.
- retailEvidence: Retail has one viewcone: the planes Render::set_view installs (pc:343750) are the only cull surface both the shell submit (planeMask=0xffffffff, pc:427922) and the mesh check (viewconeCheck 0x0054c250) read.
- acdreamEvidence: EnvCellRenderer uses WbFrustum (TestBox :612, Intersects :642, updated from envCellViewProj at GameWindow.cs:7396) + a camera-centred radius ring (:581-588, center from camPos at GameWindow.cs:7390-7391); WbDrawDispatcher/terrain use FrustumPlanes+FrustumCuller built from the same VP (GameWindow.cs:7221; WbDrawDispatcher.cs:593-595/:662-666; TerrainModernRenderer.cs:216-218); the streaming window is player-centred (GameWindow.cs:7381-7387).
- portShape: Pick FrustumPlanes/FrustumCuller as the single implementation (already shared by terrain+entities), port EnvCellRenderer's Prepare to it, and key both radius windows off the same center. A conformance test comparing the two on random AABBs first (open question) de-risks the swap.
## OPEN QUESTIONS
- Does retail ever re-enqueue a cell into cell_todo_list after its first pop when its portal_view later grows, or is all late growth handled strictly in place via AddViewToPortals (pc:433446)? acdream keeps BOTH an enqueue-once set and a MaxReprocessPerCell=16 re-enqueue path (PortalVisibilityBuilder.cs:109 vs :348) — the faithful termination rule must be confirmed in Ghidra (decompile 0x005a5ab0-area ConstructView + AddViewToPortals) before porting it, since it directly affects #109.
- What does retail's DrawMesh do with viewconeCheck's PARTIALLY_INSIDE result — draw whole, or descend to per-poly clipping? Needed to size the viewcone port for object lists (decompile DrawMesh at 0x005a08e4 region).
- DrawCells Loop 3 sets Render::PortalList = cell->portal_view.data[num_view-1] — only the LAST entry. Is portal_view a stack whose last element is the accumulated union (so this is the full view), or does retail intentionally gate objects against only the most recent slice? Affects how acdream should aggregate CellViews for the object gate.
- Do any production PhysicsScript/content paths spawn Scene-pass particle emitters with AttachedObjectId==0? If yes, they are invisible on every unified-path frame today (the only draw site requiring ==0 is the unreachable legacy branch, GameWindow.cs:7857) — needs a live capture to size the blast radius before the particle re-route.
- Is the clipRoot==null branch ever reached in-world in player mode (can RetailChaseCamera.ViewerCellId be 0 while spawned)? Code reading says only pre-spawn/legacy cameras, but a runtime assertion/probe would prove the legacy branch is safe to shrink.
- Can WbFrustum and FrustumCuller actually disagree on the same AABB+VP in practice (both are Gribb-Hartmann variants)? A randomized conformance test would either justify immediate consolidation or document equivalence.
- Retail's z-clear in DrawCells Loop 0 is full-buffer but GATED on portalsDrawnCount/forceClear (Ghidra 0x005a4840) — what increments portalsDrawnCount, and does that gate reproduce acdream's outdoor-node 'no depth clear' decision naturally? Settling this defines the faithful replacement for the ClearDepthSlice scissor trick and its IsOutdoorNode suppression (GameWindow.cs:7644).

View file

@ -0,0 +1,98 @@
# ACRender::polyClipFinish — W=0 eye-plane clip pseudocode (the knife-edge port)
**Source:** `ACRender::polyClipFinish` at `0x006b6d00`,
`docs/research/named-retail/acclient_2013_pseudo_c.txt:702749-702988`.
Read 2026-06-11 for the knife-edge in-plane portal clip port
(handoff `docs/research/2026-06-11-tower-stairs-fundamental-handoff.md` §5).
## Signature (reconstructed)
```
polyClipFinish(view_vertex** inVerts, // arg1 — homogeneous clip-space verts (x,y,z,w)
int inCount, // arg2
Vec2Dscreen** outVerts, // arg3 — output vertex pointers
int* outCount, // arg4
int planeMask) // arg5 — per-edge skip mask (bit set = poly already
// fully inside that portal_view edge)
```
## Part 1 — the W=0 eye-plane pass (0x006b6d5d0x006b6f12)
```
scan = inCount - 1
while scan >= 0: # walk verts from the END
if inVerts[scan].w < 0: break # found a vertex BEHIND the eye plane must clip
scan -= 1
if scan < 0: goto edge_clips # all w >= 0 → skip the W pass entirely
# homogeneous Sutherland-Hodgman against w = 0, intersections EMITTED:
out = []
prev = inVerts[0]; prevIn = (prev.w >= 0)
for cur in inVerts[last..0]: # retail iterates indices descending
curIn = (cur.w >= 0)
if curIn != prevIn:
t = prev.w / (prev.w - cur.w) # 0x006b6ea0: w0 / (w0 - w1)
emit(prev + t*(cur - prev)) # interpolates x, y, z, w → lands at w == 0 exactly
if curIn: emit(cur)
prev, prevIn = cur, curIn
if len(out) < 3: return # 0x006b6f00: reject fewer than 3 survivors
inVerts = out # ping-pong to tempPtBuf
```
**x87 flag-decode note** (the BN polarity trap, [[feedback_bn_decomp_field_names]]):
the scan loop's `test ah, 0x5` (C0|C2) breaks on **w < 0**, NOT w ≥ 0. Decoded by
case analysis: the all-behind polygon must reach the W pass and clip to empty
(reject), and the common all-in-front polygon must skip the pass — only the
break-on-negative decode yields both. The inside predicate in the clip pass
(`test ah, 0x41`, C0|C3) is **inside ⇔ w ≥ 0** (emit-on-sign-change with
`t = w0/(w0w1)` confirms: t∈[0,1] requires opposite signs).
## Part 2 — portal_view edge clips (0x006b6d820x006b7030)
```
for each portal_view edge (vertex pair), mask-gated (planeMask bit set → skip):
# homogeneous 2D edge function for vertex P against edge (a → b):
# side(P) = (P.x a.x·P.w)·(b.y a.y) (P.y a.y·P.w)·(b.x a.x)
# (0x006b6e05) — linear in (x, y, w): valid for w = 0 verts (directions).
Sutherland-Hodgman with intersection emission (t = s0/(s0 s1), all 4 comps)
if survivors < 3: return # 0x006b6fe1
*outCount = survivors # 0x006b7006
```
## The load-bearing semantics for acdream
1. **Clip at w ≥ 0 EXACTLY** — boundary intersections land at w == 0. A w=0
vertex is a homogeneous DIRECTION; the polygon containing it represents the
unbounded screen region extending toward that direction. This is what makes
an eye-crossing portal (climbing through a stair opening) produce the
correct large half-region instead of a bounded sliver:
- At `w ≥ ε` (our old `EyePlaneW = 1e-4`), boundary verts are finite NDC
points ~1e4 units out along the portal-plane horizon line; the polygon's
screen intersection still APPROXIMATES the half-region, but the divide
and the dedup/merge operate on degenerate near-collinear coordinates.
- At `w = 0`, the edge functions stay exact (linear in homogeneous coords)
and no divide ever touches a w=0 vertex (see invariant below).
2. **A w=0 vertex can never survive the region clip into the divide** when the
clip region is BOUNDED: for a bounded convex CCW region the edge directions
wrap 360°, so a nonzero direction fails at least one edge's inside test.
Our regions are always bounded (FullScreenQuad and its descendants), so the
post-clip divide is safe by construction. The measure-zero exception
(direction exactly on a region corner) is guarded by a non-finite check
that returns empty — identical net behavior to retail's degenerate sliver.
3. **Empty is a verdict, not an error.** `< 3 survivors → reject` at every
stage; retail has NO eye-in-opening rescue anywhere in this path. The
acdream `EyeInsidePortalOpening` rescue was the documented compensation for
the `EyePlaneW = 1e-4` divergence (T2 ledger) and is deleted with this port.
## What is NOT ported here
- `cdstW = 0.000199999995` (pinned at `0x007247d5`) — consumed elsewhere
(its consumer is still unmapped; `landPolysDraw` 0x006b7040 uses the same
0.0002 inline for plane side tests). `PortalSideEpsilon = 0.01` stays as the
documented root-lag tolerance (T2 refutation: retail's 0.0002 needs
eye-exact viewer-cell tracking first).
- The `planeMask` per-edge skip — a perf short-circuit; our ClipToRegion
clips against every region edge unconditionally.
- Retail's descending vertex iteration order — Sutherland-Hodgman output is
order-invariant up to rotation; we keep ascending.

View file

@ -0,0 +1,259 @@
# T6 (BR-7) SHIPPED + T5 gate verdict + post-T5 fixes — session handoff (2026-06-11)
**Branch:** `claude/thirsty-goldberg-51bb9b`, HEAD `0e6e24f`. **Nothing on main.**
**Suites (all green):** Core **1416 / 0 / 2 skips** (skip 1 = pre-existing
`PvsConformanceTests`; skip 2 = `BSPStepUpTests.D4` with the #116 reference),
App **227**, UI **420**, Net **294**.
This session: (1) shipped **T6 / BR-7** — the A6.P4 per-cell shadow collision
architecture, the last code phase of the holistic building-render port;
(2) ran **T5**, the single comprehensive user visual gate (PARTIAL PASS —
the entire collision half passed, four render artifacts filed); (3) fixed
**#117** (aperture punch-through), armed **#120** (flood-growth tripwire),
narrowed **#118** (exit-vanish). The holistic port (BR-1…BR-7) is now
**code-complete and partially visually validated**.
Read first, always: `claude-memory/project_render_pipeline_digest.md` +
`claude-memory/project_physics_collision_digest.md` (both updated this
session — current-truth banners on top).
---
## 1. The session's commit ledger (oldest first)
| SHA | What |
|---|---|
| `6ec4cde` | T6 C1 — signed `OtherPortalId` + the `>= 0` building-transit gate (`check_building_transit` 0x0052c5d0; BN renders the comparison unsigned — Ghidra-proven sign extension; wire 0xFFFF = 1 = no reciprocal portal). Multi-sphere `CheckBuildingTransit` overload + `hitsInteriorCell` out. |
| `abf36e2` | T6 C2 — `CellTransit.BuildShadowCellSet`: the registration-side sphere-overlap portal flood (verbatim `find_cell_list` 0x0052b4e0 via `calc_cross_cells(_static)`). Indoor seed → that cell + growing-array walk; outdoor seed → block-crossing `AddAllOutsideCells`; outdoor cells in the walk run the building bridge; statics get the `do_not_load` prune ({seed} stab list — also strips outdoor cells, retail-faithful). The spec's VisibleCellIds rule was REFUTED (WF1) — no visibility list anywhere in shadow placement. 11 unit tests. |
| `dbfbf85` | T6 C3 (FUSED — the BR-2 half-port lesson: registration+query co-dependent) — registry rewritten to flood-seeded per-cell lists (CylSphere flood rule: base pt + cyl radius, cap 10, 0x0052b9f0; keep-when-empty pc:283540; `RefloodLandblock` streaming hook = retail `init_objects → recalc_cross_cells`); **building shells left the registry** (per-LandCell channel: `Transition.FindBuildingCollisions` = `CSortCell::find_collisions` 0x005340a0 → `find_building_collisions` 0x006b5300; ONE building per ORIGIN landcell, `init_buildings` 0x0052fd80 verified verbatim + ACE cross-ref; only caller is 0x005340aa); **query strictly per-cell** (`FindObjCollisionsInCell` = `find_obj_collisions` 0x0052b750; insert order env→building→objects on the primary, then `CheckOtherCells` runs env+building+objects per OTHER cell with the carried-cell advance AFTER all object passes — `transitional_insert` 0x0050b6f0 OK_TS case); placement weakening `center_solid=0` when `BldgCheck && HitsInteriorCell` (0x0053a82e / 0x005399d8; both fields added to SpherePath, rebuilt at every cell-array build). **DELETED: the radial 9-LB sweep, the +5 m query pad, the b3ce505 indoor gate, the isViewer exemption.** 3 of the 4 #99-era reds flipped green (door apparatus → `…_Blocks`, tick-13558 asserts the door BLOCKS, tick-22760 pins the blocking invariant). |
| `ca4b482` | T6 C4 — A6.P5 `hasExitPortal` topology widening DELETED (outside cells enter the collision array ONLY on the retail straddle — same flag as the membership pick; the WF1 correction "re-gate, don't delete" implemented); **#90 stickiness REMOVED** (dead code — `ResolveCellId`'s only caller is the cache-null test fallback; the ordered-pick hysteresis owns doorway behavior). Two old A6.P5 pins inverted to retail truth. |
| `60c1070` | docs — T6 ship closeout: #99/#90 closed in ISSUES, #97 likely-close note, **#116 filed** (slide-response family), plan stamped. |
| `af5d424` | docs — **T5 gate verdict** (§3 below): #108/#109/#97 closed (user-confirmed), #117#120 filed. |
| `2d15084` | **#120 armed** — tripwire self-attribution (`DumpPropagationChain`: root, eye, per-cell frequency, 24-entry chain tail) + `ConvergenceTripwireCount` observable + 2 dat-backed convergence sweeps as regression pins (3024 builds, 0 firings — production-only ingredients suspected). Retail finding: retail RECURSES natively too (`AddViewToPortals → FixCellList → AdjustCellView`, 0x005a52d0/0x005a5250/0x005a5770, no depth guard) — depth-128 = slow-convergence laps, not necessarily a true loop. |
| `478c549` | **#117 FIXED** — depth-gated punch (§4 below). |
| `0e6e24f` | docs — **#118 narrowed** (§5 below): two suspects exonerated by read; candidates + exit-walk harness design recorded in ISSUES. |
---
## 2. T6 / BR-7 — what the architecture is now (one paragraph)
Objects register into the EXACT cells their collision footprint overlaps,
computed once at registration by the retail sphere-overlap portal flood
(`BuildShadowCellSet`); a door straddling a threshold lands in BOTH the
outdoor landcell and the vestibule list. The collision query is strictly
per-cell (`GetObjectsInCell`) at retail's two sites — the primary insert
(env → building → objects) and `check_other_cells` (the same per OTHER
overlapped cell), with the carried-cell advance after all object passes.
Building shells are NOT shadow objects: an outdoor LandCell carries at most
one building reference (its origin cell) and runs the shell part-0 BSP
through `FindBuildingCollisions` with the `bldg_check`/`hits_interior_cell`
placement weakening. There is no spatial radius anywhere in the query path
— cell membership IS the broad phase. The b3ce505 stopgap, the A6.P5
widening, and the #90 stickiness are all gone.
**#116 (filed):** tick-22760's lateral-slide loss + BSPStepUp D4's
first-frame slide are a PRE-EXISTING slide-response family (probes prove
the cell-set layer innocent; BR-7 left both byte-identical). Fix shape:
ONE oracle-driven pass over `SlideSphere` + the first-contact frame
(`get_object_info` pc:279992 only seeds the NEXT frame). Do NOT patch the
degenerate-offset guard ad hoc.
---
## 3. T5 verdict (the user's reports are AXIOMS)
**✅ PASSED:** doors block both ways incl. off-center (#99 visual); cellar
descent/ascent clean + #108 grass-sweep GONE; interiors stable through
doorways incl. edge-on; inn 2nd floor clean (#97 CLOSED); #109 far-door
oscillation GONE; formerly-popping stairs now STABLE at all ranges
(the distance-pop class is dead). Rain-indoors not verifiable (clear).
**❌ Filed:**
- **#117** — aperture-shaped see-through: doors/interiors visible through
terrain hills + through nearer buildings. → FIXED this session (§4).
- **#118** — character clipped + vanishes for a moment when exiting houses
(viewer-indoor/player-outdoor window). → narrowed (§5), fix queued.
- **#119** — old tower: stair parts invisible (pre-existing, "same issue as
before"; the tower stairs ARE visible in retail — user axiom) + an
extraneous water barrel. **Lead from the T5 log:** exactly two
`[up-null] upload returned null for 0x00010002B4 / 0x00010008A8 — caching
EMPTY render data (permanently invisible)` lines at startup
(`t5-gate-launch.log:33-34`, untracked in the worktree root). Untouched.
- **#120** — `[pv-ERROR] in-place propagation tripwire at depth 128` on
cottage cells 0x…0175/0174/0162 during normal play (T2 invariant
tripwire). → armed for self-attribution (§1, `2d15084`); wait for the
next natural firing (any launch's log will carry the chain dump).
---
## 4. #117 — the fix that needs the re-gate
Decomp-settled root cause: retail's `DrawPortalPolyInternal` (0x0059bc90)
draws the punch with **DEPTHTEST_ALWAYS** + per-vertex far-Z (0.99999899,
`maxZ1` bit0) — it stomps ANY occluder depth unconditionally. Retail is
safe only because its outdoor pass is **painter's-ordered far→near**:
anything nearer redraws after the punch. Our z-buffered MDI frame has no
such order → the far house's aperture punch erased the near house's wall /
the hill's depth, and interiors + door entities (dynamics drawn last)
painted through — both #117 shapes.
Fix (`478c549`, `PortalDepthMaskRenderer`): the punch is now two passes —
**A)** stencil-mark where the aperture fan passes LEQUAL at its true depth
biased 0.0005 NDC toward the viewer (≈6 cm at 5 m; keeps #108's
terrain-hugging-the-door case punched), no depth write; **B)** far-Z write
with depth ALWAYS, stencil-gated EQUAL 1, zeroing stencil as it goes
(self-cleaning). The frame order guarantees correctness: terrain + ALL
building shells draw in the landscape stage BEFORE `DrawExitPortalMasks`,
so pass A tests against the real occluders. The SEAL (interior roots)
stays retail-verbatim single-pass (it runs right after the gated full
depth clear — nothing nearer to stomp). `WindowOptions` now requests
8 stencil bits explicitly. The stale "RESERVED — unwired" banner on the
class was corrected (T1 wired it via `DrawRetailPViewPortalDepthWrite`).
**Acceptance = the focused re-gate:** downhill door check, behind-house
openings, AND #108 cellar stays clean (the bias is the only regression
surface).
---
## 5. #118 — narrowed; the exit-walk harness is the next step
Exonerated by read: the draw partition (the local player carries
ServerGuid → Dynamics, never dropped) and entity-cell staleness
(`pe.ParentCellId = result.CellId` syncs per tick, GameWindow ~6855).
Live candidates (the doorway-crossing decision stack), in order:
1. **Eye/cell incoherence under camera damping** — the render root is the
sweep's `RetailChaseCamera.ViewerCellId` while the projection eye
(`camPos`) is the DAMPED position; during a crossing they can disagree
by the damping lag. This is the already-VERIFIED #115/BR-8a divergence
(retail damps FROM the published collided viewer; we damp from our own
damped eye) — fixing BR-8a may fix #118 outright.
2. **Exit-portal side test at the threshold** — eye ε-outside the door
plane while the root is still the interior cell →
`CameraOnInteriorSide` culls the exit portal → OutsideView EMPTY →
`SphereVisibleOutside` culls ALL outdoor dynamics (the player) for
those frames. Retail's `AdjustPosition` demotes the viewer cell to
outdoor the same moment the point exits (`seen_outside →
adjust_to_outside`), making the inconsistent state structurally brief.
3. The doorway-aperture cone tightness for an outdoor player + indoor
viewer.
**Next step (apparatus, not guessing — 3 hypotheses = build the
harness):** a deterministic exit-walk harness over the corner-building
cells (`CornerFloodReplayTests` infrastructure): per step of an eye+player
path crossing the doorway, drive the production decision stack headlessly
— viewer-cell resolution → `PortalVisibilityBuilder.Build(root)`
`ViewconeCuller.Build` → the exact `DrawDynamicsLast` visibility predicate
— and assert the player sphere stays visible on every step. All CPU; the
failing step pins which candidate fires. Full design in the ISSUES #118
entry.
---
## 6. Watchouts / DO-NOT-RETRY for the next session
- **No radial/spatial sweeps back into `ShadowObjectRegistry`**, no
topology-based outside-add, no gates — cell membership IS the broad
phase; the straddle flag is live-binary verified. (Physics digest rules.)
- **Do not patch the `SlideSphere` degenerate-offset guard ad hoc**
#116 wants the oracle read first.
- **The punch must stay depth-gated.** Reverting to bare ALWAYS without
painter's ordering re-opens #117 by construction.
- **#120: don't tune the tripwire constant** (128). The chain dump is the
lead; retail's own recursion has no guard — the fix will be about WHY
convergence is slow (dedup/merge admitting near-duplicates per lap), not
about the limit.
- The two convergence sweeps in `CornerFloodReplayTests`
(`PortalPlaneCrossings_…` / `InCellDirectionSweep_…`) are regression
pins — keep green.
- **Building channel is origin-cell-only** (retail-verbatim,
`init_buildings` + ACE cross-checked). If the re-gate shows soft wall
collision on a LARGE building, first write the dat conformance fact
(shell extent vs origin landcell for Holtburg models) before touching
the dispatch.
- The 4 GameWindow registration sites pass `seedCellId:`; live entities
seed from the wire cell id; `IsBuildingShell` entities skip the registry
entirely. Don't re-add them.
- xunit swallows `Console.WriteLine` — use ITestOutputHelper or %TEMP%
for harness diagnostics.
## 7. New apparatus (this session)
| Tool | Where | Purpose |
|---|---|---|
| `PortalVisibilityBuilder.ConvergenceTripwireCount` | static, test-visible | #120 observable; both Build + look-in sites |
| `DumpPropagationChain` | fires with the tripwire | root + eye + per-cell frequency + 24-entry chain tail |
| `PortalPlaneCrossings_InPlacePropagationConverges` | CornerFloodReplayTests | ±6 cm sweep across every portal plane, both seed sides |
| `InCellDirectionSweep_InPlacePropagationConverges` | CornerFloodReplayTests | 3×3×2 in-cell eye grid × 8 yaw × 3 pitch (3024 builds) |
| `Diagnostic_Tick22760_DumpEngineInternals` | DoorBugTrajectoryReplayTests | #116 repro dump (door found + BSP-only dispatched correctly) |
| `[bldg-channel]` probe | `Transition.FindBuildingCollisions` | per-channel-hit line under `ACDREAM_PROBE_BUILDING` |
| `t5-gate-launch.log` | worktree root (untracked) | the T5 session log — `[up-null]` ×2 + `[pv-ERROR]` ×3 evidence |
## 8. Next-session work order (work-order autonomy: drive, don't ask)
1. **#118** — build the exit-walk harness; fix what it pins (BR-8a is the
likely fix if candidate 1 confirms — the retail damping shape is
already verified in the plan).
2. **#119** — chase the `[up-null]` pair (identify the two models; why
`ObjectMeshManager`'s upload returned null; whether the
permanently-invisible cache should retry); then the barrel
(static-inclusion question).
3. **Focused re-gate with the user** (one launch, short list): downhill
door check + behind-house openings (#117), house-exit character
(#118), tower stairs + barrel (#119), #108 cellar stays clean, and
grep the log for `[pv-ERROR]` chain dumps (#120's self-attribution).
4. Then per the plan: BR-8a camera (may already be consumed by #118),
#116 oracle pass, roadmap/milestones closeout of the holistic port.
---
## 9. Paste-ready next-session prompt
```
Pick up acdream as a SENIOR 3D ENGINE DEVELOPER on the POST-T5 residual
fixes of the holistic building-render port. Worktree branch
claude/thirsty-goldberg-51bb9b, HEAD 0e6e24f. Nothing goes to main.
STATE: the holistic port is CODE-COMPLETE and T5-gated (partial pass).
T6/BR-7 (per-cell shadow collision) SHIPPED — #99/#90/#97/#108/#109 all
CLOSED (user-confirmed at T5). #117 (aperture punch-through occluders)
FIXED 478c549 — pending visual re-gate. Suites green: Core 1416/0/2skip,
App 227, UI 420, Net 294.
READ FIRST (in order):
1. Memory digests: project_render_pipeline_digest +
project_physics_collision_digest (current-truth banners on top; the
DO-NOT-RETRY tables APPLY — note the new no-radial-sweep /
no-topology-add / punch-stays-depth-gated / #116-oracle-first rules).
2. docs/research/2026-06-11-t6-br7-shipped-t5-gate-post-t5-handoff.md
(THE handoff: T6 architecture summary, T5 verdict, #117 fix detail,
#118 narrowing + harness design, #119 up-null lead, #120 armed
tripwire, watchouts, work order).
DO NEXT — #118 (character clipped+vanishes on house exit):
build the deterministic exit-walk harness designed in ISSUES #118 /
handoff §5 (CornerFloodReplayTests infrastructure; per step of an
eye+player doorway-crossing path, drive viewer-cell resolution →
PortalVisibilityBuilder.Build → ViewconeCuller → the DrawDynamicsLast
visibility predicate; assert the player sphere stays visible). The
failing step pins one of three candidates (handoff §5); candidate 1
(eye/cell incoherence under damping) is the verified #115/BR-8a
divergence — if it confirms, the fix is BR-8a's retail damping shape
(damp FROM the published collided viewer). 3 hypotheses already exist —
apparatus first, no speculative fixes.
THEN — #119 (tower stairs + barrel): chase the [up-null]
0x00010002B4/0x00010008A8 permanently-invisible upload failures
(t5-gate-launch.log:33-34) before any draw-path theorizing; the barrel
is a separate static-inclusion question.
THEN — the FOCUSED RE-GATE with the user (one launch, short checklist):
downhill door check + behind-house openings (#117), house-exit character
(#118), tower stairs + barrel (#119), #108 cellar stays clean; grep the
log for [pv-ERROR] chain dumps (#120 self-attributes on its next firing).
User retail reports are AXIOMS; no per-artifact live-probing.
Build + test green per commit. Baselines: App 227 / Core 1416 + 2 skips
/ UI 420 / Net 294.
```

View file

@ -0,0 +1,214 @@
# The AAB3 tower "broken stairs + water barrel" — fundamental handoff (2026-06-11 late)
**Branch:** `claude/thirsty-goldberg-51bb9b`. **Nothing on main.** Suites green:
App 242 + 1 skip / Core 1422 + 2 skips / UI 420 / Net 294.
**The user logged out INSIDE the tower in the BROKEN state** — the next login
restores there (claim `0xAAB30107` validates cleanly now), so the broken state
is one login away for the next session.
## 0. The one fact that reframes everything (READ THIS FIRST)
`user-session-capture2.log` — the user standing IN the tower, broken stairs +
barrel visible on screen — final dispatcher diagnostics:
```
[WB-DIAG] entSeen=11808510 entDrawn=11808510 meshMissing=0 ...
```
**meshMissing=0. entSeen == entDrawn. Every referenced mesh is loaded and every
walked entity is drawn — while the user SEES broken stairs.** The staircase is
NOT missing from the pipeline. It is being **drawn wrong** (wrong transforms,
wrong batches, or a stale partial classification). Every "the mesh didn't
load" theory is now DEAD for the persistent symptom. (226 ids — including
stair part `0x01000E2A` — DID go transiently missing during the login churn
and were self-healed by the new point-of-use re-arm; the question is what got
built or cached wrong during that window and STAYS wrong.)
## 1. Symptom + reproduction (user-verified, multiple sessions)
- The AAB3 tower (building[1], model `0x01001117`, cells `0x0107..0x010A`;
user pinned it by logging out inside — `[snap] claim=0xAAB30107`).
- Its spiral staircase = ONE cell static: **Setup `0x020003F2`, 43 parts**
(5 platforms `0x01000E2A` + 38 steps `0x01000E2B/2C/2D/2F/31/32`),
placement frames spiral z 0.35→15.15 (dat-proven,
`Issue119TowerDumpTests`). Four `0x020005D8` statics (part `0x01001774`,
barrel-shaped — REAL water-barrel models per the user's screenshot) sit at
wall positions.
- **Broken state** (reproduces on teleport-heavy / run-back logins, ~3 of 4
attempts): stairs render PARTIALLY or not at all (collision intact — the
invisible stairs are walkable to the top), and "a water barrel" shows near
the floor. **Clean state** (reproduced twice, screenshots in worktree:
`tower-rearm-verify.png`, `tower-selfheal-verify.png`): full spiral
staircase, no complaints. Same build can produce both — the divergence is
SESSION-SHAPED (what happened during login/streaming), and once broken it
stays broken for the session.
- User axiom: **the barrel is NOT in the tower in retail.** (It IS in the
dat's `0x0107.StaticObjects`. Unresolved tension — but note H-A below
predicts the "barrel" may not be the dat barrel at all.)
## 2. What was FIXED today (all verified, all committed — do not re-litigate)
| Fix | Commit | Verified by |
|---|---|---|
| #118 house-exit vanish (seal vs dynamics order) | `5a80a2e` | user gate "Yes solved" |
| #120 flood ping-pong (CellView containment) | `dede7e4` | 0 `[pv-ERROR]` since (was 24/session); #122 cured |
| #121 portals invisible (dynamics-owner particle pass) | `c446473` | user gate "Yes" |
| #125 WB_DIAG GL-error cascade (query ring begun-flags) | `fcade06` | 0 `[wb-error]` under diag |
| Render lift leaking into the visibility graph | `f35cb8b` | captured-frame replay both arms (`CapturedTopOfStairs_*`) |
| #126 restore re-derived Z (now commits server Z — retail SetPositionInternal 0x00515bd0 shape) | `120aeff` | clean `VALIDATED` snaps since |
| #128 first-ever-only Prepare gate | re-arm commit | `[mesh-miss]` self-heal observed live (226 ids re-requested) |
| Point-of-use re-request (mesh absence now impossible) | last commit | final `meshMissing=0` in the broken session — which is exactly what KILLED the absence theory |
Each fix was real; none was THE tower bug. The user's "running in circles"
critique stands: the persistent symptom survives all of them.
## 3. The live hypothesis space (ranked — design the probe, don't guess)
**H-A — hydration-time MeshRef corruption (top suspect).** The staircase
entity's 43 MeshRefs are built ONCE at landblock hydration
(`GameWindow.BuildInteriorEntitiesForStreaming``SetupMesh.Flatten(setup)`,
GameWindow ~5611-5627). `SetupMesh.Flatten` falls back to **identity
transforms** when the placement-frame lookup comes up short
(`SetupMesh.cs:57-61`: `i < defaultAnim.Frames.Count` else identity), and
returns per-part frames from `setup.PlacementFrames` (Resting → Default →
first). If, during the login burst, the Setup object or its frames read
DEGRADED (a dat-race / partially-hydrated object — see
`feedback_phase_a1_hotfix_saga`: DatCollection thread-safety + "objects can
cache half-parsed"), Flatten yields identity (or partial) transforms → **all
43 parts draw stacked at the entity origin = a barrel-shaped pile** ("the
water barrel"!!) with a few parts elsewhere ("broken stairs"). MeshRefs are
never rebuilt → broken all session. PREDICTS: the "barrel" the user sees may
be the collapsed staircase, not the dat barrel; meshMissing=0; entity drawn.
**Probe:** dump the live entity's MeshRefs (count + per-part transform
translations) in the broken state — if translations are ~zero/identity, H-A
is confirmed and the fix is hydration-side (retry/validate Flatten inputs, or
rebuild MeshRefs when degraded).
**H-B — Tier-1 classification cache served a partial/stale batch set.** The
entity classified during the transient-miss window; some path caches an
incomplete batch set that the cache hit then serves forever (static entity →
fast path → never re-classified). The known #53 vetoes (null renderData per
MeshRef; null Setup part — both patched) read correct, but a batch-level
partial (renderData present, batches not yet complete during atlas staging)
may not be vetoed. PREDICTS: drawn entity, wrong/missing batches; fixable by
invalidating the cache entry when any of the entity's ids finishes loading
AFTER the classification. **Probe:** `EntityClassificationCache` dump for the
staircase entity id in the broken state (batch count vs the clean session's).
**H-C — draw-side transform composition** (`ComposePartWorldMatrix` /
`meshRef.PartTransform` path) — least likely (the same code draws the clean
sessions), but the per-part dump from H-A's probe exonerates or implicates it
for free.
## 4. The decisive next step (ONE probe, one launch)
Add a one-shot diagnostic (env-gated, e.g. `ACDREAM_DUMP_ENTITY=0x020003F2`):
at first draw of any entity whose `SourceGfxObjOrSetupId` matches, print:
- `MeshRefs.Count` (expect 43),
- per MeshRef: `GfxObjId` + `PartTransform.Translation` (expect the dat
spiral: platforms at (0,3,1.55)…(3,3,11.95), steps ascending — compare
`Issue119TowerDumpTests.DumpTowerStairSetups` output),
- whether the Tier-1 cache has an entry + its batch count.
Launch (the user's save restores INSIDE the tower; the broken state is
probable on first login), read the dump:
- identity/collapsed translations → **H-A**: fix at hydration (validate
Flatten's inputs; rebuild MeshRefs on degraded reads; likely also explains
"barrel" as the collapsed pile).
- correct translations + small/odd batch count → **H-B**: cache
invalidation on late load completion.
- correct everything → H-C: instrument ComposePartWorldMatrix for this id.
## 5. Also pinned today, port pending (the SECOND remaining tower artifact)
The climb strobes + top-of-tower roof/floor flap while TURNING (user: "the
roof and floor up top still flaps when turning") = the knife-edge in-plane
portal clip family, mechanically pinned by capture + replay:
- The eye riding/crossing HORIZONTAL portal planes (spiral climbs, the deck)
→ side test allows (in-plane window) but OUR clip collapses the portal to
EMPTY → the cell behind drops ([viewer-diff] `removed=[0xAAB30107,0x010A]`
at the top; mid-climb `removed=[0x0108/0x0109]`).
- Retail has no hole: `ACRender::polyClipFinish` (0x006b6d00, pc:702749) —
read today: a homogeneous Sutherland-Hodgman whose FIRST pass clips the
polygon at **W=0 (the eye plane)** with full intersection emission
(pc:702889-702978: scans vertex W, runs the W-clip pass, REQUIRES ≥3
output verts, THEN clips against the portal-view edges in homogeneous
space, `< 3 → return` per edge). **cdstW = 0.000199999995, PINNED at
0x007247d5** (where it's consumed still to be mapped — grep reads of the
global 0x008fb788; `landPolysDraw` at 0x006b7040 uses 0.0002 inline for
plane side tests).
- THE PORT: match `PortalProjection.ProjectToClip`'s near-eye behavior
(currently `EyePlaneW=1e-4` + empty-collapse) to polyClipFinish's W=0-clip
semantics; then DELETE the `EyeInsidePortalOpening` rescue (the documented
cdstW-gap compensation, T2 ledger) and re-run the full harness suite
(CornerFloodReplay + Issue120 + TowerAscent + the captured-frame pins).
## 6. Apparatus inventory (new this session — use, don't rebuild)
| Tool | Where | Purpose |
|---|---|---|
| `[viewer]` probe | `ACDREAM_PROBE_VIEWER=1` | print-on-change root/flood/outPolys/pCell + eye@mm + fwd |
| `[viewer-diff]` | same flag | names cells entering/leaving the flood per change |
| `[mesh-miss]` | `ACDREAM_WB_DIAG=1` | once-per-id missing-mesh naming + point-of-use re-request |
| `HouseExitWalkReplayTests` | App.Tests | #118 pins (cone + seal-depth + straddle) |
| `Issue120ReciprocalPingPongTests` | App.Tests | #120 pins + `LoadAllInteriorCells` helper |
| `TowerAscentReplayTests` | App.Tests | captured-frame replay + lift canary + gate-by-gate diagnostic |
| `Issue127FloodFlipReplayTests` | App.Tests | outdoor flood replay (stable — flood math exonerated for the 4 cm pair) |
| `Issue119TowerDumpTests` / `Issue119UpNullGfxObjDumpTests` | Core.Tests | tower dat truth / no-draw GfxObj class |
| Session logs (worktree root) | `user-session-capture2.log` (THE broken-state evidence), `tower-rearm-gate.log`, `flap-diff-capture.log`, screenshots | capture record |
## 7. Open issues ledger (post-session state)
- **#119/#128 tower stairs**: OPEN — the drawn-but-wrong layer (§3-4). THE
priority.
- **knife-edge clip port** (§5): OPEN — second priority; kills climb strobes,
top flap, and retires the rescue + #120's window class.
- **#124** far-building back walls (interior-root look-in floods missing —
lead documented in ISSUES), **#127** distant-building churn (narrowed),
**#108-residual** cellar grass band, **#112** hill-cottage transparency,
**#113** phantom stairs: all OPEN with leads in ISSUES.md.
- The user axiom stands: **barrel not in retail** — re-evaluate after H-A
resolves (the "barrel" may be the collapsed staircase).
## 8. Paste-ready pickup prompt
```
Pick up acdream as a SENIOR 3D ENGINE DEVELOPER on the AAB3 tower
"broken stairs + water barrel" bug. Worktree branch
claude/thirsty-goldberg-51bb9b. Nothing goes to main.
READ FIRST (in order):
1. docs/research/2026-06-11-tower-stairs-fundamental-handoff.md — THE
handoff. Its §0 fact reframes the bug: in the user's broken-state
session (user-session-capture2.log) the dispatcher reports
meshMissing=0 / entSeen==entDrawn WHILE broken stairs are on screen —
the staircase is DRAWN WRONG, not missing. All mesh-absence theories
are dead (8 real fixes shipped today; none was this).
2. Memory digests: project_render_pipeline_digest +
project_physics_collision_digest (DO-NOT-RETRY tables apply).
DO NEXT — the decisive probe (handoff §4): add ACDREAM_DUMP_ENTITY-style
one-shot diag printing the staircase entity's (SourceGfxObjOrSetupId
0x020003F2) MeshRefs count + per-part transform translations + Tier-1
cache state at first draw. The user's save restores INSIDE the tower;
the broken state reproduces on teleport-heavy logins (~3 of 4). One
launch + the dump decides H-A (hydration-time MeshRef corruption via
SetupMesh.Flatten identity fallback — top suspect; predicts the "barrel"
IS the collapsed staircase) vs H-B (Tier-1 cache partial batch set) vs
H-C (draw-side compose). Fix the confirmed branch ROOT-CAUSE-FIRST (no
band-aids; the user has explicitly demanded the fundamental fix).
THEN — the knife-edge clip port (handoff §5): match
PortalProjection.ProjectToClip's near-eye clip to retail
ACRender::polyClipFinish (0x006b6d00, pc:702749; cdstW=0.0002 pinned at
0x007247d5): W=0 eye-plane clip with intersection emission, never
empty-collapse for in-plane portals; then DELETE the
EyeInsidePortalOpening rescue and re-run the full harness suite. This
kills the climb strobes + the top-of-tower roof/floor flap while
turning (the user's other standing report).
The user's reports are AXIOMS. Visual gates are the acceptance tests.
Build + test green per commit (App 242+1skip / Core 1422+2skip / UI 420
/ Net 294). When launching for the user: launch, hand over, do NOT
foreground/screenshot the window while they play; read logs when told.
```

View file

@ -241,6 +241,17 @@ EnvCell.check_building_transit(portalId, pos, numSphere, spheres[], cellArray, p
## LandCell.add_all_outside_cells (sphere variant)
> ⚠️ **Correction (2026-06-09, issue #106):** this section understates the math.
> Retail's lcoords are **GLOBAL** map-wide cell coordinates (0..2039), not
> block-relative — `AdjustToOutside` doesn't just "normalise to correct block",
> it can re-seat the cell id into a NEIGHBOUR landblock, and `add_outside_cell`
> has no same-block filter. The original acdream port read this section as
> single-block math and clamped to the current landblock's 8×8 grid, which froze
> outdoor membership at landblock boundaries (#106). The full corrected
> pseudocode (with the BN decomp artifacts and the ACE `add_cell_block` FIXME
> divergence) is in
> `docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md`.
Determines which outdoor landblock cells an entity's spheres overlap. Each outdoor
cell is a 24×24m square. The function adds the primary cell plus up to 3 neighbors
when the sphere radius reaches a boundary.

View file

@ -0,0 +1,552 @@
# Indoor Viewer-Cell Flicker + Bluish-Void Fix — 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:** Kill the indoor render **flicker (grey↔texture at rest)** and the **stable bluish void** at cottage cell boundaries by porting the three retail mechanisms that keep retail's `viewer_cell` rock-stable — camera-boom convergence snap, viewer-cell dead-zone, and w-space portal clip — confirmed root cause by decomp + live cdb (`docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md`).
**Architecture:** Three independent, retail-faithful ports, each TDD'd and **independently visual-verified** in this order (highest leverage first):
1. **Camera boom stability** (`RetailChaseCamera`) — add the retail `UpdateCamera` convergence snap so the damped eye reaches an **exact fixed point** at rest instead of dithering sub-millimetre forever. Removes the *trigger* (the eye walking across a portal plane). The collided-eye firewall is already present (verified).
2. **Viewer-cell dead-zone** (`BSPQuery.PointInsideCellBsp`) — port retail's symmetric **±0.000199999995 m** dead-zone so a point grazing a splitting plane belongs to *neither* child → membership stays sticky. Belt-and-suspenders for the flicker; shared Core primitive (also used by physics), so the full Core suite gates it.
3. **w-space portal clip** (`PortalProjection` / `PortalVisibilityBuilder`) — verify the void is gone after 1+2, port the InitCell side-test dead-band for faithfulness, then **reassess/revert** the `9f95252` eye-in-portal flood band-aid.
**Tech Stack:** C# .NET 10, `System.Numerics`, xUnit. No new dependencies. Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt`.
**DON'T (from handoff §5):**
- No render-side debounce/grace-period for the flicker — fix the *input* (boom + cell), never the render.
- Don't switch the render root to the *player* cell — retail roots `DrawInside` at the *viewer* cell; make the viewer cell *stable*, don't change which cell roots.
- Don't reopen "the flood doesn't reach the cellar" — refuted.
- Don't revert Residual A (the `update_viewer` camera-collision port) — it made the viewer cell *accurate*; we're stabilising it.
**Test baseline (must hold):** App **183 pass / 0 fail**. Core **1326 pass / 4 fail (documented: 2× DoorBugTrajectoryReplay LiveCompare, BSPStepUpTests.D4, DoorCollisionApparatus) / 1 skip**. Build green.
---
## File Structure
| File | Change | Responsibility |
|---|---|---|
| `src/AcDream.App/Rendering/RetailChaseCamera.cs` | Modify | Part 1: add `SnapEpsilon`/`RotCloseEpsilon` consts + `ApplyConvergenceSnap` static + wire it into `Update`'s damping branch. |
| `tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs` | Modify | Part 1: unit test for `ApplyConvergenceSnap` + integration "boom freezes at rest" test. |
| `src/AcDream.Core/Physics/BSPQuery.cs` | Modify | Part 2: add the ±0.000199999995 dead-zone to `PointInsideCellBsp` (3-way classify). |
| `tests/AcDream.Core.Tests/Physics/PointInsideCellBspDeadZoneTests.cs` | Create | Part 2: RED→GREEN dead-zone tests + a `FindVisibleChildCell` stickiness test. |
| `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` | Modify (Part 3, gated) | Part 3: port InitCell side-test dead-band into `CameraOnInteriorSide`; reassess/remove `EyeInsidePortalOpening` flood (`9f95252`). |
| `tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs` | Modify (Part 3, gated) | Part 3: close-portal robustness regression if a residual void persists. |
Each part is one or more commits, each ending in a **VISUAL GATE** (the user looks at the running client). Parts 2 and 3 only start after the previous part's visual gate passes.
---
## Part 1 — Camera boom convergence snap (highest leverage)
**Root cause (decomp-confirmed):** `RetailChaseCamera.Update` lerps `_dampedEye` toward `targetEye` with `Vector3.Lerp` every frame (`RetailChaseCamera.cs:149`). `Vector3.Lerp` is asymptotic — it never reaches an exact fixed point, so the eye makes a tiny sub-millimetre step *every frame forever*. At rest that walks the eye across the vestibule/room portal plane → the per-frame viewer-cell resolve flips `0170↔0171` → the render redraws two solves → flicker. Retail's `CameraManager::UpdateCamera` (`0x00456660`) has a **convergence snap** at `0x00456fcd`: after interpolating, if the translation step `< 0.0004 m` (= `2 × 0.000199999995`, `0x00456fe1`) AND the rotation is within `0.000199999995` (`0x00456fdd`, `Frame::close_rotation`), it returns the input unchanged (`Position::Position(__return, ebx_1)`) — an exact fixed point.
**The collided-eye firewall is already present** — `Update` collides into a separate `publishedEye` local and never writes `_dampedEye` (`RetailChaseCamera.cs:162-172`, comment at `:153-161`). So Part 1 is ONLY the snap.
**Acceptance:**
- New `ApplyConvergenceSnap` static freezes when both deltas are sub-epsilon, else returns the candidate.
- After convergence with a constant pose, two consecutive `Update` frames produce a **bit-identical** `Position` (collision off).
- All existing `RetailChaseCameraTests` stay green (esp. `SecondUpdate_LerpsTowardTarget` — step 0.75 ≫ epsilon, no snap).
- **VISUAL GATE 1:** at the Holtburg cottage vestibule/room boundary, standing still, the `[flap-sweep] desiredBack` value holds flat (no `3.11→3.07` drift) and the grey↔texture flicker is gone or sharply reduced.
### Task 1.1: Unit-test the convergence-snap helper (RED)
**Files:**
- Test: `tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs`
- [ ] **Step 1: Write the failing test.** Append these tests to the class (after the existing `Update_CollisionDoesNotCorruptDampedState`, before the closing brace):
```csharp
// ── Convergence snap (Part 1: kills the at-rest boom drift) ────────
[Fact]
public void ConvergenceSnap_StepBelowEpsilon_FreezesAtCurrent()
{
// Both the translation step and the rotation step are below the retail snap
// thresholds (0.0004 m / 0.0002) → freeze: return the CURRENT damped state,
// not the candidate. This is the exact fixed point retail's UpdateCamera reaches.
var damped = new Vector3(5f, 6f, 7f);
var forward = Vector3.Normalize(new Vector3(1f, 0f, 0f));
var candidate = damped + new Vector3(0.0001f, 0f, 0f); // 0.1 mm step < 0.4 mm
var candFwd = forward; // no rotation step
var (eye, fwd, frozen) = RetailChaseCamera.ApplyConvergenceSnap(damped, forward, candidate, candFwd);
Assert.True(frozen);
Assert.Equal(damped, eye); // exact — returns the input, freezing the drift
Assert.Equal(forward, fwd);
}
[Fact]
public void ConvergenceSnap_TranslationStepAboveEpsilon_ReturnsCandidate()
{
var damped = new Vector3(5f, 6f, 7f);
var forward = Vector3.Normalize(new Vector3(1f, 0f, 0f));
var candidate = damped + new Vector3(0.01f, 0f, 0f); // 1 cm step ≫ 0.4 mm
var candFwd = forward;
var (eye, fwd, frozen) = RetailChaseCamera.ApplyConvergenceSnap(damped, forward, candidate, candFwd);
Assert.False(frozen);
Assert.Equal(candidate, eye); // still converging → apply the lerp step
Assert.Equal(candFwd, fwd);
}
[Fact]
public void ConvergenceSnap_RotationStepAboveEpsilon_ReturnsCandidate()
{
// Translation has converged but the heading is still turning — retail does NOT
// freeze unless BOTH are close (it returns the interpolated frame). So a small
// translation step must NOT freeze while the forward is still rotating.
var damped = new Vector3(5f, 6f, 7f);
var forward = Vector3.Normalize(new Vector3(1f, 0f, 0f));
var candidate = damped + new Vector3(0.0001f, 0f, 0f); // sub-epsilon translation
var candFwd = Vector3.Normalize(new Vector3(1f, 0.05f, 0f)); // ~0.05 rad turn ≫ 0.0002
var (_, _, frozen) = RetailChaseCamera.ApplyConvergenceSnap(damped, forward, candidate, candFwd);
Assert.False(frozen);
}
```
- [ ] **Step 2: Run the test to verify it fails (compile error — method doesn't exist).**
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --filter "FullyQualifiedName~ConvergenceSnap"`
Expected: **FAIL** — build error `'RetailChaseCamera' does not contain a definition for 'ApplyConvergenceSnap'`.
### Task 1.2: Implement the convergence-snap helper + constants (GREEN)
**Files:**
- Modify: `src/AcDream.App/Rendering/RetailChaseCamera.cs`
- [ ] **Step 1: Add the snap constants.** Immediately after the `DistanceMin/Max/PitchMin/Max` const block (after `RetailChaseCamera.cs:78`, the `public const float PitchMax = 1.4f;` line), add:
```csharp
// Retail CameraManager::UpdateCamera convergence-snap thresholds (decomp
// acclient_2013_pseudo_c.txt, 0x00456fcd0x00457035). SnapEpsilon = 2 ×
// 0.000199999995 m ≈ 0.0004 m — the per-frame translation step below which retail
// freezes the boom at an exact fixed point (0x00456fe1). RotCloseEpsilon =
// 0.000199999995 — the Frame::close_rotation tolerance (0x00456fdd). Without the
// snap, Vector3.Lerp asymptotes forever and the boom drifts at rest, walking the eye
// across a portal plane and flipping the viewer cell → the indoor flicker.
private const float SnapEpsilon = 0.000199999995f * 2f;
private const float RotCloseEpsilon = 0.000199999995f;
```
- [ ] **Step 2: Add the `ApplyConvergenceSnap` static.** Add this method just after `ComputeDampingAlpha` (after `RetailChaseCamera.cs:370`, the closing brace of `ComputeDampingAlpha`):
```csharp
/// <summary>
/// Retail <c>CameraManager::UpdateCamera</c> convergence snap (decomp 0x00456fcd).
/// After the per-frame lerp, if the translation step from <paramref name="dampedEye"/>
/// to <paramref name="candidateEye"/> is below <see cref="SnapEpsilon"/> AND the
/// rotation step is below <see cref="RotCloseEpsilon"/>, retail returns the input
/// position unchanged — an exact fixed point. Returns <c>frozen=true</c> with the
/// current state in that case; otherwise <c>frozen=false</c> with the candidate.
/// Both conditions are required (retail couples origin + rotation in the snap test),
/// so the boom keeps converging while the heading is still turning.
/// </summary>
internal static (Vector3 eye, Vector3 forward, bool frozen) ApplyConvergenceSnap(
Vector3 dampedEye, Vector3 dampedForward, Vector3 candidateEye, Vector3 candidateForward)
{
bool translationConverged = Vector3.Distance(candidateEye, dampedEye) < SnapEpsilon;
bool rotationConverged = Vector3.Distance(candidateForward, dampedForward) < RotCloseEpsilon;
if (translationConverged && rotationConverged)
return (dampedEye, dampedForward, true); // freeze: exact fixed point
return (candidateEye, candidateForward, false);
}
```
- [ ] **Step 3: Run the unit tests to verify they pass.**
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --filter "FullyQualifiedName~ConvergenceSnap"`
Expected: **PASS** (3 tests).
### Task 1.3: Wire the snap into `Update` + integration test
**Files:**
- Modify: `src/AcDream.App/Rendering/RetailChaseCamera.cs`
- Test: `tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs`
- [ ] **Step 1: Write the failing integration test.** Append to `RetailChaseCameraTests`:
```csharp
[Fact]
public void Update_AtRestAfterConvergence_BoomFreezesAtExactFixedPoint()
{
// The retail UpdateCamera snap freezes the boom at an exact fixed point once the
// per-frame step falls below ~0.4 mm. Without it, Vector3.Lerp asymptotes forever
// — the eye dithers sub-millimetre every frame and walks across the portal plane,
// flipping the viewer cell (the indoor flicker). Hold a pose DIFFERENT from the
// init pose so the boom has to converge over many frames; with collision OFF
// (Position == _dampedEye), two consecutive post-convergence frames must be
// BIT-IDENTICAL. (At frame 120, α≈0.075, displacement ~7 m, the un-snapped step is
// ~5e-5 m ≈ tens of float ULP — distinguishably nonzero — so this is a real RED.)
bool savedAlign = CameraDiagnostics.AlignToSlope;
bool savedColl = CameraDiagnostics.CollideCamera;
float savedT = CameraDiagnostics.TranslationStiffness;
float savedR = CameraDiagnostics.RotationStiffness;
try
{
CameraDiagnostics.AlignToSlope = false; // deterministic heading
CameraDiagnostics.CollideCamera = false; // Position == _dampedEye
CameraDiagnostics.TranslationStiffness = 0.45f;
CameraDiagnostics.RotationStiffness = 0.45f;
var cam = new RetailChaseCamera { Distance = 2.61f, Pitch = 0.291f };
// Frame 1 at pose A: init snaps the damped eye to A's target.
cam.Update(Vector3.Zero, 0.5f, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f);
// Hold pose B for many frames → the boom lerps A's target → B's target.
var posB = new Vector3(5f, 5f, 0f);
for (int i = 0; i < 120; i++)
cam.Update(posB, 0.5f, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f);
Vector3 a = cam.Position;
cam.Update(posB, 0.5f, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f);
Vector3 b = cam.Position;
Assert.Equal(a, b); // exact — frozen, not dithering
}
finally
{
CameraDiagnostics.AlignToSlope = savedAlign;
CameraDiagnostics.CollideCamera = savedColl;
CameraDiagnostics.TranslationStiffness = savedT;
CameraDiagnostics.RotationStiffness = savedR;
}
}
```
- [ ] **Step 2: Run it to verify it fails.**
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --filter "FullyQualifiedName~BoomFreezesAtExactFixedPoint"`
Expected: **FAIL**`Assert.Equal()` failure, `a` and `b` differ by ~5e-5 m (the un-snapped asymptotic step).
- [ ] **Step 3: Wire the snap into the damping branch.** In `RetailChaseCamera.Update`, replace the `else` branch of the `if (!_initialised)` block (`RetailChaseCamera.cs:145-151`):
```csharp
else
{
float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, dt);
float rAlpha = ComputeDampingAlpha(CameraDiagnostics.RotationStiffness, dt);
_dampedEye = Vector3.Lerp(_dampedEye, targetEye, tAlpha);
_dampedForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha));
}
```
with:
```csharp
else
{
float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, dt);
float rAlpha = ComputeDampingAlpha(CameraDiagnostics.RotationStiffness, dt);
Vector3 candidateEye = Vector3.Lerp(_dampedEye, targetEye, tAlpha);
Vector3 candidateForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha));
// Retail UpdateCamera convergence snap (0x00456fcd): freeze at an exact fixed
// point once the lerp step is sub-epsilon, instead of dithering forever. This is
// the at-rest flicker fix — see ApplyConvergenceSnap + SnapEpsilon.
(_dampedEye, _dampedForward, _) =
ApplyConvergenceSnap(_dampedEye, _dampedForward, candidateEye, candidateForward);
}
```
- [ ] **Step 4: Run the integration test + the full RetailChaseCamera suite to verify pass + no regression.**
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --filter "FullyQualifiedName~RetailChaseCameraTests"`
Expected: **PASS** (all existing + 4 new). Confirm `SecondUpdate_LerpsTowardTarget` and `Update_CollisionDoesNotCorruptDampedState` still pass.
### Task 1.4: Full build + test + commit Part 1
- [ ] **Step 1: Build.** Run: `dotnet build`. Expected: green.
- [ ] **Step 2: Full App suite.** Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj`. Expected: **187 pass / 0 fail** (183 baseline + 4 new).
- [ ] **Step 3: Commit.**
```bash
git add src/AcDream.App/Rendering/RetailChaseCamera.cs tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
git commit -m "fix(render): Part 1 — camera boom convergence snap (kills the at-rest viewer-cell flicker trigger)
Port retail CameraManager::UpdateCamera's convergence snap (0x00456fcd):
once the per-frame lerp step is below 0.0004 m AND the rotation within
0.000199999995, freeze the damped eye at an exact fixed point instead of
Vector3.Lerp's endless sub-mm asymptote. The drift was walking the 3rd-person
eye across the vestibule/room portal plane at rest, flipping the per-frame
viewer-cell resolve 0170<->0171 -> the indoor grey/texture flicker.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
- [ ] **Step 4: VISUAL GATE 1 — STOP.** Launch the client (CLAUDE.md "Running the client"), with `ACDREAM_PROBE_FLAP=1`. Stand still at the Holtburg cottage vestibule/room boundary. Ask the user to confirm: (a) the grey↔texture flicker at rest is gone/reduced; (b) in the log, `[flap-sweep] desiredBack` holds a constant value (no `3.11→3.07` drift). Do not start Part 2 until the user confirms.
---
## Part 2 — Viewer-cell dead-zone (belt-and-suspenders for the flicker)
**Root cause (decomp-confirmed):** `BSPQuery.PointInsideCellBsp` (`BSPQuery.cs:1034`) uses a hard split — `dist >= 0f` → inside, `dist < 0f` → outside — with **no dead-zone**. A point grazing a splitting plane flips inside/outside on a sub-millimetre wobble. Retail's `BSPNODE::point_inside_cell_bsp` (`0x0053c1f0`, pc:325513/325522) uses a **symmetric ±0.000199999995 m** band: a point within ±0.2 mm of a plane is in *neither* child → the traversal short-circuits and classifies it "inside this cell," so a grazing point stays in the cell it was last in (`check_cell` is null → `curr_cell` unchanged, validate_transition pc:272608). This makes the viewer/player cell sticky at boundaries.
**Faithful 3-way classify** (matches retail's `eax = 0 / 1 / 2`):
- `dist >= +ε` → clearly in front → descend `PosNode` (may still reject on a deeper plane).
- `-ε < dist < +ε`**dead zone** → short-circuit `true` (inside this cell).
- `dist <= -ε` → clearly behind → `false` (outside).
**Shared-primitive risk:** `PointInsideCellBsp` is also used by physics cell membership (`CellTransit.FindVisibleChildCell/FindCellList/FindCellSet`). This is retail-faithful (retail's `point_inside_cell_bsp` has the dead-zone for ALL callers), but the **full Core suite must stay at baseline** (1326/4/1). The change only affects the `(-ε, 0)` band (00.2 mm behind a plane flips outside→inside) and the `[0, +ε)` band (short-circuits true instead of testing deeper) — both ≤0.2 mm, retail-exact.
**Acceptance:**
- A point 0.1 mm behind a single splitting plane returns `true` (was `false`); 1 mm behind still `false`; existing `SphereIntersectsCellBspTests.PointInsideCellBsp_PointJustOutside…` (x = 0.3 m) stays `false`.
- A `FindVisibleChildCell` graze keeps the start cell.
- Full Core suite at baseline; App suite at 187.
- **VISUAL GATE 2:** the flicker is fully gone even with deliberate slow boom motion across the boundary (no residual flip).
### Task 2.1: RED — dead-zone unit tests
**Files:**
- Create: `tests/AcDream.Core.Tests/Physics/PointInsideCellBspDeadZoneTests.cs`
- [ ] **Step 1: Write the failing test file.**
```csharp
using System.Numerics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Tests for the retail dead-zone in <see cref="BSPQuery.PointInsideCellBsp"/>
/// (port of BSPNODE::point_inside_cell_bsp, acclient_2013_pseudo_c.txt:325508 /
/// 0x0053c1f0). A point within ±0.000199999995 m of a splitting plane is in
/// NEITHER child → classified "inside this cell" (short-circuit true). This is
/// what keeps the viewer/player cell sticky at a boundary graze (the flicker fix).
/// </summary>
public class PointInsideCellBspDeadZoneTests
{
// One splitting plane at x = 0, normal +X → the "inside" half-space is x ≥ 0.
private static CellBSPNode SinglePlaneTree()
{
var leaf = new CellBSPNode { Type = BSPNodeType.Leaf };
return new CellBSPNode
{
SplittingPlane = new Plane(new Vector3(1f, 0f, 0f), 0f),
PosNode = leaf,
};
}
[Fact]
public void PointJustBehindPlane_WithinDeadZone_ReturnsTrue()
{
// 0.1 mm behind the plane → inside the ±0.2 mm dead zone → inside this cell.
// Pre-fix this returned FALSE (hard dist < 0 outside) the flicker.
var root = SinglePlaneTree();
Assert.True(BSPQuery.PointInsideCellBsp(root, new Vector3(-0.0001f, 0f, 0f)));
}
[Fact]
public void PointOnPlane_ReturnsTrue()
{
var root = SinglePlaneTree();
Assert.True(BSPQuery.PointInsideCellBsp(root, new Vector3(0f, 0f, 0f)));
}
[Fact]
public void PointJustInFront_ReturnsTrue()
{
var root = SinglePlaneTree();
Assert.True(BSPQuery.PointInsideCellBsp(root, new Vector3(0.0001f, 0f, 0f)));
}
[Fact]
public void PointClearlyBehind_BeyondDeadZone_ReturnsFalse()
{
// 1 mm behind → outside the ±0.2 mm band → outside the cell (unchanged).
var root = SinglePlaneTree();
Assert.False(BSPQuery.PointInsideCellBsp(root, new Vector3(-0.001f, 0f, 0f)));
}
[Fact]
public void PointFarBehind_ReturnsFalse_RegressionGuard()
{
// The existing SphereIntersectsCellBspTests regression pin (x = -0.3 m) must
// stay FALSE — the dead zone is only ±0.2 mm, 300 mm is far outside.
var root = SinglePlaneTree();
Assert.False(BSPQuery.PointInsideCellBsp(root, new Vector3(-0.3f, 0f, 0f)));
}
}
```
- [ ] **Step 2: Run to verify it fails.**
Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PointInsideCellBspDeadZone"`
Expected: **FAIL**`PointJustBehindPlane_WithinDeadZone_ReturnsTrue` fails (returns false). The other 4 pass already (they pin unchanged behaviour).
### Task 2.2: GREEN — add the dead-zone to `PointInsideCellBsp`
**Files:**
- Modify: `src/AcDream.Core/Physics/BSPQuery.cs`
- [ ] **Step 1: Replace the split test.** In `PointInsideCellBsp` (`BSPQuery.cs:1034-1047`), replace the body after the leaf checks:
```csharp
float dist = Vector3.Dot(node.SplittingPlane.Normal, point) + node.SplittingPlane.D;
// Front or on-plane → follow positive child (inside).
if (dist >= 0f)
return node.PosNode is not null ? PointInsideCellBsp(node.PosNode, point) : true;
// Behind → outside.
return false;
```
with:
```csharp
float dist = Vector3.Dot(node.SplittingPlane.Normal, point) + node.SplittingPlane.D;
// Retail BSPNODE::point_inside_cell_bsp dead-zone (0x0053c1f0, pc:325513/325522):
// a symmetric ±0.000199999995 m band around the splitting plane belongs to NEITHER
// child. A point in the band short-circuits "inside this cell" (true) — this is what
// keeps the viewer/player cell sticky at a boundary graze (no sub-mm membership flip
// → no indoor flicker). Only a point clearly BEHIND the plane is outside.
const float CellBspPlaneEpsilon = 0.000199999995f;
if (dist >= CellBspPlaneEpsilon)
return node.PosNode is not null ? PointInsideCellBsp(node.PosNode, point) : true;
if (dist <= -CellBspPlaneEpsilon)
return false; // clearly behind → outside the cell
return true; // dead zone (within ±0.2 mm) → inside this cell
```
- [ ] **Step 2: Run the dead-zone tests to verify pass.**
Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PointInsideCellBspDeadZone"`
Expected: **PASS** (5 tests).
### Task 2.3: Stickiness regression at the `FindVisibleChildCell` level
**Files:**
- Test: `tests/AcDream.Core.Tests/Physics/CellTransitFindVisibleChildCellTests.cs`
- [ ] **Step 1: Inspect the existing fixtures.** Read `tests/AcDream.Core.Tests/Physics/CellTransitFindVisibleChildCellTests.cs` to reuse its cell-cache/`CellPhysics` builder. Add one test: a point that grazes the start cell's boundary plane (within ±0.2 mm, on the outside) resolves back to the **start cell** (not a neighbour). If the existing fixtures don't expose a single-plane start cell conveniently, assert the primitive instead via `BSPQuery.PointInsideCellBsp` on the start cell's `CellBSP.Root` (the dead-zone test in Task 2.1 already covers the primitive; this task is satisfied if no cheap `FindVisibleChildCell`-level fixture exists — note that in the commit message rather than forcing a brittle fixture).
- [ ] **Step 2: Run the CellTransit suite.**
Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellTransit"`
Expected: **PASS** (no regression).
### Task 2.4: Full build + test + commit Part 2
- [ ] **Step 1: Build.** Run: `dotnet build`. Expected: green.
- [ ] **Step 2: FULL Core suite (shared-primitive gate).** Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj`. Expected: **1331 pass / 4 fail / 1 skip** (1326 + 5 new dead-zone tests; the 4 documented failures unchanged — verify the failing set is the SAME 4, not a new one).
- [ ] **Step 3: Commit.**
```bash
git add src/AcDream.Core/Physics/BSPQuery.cs tests/AcDream.Core.Tests/Physics/PointInsideCellBspDeadZoneTests.cs
git commit -m "fix(physics): Part 2 — point_inside_cell_bsp dead-zone (sticky cell membership at boundary graze)
Port retail BSPNODE::point_inside_cell_bsp's symmetric ±0.000199999995 m
dead-zone (0x0053c1f0, pc:325513/325522): a point within 0.2 mm of a splitting
plane is in neither child -> short-circuit 'inside this cell'. Belt-and-suspenders
for the indoor flicker: the viewer cell stays sticky when the boom grazes the
vestibule/room portal plane instead of flipping 0170<->0171 on a sub-mm wobble.
Shared with physics cell membership (retail-faithful: retail uses the same band
for all callers). Full Core suite at baseline.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
- [ ] **Step 4: VISUAL GATE 2 — STOP.** Launch with `ACDREAM_PROBE_FLAP=1` + `ACDREAM_PROBE_CELL=1`. Walk the boom slowly across the vestibule/room boundary. Ask the user to confirm the flicker is fully gone and `[cell-transit]` shows clean single crossings (no oscillation). Do not start Part 3 until confirmed.
---
## Part 3 — w-space portal clip robustness + reassess `9f95252` (gated on the void state)
**Status after Parts 1+2:** the eye now rests stable in the *substantial* cell (room/outside), not lingering in the thin vestibule. The `proj=0` "stable bluish void" was a degenerate projection that occurs only when the eye stands IN a portal plane looking along it — a position Parts 1+2 keep the eye out of. So Part 3 is **diagnose-then-decide**, not a fixed code change.
**Key correction vs the handoff:** do **NOT** lower `PortalProjection.MinW` (0.05) to exactly `w = 0`. acdream's `ProjectToNdc` computes a 2D screen *region* (not a homogeneous rasterisation), so a vertex AT `w = 0` divides to `inf`/`NaN`. Retail's `polyClipFinish` produces w=0 synthetic verts because its downstream is a homogeneous rasteriser; acdream's region-clip needs `w > 0` strictly. The existing `MinW = 0.05` clip-space SutherlandHodgman (commit `5f596f2`) is the correct adaptation and is **kept**. The remaining faithful pieces are the InitCell side-test dead-band and removing the band-aid.
**Acceptance:**
- After Parts 1+2's visual gates, capture `ACDREAM_PROBE_FLAP` at the cottage boundary + cellar; confirm whether any `proj=0` / `terrain=Skip` void remains.
- If clean: **revert `9f95252`** (the `EyeInsidePortalOpening` flood) and re-verify the void stays gone — this is the goal (the boom + dead-zone made it redundant).
- For faithfulness, tighten `CameraOnInteriorSide`'s `PortalSideEpsilon` toward retail's InitCell band only if it does not re-introduce culling (test-gated).
- **VISUAL GATE 3:** no stable bluish void anywhere at the cottage (boundary, cellar, exiting); the cellar still seals; no new flap.
### Task 3.1: Diagnose the residual void (no code change)
- [ ] **Step 1.** Launch with `ACDREAM_PROBE_FLAP=1`. At the cottage doorway and in the cellar, capture `[flap]`/`[flap-sweep]` lines. Identify any portal still showing `proj=0` while its neighbour should be visible. Record the eye position + cell relative to that portal.
- [ ] **Step 2.** Decide:
- **(A) No residual void** → go to Task 3.2 (revert the band-aid).
- **(B) Residual void at a close portal** → the eye is still reaching a degenerate position; first re-check Parts 1+2 at that spot (boom flat? cell sticky?). Only if the eye is legitimately close to a portal it must see through, port the InitCell side-test dead-band (Task 3.3) before reverting the band-aid.
### Task 3.2: Reassess / revert the `9f95252` eye-in-portal flood band-aid
**Files:**
- Modify: `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs`
- [ ] **Step 1: Write a guard test first.** In `tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs` (or the nearest existing builder test file — locate with a glob), add/keep a test that a normal interior doorway floods its neighbour through the projected+clipped region WITHOUT relying on `EyeInsidePortalOpening` (i.e. with the eye a normal distance back). Run it to confirm it passes BEFORE removing the band-aid.
- [ ] **Step 2: Remove the band-aid.** Delete the `clippedRegion.Count == 0``EyeInsidePortalOpening` flood block (`PortalVisibilityBuilder.cs:171-177`) and replace with the plain cull:
```csharp
if (clippedRegion.Count == 0)
continue; // portal not visible through this chain
```
Then delete the now-unused `EyeInsidePortalOpening`, `PointInPoly2D`, and `EyeStandingPerpDist` members (`PortalVisibilityBuilder.cs:415-474`) — confirm no other references with a grep before deleting.
- [ ] **Step 3: Build + App suite.** Run: `dotnet build` then `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj`. Expected: green, no regression.
- [ ] **Step 4: VISUAL CHECK (mini-gate).** Launch; confirm the cellar **ceiling** (the thing `9f95252` originally fixed) is still sealed with the band-aid removed (Parts 1+2 should now keep it sealed via the stable viewer cell). If the ceiling drops, the band-aid was load-bearing → **restore it** (`git revert` the removal) and record that the boom/dead-zone did not fully subsume it; keep it and move on. Either outcome is a valid, documented result.
### Task 3.3 (conditional): Port the InitCell side-test dead-band
Only if Task 3.1 chose path (B). Retail `PView::InitCell` (`0x005a4b70`, pc:432896-432936) treats a viewer within **±0.000199999995 m** of a portal plane as the front (positive) side. acdream's `CameraOnInteriorSide` (`PortalVisibilityBuilder.cs:326-333`) uses `PortalSideEpsilon = 0.01f`.
- [ ] **Step 1.** Add a test in the builder test file: a camera exactly on a portal plane is treated as interior-side (traverses). A camera 1 cm clearly behind is culled.
- [ ] **Step 2.** Only change `PortalSideEpsilon` if the test + visual gate confirm it does not re-introduce a flap (tightening can cull a portal the eye is slightly behind). If it regresses, leave `0.01f` and note the divergence. Retail-faithfulness here is secondary to not re-opening the flap.
### Task 3.4: Commit Part 3 + VISUAL GATE 3
- [ ] **Step 1: Commit** whatever Part 3 landed (band-aid removed, or kept-and-documented, ± side-band):
```bash
git add -A
git commit -m "fix(render): Part 3 — reassess eye-in-portal flood after boom+dead-zone stabilise the viewer cell
<describe the actual outcome: band-aid 9f95252 removed as redundant / kept as
load-bearing; ± InitCell side-test dead-band>. The stable bluish void is gone
because Parts 1+2 keep the eye out of the degenerate in-portal-plane position;
MinW=0.05 clip-space Sutherland-Hodgman (5f596f2) is kept (region-clip needs w>0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
- [ ] **Step 2: VISUAL GATE 3 — STOP.** Full cottage tour (outside → doorway → room → cellar → back). Ask the user to confirm: no stable bluish void, cellar seals, no flicker, no new flap. This closes the flicker/void fix.
---
## Post-completion
- [ ] Update `docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md` status (or write a short ship note) and the `reference_render_pipeline_state.md` memory: flicker + void fixed via the 3-part port; note which of Parts 2/3 (band-aid) ended up load-bearing.
- [ ] Update `docs/plans/2026-05-12-milestones.md` M1.5 narrative if the indoor world now "feels right."
- [ ] If a residual remains (e.g. #78 outdoor terrain gating), file/refresh the issue — do not fold it into this fix.
---
## Self-Review
**Spec coverage (handoff §4):**
- Part 1 (camera boom stability) → Tasks 1.11.4. ✔ Snap ported; firewall already present (verified, noted).
- Part 2 (viewer-cell dead-zone) → Tasks 2.12.4. ✔ ±0.000199999995 ported into the shared point-in-cell test.
- Part 3 (w-space clip + reassess `9f95252`) → Tasks 3.13.4. ✔ Diagnose-then-decide; band-aid reassessment explicit; MinW correction documented.
- Per-part acceptance + visual gates → every part ends in a VISUAL GATE. ✔
- Order Part 1 → gate → Part 2 → gate → Part 3 → gate. ✔
**Placeholder scan:** Part 1 and Part 2 have complete code. Part 3 is intentionally diagnostic (its concrete change depends on the Part 1+2 visual outcome) — the conditional branches and the exact files/lines are specified, and the "no MinW→0" correction is concrete. This is honest given the gating, not a placeholder.
**Type consistency:** `ApplyConvergenceSnap(Vector3, Vector3, Vector3, Vector3) → (Vector3, Vector3, bool)` used identically in test and wiring. `SnapEpsilon`/`RotCloseEpsilon` consts referenced in helper + comment. `CellBspPlaneEpsilon` local to `PointInsideCellBsp`. `CellBSPNode { Type=…, SplittingPlane=new Plane(Vector3,float), PosNode=… }` matches `SphereIntersectsCellBspTests`. `CameraDiagnostics` statics saved/restored (they leak between tests) as the existing tests do.
**Risk notes:** Part 2 changes a Core primitive shared with physics — the FULL Core suite is the gate, and the change is bounded to a ±0.2 mm band (retail-exact). Part 1's integration test is float-ULP-sensitive; frame count (120) + displacement (~7 m) were chosen so the un-snapped step (~5e-5 m) is tens of ULP above zero — a real RED — while still below SnapEpsilon (snapped GREEN).

View file

@ -0,0 +1,567 @@
# Verbatim Retail Indoor Render Port — 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 the indoor-render approximation layer with a verbatim port of retail `PView::DrawCells` so interiors seal (no grey, no bleed, no half-character) and inside↔outside↔inside is seamless.
**Architecture:** Keep the faithful membership (`PortalVisibilityBuilder` = `cell_draw_list`) and clip math (`PortalProjection.ProjectToClip`/`ClipToRegion` = `GetClip`/`polyClipFinish`). Rewrite `RetailPViewRenderer.DrawInside`/`DrawPortal` into retail's three `DrawCells` loops: draw **every** visible cell's shell, trimmed **per-slice** with `ClipPlaneSet``gl_ClipDistance`; draw objects membership+depth gated with **no** clip. Delete the `ClipFrameAssembler` slot-pool + `drawableCells` filter that drop shells (grey) and the global clip-off that caused bleed.
**Tech Stack:** C# .NET 10, Silk.NET OpenGL 4.3 (bindless + MDI), xUnit. PowerShell on Windows; launch logs UTF-16.
**Spec:** `docs/superpowers/specs/2026-06-06-verbatim-retail-indoor-render-port-design.md` (commit `eb7b1fa`). Read it first.
**Worktree:** `thirsty-goldberg-51bb9b`, branch `claude/thirsty-goldberg-51bb9b`. Do NOT branch/worktree, push, or `git stash`/`gc`. Do NOT revert the dirty render tree. Build before launch.
---
## Orientation for the executing session (read once)
- **Baseline:** App.Tests 205/205; Core.Tests 1331 pass / 4 fail (pre-existing Physics door/step-up, unrelated) / 1 skip; `dotnet build -c Debug` 0 errors. If these don't hold at start, stop and report — something drifted.
- **The two bugs this plan kills** (both in `src/AcDream.App/Rendering/RetailPViewRenderer.cs`):
1. `:52` `drawableCells = clipAssembly.CellIdToSlot.Keys` + `if (!drawableCells.Contains(cellId)) continue;` in every loop → cells without a clip-slot are dropped → **grey**.
2. `:237` `UseIndoorMembershipOnlyRouting()``_envCells.SetClipRouting(null)` → trim globally off → **bleed**; it was disabled because the clip was (wrongly) applied to objects/characters → **half-character**.
- **Retail oracle** (`docs/research/named-retail/acclient_2013_pseudo_c.txt`): `PView::DrawCells` 0x5a4840 — three loops over reverse `cell_draw_list`: exit-portal masks → shells (`DrawEnvCell`, per-slice `setup_view` clip) → objects (`DrawObjCellForDummies`, visibility-gated, NOT hard-clipped).
- **Key existing APIs you'll reuse:**
- `PortalVisibilityFrame` (in `PortalView.cs`): `OrderedVisibleCells` (List<uint>, closest-first), `CellViews` (Dictionary<uint, CellView>), `OutsideView` (CellView). `CellView.Polygons` is the slice list; `ViewPolygon.Vertices` is Vector2[] NDC.
- `ClipPlaneSet.From(CellView)``Count`/`Planes` (≤8), `UseScissorFallback`+`ScissorNdcAabb`, `IsNothingVisible`.
- `ClipFrame`: `Reset()`, `AppendSlot(ClipPlaneSet)`→slot index, `SetTerrainClip(planes)`, `UploadShared(gl)`, `RegionSsbo`. Slot 0 is reserved no-clip.
- `EnvCellRenderer`: `SetClipRegionSsbo(uint)`, `SetClipRouting(IReadOnlyDictionary<uint,int>?)` (null = no-clip), `Render(WbRenderPass, HashSet<uint> filter)`, `PrepareRenderBatches(...)`.
- `WbDrawDispatcher`: `Draw(camera, entries, frustum, neverCullLandblockId, visibleCellIds, animatedEntityIds)`, `SetClipRegionSsbo`, `ClearClipRouting()`.
- **Verification reality:** the pure draw-ORDER is unit-tested (Task 1). Every GL task is verified by **launch + your eyes** + the `[render-sig]` / `[shell]` probes — there is no unit test for a GPU draw. Launch command is in the Appendix. **Do not mark a GL task done on a green build alone — only on the user's visual confirmation.**
---
## File Structure
- **Create:** `src/AcDream.App/Rendering/IndoorDrawPlan.cs` — pure (GL-free) function turning a `PortalVisibilityFrame` into the reverse-ordered shell draw list (every visible cell, no filter). Test seam for the grey regression.
- **Create:** `tests/AcDream.App.Tests/Rendering/IndoorDrawPlanTests.cs`.
- **Rewrite:** `src/AcDream.App/Rendering/RetailPViewRenderer.cs` — the three `DrawCells` loops; remove `drawableCells`, `UseIndoorMembershipOnlyRouting`, the `ClipFrameAssembler` dependency.
- **Delete:** `src/AcDream.App/Rendering/ClipFrameAssembler.cs` + `tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs` (and the `ClipViewSlice`/`ClipFrameAssembly` types if unused after the rewrite).
- **Light touch:** `src/AcDream.App/Rendering/GameWindow.cs` — the indoor/look-in call sites + `sig*` diagnostics that read `ClipAssembly.OutsideViewSlices` (switch to `pvFrame.OutsideView.Polygons.Count`).
- **Untouched:** `PortalVisibilityBuilder.cs`, `PortalProjection.cs`, `ClipPlaneSet.cs`, `ClipFrame.cs`, `EnvCellRenderer.cs`, `WbDrawDispatcher.cs`, the outdoor `LScape` branch.
---
## Task 1: Pure shell-draw-order function (pins the grey regression)
**Files:**
- Create: `src/AcDream.App/Rendering/IndoorDrawPlan.cs`
- Test: `tests/AcDream.App.Tests/Rendering/IndoorDrawPlanTests.cs`
- [ ] **Step 1: Write the failing test**
```csharp
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
public class IndoorDrawPlanTests
{
private static ViewPolygon Quad() => new(new[]
{ new Vector2(-1,-1), new Vector2(1,-1), new Vector2(1,1), new Vector2(-1,1) });
private static PortalVisibilityFrame FrameWith(params uint[] orderedCells)
{
var f = new PortalVisibilityFrame();
foreach (var id in orderedCells)
{
f.OrderedVisibleCells.Add(id);
var v = new CellView(); v.Add(Quad());
f.CellViews[id] = v;
}
return f;
}
[Fact]
public void ShellPass_IncludesEveryVisibleCell_NoFilter()
{
// The grey bug: a cell in OrderedVisibleCells must NEVER be dropped from the
// shell pass. (Old code dropped cells lacking a ClipFrameAssembler slot.)
var f = FrameWith(0x01, 0x02, 0x03);
var plan = IndoorDrawPlan.ShellPass(f);
Assert.Equal(new uint[] { 0x03, 0x02, 0x01 }, plan.Select(e => e.CellId).ToArray()); // reverse = far→near
Assert.All(plan, e => Assert.NotEmpty(e.Slices));
}
[Fact]
public void ShellPass_ExcludesEmptyViewCells()
{
var f = FrameWith(0x01);
f.OrderedVisibleCells.Add(0x02); // present in the list…
f.CellViews[0x02] = new CellView(); // …but empty view → not drawable
var plan = IndoorDrawPlan.ShellPass(f);
Assert.Equal(new uint[] { 0x01 }, plan.Select(e => e.CellId).ToArray());
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --filter "FullyQualifiedName~IndoorDrawPlanTests"`
Expected: FAIL — `IndoorDrawPlan` does not exist (compile error).
- [ ] **Step 3: Write minimal implementation**
```csharp
// IndoorDrawPlan.cs
//
// Pure (GL-free) port of the membership half of retail PView::DrawCells (0x5a4840):
// the reverse cell_draw_list iterated per portal_view slice. EVERY visible cell with a
// non-empty view is included — there is NO "drawable" filter. Dropping cells without a
// clip-slot was the grey-walls bug (the cell's sealed shell never drew → clear color showed).
using System.Collections.Generic;
namespace AcDream.App.Rendering;
public readonly record struct CellDrawEntry(uint CellId, IReadOnlyList<ViewPolygon> Slices);
public static class IndoorDrawPlan
{
/// <summary>Reverse OrderedVisibleCells (far→near), each visible cell with its view
/// slices. Mirrors DrawCells' shell/object loops. Cells whose view is empty are skipped
/// (they are not actually visible); no other cell is ever dropped.</summary>
public static List<CellDrawEntry> ShellPass(PortalVisibilityFrame frame)
{
var result = new List<CellDrawEntry>(frame.OrderedVisibleCells.Count);
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = frame.OrderedVisibleCells[i];
if (!frame.CellViews.TryGetValue(cellId, out var view) || view.IsEmpty)
continue;
result.Add(new CellDrawEntry(cellId, view.Polygons));
}
return result;
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --filter "FullyQualifiedName~IndoorDrawPlanTests"`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```powershell
git add src/AcDream.App/Rendering/IndoorDrawPlan.cs tests/AcDream.App.Tests/Rendering/IndoorDrawPlanTests.cs
git commit -m @'
feat(render): IndoorDrawPlan.ShellPass — every visible cell, no drawable filter (R1)
Pure port of retail DrawCells membership: reverse cell_draw_list, per-slice. Pins the
grey regression — a cell in OrderedVisibleCells is never dropped from the shell pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@
```
---
## Task 2: Shell pass draws EVERY visible cell (kill the grey) — highest-value, smallest change
This is the change that should make the grey disappear. Keep the existing per-slice clip routing for now (Task 4 cleans it); only remove the `drawableCells` filter and iterate `IndoorDrawPlan.ShellPass`.
**Files:** Modify `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawEnvCellShells`, ~`:175-197`).
- [ ] **Step 1: Replace the body of `DrawEnvCellShells`**
Replace the loop so it iterates `IndoorDrawPlan.ShellPass(pvFrame)` (every visible cell) instead of `pvFrame.OrderedVisibleCells` gated by `drawableCells.Contains`. Keep `GetCellSlicesOrNoClip` + `UseShellClipRouting` exactly as they are for this task. New body:
```csharp
private void DrawEnvCellShells(
IRetailPViewCellDrawCallbacks ctx,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells) // param kept this task; removed in Task 4
{
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
{
uint cellId = entry.CellId;
_oneCell.Clear();
_oneCell.Add(cellId);
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
{
UseShellClipRouting(cellId, slice);
_envCells.Render(WbRenderPass.Opaque, _oneCell);
_envCells.Render(WbRenderPass.Transparent, _oneCell);
}
}
}
```
Note: `GetCellSlicesOrNoClip` already returns `NoClipSlice` for a cell with no assembler slice, so a cell that used to be dropped now draws **unclipped** — sealed (grey gone), possibly with some bleed (Task 4 fixes the trim).
- [ ] **Step 2: Build**
Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug --nologo`
Expected: `Build succeeded. 0 Error(s)`.
- [ ] **Step 3: Run the App test suite (no regression)**
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --no-build`
Expected: 207/207 pass (205 baseline + 2 new).
- [ ] **Step 4: Launch + VISUAL verify (user)**
Launch with probes (Appendix). Stand inside the cottage + go to the cellar. **Acceptance:** the interior walls/floor no longer render grey — every room you're in is sealed. Some bleed through doorways is expected at this step. In the log, `[render-sig] draw=[…]` should list the same cells as `ids=[…]` (no cell dropped). **Do not proceed until the user confirms the grey is gone.**
- [ ] **Step 5: Commit**
```powershell
git add src/AcDream.App/Rendering/RetailPViewRenderer.cs
git commit -m @'
fix(render): shell pass draws every visible cell — kill the grey (R1)
Iterate IndoorDrawPlan.ShellPass (all visible cells) instead of gating on the
drawableCells slot filter. A visible cell whose shell was dropped for lack of a
ClipFrameAssembler slot now draws, sealing the interior. Per-slice trim unchanged
this commit (Task 4 replaces it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@
```
---
## Task 3: Object pass draws every visible cell's objects, no clip (kill the half-character)
**Files:** Modify `RetailPViewRenderer.cs` (`DrawCellObjectLists`, ~`:199-224`).
- [ ] **Step 1: Replace the loop to use `IndoorDrawPlan.ShellPass` order + ensure no clip on objects**
```csharp
private void DrawCellObjectLists(
IRetailPViewCellDrawContext ctx,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells, // kept this task; removed in Task 4
InteriorEntityPartition.Result partition)
{
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
{
uint cellId = entry.CellId;
if (!partition.ByCell.TryGetValue(cellId, out var bucket) || bucket.Count == 0)
continue;
_oneCell.Clear();
_oneCell.Add(cellId);
UseIndoorMembershipOnlyRouting(); // objects: NO clip planes (retail DrawObjCellForDummies)
DrawEntityBucket(ctx, bucket, _oneCell);
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, bucket));
}
}
```
(Functionally close to today, but iterating the full visible set. `UseIndoorMembershipOnlyRouting` stays here on purpose — objects are never clip-planed. It is removed from the shell path in Task 4.)
- [ ] **Step 2: Build + App tests**
Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug --nologo` then `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --no-build`
Expected: build 0 errors; 207/207.
- [ ] **Step 3: Launch + VISUAL verify (user)**
Stand on the cellar stairs / near a door with an NPC. **Acceptance:** characters/objects are **whole** (no half-character), and objects in visible cells appear. Confirm with the user before proceeding.
- [ ] **Step 4: Commit**
```powershell
git add src/AcDream.App/Rendering/RetailPViewRenderer.cs
git commit -m @'
fix(render): object pass over every visible cell, no clip planes (R1)
Objects drawn membership+depth gated (retail DrawObjCellForDummies), never hard-clipped
to the 2D portal view — fixes the half-character. Iterates the full visible-cell set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@
```
---
## Task 4: Per-slice shell trim from `ClipPlaneSet.From` directly (drop the ClipFrameAssembler dependency in the shell loop)
Now make the shell trim faithful and self-contained: compute each slice's planes from the cell's own `CellView.Polygons` via `ClipPlaneSet.From`, pack one region into `ClipFrame`, route the cell to it. This removes the shell loop's use of `clipAssembly` and `drawableCells`.
**Files:** Modify `RetailPViewRenderer.cs` (`DrawEnvCellShells`, helpers).
- [ ] **Step 1: Add a per-slice shell-clip helper**
```csharp
// Pack ONE slice's convex region into the clip frame (slot 1) and route this cell to it,
// matching retail setup_view(cell, slice). Slot 0 stays no-clip. Returns false when the
// slice is empty (draw nothing) — the caller skips it.
private bool ApplyShellSliceClip(uint cellId, ViewPolygon slice, Action<uint> setTerrainClipUbo)
{
var oneSlice = new CellView();
oneSlice.Add(slice);
var planes = ClipPlaneSet.From(oneSlice);
if (planes.IsNothingVisible)
return false;
_clipFrame.Reset(); // slot 0 = no-clip
int slot = _clipFrame.AppendSlot(planes); // slot 1 = this slice (or no-clip region if scissor fallback*)
UploadClipFrame(setTerrainClipUbo); // re-upload SSBO (cheap; few indoor cells)
_oneCellSlot.Clear();
_oneCellSlot[cellId] = slot;
_envCells.SetClipRouting(_oneCellSlot);
_entities.ClearClipRouting();
return true;
}
```
\* `ClipFrame.AppendSlot(ClipPlaneSet)` packs the planes when `Count>0`, else a no-clip region. The scissor-AABB fallback (`UseScissorFallback`) is not expressible as planes — for this first port, treat scissor-fallback as no-clip (over-include, never grey). A later refinement can `glScissor` the `ScissorNdcAabb`; note it in the handoff if you see bleed only on >8-edge slices.
- [ ] **Step 2: Rewrite `DrawEnvCellShells` to use it (and drop `clipAssembly`/`drawableCells` params)**
```csharp
private void DrawEnvCellShells(
RetailPViewDrawContext ctx, // need SetTerrainClipUbo; use the concrete context
PortalVisibilityFrame pvFrame)
{
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
{
_oneCell.Clear();
_oneCell.Add(entry.CellId);
foreach (var slice in entry.Slices)
{
if (!ApplyShellSliceClip(entry.CellId, slice, ctx.SetTerrainClipUbo))
continue;
_envCells.Render(WbRenderPass.Opaque, _oneCell);
_envCells.Render(WbRenderPass.Transparent, _oneCell);
}
}
}
```
(For `DrawPortal`, which uses `RetailPViewPortalDrawContext`, either add `SetTerrainClipUbo` access via the shared `IRetailPViewCellDrawContext` interface or pass the `Action<uint>` directly. Both contexts already expose `SetTerrainClipUbo`.)
Update the two call sites (`DrawInside` `:77`, `DrawPortal` `:126`) to `DrawEnvCellShells(ctx, pvFrame)`.
- [ ] **Step 3: Build + App tests**
Run: build + `dotnet test … --no-build`. Expected: 0 errors; 207/207 (the deleted clip-slot routing isn't unit-tested).
- [ ] **Step 4: Launch + VISUAL verify (user)**
**Acceptance:** interior is sealed AND trimmed — looking through a doorway shows only the slice of the next room visible through the opening; no bleed of a neighbour room past a wall edge; no shell gap at stair/door boundaries. If a shell gaps, the slice is too small (a `ClipToRegion` case to inspect) — report it; do not re-add the filter. Confirm with the user.
- [ ] **Step 5: Commit**
```powershell
git add src/AcDream.App/Rendering/RetailPViewRenderer.cs
git commit -m @'
feat(render): per-slice shell trim from ClipPlaneSet.From (retail setup_view) (R1)
Each visible cell's shell is clipped per portal_view slice via ClipPlaneSet→gl_ClipDistance,
computed from the cell's own CellView — no ClipFrameAssembler slot pool, no drawableCells
filter. Objects remain unclipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@
```
---
## Task 5: Look-out (landscape through OutsideView) from `OutsideView` slices directly
Drop the shell/object loops' last `clipAssembly` use and make the landscape pass read `pvFrame.OutsideView` directly.
**Files:** Modify `RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` `:133-153`, `DrawInside` `:74`).
- [ ] **Step 1: Rewrite `DrawLandscapeThroughOutsideView` to iterate `pvFrame.OutsideView.Polygons`**
```csharp
private void DrawLandscapeThroughOutsideView(
RetailPViewDrawContext ctx,
PortalVisibilityFrame pvFrame,
InteriorEntityPartition.Result partition)
{
if (pvFrame.OutsideView.IsEmpty)
return;
foreach (var slicePoly in pvFrame.OutsideView.Polygons)
{
var oneSlice = new CellView();
oneSlice.Add(slicePoly);
var planes = ClipPlaneSet.From(oneSlice);
if (planes.IsNothingVisible)
continue;
_clipFrame.SetTerrainClip(planes.Count > 0 ? ToSpan(planes) : ReadOnlySpan<Vector4>.Empty);
UploadClipFrame(ctx.SetTerrainClipUbo);
// Reuse the existing ClipViewSlice DTO ONLY as the landscape callback payload, or
// introduce a small RetailPViewLandscapeSliceContext from (planes, slicePoly bounds).
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(/* slice payload */, partition.Outdoor));
}
// depth-clear over the OutsideView bounds (retail Clear(DEPTH) when outside_view.view_count>0)
ctx.ClearDepthSlice?.Invoke(/* OutsideView NDC bounds */);
}
private static ReadOnlySpan<Vector4> ToSpan(ClipPlaneSet s)
{
var a = new Vector4[s.Count];
for (int i = 0; i < s.Count; i++) a[i] = s.Planes[i];
return a;
}
```
Note: `RetailPViewLandscapeSliceContext` / `ClearDepthSlice` currently take a `ClipViewSlice`. Simplest path: keep a tiny `ClipViewSlice` record (just `{ Planes, NdcAabb }`) as a payload DTO even after deleting `ClipFrameAssembler`, OR change those two callback signatures to take `(ReadOnlySpan<Vector4> planes, Vector4 ndcAabb)`. Pick one and apply consistently to the `GameWindow` callback implementations (`DrawRetailPViewLandscapeSlice`, the `ClearDepthSlice` lambda at `GameWindow.cs:7480`).
- [ ] **Step 2: Update `DrawInside` to call the new signatures and drop `clipAssembly`/`drawableCells`**
In `DrawInside` (`:39-81`): remove `var clipAssembly = ClipFrameAssembler.Assemble(...)`, `drawableCells`, and the `UseIndoorMembershipOnlyRouting()` calls around the shell loop. Partition with **all** visible cells:
```csharp
var partition = InteriorEntityPartition.Partition(
new HashSet<uint>(pvFrame.OrderedVisibleCells), ctx.LandblockEntries);
```
Final `DrawInside` order: `Build``_envCells.PrepareRenderBatches(filter: visibleSet)``DrawLandscapeThroughOutsideView(ctx, pvFrame, partition)``DrawExitPortalMasks` (if kept) → `DrawEnvCellShells(ctx, pvFrame)``DrawCellObjectLists(ctx, pvFrame, partition)`.
- [ ] **Step 3: Build + App tests + Launch VISUAL verify (user)**
Build 0 errors; 207/207. **Acceptance:** standing inside, looking out a door/window shows the outdoor world through the opening (not grey, not full-screen); depth correct. Confirm with the user.
- [ ] **Step 4: Commit**
```powershell
git add src/AcDream.App/Rendering/RetailPViewRenderer.cs src/AcDream.App/Rendering/GameWindow.cs
git commit -m @'
feat(render): look-out landscape from OutsideView slices; drop clipAssembly in DrawInside (R1)
DrawInside no longer builds a ClipFrameAssembler; landscape-through-outside_view reads
pvFrame.OutsideView directly per slice. Partition runs over all visible cells.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@
```
---
## Task 6: Delete `ClipFrameAssembler` + the slot-pool dead code
After Tasks 45 nothing should reference `ClipFrameAssembler` / `ClipFrameAssembly` / `drawableCells` in the draw path.
**Files:** Delete `ClipFrameAssembler.cs`, `ClipFrameAssemblerTests.cs`; edit `RetailPViewRenderer.cs`, `GameWindow.cs`.
- [ ] **Step 1: Remove remaining references**
- `RetailPViewRenderer.cs`: remove `using`/fields/params for `ClipFrameAssembly`, `drawableCells`, `_oneCellSlot` if now unused, `GetCellSlicesOrNoClip`, `UseShellClipRouting`, and `RetailPViewFrameResult.ClipAssembly`/`DrawableCells` (keep `PortalFrame`, `Partition`). Update `DrawExitPortalMasks` to iterate `IndoorDrawPlan.ShellPass` + `entry.Slices` (or delete it if exit masks are not needed — see note).
- `GameWindow.cs`: `sigTerrainDrawn`/`sigSkyDrawn`/`sigDepthClear`/`sigSceneParticles` (`:7506-7514`) currently read `pviewResult.ClipAssembly.OutsideViewSlices.Length`; change to `pviewResult.PortalFrame.OutsideView.Polygons.Count`. Remove other `ClipAssembly`/`DrawableCells` reads.
**Exit-masks note:** retail Loop 1 (`DrawPortalPolyInternal`) punches exit-portal openings into depth for the look-out landscape. If, after Task 5, the look-out landscape shows correctly without it, delete `DrawExitPortalMasks`. If the outdoor world z-fights or is occluded wrong through the opening, keep it and port it as: per visible cell, per slice, draw the cell's exit-portal polygons depth-only. Decide from the Task 5 visual.
- [ ] **Step 2: Delete the files**
```powershell
git rm src/AcDream.App/Rendering/ClipFrameAssembler.cs tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs
```
If `ClipViewSlice`/`ClipFrameAssembly` live in `ClipFrameAssembler.cs` and a payload DTO is still needed (Task 5), move the minimal record to a new small file `RetailPViewTypes.cs` (or inline into `RetailPViewRenderer.cs`).
- [ ] **Step 3: Build + FULL test suite**
Run: `dotnet build -c Debug --nologo` then `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --no-build` and `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj -c Debug --no-build`.
Expected: build 0 errors; App green (now ~205207 depending on removed assembler tests); Core 1331/4(pre-existing)/1.
- [ ] **Step 4: Commit**
```powershell
git add -A
git commit -m @'
refactor(render): delete ClipFrameAssembler slot-pool + drawableCells filter (R1)
The verbatim DrawCells loop (Tasks 15) no longer needs the slot pool or the
drawable-cells filter that dropped shells. Removes the approximation layer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@
```
---
## Task 7: Look-in (B) — `DrawPortal` on the same loops
`DrawPortal` already calls `DrawEnvCellShells`/`DrawCellObjectLists`; after Tasks 46 those are filter-free and clip-correct, so look-in should "just work." This task verifies it and removes any residual `clipAssembly` use in `DrawPortal`.
**Files:** Modify `RetailPViewRenderer.cs` (`DrawPortal` `:83-131`).
- [ ] **Step 1: Mirror DrawInside's cleanup in DrawPortal**
Remove `ClipFrameAssembler.Assemble`, `drawableCells`; partition over `pvFrame.OrderedVisibleCells`; call `DrawEnvCellShells(ctx, pvFrame)` + `DrawCellObjectLists(ctx, pvFrame, partition)`; keep the `RestoreNoClip` at the end.
- [ ] **Step 2: Build + App tests**
Build 0 errors; App green.
- [ ] **Step 3: Launch + VISUAL verify (user)**
Walk up to a building from OUTSIDE with an open door. **Acceptance:** you see the room interior (shell + objects) through the doorway as you approach, sealed and trimmed; walking through the door is seamless (no flash/grey at the threshold). Confirm with the user.
- [ ] **Step 4: Commit**
```powershell
git add src/AcDream.App/Rendering/RetailPViewRenderer.cs
git commit -m @'
feat(render): look-in DrawPortal on the verbatim DrawCells loops (R1, scope B)
Outside-looking-in reuses the same per-cell, per-slice shell + object passes as DrawInside;
no separate engine, no slot pool.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@
```
---
## Task 8: Final verification + handoff
- [ ] **Step 1: Full green**
`dotnet build -c Debug --nologo`; App + Core test suites. Record counts.
- [ ] **Step 2: Clean launch (no probes) for FPS/feel + the four seamless scenarios**
With the user: (1) stand inside cottage — sealed, textured-or-grey noted; (2) cellar — floor + stairs present, character whole; (3) room↔room↔cellar transitions — no flap; (4) walk outside→in and look in through a door — seamless. Get explicit visual sign-off on each.
- [ ] **Step 3: If walls are sealed but UNTEXTURED (grey-but-drawn)**
That is the separate surface/texture bug the spec flags as out of scope (HEAD's "interior walls grey"). File it as a new issue in `docs/ISSUES.md` with the evidence (every shell now draws per `[render-sig]`, but surfaces render untextured) — do NOT reopen the membership/clip work for it.
- [ ] **Step 4: Update memory + roadmap**
If the seal holds and the user confirms: add a `memory/` entry (e.g. `feedback_verbatim_drawcells_port.md`) capturing that the grey was the `drawableCells` filter + the verbatim `DrawCells` loop (every cell, per-slice shell clip, objects unclipped) fixed it; update `MEMORY.md`. Note the two-handoff contradiction (2026-06-05 shell-sealing vs 2026-06-06 projection) so it isn't re-litigated.
---
## Self-Review (done while writing — recorded for the executor)
- **Spec coverage:** §4.2 loop → Tasks 2/3/4 (shells/objects/trim); §4.3 look-in → Task 7; §4 look-out → Task 5; §4.1 deletions → Task 6; testing §7 → Task 1 (pure pin) + per-task visual gates; risks §8 → Task 4 Step 4 note + Task 8 Step 3. Covered.
- **Type consistency:** `IndoorDrawPlan.ShellPass``List<CellDrawEntry>`; `CellDrawEntry(uint CellId, IReadOnlyList<ViewPolygon> Slices)`; `ClipPlaneSet.From(CellView)`; `ClipFrame.Reset()/AppendSlot/SetTerrainClip/UploadShared`; `EnvCellRenderer.SetClipRouting/Render`. Consistent across tasks.
- **Known soft spots (flagged inline, not placeholders):** (a) the landscape/depth-clear callback DTO (`ClipViewSlice` vs a new `(planes, aabb)`) — Task 5 Step 1 picks one explicitly; (b) exit-masks keep-or-delete — Task 6 Step 1 decides from the Task 5 visual; (c) scissor-AABB fallback rendered as no-clip for now — Task 4 note. These are genuine implementation choices for the executor, each with a stated default.
---
## Appendix: launch command (probes)
```powershell
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | ForEach-Object { $_.CloseMainWindow() | Out-Null; if (-not $_.WaitForExit(5000)) { $_ | Stop-Process -Force } }
Start-Sleep -Seconds 3
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"
$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"
$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword"
$env:ACDREAM_PROBE_FLAP="1"; $env:ACDREAM_PROBE_SHELL="1"; $env:ACDREAM_PROBE_VIS="1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-drawcells.log"
```
Run in the background; the user tests then closes the window. For a clean FPS/feel run, drop the three `ACDREAM_PROBE_*` vars. **Build before every launch.** Heavy probe output can make the client sluggish — keep probe runs short.

View file

@ -0,0 +1,357 @@
# Render Unification (Outdoor-as-a-Cell) 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:** Collapse acdream's two render paths (`OutdoorRoot` vs `RetailPViewInside`) into one — a single portal flood rooted at the viewer cell (indoor *or* a new outdoor cell node) and a single draw of every visible cell — so the indoor/outdoor FLAP is impossible by construction.
**Architecture:** Model the outdoor world as a synthetic `LoadedCell` flood node whose "shell" is the landscape and whose "doorways" are nearby building entrances. `PortalVisibilityBuilder.Build` roots at the viewer cell; building exit portals lead to the outdoor node; the draw path renders each visible cell uniformly (outdoor node → terrain/sky; interior → shell). Matches retail `SmartBox::RenderNormalMode → DrawInside(viewer_cell)`.
**Tech Stack:** C# .NET 10, Silk.NET OpenGL, xUnit. Render code in `src/AcDream.App/Rendering/`. Tests in `tests/AcDream.App.Tests/Rendering/`.
**Spec:** [docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md](../specs/2026-06-07-render-unification-outdoor-as-cell-design.md)
## Progress (2026-06-07)
- **Task 1 — `OutdoorCellNode.Build` — DONE** (`2a2cc97`). 2 unit tests; App.Tests 212/212.
- **Task 3 — outdoor-root flood — DONE** (`c5b4f77`, done before Task 2 to de-risk the
core hypothesis). **KEY RESULT: the flood roots at the outdoor node and reaches
buildings with ZERO production changes** — `PortalVisibilityBuilder.Build` and
`OutdoorCellNode.Build` are correct as-is; cycle termination holds. App.Tests 214/214.
(The plan's Task 3 fixture sketch had `InsideSide=0`; the shipped test uses the correct
`InsideSide=1` — building interior at Y>5 is the negative half-space. `OutdoorCellNode`
flips it so the outdoor camera passes the side test.)
- **Task 2 — build the outdoor node each frame — DONE** (`d01fe30`). Additive: `_outdoorNode`
built each outdoor frame from nearby building-entrance portals (Chebyshev ≤1), with an
`[outdoor-node]` probe (ACDREAM_PROBE_FLAP) reporting the live portal count. Not yet rooted
→ behaviour unchanged. App.Tests 214/214, build green. (Insertion: `GameWindow.cs` just
before the branch at the old line ~7341; `playerLb` is in scope there.)
- **NEXT — THE CUTOVER FLIP (the remaining risky, launch-gated chunk), INLINE.** Now fully
de-risked by reading the draw path:
- The shell pass is a **safe no-op** for the synthetic node id — `DrawEnvCellShells`
`_envCells.Render(pass, {id})` renders nothing for an id with no prepared geometry
(`RetailPViewRenderer.cs:190-202`). So **no explicit shell-exclusion is needed.**
- Indoor→outdoor terrain **already works** via the existing `OutsideView` → terrain-slice
path (`DrawInside``DrawLandscapeThroughOutsideView`, `RetailPViewRenderer.cs:79,138`).
The ONLY new piece is the **outdoor-ROOT** case: when `DrawInside` is rooted at the
outdoor node, the node's full-screen view region must become an `OutsideView` slice so
terrain draws full-screen. → Read `ClipFrameAssembler` (how `pvFrame.OutsideView` becomes
`OutsideViewSlices`; how a full-screen region maps to a no-clip terrain slice), then in
`PortalVisibilityBuilder.Build` (or `DrawInside`): when the root is the outdoor node
(`SeenOutside` + outdoor id), `AddRegion(frame.OutsideView, <full-screen NDC quad>)`.
- Then flip: `viewerRoot = _outdoorNode` when outdoors; `clipRoot = viewerRoot` always
(drop the `playerIndoorGate && viewerRoot != null` gate at `GameWindow.cs:~7346`). This
routes EVERY frame through `_retailPViewRenderer.DrawInside` (the `else` outdoor block
becomes dead — leave it for the post-visual-gate delete).
- **Build → launch (`ACDREAM_PROBE_FLAP` only) → USER VISUAL GATE** at the cottage. Then
delete `BuildFromExterior` / `DrawPortal` / the dead `else` block / `OutsideView`-only
plumbing (Task 7) + cleanup (Phase 4).
- **WARNING:** this is coordinated surgery (Build + ClipFrameAssembler + GameWindow) that
ends at a launch + visual gate; a first attempt rarely renders right. Do it with adequate
context headroom (the dead-zone regression came from rushing a render change before a
visual gate). Verify the [outdoor-node] probe shows real portals FIRST.
- Tree clean; HEAD `d01fe30`; baselines App 214 / Core 1331-4-1.
**Baselines that must hold:** build 0 errors; App.Tests 210 pass; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip. Run the client per CLAUDE.md "Running the client"; `+Acdream` spawns at the Holtburg/Arcanum cottage. Launch logs are UTF-16. Use `ACDREAM_PROBE_FLAP` only (NOT `ACDREAM_PROBE_SHELL`).
---
## File structure
| File | Responsibility | Change |
|---|---|---|
| `src/AcDream.App/Rendering/OutdoorCellNode.cs` | Build a synthetic outdoor `LoadedCell` from nearby building exit portals | **Create** |
| `src/AcDream.App/Rendering/CellVisibility.cs` | `LoadedCell`/`CellPortalInfo`/`PortalClipPlane` types; cell registry; `TryGetCell`; resolve the outdoor node | Modify |
| `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` | The one flood; root at outdoor node; exit portals → outdoor node | Modify |
| `src/AcDream.App/Rendering/RetailPViewRenderer.cs` | The one draw path; outdoor-node-aware cell draw | Modify |
| `src/AcDream.App/Rendering/GameWindow.cs` | Per-frame: resolve viewer cell, one flood, one draw; delete the branch | Modify |
| `tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs` | Outdoor node construction + portal wiring | **Create** |
| `tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs` | Build rooted at the outdoor node; cycle termination | **Create** |
**Deletions (Phase 3):** `PortalVisibilityBuilder.BuildFromExterior`; `RetailPViewRenderer.DrawPortal`; the `OutsideView` mechanism + `GameWindow.DrawRetailPViewLandscapeSlice` / `DrawLandscapeThroughOutsideView`; the two-branch gate at `GameWindow.cs:7342-7349`.
---
## PHASE 1 — The outdoor cell node (additive; not yet consumed by the draw)
### Task 1: `OutdoorCellNode.Build` — synthesize the outdoor node from nearby building entrances
**Files:**
- Create: `src/AcDream.App/Rendering/OutdoorCellNode.cs`
- Test: `tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs`
Context: a building cell stores its entrance as a portal with `OtherCellId == 0xFFFF` (exit-to-outdoors) in `Portals[i]`, with the matching `ClipPlanes[i]` (local-space `Normal`,`D`,`InsideSide`) and `PortalPolygons[i]` (local-space verts). The outdoor node is a `LoadedCell` with `WorldTransform = Identity` whose `Portals` point *back into* each building cell, with the entrance polygon transformed to world space and the clip plane reversed (`InsideSide` flipped) so "inside the outdoor node" is the half-space outside the building.
- [ ] **Step 1: Write the failing test**
```csharp
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
public class OutdoorCellNodeTests
{
// A building cell at world-translate (10,0,0) with one exit portal (OtherCellId=0xFFFF)
// whose local plane faces +X (InsideSide=0). The outdoor node must expose ONE portal
// back into that building cell, with the entrance polygon moved to world space and the
// inside-side flipped (so the outdoor half-space is "inside" the node).
private static LoadedCell BuildingWithOneExit(uint cellId)
{
var cell = new LoadedCell { CellId = cellId };
cell.WorldTransform = Matrix4x4.CreateTranslation(10f, 0f, 0f);
cell.InverseWorldTransform = Matrix4x4.CreateTranslation(-10f, 0f, 0f);
cell.Portals.Add(new CellPortalInfo(OtherCellId: 0xFFFF, PolygonId: 0, Flags: 0, OtherPortalId: 0));
cell.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(1, 0, 0), D = 0f, InsideSide = 0 });
cell.PortalPolygons.Add(new[]
{
new Vector3(0, -1, 0), new Vector3(0, 1, 0), new Vector3(0, 1, 2), new Vector3(0, -1, 2)
});
return cell;
}
[Fact]
public void Build_FromBuildingExit_AddsReversePortalIntoBuilding()
{
uint outdoorId = 0xA9B40031;
var building = BuildingWithOneExit(0xA9B40170);
var node = OutdoorCellNode.Build(outdoorId, new[] { building });
Assert.Equal(outdoorId, node.CellId);
Assert.True(node.SeenOutside);
Assert.Equal(Matrix4x4.Identity, node.WorldTransform);
Assert.Single(node.Portals);
Assert.Equal((ushort)(0xA9B40170 & 0xFFFF), node.Portals[0].OtherCellId);
// Reversed inside-side: the building's exit was InsideSide=0, the node's is 1.
Assert.Equal(1, node.ClipPlanes[0].InsideSide);
// Entrance polygon moved to world space (building translated +10 X): first vert x≈10.
Assert.Equal(10f, node.PortalPolygons[0][0].X, 3);
}
[Fact]
public void Build_NoBuildings_ReturnsEmptyPortalNode()
{
var node = OutdoorCellNode.Build(0xA9B40031, System.Array.Empty<LoadedCell>());
Assert.Empty(node.Portals);
Assert.True(node.SeenOutside);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~OutdoorCellNodeTests"`
Expected: FAIL — `OutdoorCellNode` does not exist (compile error).
- [ ] **Step 3: Write minimal implementation**
```csharp
// src/AcDream.App/Rendering/OutdoorCellNode.cs
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// Builds the synthetic outdoor cell node — the outdoor world as a flood-graph cell
/// (spec 2026-06-07-render-unification-outdoor-as-cell). Its "shell" is the landscape
/// (drawn by the terrain renderer); its portals are the reverse of each nearby
/// building's exit portal (OtherCellId==0xFFFF). One node per frame, keyed by the
/// viewer's outdoor landcell id. WorldTransform is identity (portals stored in world
/// space). Mirrors retail's outdoor landcell that DrawInside(viewer_cell) roots at.
/// </summary>
public static class OutdoorCellNode
{
public static LoadedCell Build(uint outdoorCellId, IReadOnlyList<LoadedCell> nearbyBuildingCells)
{
var node = new LoadedCell
{
CellId = outdoorCellId,
SeenOutside = true,
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
};
foreach (var bcell in nearbyBuildingCells)
{
for (int i = 0; i < bcell.Portals.Count; i++)
{
if (bcell.Portals[i].OtherCellId != 0xFFFF) continue; // only exit-to-outdoors
if (i >= bcell.ClipPlanes.Count || i >= bcell.PortalPolygons.Count) continue;
// Reverse portal: outdoor node -> this building cell.
node.Portals.Add(new CellPortalInfo(
OtherCellId: (ushort)(bcell.CellId & 0xFFFFu),
PolygonId: bcell.Portals[i].PolygonId,
Flags: bcell.Portals[i].Flags,
OtherPortalId: (ushort)i));
// Entrance polygon -> world space (node transform is identity).
var srcPoly = bcell.PortalPolygons[i];
var worldPoly = new Vector3[srcPoly.Length];
for (int v = 0; v < srcPoly.Length; v++)
worldPoly[v] = Vector3.Transform(srcPoly[v], bcell.WorldTransform);
node.PortalPolygons.Add(worldPoly);
// Clip plane -> world space, inside-side flipped (outdoor half-space is "inside").
var src = bcell.ClipPlanes[i];
var worldNormal = Vector3.TransformNormal(src.Normal, bcell.WorldTransform);
worldNormal = Vector3.Normalize(worldNormal);
var pointOnPlane = Vector3.Transform(src.Normal * -src.D, bcell.WorldTransform);
node.ClipPlanes.Add(new PortalClipPlane
{
Normal = worldNormal,
D = -Vector3.Dot(worldNormal, pointOnPlane),
InsideSide = src.InsideSide == 0 ? 1 : 0,
});
}
}
return node;
}
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~OutdoorCellNodeTests"`
Expected: PASS (2 tests). If `LoadedCell.CellId` is not settable from tests, confirm its declaration in `CellVisibility.cs` and adjust (it is a public field used as `cameraCell.CellId` throughout the builder).
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/Rendering/OutdoorCellNode.cs tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs
git commit -m "feat(render): Phase 1 — OutdoorCellNode.Build (outdoor world as a flood node)"
```
### Task 2: Resolve `viewerRoot` to the outdoor node when the eye is outdoors
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs:7201-7204` (viewerRoot resolution)
- Modify: `src/AcDream.App/Rendering/CellVisibility.cs` (add `GetNearbyBuildingCellsForExterior` if not already exposed; the look-in enumeration at `GameWindow.cs:~7538-7565` already gathers candidate cells — reuse it)
Note: this step builds the node and stores it on a field but **does not yet feed it to the flood/draw** — the existing branch still runs. Purely additive; the only observable change is that `viewerRoot` is non-null outdoors (verify via `[render-sig]` `viewerRoot=` once wired in Phase 3; for now assert via a focused test or a temporary log).
- [ ] **Step 1:** Add a `private LoadedCell? _outdoorNode;` field to `GameWindow` and, right after the existing `viewerRoot` block (`GameWindow.cs:7201-7203`), when `viewerRoot is null && viewerCellId != 0u` (outdoor id), build the node from the nearby building cells (reuse the exterior-candidate enumeration already at ~7538-7565, extracted into a helper `GatherNearbyBuildingCells(playerLb)` returning `IReadOnlyList<LoadedCell>`), assign `_outdoorNode = OutdoorCellNode.Build(viewerCellId, nearby);` and **leave `viewerRoot` unchanged for now** (Phase 3 flips the consumer). Add a one-line `[render-sig]`-adjacent log behind `ProbeFlapEnabled`: `outdoorNode portals=N` to confirm wiring live.
- [ ] **Step 2:** `dotnet build -c Debug` → 0 errors. `dotnet test` both suites → baselines hold (210 / 1331-4-1). No behavior change yet.
- [ ] **Step 3: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/Rendering/CellVisibility.cs
git commit -m "feat(render): Phase 1 — build the outdoor node each frame (additive, unconsumed)"
```
---
## PHASE 2 — Outdoor-root flood capability (additive; old exit-portal behaviour untouched)
### Task 3: `PortalVisibilityBuilder.Build` floods from the outdoor node into buildings
**Files:**
- Modify: `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (the `Build` seed + portal loop at lines 63, 133-318)
- Test: `tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs`
Context: `Build(cameraCell, cameraPos, lookup, viewProj)` seeds the root full-screen and floods interior portals. Rooting at the outdoor node already works structurally (it's a `LoadedCell` with portals into buildings). This task is a **characterization test** proving Build floods outdoor→building, plus any fix needed for the outdoor node's identity-transform portals (its polygons are already world-space, so `ProjectToClip(localPoly, node.WorldTransform=Identity, viewProj)` is correct).
- [ ] **Step 1: Write the failing test** (real fixture: outdoor node + one building cell reachable through it)
```csharp
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
public class UnifiedFloodTests
{
[Fact]
public void Build_RootedAtOutdoorNode_FloodsIntoBuilding()
{
// Building cell directly in front of the eye, with an exit portal facing the eye.
var building = new LoadedCell { CellId = 0xA9B40170, SeenOutside = true };
building.WorldTransform = Matrix4x4.Identity;
building.InverseWorldTransform = Matrix4x4.Identity;
building.Portals.Add(new CellPortalInfo(0xFFFF, 0, 0, 0));
building.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, -1, 0), D = 5f, InsideSide = 0 });
building.PortalPolygons.Add(new[]
{
new Vector3(-1, 5, 0), new Vector3(1, 5, 0), new Vector3(1, 5, 2), new Vector3(-1, 5, 2)
});
var node = OutdoorCellNode.Build(0xA9B40031, new[] { building });
LoadedCell? Lookup(uint id) => (id & 0xFFFFu) == 0x0170 ? building : null;
// Eye in front of the entrance, looking +Y toward it.
var eye = new Vector3(0, -3, 1);
var view = Matrix4x4.CreateLookAt(eye, new Vector3(0, 5, 1), Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1f, 5000f);
var frame = PortalVisibilityBuilder.Build(node, eye, Lookup, view * proj);
Assert.Contains(0xA9B40031u, frame.OrderedVisibleCells); // the outdoor node itself
Assert.Contains(0xA9B40170u, frame.OrderedVisibleCells); // flooded into the building
}
}
```
- [ ] **Step 2: Run to verify it fails or passes.** Run the filter `UnifiedFloodTests`. If it FAILS (building not flooded), inspect why (likely the lookup keys on full id vs low id, or the node's world-space polygon needs identity transform in `Build`'s projection call). Fix minimally in `PortalVisibilityBuilder`. If it PASSES first try, it's a characterization test that locks the behaviour — keep it.
- [ ] **Step 3: Cycle-termination test** — add a reciprocal exit portal on the building back to the outdoor node and assert `Build` terminates (no hang, bounded `OrderedVisibleCells`). The existing `queued`/`MaxReprocessPerCell` guards should cover it; this test pins it.
- [ ] **Step 4:** `dotnet test` both suites → baselines hold + the new tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/Rendering/PortalVisibilityBuilder.cs tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs
git commit -m "feat(render): Phase 2 — Build floods from the outdoor node into buildings (+cycle guard test)"
```
---
## PHASE 3 — The cutover (the one risky, visual-gated step)
> Each task here is git-revertible as a unit. After Task 7, **stop for the user's visual gate** before Task 8's deletions.
### Task 4: Exit portals enqueue the outdoor node
- [ ] In `PortalVisibilityBuilder.Build`, at the exit-portal branch (`PortalVisibilityBuilder.cs:234`, `portal.OtherCellId == 0xFFFF`), instead of (only) `AddRegion(frame.OutsideView, clippedRegion)`, resolve the outdoor node via the `lookup` (Phase 1 makes it resolvable) and enqueue it with `clippedRegion` as its view, exactly like an interior neighbour (the `AddRegion(nview,…)` + `queued.Add` path at lines 296-316). Keep `OutsideView` populated too **for this task only** (so the old draw still works) — it is removed in Task 7. Run `UnifiedFloodTests` + add a test: indoor root → flood reaches the outdoor node through the exit portal.
- [ ] Commit: `feat(render): Phase 3 — exit portals flood into the outdoor node`.
### Task 5: Unified draw — render the outdoor node's shell as terrain
- [ ] In `RetailPViewRenderer` (the visible-cell draw walk, `DrawEnvCellShells`/`IndoorDrawPlan.ShellPass`), special-case the outdoor node: when the visible cell is the outdoor node, draw terrain + sky + outdoor scenery clipped to that cell's view region (reuse the existing terrain clip mechanism — drive `TerrainModernRenderer`'s binding=2 clip UBO from the node's region planes; full-screen region → the existing no-clip UBO) instead of EnvCell shell geometry. Interior cells unchanged.
- [ ] Build green; commit: `feat(render): Phase 3 — draw the outdoor node's shell as terrain (unified draw)`.
### Task 6: Route the frame through the single path
- [ ] At `GameWindow.cs:7342-7349`, replace the branch so `viewerRoot` is the outdoor node when outdoors (Task 2 already builds it; assign `viewerRoot = _outdoorNode` when the prior lookup was null and an outdoor node exists). Set `clipRoot = viewerRoot` unconditionally (drop the `playerIndoorGate && viewerRoot != null` gate). The single draw path (`RetailPViewRenderer.DrawInside`) now runs every frame, rooted at the viewer cell.
- [ ] Build green; `dotnet test` baselines. Commit: `feat(render): Phase 3 — single render path rooted at the viewer cell`.
### Task 7: **VISUAL GATE** — user verifies, then delete the old paths
- [ ] Build, launch (`ACDREAM_PROBE_FLAP=1`, UTF-16 log). **User test** at the Holtburg cottage: walk in/out, pan at the threshold, cellar down/up, look at the cottage from outside. Acceptance: no flap; no missing wall/roof textures; terrain + sky correct; no see-through walls; pure-outdoor FPS unchanged. Capture `[render-sig]``branch` is now always the single path; `viewerCell`/`draw` transition cleanly with no 4↔6 cell-set jump.
- [ ] **Only after the user confirms:** delete `PortalVisibilityBuilder.BuildFromExterior`, `RetailPViewRenderer.DrawPortal`, the `OutsideView` field + `AddRegion(frame.OutsideView,…)`, and `GameWindow.DrawRetailPViewLandscapeSlice`/`DrawLandscapeThroughOutsideView` + the now-dead outdoor-branch block. Build green; `dotnet test` baselines.
- [ ] Commit: `feat(render): Phase 3 — delete two-pipe split (BuildFromExterior/DrawPortal/OutsideView)`.
---
## PHASE 4 — Cleanup
### Task 8: Reconcile probes + dead code
- [ ] Update the `[render-sig]` emit (`GameWindow.cs:~9039-9082`) so `branch` reflects the single path and the now-removed `extPortal/extIds/outdoorRoot*` fields are dropped or repurposed. Remove any now-unreachable helpers flagged by the build. Update `docs/research` / memory `project_indoor_flap_rootcause` + `reference_render_pipeline_state` with the shipped outcome.
- [ ] Update the roadmap "shipped" table (`docs/plans/2026-04-11-roadmap.md`) + the milestones doc M1.5 note. Commit: `chore(render): Phase 4 — probe + docs reconcile after unification`.
---
## Self-review
- **Spec coverage:** §6.1 outdoor node → Task 1/2; §6.2 one flood → Task 3/4; §6.3 one draw + deletions → Task 5/6/7; §6.4 terrain clip reuse → Task 5; §9 phasing → Phases 1-4 (1-2 additive, 3 cutover, 4 cleanup); §10 testing → Tasks 1/3 unit + Task 7 visual gate + the pure-outdoor regression guard (assert in Task 5/6 that an outdoor root with no buildings yields a full-screen no-clip terrain draw). **Gap fixed:** add to Task 6 an explicit assertion/log that the no-building outdoor case routes to the no-clip terrain UBO (regression guard from spec §10).
- **Placeholders:** Phases 1-2 carry real test + impl code. Phase 3-4 are concrete wiring/deletion tasks against named methods (their exact code is finalized against the Phase 1-2 APIs at execution — the cutover is inherently wire-and-delete + visual gate, not new algorithm). No "TBD"/"add error handling".
- **Type consistency:** `LoadedCell` (fields `CellId`, `Portals`, `ClipPlanes`, `PortalPolygons`, `WorldTransform`, `InverseWorldTransform`, `SeenOutside`), `CellPortalInfo(OtherCellId,PolygonId,Flags,OtherPortalId)`, `PortalClipPlane{Normal,D,InsideSide}`, `OutdoorCellNode.Build(uint, IReadOnlyList<LoadedCell>) → LoadedCell`, `PortalVisibilityBuilder.Build(LoadedCell, Vector3, Func<uint,LoadedCell?>, Matrix4x4)` — consistent across tasks.
**Note for the executor:** confirm `LoadedCell.CellId` is a settable public field and the exact `PortalVisibilityBuilder.Build` signature against `CellVisibility.cs`/`PortalVisibilityBuilder.cs:63` before Task 1/3 (the plan assumes the signatures observed 2026-06-07). Phase 3 tasks reference real method names to wire/delete; read each call site before editing.

View file

@ -0,0 +1,396 @@
# Full Retail Render Port (Option A) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace acdream's synthetic-outdoor-node + unified-flood render orchestration with retail's one structural path — root the render at the *real* cell the camera eye occupies, run ONE `DrawInside`, and render building interiors as many small per-building floods (robust to the eye's ~36 µm rest jitter) — so the indoor doorway "flap" dies by construction, not by tuning.
**Architecture:** Retail (measured + decompiled) renders every in-world frame through a single `DrawInside(viewer_cell)`. `viewer_cell` is whatever cell the camera-collision sweep resolves — an outdoor `CLandCell` or an indoor `CEnvCell`; there is **no inside/outside branch**. The flood from that cell fills `outside_view` (full-screen for an outdoor root; the door-shaped region for an indoor root looking out); `outside_view > 0` is the single switch that draws terrain+sky+buildings via `LScape::draw`. Building **interiors** are flooded **separately and per-building** during the landscape draw (terrain BSP → `DrawPortal``ConstructView(CBldPortal)`), each touching ≈2 cells — that per-building granularity is what makes retail robust to a jittering eye. acdream's job is to reproduce this: one root, one path, per-building floods.
**Tech Stack:** C# / .NET 10, Silk.NET GL 4.3 (bindless + MDI). GL-free pure-logic flood (`PortalVisibilityBuilder`) unit-tested without a GPU. xUnit. Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` + `acclient.h` (Sept 2013 EoR PDB).
---
## A. The Oracle (measured live + decompiled — DO NOT RE-DERIVE)
This is the expensive, settled ground truth (handoff `docs/research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md` §3, plus this session's trace closures). Cite it; never re-guess it.
### A.1 Retail render architecture: ONE path
- `SmartBox::RenderNormalMode` (`0x453aa0`, decomp:92635) **always** calls `RenderDevice::DrawInside(viewer_cell)`. The "outside" branch (`LScape::draw` directly) is dead code — the BN predicate `edi_2 = -(edi - edi)` is a compile-time `0`. `is_player_outside` (`0x451e80`) only gates sky/lighting, never the render path.
- "Entering a building" is NOT a render event — only the camera sweep resolving a different `viewer_cell` (outdoor `CLandCell` → indoor `CEnvCell`). Same code path before/after the threshold → no seam → no flap.
### A.2 The flood (trace #2 — read verbatim this session)
- `PView::ConstructView(CObjCell*, 0xffff)` (`0x5a57b0`, decomp:433750): reset `outside_view`; LIFO worklist; seed root; pop cell → append to `cell_draw_list` (membership) → `ClipPortals(cell, 0)` → if nonzero, `AddViewToPortals(cell)`.
- `PView::ClipPortals` (`0x5a5520`, decomp:433572): for each portal of the cell, if it survives the near clip: a portal leading **outside** (`other_cell_id == 0xffffffff`) copies its clipped region into the PView's **`outside_view`** (gated on `draw_landscape`, decomp:433664-433684); a portal to another cell does the reciprocal `OtherPortalClip` + `copy_view` into the neighbour's view slice.
- `PView::AddViewToPortals` (`0x5a52d0`, decomp:433446): first visit (`ecx_5==0`) → `InitCell`+`InsCellTodoList` (enqueue); already-visited but view **grew** (`ecx_5!=eax_2`) → `AddToCell`/`FixCellList` re-process **in place**. Retail DOES re-process grown cells; it does NOT re-enqueue them. (This is why acdream's `Build_ViewGrowthAfterDoneCell_*` tests are correct and must stay green.)
### A.3 Indoor vs outdoor differ ONLY in the root (trace #3 — resolved this session)
- The fields `num_stabs`, `stab_list`, `seen_outside`, `num_view`, `portal_view`, `num_portals`, `portals`, `pos` are on the **`CObjCell` base** (`acclient.h:30925-30931`, the struct carrying `myLandBlock_`). So `DrawInside`/`ConstructView`/`ClipPortals` operate on BOTH `CLandCell` and `CEnvCell` — the BN `CEnvCell*` typing is heuristic; the real param is `CObjCell*`.
- `PView::DrawCells` (`0x5a4840`, decomp:432709): `if (outside_view.view_count > 0) { LScape::draw(lscape); <depth Clear>; <draw flooded env-cell interior surfaces> }` then a second lit pass over `cell_draw_list`. **`outside_view > 0` is the single terrain switch.**
- **Outdoor root** (`CLandCell`): the flood trivially "sees outside" → `outside_view` full → `LScape::draw` renders terrain+sky+**all buildings**. Buildings are flooded **separately**, per-building, by the terrain BSP walk: `BSPPORTAL::portal_draw_portals_only` (`0x53d870`, decomp:326881) → `DrawPortal` (`0x5a5ab0`, decomp:433895) → `ConstructView(CBldPortal*, …)` (`0x5a59a0`, decomp:433827). The land-cell root flood does **not** flood into buildings.
- **Indoor root** (`CEnvCell`): `outside_view` starts empty; the flood walks the building's cells; an exit portal (`0xffffffff`) adds a door-shaped region to `outside_view`, pulling in terrain-through-the-door.
### A.4 LIVE MEASUREMENTS (cdb on retail at the Holtburg doorway, handoff §3.4)
- Membership at rest is **stable**: `PView.cell_draw_num` settled to a long unbroken run of **2**; `viewer_cell` pointer = 1 distinct value.
- Retail does **per-building** floods: `ConstructView(CBldPortal*)` fired **~7×/frame**, each `cell_draw_num ≈ 2`. NOT one unified flood.
- Retail's **eye jitters ~36 µm at rest** (X≈15 µm, Y≈36 µm, Z≈8 µm; `pub == sought`, uncollided). Retail's eye is NOT byte-stable; its **membership** is stable anyway → robustness is structural, not a stable eye.
### A.5 Camera boom (trace #1 — decomp, secondary/R-A4)
- `viewer_sought_position` (`SmartBox+0x58`) is written per physics tick in `SmartBox::PlayerPhysicsUpdatedCallback` (`0x452d60`) from `CameraManager::UpdateCamera` (`0x456660`).
- `UpdateCamera` is **first-order exponential smoothing**: `alpha = clamp(stiffness · dt · 10, 0, 1)`; default `t_stiffness = r_stiffness = 0.45` → ~7.5%/frame at 60 Hz (~93 ms time constant). `viewer_offset.y = -3` (3 m behind pivot).
- The convergence early-exit (distance < 0.0004, rotation < 0.0002) **requires `r_stiffness ≥ 0.9998`**, which the 0.45 default never meets retail's boom chases forever the 36 µm rest jitter is structural. **Byte-stable eye is the wrong target.**
- `update_viewer`'s `viewer_sphere.radius = 0.3` (matches our `PhysicsCameraCollisionProbe.ViewerSphereRadius`).
---
## B. Refinement of handoff §6 (what reading the current code changed)
The handoff was written against the *pre-flip* mental model (a live inside/outside **branch toggle**). Reading the actual code (HEAD `9b1857a`, post the 2026-06-07 cutover flip) shows the flip **already** moved every in-world frame onto `DrawInside`:
- `clipRoot = viewerRoot ?? _outdoorNode` (`GameWindow.cs:7396`). When in-world, `viewerCellId != 0` → either `viewerRoot` (indoor cell, registered) or `_outdoorNode` (built when `viewerRoot is null && viewerCellId != 0`, `GameWindow.cs:7357-7381`) is non-null → `clipRoot` is non-null → `DrawInside` (`GameWindow.cs:7498`).
- Terrain draws outdoors via DrawInside's full-screen `OutsideView` slice (the `IsOutdoorNode` seed at `PortalVisibilityBuilder.cs:88-89``DrawLandscapeThroughOutsideView`), NOT via the `if (clipRoot is null)` outdoor block (`GameWindow.cs:7445-7486`, which now runs only at `viewerCellId == 0` = pre-spawn/login).
- The `else { … DrawPortal/BuildFromExterior … }` branch (`GameWindow.cs:7613-7719`) is effectively dead in normal play (it requires `clipRoot is null`, i.e. `viewerCellId == 0`, where there are no candidate cells, so it falls to `_wbDrawDispatcher.Draw`).
**Therefore the residual divergence is NOT a branch toggle.** It is:
- **D2/D3 (the live flap source):** the outdoor root is the **synthetic `_outdoorNode`**, which carries **reverse portals into every nearby building** and floods them all in **ONE unified flood** gated by a **root-level portal-side knife-edge** (`CameraOnInteriorSide`). As the chase eye grazes a doorway, that knife-edge flips → the building cell set oscillates (the measured `flood 2↔6` / `1↔13`). Retail reaches buildings **spatially** (terrain BSP), per-building, with no root-level knife-edge — hence stable.
- **D1 (leftover):** the `if (clipRoot is null) … else …` structure and the `ReferenceEquals(clipRoot, _outdoorNode)` conditionals (`GameWindow.cs:7539, 7571, 7603`) still encode an inside/outside distinction by node identity.
- **D4/D5/D6 (band-aids):** `MaxReprocessPerCell` cap (`PortalVisibilityBuilder.cs:51`), `EyeInsidePortalOpening` (`:202/:243/:826`), reciprocal-on-`ProjectToNdc` (`:758`).
The phase mapping (below) reflects this: **R-A1** unifies the root and deletes the dead branch (behavior-preserving — *no flap fix yet*); **R-A2** replaces the unified knife-edge flood with per-building floods (*the flap fix*); **R-A3** removes the now-dead band-aids; **R-A4** (optional) tightens the camera/interp.
---
## C. Divergence → phase map
| # | Divergence | Where | Phase |
|---|---|---|---|
| D1 | Inside/outside structure + `ReferenceEquals(_outdoorNode)` conditionals | `GameWindow.cs:7396,7445,7539,7571,7603,7613-7719` | R-A1 |
| D2 | Synthetic `_outdoorNode` root (reverse-portals into buildings) | `GameWindow.cs:7357-7381`, `OutdoorCellNode.cs` | R-A1 (root unify) + R-A2 (drop reverse-portal building flood) |
| D3 | ONE unified flood gated by a root-level portal-side knife-edge | `PortalVisibilityBuilder.Build` from one root | R-A2 |
| D4 | `MaxReprocessPerCell = 16` cap | `PortalVisibilityBuilder.cs:51,331,509` | R-A3 |
| D5 | `EyeInsidePortalOpening` degenerate-portal hack | `PortalVisibilityBuilder.cs:202,243,826` | R-A3 |
| D6 | Reciprocal clip on `ProjectToNdc` not `ProjectToClip` | `PortalVisibilityBuilder.cs:758` | R-A3 |
| D7 | Render-position interpolation layer | `PlayerMovementController.ComputeRenderPosition` | R-A4 (reconsider; do NOT rip blindly) |
| D8 | Camera boom ~36× looser than retail | `RetailChaseCamera.cs` | R-A4 (tune toward stiffness 0.45) |
`ProjectToClip`/`ClipToRegion` (`PortalProjection.cs`) and `CellVisibility` side-test are **faithful** — KEEP. The clip math is never the problem; what feeds it (root + flood structure) is.
---
## D. File structure
**Modified:**
- `src/AcDream.App/Rendering/GameWindow.cs` — the render dispatch (~7185-7729). R-A1 unifies the root + deletes the dead branch; R-A2 adds the per-building flood call into the landscape draw path.
- `src/AcDream.App/Rendering/RetailPViewRenderer.cs``DrawInside`; R-A2 issues per-building floods during/after `DrawLandscapeThroughOutsideView`.
- `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` — R-A2 adds a per-building entry point (or formalizes `BuildFromExterior` as per-building); R-A3 removes D4/D5/D6.
- `src/AcDream.App/Rendering/OutdoorCellNode.cs` — R-A1 repurposes (land root, no reverse-portals after R-A2) or is deleted in R-A2.
- `src/AcDream.App/Rendering/CellVisibility.cs``LoadedCell` already has `IsOutdoorNode`/`SeenOutside`/`BuildingId`; no schema change expected.
- `src/AcDream.App/Rendering/RetailChaseCamera.cs` — R-A4 only.
**Created (tests):**
- `tests/AcDream.App.Tests/Rendering/PortalVisibilityRobustnessTests.cs` — the flap PRE-gate: membership stable under ~36 µm eye jitter; per-building flood ≈2 cells.
**Reference-only (oracle):** `docs/research/named-retail/acclient_2013_pseudo_c.txt`, `acclient.h`.
**Apparatus (throwaway — strip after the visual gate):** `ACDREAM_PROBE_PVINPUT` (`[pv-input]`), `ACDREAM_PROBE_PORTAL_CHURN`, `ACDREAM_PROBE_FLAP`, `tools/cdb/flap-*.cdb`.
---
## Task R-A1: Canonicalize outdoor-root detection on the `IsOutdoorNode` flag (behavior-preserving prep)
**Scope correction (found during execution — supersedes handoff §6's "collapse to one root"):** Reading the live dispatch, the `clipRoot = viewerRoot ?? outdoorRoot` structure is **already correct and must NOT be collapsed.** `viewerRoot` deliberately stays null outdoors because it feeds `cameraInsideCell` + lighting via the older `CellVisibility` BFS (`GameWindow.cs:7212`, `:7219`, `:7236`); `clipRoot` is the render root. Forcibly unifying them is a risky lighting/sky-gating refactor unrelated to the flap. Separately, the 2026-06-07 cutover flip already routed every in-world frame through ONE `DrawInside` — the `else` branch runs only at `viewerCellId == 0` (pre-spawn/login), not an inside/outside toggle. So R-A1 reduces to its genuinely useful, zero-risk core: replace the 4 `ReferenceEquals(clipRoot, _outdoorNode)` object-identity checks with the documented `LoadedCell.IsOutdoorNode` flag, so they survive R-A2 changing the node's portals. Dead-code deletion (the exterior `DrawPortal` look-in, `:7635-7711`) moves to **R-A3** (definitively dead only after R-A2). The deeper viewerRoot/clipRoot unification is a separate, optional faithfulness cleanup — out of scope for the flap fix.
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs``:7539` (ClearDepthSlice — FUNCTIONAL), `:7603` (LiveDynamic guard — FUNCTIONAL), `:7571` (`[pv-input]` probe), `:9219` (`[render-sig]` probe)
- Test: existing `PortalVisibilityBuilderTests` (24/24) + `PlayerMovementControllerTests` (14/14) — must stay green (behavior-preserving)
- [x] **Step 1: Swap the 4 outdoor-root checks** from `ReferenceEquals(clipRoot, _outdoorNode)` to `clipRoot.IsOutdoorNode` (the 3 sites inside `if (clipRoot is not null)`) / `clipRoot is { IsOutdoorNode: true }` (the null-reachable `:9219` probe). Equivalent for every functional path: `OutdoorCellNode.Build` is the only `IsOutdoorNode` setter, and registered `viewerRoot` cells are always indoor EnvCells. (Only difference: the pre-spawn `[render-sig]` `outRoot=` char flips `Y→n` when both are null — throwaway apparatus, irrelevant.)
- [ ] **Step 2: Build green.** `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug`
- [ ] **Step 3: Targeted suites green.** App `PortalVisibilityBuilderTests` 24/24; Core `PlayerMovementControllerTests` 14/14. No separate visual gate — behavior-preserving; the R-A2 doorway gate covers it.
- [ ] **Step 4: Commit** (code + this plan-doc scope correction together).
```bash
git add src/AcDream.App/Rendering/GameWindow.cs docs/superpowers/plans/2026-06-08-full-retail-render-port-option-a.md
git commit -m "refactor(render): R-A1 — canonicalize outdoor-root detection on IsOutdoorNode
Replace ReferenceEquals(clipRoot, _outdoorNode) object-identity checks with the
documented LoadedCell.IsOutdoorNode flag (4 sites) so they survive R-A2 changing
the outdoor root's portals. Behavior-preserving. Right-sized from the planned
'collapse to one root': the viewerRoot ?? outdoorRoot split is already correct
(viewerRoot feeds cameraInsideCell/lighting), and the cutover flip already made
in-world frames single-path DrawInside. Dead-code deletion deferred to R-A3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task R-A2: Per-building floods — the flap fix (remove D3, finish D2)
**AS-BUILT (2026-06-08, conformance-green, pending visual gate):** `OutdoorCellNode.Build(uint)` is now
portal-less (reverse portals removed → the land root floods only itself → full-screen OutsideView for
terrain). `PortalVisibilityBuilder.ConstructViewBuilding` is the per-building contract (thin wrapper over
`BuildFromExterior`). `RetailPViewRenderer.DrawInside` groups the nearby building cells by `BuildingId`
(owned by the render layer — a reused dict, keeps GameWindow thin) and merges each small per-building
flood into the frame before assembly (`MergeNearbyBuildingFloods` / `MergeBuildingFrame`; 48 m seed
cutoff); the existing draw path (assemble → shells → object lists) is unchanged. `GameWindow` passes the
flat `NearbyBuildingCells` only on outdoor-node frames. `UnifiedFloodTests` retired (its subject — the
unified flood from the outdoor node — is removed); its surviving full-screen-OutsideView coverage moved
to `OutdoorCellNodeTests`. Conformance + render suites green (App Rendering 207, Core movement 14,
incl. +3 `PortalVisibilityRobustnessTests`). The detailed steps below are the original design rationale;
this note is the as-built. **Visual gate (grazing doorway) is the acceptance test for "flap gone."**
**Intent:** Replace the single unified flood from the outdoor land root (which reaches buildings through reverse portals gated by a root-level portal-side knife-edge → the oscillation) with retail's **per-building** floods: for each building near the camera, run a small `ConstructView` seeded at that building's entrance portal, touching ≈2 cells. The land-cell root then floods **nothing** into buildings — it is a pure terrain root (full-screen `OutsideView`). This makes building membership robust to the eye's ~36 µm jitter → the flap dies.
**Retail oracle:** `BSPPORTAL::portal_draw_portals_only` (`0x53d870`, decomp:326881) → `DrawPortal` (`0x5a5ab0`, decomp:433895) → `ConstructView(CBldPortal*, …)` (`0x5a59a0`, decomp:433827): viewpoint side-test vs the building portal plane (0.0002 epsilon), `GetClip`, `CEnvCell::GetVisible(other_cell_id)`, `copy_view`, recurse into the building's cells. acdream's `BuildFromExterior` (`PortalVisibilityBuilder.cs:373`) already implements this shape (seed from an exit portal, flood inward); R-A2 calls it **per building** instead of once over all candidates, and removes the root-level building reverse-portals.
**Files:**
- Modify: `src/AcDream.App/Rendering/OutdoorCellNode.cs` — stop adding reverse building portals (the land root keeps only `IsOutdoorNode/SeenOutside`; its flood touches just itself → full-screen `OutsideView`).
- Modify: `src/AcDream.App/Rendering/RetailPViewRenderer.cs` — in `DrawInside`, when `RootCell.IsOutdoorNode`, after the landscape slice, run one per-building flood per nearby building and draw each building's interior (shells + objects) clipped to that building's entrance-portal region.
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — pass the nearby-building set (grouped by `LoadedCell.BuildingId`) into the `RetailPViewDrawContext`.
- Create: `tests/AcDream.App.Tests/Rendering/PortalVisibilityRobustnessTests.cs`
- [ ] **Step 1: Write the failing robustness conformance test (the flap PRE-gate).** Encodes A.4: a building's per-building flood membership is identical under a ~36 µm eye perturbation at a grazing entrance, and touches ≈2 cells. Uses the existing fixture helpers' idiom.
```csharp
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
public class PortalVisibilityRobustnessTests
{
private static Matrix4x4 ViewProj()
{
var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f);
return view * proj;
}
private static Vector3[] Quad(float cx, float cy, float halfW, float halfH, float z) => new[]
{
new Vector3(cx - halfW, cy - halfH, z), new Vector3(cx + halfW, cy - halfH, z),
new Vector3(cx + halfW, cy + halfH, z), new Vector3(cx - halfW, cy + halfH, z),
};
private static LoadedCell Cell(uint id, params CellPortalInfo[] portals) => new LoadedCell
{
CellId = id, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity,
Portals = new List<CellPortalInfo>(portals),
};
// A two-cell building: vestibule 0x0170 (entrance to outside + interior portal to room)
// and room 0x0171 (sealed). The entrance opening is small (a doorway), modelling the
// grazing-doorway scenario where the eye sits ~at the entrance plane.
private static (LoadedCell entrance, Dictionary<uint, LoadedCell> lookup) TwoCellBuilding()
{
const uint VEST = 0x0170, ROOM = 0x0171;
var vest = Cell(VEST,
new CellPortalInfo(0xFFFF, PolygonId: 0, Flags: 0, OtherPortalId: 0), // entrance to outside
new CellPortalInfo((ushort)ROOM, PolygonId: 1, Flags: 0, OtherPortalId: 0));
vest.PortalPolygons.Add(Quad(0f, 0f, 0.4f, 0.8f, -2f)); // doorway opening
vest.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.8f, -4f)); // vestibule->room
vest.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, 0, 1), D = 1.9f, InsideSide = 1 });
var room = Cell(ROOM, new CellPortalInfo((ushort)VEST, 0, 0, 1));
room.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.8f, -4f));
var all = new Dictionary<uint, LoadedCell> { [VEST] = vest, [ROOM] = room };
return (vest, all);
}
[Fact]
public void PerBuildingFlood_MembershipStableUnderMicrometreEyeJitter()
{
// Conformance to handoff §3.4: retail's per-building membership is stable while the eye
// jitters ~36 µm at rest. The per-building flood, seeded at the entrance, must return the
// SAME OrderedVisibleCells for an eye and an eye+36µm — no flap.
var (entrance, lookup) = TwoCellBuilding();
var vp = ViewProj();
var eye = new Vector3(0f, 0f, 0.5f); // just outside the entrance plane (z=1.9 inside)
var a = PortalVisibilityBuilder.ConstructViewBuilding(
entrance, eye, id => lookup.TryGetValue(id, out var c) ? c : null, vp);
var b = PortalVisibilityBuilder.ConstructViewBuilding(
entrance, eye + new Vector3(15e-6f, 36e-6f, 8e-6f),
id => lookup.TryGetValue(id, out var c) ? c : null, vp);
Assert.Equal(a.OrderedVisibleCells, b.OrderedVisibleCells); // robust to the 36 µm jitter — no flap
}
[Fact]
public void PerBuildingFlood_TouchesAboutTwoCells()
{
// Conformance to handoff §3.4: each retail per-building flood has cell_draw_num ≈ 2.
var (entrance, lookup) = TwoCellBuilding();
var vp = ViewProj();
var frame = PortalVisibilityBuilder.ConstructViewBuilding(
entrance, new Vector3(0f, 0f, 0.5f),
id => lookup.TryGetValue(id, out var c) ? c : null, vp);
Assert.InRange(frame.OrderedVisibleCells.Count, 1, 3); // ≈2 (the 2-cell building)
}
}
```
- [ ] **Step 2: Run the test to verify it fails.**
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --filter "FullyQualifiedName~PerBuildingFlood" --nologo`
Expected: FAIL — `PortalVisibilityBuilder.ConstructViewBuilding` does not exist.
- [ ] **Step 3: Implement `ConstructViewBuilding`.** Add a per-building entry point to `PortalVisibilityBuilder` that seeds the flood from a single building's entrance portal(s) and floods only that building's cells. This is `BuildFromExterior` scoped to ONE building's entrance: reuse its body but seed from the supplied entrance cell's exit portal(s), and constrain the flood to the building (use `LoadedCell.BuildingId` so the flood never leaves the building — exactly retail's `CBldPortal` channel staying inside `bp->other_cell_id`). Faithful to `ConstructView(CBldPortal)` (decomp:433827): the seed is the entrance opening's near-clip region; recursion stays in-building.
```csharp
/// <summary>
/// Retail per-building flood: ConstructView(CBldPortal*, …) (decomp:433827) reached from the
/// terrain BSP at DrawPortal (decomp:433895). Seeds at <paramref name="entrance"/>'s exit
/// portal(s) (the building's CBldPortal opening) and floods ONLY this building's cells (bounded by
/// BuildingId), producing the small ≈2-cell view retail draws per visible building. Robust to eye
/// jitter because the seed is the finite entrance opening's projection, not a root-level
/// portal-side knife-edge over the whole building set.
/// </summary>
public static PortalVisibilityFrame ConstructViewBuilding(
LoadedCell entrance,
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj)
{
uint? building = entrance.BuildingId;
// BuildFromExterior already seeds from a cell's exit portal and floods inward. Constrain it to
// this building: a neighbour outside `building` is not traversed (retail's CBldPortal flood
// never leaves bp->other_cell_id's building). Implemented by passing a building-membership
// predicate down into the shared flood body (extract the BuildFromExterior loop to accept one).
return BuildFromExterior(
new[] { entrance }, cameraPos, lookup, viewProj,
maxSeedDistance: float.PositiveInfinity,
buildingMembership: building is null ? null : id => lookup(id)?.BuildingId == building);
}
```
(If `BuildFromExterior` lacks a `buildingMembership` param, add it mirroring `Build`'s existing `buildingMembership` escape hatch at `PortalVisibilityBuilder.cs:62-68,273-279`.)
- [ ] **Step 4: Run the test to verify it passes.**
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --filter "FullyQualifiedName~PerBuildingFlood" --nologo`
Expected: PASS (both facts).
- [ ] **Step 5: Stop the outdoor land root from flooding buildings.** In `OutdoorCellNode.Build`, remove the reverse-portal loop (`OutdoorCellNode.cs:28-60`) — the land root now carries only `CellId/IsOutdoorNode/SeenOutside/identity transforms` and NO portals, so `PortalVisibilityBuilder.Build` from it floods just itself → full-screen `OutsideView` (the `IsOutdoorNode` seed at `:88-89`). Rename the param `nearbyBuildingCells` away or drop it (the buildings are now flooded per-building in Step 6, not from this root).
- [ ] **Step 6: Issue per-building floods during the landscape draw.** In `RetailPViewRenderer.DrawInside`, when `ctx.RootCell.IsOutdoorNode`, after `DrawLandscapeThroughOutsideView` (where retail's `LScape::draw` walks the terrain BSP), iterate the nearby buildings (grouped by `BuildingId` from `ctx`'s candidate cells), call `ConstructViewBuilding` per building, assemble each into the clip frame, and draw that building's shells + cell object lists clipped to its region — reusing the existing `DrawEnvCellShells` / `DrawCellObjectLists` paths per building. Pass the nearby-building set from `GameWindow` (the same Chebyshev≤1 gather the old `_outdoorNode` used, now grouped by `BuildingId`) via a new `RetailPViewDrawContext.NearbyBuildingEntrances` field.
- [ ] **Step 7: Build + full App suite green.**
Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug`
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --nologo`
Expected: build green; all App tests pass (24 existing + 2 new robustness).
- [ ] **Step 8: Visual gate (THE flap acceptance test).** Launch; walk slowly up to and through the Holtburg cottage doorway, and stand at the grazing angle that flapped at baseline. Expected: **no flap** — the doorway, terrain-through-the-door, and the cellar/interior render stably as the eye micro-jitters; building interiors are visible through the door from outside without oscillation. Capture `[pv-input]` (light: `launch-flap-verify.ps1`) and confirm `flood` no longer oscillates while standing still. If the flap persists, do NOT add hysteresis — capture and compare per-building `cell_draw_num` against the measured ≈2 (re-attach cdb per handoff §9 if needed).
- [ ] **Step 9: Commit.**
```bash
git add src/AcDream.App/Rendering/PortalVisibilityBuilder.cs src/AcDream.App/Rendering/RetailPViewRenderer.cs src/AcDream.App/Rendering/OutdoorCellNode.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/Rendering/PortalVisibilityRobustnessTests.cs
git commit -m "feat(render): R-A2 — per-building floods (the flap fix)
Outdoor land root no longer floods buildings through reverse portals (the
root-level portal-side knife-edge that oscillated as the chase eye grazed a
doorway). Buildings now flood per-building, seeded at each entrance (retail
ConstructView(CBldPortal) 0x5a59a0 via DrawPortal 0x5a5ab0), ≈2 cells each —
robust to the eye's ~36µm rest jitter (measured retail, handoff §3.4).
Conformance: PerBuildingFlood_MembershipStableUnderMicrometreEyeJitter +
PerBuildingFlood_TouchesAboutTwoCells.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task R-A3: Remove the band-aids made dead by R-A1/R-A2 (D4, D5, D6)
**Intent:** Per-building bounded floods (≈2 cells) make the unified-flood termination hacks unnecessary. Remove each **deliberately**, re-running the full conformance suite after each removal. Do NOT touch `ProjectToClip`/`ClipToRegion` (faithful).
**Files:** Modify `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs`. Test: full `tests/AcDream.App.Tests/`.
- [ ] **Step 1: Remove the `MaxReprocessPerCell` cap (D4).** Delete the const (`:51`) and the `popCounts.GetValueOrDefault(...) < MaxReprocessPerCell` clause from both re-enqueue gates (`:331`, `:509`), keeping the `queued.Add(...)` enqueue-once guard. Run the full App suite — the cyclic/hub/diamond termination tests (`Builder_CyclicGraph_TerminatesWithBoundedPolys`, `Build_CyclicHub_TerminatesAndBounds`, `Build_IsDeterministic_*`) MUST stay green (enqueue-once is the real termination guarantee; the cap was belt-and-braces). If any hangs, STOP — the cap was load-bearing; revert and investigate before proceeding.
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --nologo` → all green.
- [ ] **Step 2: Remove `EyeInsidePortalOpening` (D5).** Delete the degenerate-portal substitution at `:241-250` and `:484-490`, the `eyeInsideOpening` locals (`:202,:301,:471,:497`), and the helper (`:826-855`) + `EyeStandingPerpDist` (`:815`). This hack covered the unified flood rooting in a thin doorway cell with a degenerate near-projection; per-building floods seed at the entrance opening (never root in a thin cell with a collapsed projection), so it is dead. Run the full suite. The tests that pinned the hack (`Build_EyeStandingInInteriorPortal_FloodsNeighbour`, `Build_DegeneratePortalToTheSide_NotFlooded_NoOverInclusion`, `Build_CollapsedInteriorPortalNearEyeBeyondHalfMeter_FloodsNeighbour`) describe the OLD unified-root behavior — update or remove them to match per-building rooting (they assert a non-bug under the new structure). If removal causes a real interior under-include at the visual gate, STOP and reassess (do NOT re-add as a blind guard).
- [ ] **Step 3: Move the reciprocal clip onto `ProjectToClip` (D6).** Change `ApplyReciprocalClip` (`:758`) from `PortalProjection.ProjectToNdc` to the homogeneous `ProjectToClip` + `ClipToRegion` path, matching the near-side clip, now that per-building floods don't re-enqueue across many drift rounds (the reason D6 used `ProjectToNdc` was unified-flood re-enqueue drift). Run the reciprocal tests (`Build_AppliesReciprocalOtherPortalClip`, `Build_ReciprocalClip_DegradesGracefully_WhenNoBackPortal`, `Build_MultiplePortalsToSameNeighbour_EachResolvesOwnReciprocal`) — they MUST stay green. If `Build_AppliesReciprocalOtherPortalClip` inflates (the drift the comment at `:751-757` warns about), the unified-flood drift is still present somewhere — STOP, keep `ProjectToNdc`, and note D6 as a documented retained divergence.
- [ ] **Step 4: Visual gate + commit.** Launch; confirm the doorway + interiors still render correctly (no new under-include, no flap regression).
```bash
git add src/AcDream.App/Rendering/PortalVisibilityBuilder.cs tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs
git commit -m "refactor(render): R-A3 — remove unified-flood band-aids (D4/D5/D6)
Per-building bounded floods make MaxReprocessPerCell, EyeInsidePortalOpening,
and the ProjectToNdc reciprocal dead. Removed deliberately; enqueue-once is the
real termination guarantee, ProjectToClip is the faithful path (PView::GetClip
0x5a4320). Faithful clip math (ProjectToClip/ClipToRegion) untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
> **STATUS 2026-06-08 (late) — R-A4 RULED OUT by live measurement; remaining work is R-A2b (indoor-flood
> edge-on robustness).** Shipped + visual-confirmed: R-A1 `7fe9809`, R-A2 `c62663d` (outside flap GONE),
> seam fix `2ec189c` (missing textures GONE). The indoor crossing flicker is CONCLUSIVELY pinned to the
> **flood/clip being non-monotonic near a doorway's EDGE-ON angle** — NOT the camera: on a clean one-way
> pass the eye glided smoothly (3 X / 18 Y direction-changes over 25.7k frames) and is ~1µm stable at
> rest (more stable than retail's settled tens-of-µm), yet the visible-cell count oscillated 414× with
> 648 `clip=0` events. So R-A4 (camera/eye-jitter) is OFF. Next = **R-A2b**: make the "is the room behind
> this opening visible?" decision robust when the opening is near edge-on (its on-screen area hovers at
> zero — coin-on-edge). FIRST read retail `GetClip` (0x5a4320) / `ClipPortals` near-edge-on handling to
> see how retail keeps it stable, THEN design + conformance-test + visual-gate. Canonical pinned
> diagnosis: memory `project_indoor_flap_rootcause` (2026-06-08 late CORRECTION).
## Task R-A4 (OPTIONAL — SUPERSEDED: eye-jitter ruled out; see STATUS note above. Kept for history.)
**Intent:** Tighten the camera boom toward retail's exponential smoothing (D8) and reconsider — DO NOT blindly rip out — the render-position interpolation (D7). Gate: only do this if, after R-A1R-A3, the visual gate still shows flicker AND `[pv-input]` shows our eye jittering well beyond retail's ~36 µm.
**Files:** Modify `src/AcDream.App/Rendering/RetailChaseCamera.cs`. Reference: A.5.
- [ ] **Step 1: Conformance-pin retail's boom math.** Add a unit test asserting `RetailChaseCamera`'s per-frame convergence equals `alpha = clamp(0.45 · dt · 10, 0, 1)` (≈0.075 at 60 Hz) and that, with default stiffness 0.45, the convergence snap (distance < 0.0004 rotation < 0.0002) does NOT fire (it requires `r_stiffness ≥ 0.9998`). This pins retail-faithful behavior and prevents re-introducing a byte-stable-eye snap.
- [ ] **Step 2: Match the constants** (`t_stiffness = r_stiffness = 0.45`, the `·10` factor, `viewer_offset.y = -3`, viewer_sphere 0.3) and re-run. **Do NOT chase a byte-stable eye** (retail's isn't — A.4). Treat `ComputeRenderPosition` (D7) as suspect but do not remove it (it prevents 30 Hz judder; removing it regressed before — handoff §7).
- [ ] **Step 3: Visual gate + commit** (only if it measurably helps).
---
## E. Testing strategy (the PRE-gate discipline)
- **Conformance tests run WITHOUT the live client** and gate against the measured retail values in A.4: membership stable under ~36 µm eye jitter (`PerBuildingFlood_MembershipStableUnderMicrometreEyeJitter`), ≈2 cells per building (`PerBuildingFlood_TouchesAboutTwoCells`), flood determinism (existing `Build_IsDeterministic_*`).
- **All existing `PortalVisibilityBuilderTests` (24) + `PlayerMovementControllerTests` (14) stay green** at every step. Tests that pinned removed band-aids are updated to the new structure, not left red.
- **The visual gate is the acceptance test** (user at the doorway). But conformance is the PRE-gate — **never ship to the visual gate on a red/absent conformance test.**
- **Re-attach cdb to retail** (handoff §9 workflow, proven) to capture any NEW retail value an implementation step needs. MEASURE, don't infer.
## F. DO NOT (evidence-disproven — handoff §7)
- Byte-stable eye / render-position rest-snap (retail jitters ~36 µm; `cd974b2` failed + regressed → reverted `9b1857a`).
- Bounded-propagation / enqueue-once / "churn" fix (measured `maxPop=1`, 0 churn — REFUTED).
- Physics rest-jitter, viewer-cell dead-zone, two-pipe split, render-side debounce/hysteresis on the branch or clip.
- Trusting a decomp INFERENCE about runtime behavior without a live trace.
## G. Self-review
- **Spec coverage:** handoff §6 R-A1→R-A4 each map to a Task; D1-D8 each map to a phase (§C). The land-cell-as-floodable-root open design point (handoff §8 trace #3) is resolved: the outdoor root is a real `LoadedCell` with `IsOutdoorNode` → full-screen `OutsideView`, no building portals; buildings flood per-building (A.3).
- **Placeholder scan:** R-A1 steps quote real lines + code; R-A2 provides full conformance test code + the `ConstructViewBuilding` body + integration steps; R-A3 removals cite exact line ranges + guard tests; R-A4 cites measured constants. No "TODO/handle edge cases."
- **Type consistency:** `ConstructViewBuilding` (R-A2 Step 3) is the same name used by the R-A2 conformance test (Step 1) and referenced in §D. `BuildOutdoorLandRoot` (R-A1 Step 1) used consistently. `LoadedCell.IsOutdoorNode`/`BuildingId` exist in the current schema (`CellVisibility.cs:87,116`).
- **Open execution-time verifications** (each a ~10-min decomp read or cdb capture, NOT a plan blocker): the exact land-cell `outside_view` fill (full-screen seed vs portal-driven — A.3 says full-screen is faithful); the exact per-building draw ordering in `DrawCells` two-pass structure (decomp:432715-432848) when integrating R-A2 Step 6.
---
## Execution Handoff
Plan complete. Two execution options:
1. **Subagent-Driven (recommended)** — fresh subagent per task, two-stage review between tasks.
2. **Inline Execution** — execute in this session with checkpoints.
R-A1 and R-A2 each end at a **visual gate** (user at the doorway) — those are hard stops requiring the user's eyes regardless of execution mode.

View file

@ -0,0 +1,202 @@
> **✅ REVIVED 2026-06-09.** Phase 1 (the churn probe) is done; **Phase 2 (port the bound) is now the
> active R-A2b work** — design: [`../specs/2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md`](../specs/2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md).
> The `⛔` banner below was wrong: the `maxPop=1` reading came from a **camera-turn-at-rest** capture
> (the calm position, root `0172`), the wrong reproduction. A 2026-06-09 doorway **walk-through** capture
> (`launch-churn-confirm.log`, the proper Phase-1 Task-4 pin) measured `maxPop=16` on 44 % of frames →
> churn confirmed. Task 4's prediction ("redundant reciprocal back-contribution stays non-empty") is
> confirmed by `recip=1->1, grew=True`.
>
> ---
>
> **⛔ (HISTORICAL — corrected above) SUPERSEDED 2026-06-08 (evening).** Live `ACDREAM_PROBE_PORTAL_CHURN`
> measured `maxPop=1` — **but on the wrong reproduction (camera turn at rest), not a doorway crossing
> (see the revival note above).** The Phase-1 churn *probe* this plan added is correct and is the tool
> that ultimately confirmed (not disproved) the churn once aimed at the actual flap.
# Portal-Flood Bounded-Propagation Port 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:** Eliminate the indoor doorway "flap" by porting retail's *bounded* portal-flood propagation into `PortalVisibilityBuilder` — keeping re-processing on growth (retail-faithful) but stopping the reciprocal/drift churn that makes membership eye-sensitive.
**Architecture:** The flap is `PortalVisibilityBuilder.Build`'s unbounded re-enqueue churn (`cs:322`, `MaxReprocessPerCell=16` hack): redundant reciprocal back-contributions yield drifted non-empty slivers → `grew` → re-enqueue, and the churn's fixpoint shifts under sub-cm eye motion. Retail bounds it structurally (monotonic `update_count` watermark + empty reciprocal). **Phase 1 instruments + pins the exact acdream divergence at the live doorway (it's float-drift-dependent — a runtime fact, not derivable from decomp). Phase 2 ports the bound, gated on Phase 1's evidence.** Spec: `docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md` (REVISION banner).
**Tech Stack:** C# / .NET 10, xUnit. GL-free unit tests (`AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs`). Live capture via `dotnet run` against local ACE.
**Non-goals (unchanged from spec):** no rooting/camera/clip-math-rewrite/seal change; physics + the 4 rest-stability regression tests stay; `Build_ViewGrowthAfterDoneCell` stays GREEN (re-processing is kept).
---
## PHASE 1 — Instrument & pin the exact divergence
### Task 1: Add the portal-churn probe flag
**Files:**
- Modify: `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` (after `ProbePvInputEnabled`, ~line 144)
- Test: `tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs`
- [ ] **Step 1: Write the failing test**
```csharp
[Fact]
public void ProbePortalChurn_DefaultsFalse_WhenEnvUnset()
{
// Env var is absent in the test host, so the flag must default false (inert probe).
Assert.False(AcDream.Core.Rendering.RenderingDiagnostics.ProbePortalChurnEnabled);
}
```
- [ ] **Step 2: Run it — expect FAIL** (compile error: `ProbePortalChurnEnabled` does not exist)
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ProbePortalChurn_DefaultsFalse"`
Expected: FAIL (does not compile).
- [ ] **Step 3: Add the flag** (in `RenderingDiagnostics`, mirroring `ProbePvInputEnabled`)
```csharp
/// <summary>
/// Bounded-propagation port apparatus (2026-06-08). When true, PortalVisibilityBuilder.Build emits
/// one [portal-churn] summary line per call: per-cell pop count (re-pops = churn), total re-enqueues,
/// max pop count, and — per re-enqueue — the reciprocal-clip pre→post region count + grew flag. Pins
/// whether the flap's churn is redundant reciprocal back-contributions producing non-empty drifted
/// slivers (the hypothesis) vs another source. Throwaway apparatus — strip once the bound ships.
/// Initial state from ACDREAM_PROBE_PORTAL_CHURN=1.
/// </summary>
public static bool ProbePortalChurnEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_PORTAL_CHURN") == "1";
```
- [ ] **Step 4: Run it — expect PASS.** Run the same filter. Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.Core/Rendering/RenderingDiagnostics.cs tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs
git commit -m "feat(render-diag): add ACDREAM_PROBE_PORTAL_CHURN flag for the bounded-propagation pin"
```
### Task 2: Instrument `PortalVisibilityBuilder.Build` churn + reciprocal
**Files:**
- Modify: `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (the pop loop ~145-178; the reciprocal site ~295-306; the re-enqueue ~322; before `return frame` ~341)
- [ ] **Step 1: Add a per-Build churn accumulator** (top of `Build`, after `var popCounts = ...` ~line 112)
```csharp
// [portal-churn] apparatus (2026-06-08): when ProbePortalChurnEnabled, accumulate re-enqueue churn
// + reciprocal pre/post region counts, emitted as one summary line at end of Build. Inert when off.
bool churnProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbePortalChurnEnabled;
int churnReenqueues = 0;
var churnReciprocal = churnProbe ? new System.Text.StringBuilder(256) : null;
```
- [ ] **Step 2: Record reciprocal pre→post** (at the reciprocal site, right after `ApplyReciprocalClip(...)` ~line 297, before the `if (clippedRegion.Count == 0)` check)
```csharp
if (churnProbe)
churnReciprocal!.Append(System.FormattableString.Invariant(
$" recip[0x{neighbourId:X8} {preReciprocalCount}->{clippedRegion.Count}]"));
```
- [ ] **Step 3: Count re-enqueues** (inside the `if (grew && ... queued.Add(neighbourId))` block ~line 322, after `todo.Insert(neighbour, dist)`)
```csharp
if (churnProbe) churnReenqueues++;
```
- [ ] **Step 4: Emit the summary** (just before `return frame;` ~line 341)
```csharp
if (churnProbe)
{
int maxPop = 0; uint maxCell = 0; int rePopped = 0;
foreach (var kv in popCounts)
{
if (kv.Value > maxPop) { maxPop = kv.Value; maxCell = kv.Key; }
if (kv.Value > 1) rePopped++;
}
Console.WriteLine(System.FormattableString.Invariant(
$"[portal-churn] root=0x{cameraCell.CellId:X8} cells={frame.OrderedVisibleCells.Count} " +
$"reEnqueues={churnReenqueues} rePoppedCells={rePopped} maxPop=0x{maxCell:X8}:{maxPop}" +
churnReciprocal));
}
```
- [ ] **Step 5: Build** — Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. Expected: `Build succeeded`.
- [ ] **Step 6: Commit**
```bash
git add src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
git commit -m "diag(render): [portal-churn] probe — per-Build re-enqueue + reciprocal pre/post"
```
### Task 3: Deterministic re-pop unit test (probe baseline)
**Files:**
- Modify: `tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs`
- [ ] **Step 1: Write the test** — the `ViewGrowthAfterDoneCell` topology re-pops `B` (legitimate late growth from `D`). This proves the re-pop path is exercised deterministically (so the probe + later fix have a non-flaky anchor) without needing live float-drift.
```csharp
[Fact]
public void Build_ViewGrowthAfterDoneCell_RePopsGrownCell()
{
// Same A->B(near LEFT) + A->D(far RIGHT) + D->B(later) topology as
// Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit: B is popped via LEFT, then D grows B
// through RIGHT after B is done -> B re-pops. This is retail-faithful late growth (kept by the fix).
const uint A = 0x0001, B = 0x0002, D = 0x0003;
var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0, 0), new CellPortalInfo((ushort)D, 1, 0, 0));
a.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f));
a.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f));
var b = Cell(B, new CellPortalInfo((ushort)A, 0, 0, 0),
new CellPortalInfo(0xFFFF, 1, 0, 0), new CellPortalInfo((ushort)D, 2, 0, 0));
b.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f));
b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f));
b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f));
var d = Cell(D, new CellPortalInfo((ushort)B, 0, 0, 2));
d.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f));
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b, [D] = d };
var frame = Build(a, all);
// Membership (the flap-relevant output) is each cell once, regardless of re-pops.
Assert.Equal(new[] { B }, frame.OrderedVisibleCells.Where(c => c == B).ToArray());
Assert.Contains(D, frame.OrderedVisibleCells);
}
```
- [ ] **Step 2: Run it — expect PASS** (documents current behavior; the re-pop happens internally, membership stays deduped).
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~Build_ViewGrowthAfterDoneCell_RePopsGrownCell"`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs
git commit -m "test(render): deterministic re-pop anchor for the bounded-propagation pin"
```
### Task 4: Live capture at the doorway + pin (CHECKPOINT — needs the user)
- [ ] **Step 1: Launch with the churn probe** (add `$env:ACDREAM_PROBE_PORTAL_CHURN = "1"` to a copy of `launch-flap-capture.ps1`; keep `ACDREAM_PROBE_PVINPUT=1` for correlation). `dotnet build` green first, then launch in background, tee to `flap-churn.log`.
- [ ] **Step 2: User reproduces** — stand at the cottage doorway, turn the camera back and forth (the flap). ~15 s.
- [ ] **Step 3: Analyze** `flap-churn.log`: for the flap frames (correlate with `[pv-input]` flood flips), inspect `[portal-churn]`: which cells hit high `maxPop` (churn → near 16), and the `recip[...]` pre→post counts — is a redundant reciprocal back-contribution staying **non-empty** (`pre->post` both >0 on a cell that already contributed)? That is the predicted divergence.
- [ ] **Step 4: Pin + write the Phase 2 fix plan.** Record the pinned divergence (the exact cell/portal/condition where the redundant contribution stays non-empty) in a short findings note, and write `docs/superpowers/plans/2026-06-08-portal-flood-bound-fix.md` (Phase 2) with the exact, evidence-grounded code change. **Do not write Phase 2 code before this pin.**
---
## PHASE 2 — Port the bound (outline; finalized from Phase 1's pin)
**Shape (locked; exact predicate from Task 4):** make redundant reciprocal / re-clip contributions **not** generate a new propagatable slice — matching retail's empty-reciprocal (`OtherPortalClip`→no `copy_view`) + monotonic `update_count` watermark — then **remove `MaxReprocessPerCell` + `popCounts`** (termination becomes structural). Keep re-processing on growth (the `AddRegion` union + the deferred re-process), so `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` stays GREEN. Most-likely concrete form (confirm in Task 4): gate the `grew → re-enqueue` so a back-contribution whose clipped region is already covered by the cell's accumulated view (retail: empty after reciprocal clip) does not count as growth — via the faithful reciprocal/watermark, NOT an epsilon dedup heuristic.
**Tests:** the new eye-sweep stability test (membership a single contiguous run as the eye sweeps — synthetic grazing topology if reproducible, else the live `[pv-input]` gate); all existing `PortalVisibilityBuilderTests` green incl. `Build_ViewGrowthAfterDoneCell_*`, `Build_IsDeterministic_*`, `Builder_CyclicGraph/Hub` termination; the 4 physics rest-stability guards green.
**Acceptance (visual gate — the real one):** at the cottage doorway, turn the camera back and forth and walk through — interior rooms render steadily, no battling/popping; `[pv-input]` flood stable per eye pose; `[portal-churn]` `maxPop` ≤ small constant (no near-16 churn). Then strip the apparatus (`[portal-churn]`, `[pv-input]`, the launch scripts).
---
## Self-Review notes
- Spec coverage: Phase 1 implements the spec's "instrument + pin first" requirement; Phase 2 implements the bounded-propagation fix. `Build_ViewGrowthAfterDoneCell` explicitly kept green (spec correction). No rooting/camera/clip/seal change.
- Phase 2 is intentionally an outline: its exact predicate is the runtime fact Phase 1 pins (the spec + this plan both flag this). Phase 2 gets its own no-placeholder plan after Task 4 — this is the apparatus-first discipline, not a deferred placeholder.

View file

@ -0,0 +1,290 @@
# R-A2b — Portal-Flood Back-Portal Side-Cull (indoor flap fix) 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:** Eliminate the indoor doorway "flap" by making the portal flood acyclic like retail — cull the "back" portal (the doorway just flooded through) so the `0171↔0173` re-enqueue churn cannot form.
**Architecture:** The flap is a flood-membership oscillation from a re-enqueue **churn** in `PortalVisibilityBuilder.Build` (confirmed live: `maxPop=16` on 44% of frames at the cottage doorway). The churn needs a **cycle**: `0171→0173→0171→…`. Retail's flood is acyclic because `PView::InitCell`'s per-portal **side test culls the back portal** (`:432962` — traverse iff the viewpoint's front/back classification equals the portal's stored side; the back portal fails it). Our flood traverses the back portal, so it cycles. **Phase 1 pins WHY** (B1 = `EyeInsidePortalOpening` bypasses our side-cull; B2 = `CameraOnInteriorSide` returns the wrong answer vs retail). **Phase 2 applies the pinned fix.** Phase 3 removes the now-dead `MaxReprocessPerCell` cap. Spec: `docs/superpowers/specs/2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md` (REVISION → Option B).
**Tech Stack:** C# / .NET 10, xUnit. GL-free unit tests in `tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs`. Live capture via `dotnet run` against local ACE (the user drives the doorway crossing).
**Non-goals:** no camera / rooting / clip-math-rewrite / seal change; the **forward-portal clip-empty void rescue** (`Build` ~241-250, the 2026-06-05 fix) is **preserved**; physics + the rest-stability regression tests stay green.
**Build-while-running gotcha:** the running client locks the DLLs (MSB3027). Always close the client (graceful `CloseMainWindow`) before `dotnet build`.
**PINNED (Phase 1 Task 2, 2026-06-09 capture `flap-sidechk.log`): B1 — the `eyeInsideOpening` bypass.**
Every back portal (`0173→0171` at `D=-1.51…-1.70`; `0172→0173` at `D=1.71`) shows `camInterior=False` (our
`CameraOnInteriorSide` already agrees with retail — it WANTS to cull) and is traversed **only when
`eyeIn=True`** (eye within 1.75 m of the shared doorway). At `D=-2.32` (farther, `eyeIn=False`) the same
back portal is correctly culled. So the cycle is the `&& !eyeInsideOpening` bypass. Forward portals
(`0171→0173`) show `camInterior=True` (unaffected; the clip-empty void rescue is preserved). **Fix = Branch
B1 (Task 4): drop `&& !eyeInsideOpening` from the side-cull.** B2 (side-test convention) is NOT needed.
---
## PHASE 1 — Pin the back-portal traversal mechanism (B1 vs B2)
### Task 1: Add the side-test pin probe to `PortalVisibilityBuilder.Build`
**Files:**
- Modify: `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (in `Build`, right after `bool eyeInsideOpening = EyeInsidePortalOpening(...)`, ~line 202)
- [ ] **Step 1: Add the probe line** (throwaway apparatus; fires only under the existing `PortalBuildTrace`, which is gated on `ProbeFlapEnabled` + `IsHoltburgIndoorProbeCell`)
```csharp
// (R-A2b Phase 1 pin, throwaway) Log the side-test inputs for EVERY portal so a back-portal traversal
// (cell=0x..0173 p->0x0171) can be attributed to B1 (eyeInsideOpening bypass) vs B2 (CameraOnInteriorSide
// returns interior where retail culls). camInterior = our side-test result; D = eye signed distance to the
// portal plane (|D|<=1.75 means eyeInsideOpening is in range). Strip with the rest of the [pv-trace] apparatus.
if (trace != null)
{
bool camInterior = i >= cell.ClipPlanes.Count || CameraOnInteriorSide(cell, i, cameraPos);
float sideD = (i < cell.ClipPlanes.Count && cell.ClipPlanes[i].Normal.LengthSquared() >= 1e-8f)
? Vector3.Dot(cell.ClipPlanes[i].Normal, Vector3.Transform(cameraPos, cell.InverseWorldTransform)) + cell.ClipPlanes[i].D
: float.NaN;
trace.Add($"sidechk cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} camInterior={camInterior} eyeIn={eyeInsideOpening} D={(float.IsNaN(sideD) ? "na" : sideD.ToString("F2"))}");
}
```
- [ ] **Step 2: Build** — close the client first if running. Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. Expected: `Build succeeded`.
- [ ] **Step 3: Commit**
```bash
git add src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
git commit -m "diag(render): [pv-trace] sidechk — pin back-portal traversal (B1 bypass vs B2 side-test) for R-A2b"
```
### Task 2: Live capture at the doorway + pin (CHECKPOINT — needs the user)
- [ ] **Step 1: Launch** with the flap probe. `dotnet build` green, then (background, tee to `flap-sidechk.log`):
```powershell
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"
$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"
$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword"
$env:ACDREAM_PROBE_FLAP="1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "flap-sidechk.log"
```
- [ ] **Step 2: User reproduces** — slow one-way walk through the cottage interior doorway (room→room), camera still. Then graceful-close.
- [ ] **Step 3: Analyze.** Find the **back-portal traversal** (the cell whose flood came *from* `0171` traversing back *to* `0171`): grep `flap-sidechk.log` for `sidechk cell=0xA9B40173 p.*->0x0171` AND the adjacent `portal cell=0xA9B40173 p.*->0xA9B40171 addCell` (traversed, not `skip=side`). Read its `camInterior` / `eyeIn` / `D`:
- `eyeIn=True` (and `|D| <= 1.75`) → **B1**: `EyeInsidePortalOpening` is bypassing the side-cull.
- `eyeIn=False` AND `camInterior=True` (`|D| > 1.75`) → **B2**: `CameraOnInteriorSide` returns interior where retail culls. Also record the back portal's `D` sign + (from a one-off log or the cell fixture) its `ClipPlanes[p].InsideSide` and `Normal`, so the exact convention edit is known.
- (If both back-portal directions show `skip=side` and the cycle is absent, the flap reproduced via a different cell pair — record which, and repeat the read for that pair.)
- [ ] **Step 4: Record the pin** in a one-paragraph note at the top of this plan ("PINNED: B1" or "PINNED: B2, convention = …") with the captured `camInterior/eyeIn/D`. **Do not start Phase 2 before this pin.**
---
## PHASE 2 — Apply the pinned fix (TDD)
### Task 3: Eye-sweep membership-stability test (the RED→GREEN driver)
**Files:**
- Modify: `tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs`
- [ ] **Step 1: Write the failing test** — a synthetic two-room + shared-doorway topology reproducing the cycle, swept monotonically across the doorway. Use the existing `Cell` / `QuadX` / `Build` helpers in this test file. The portal-side data (`CellPortalInfo` ctor args + `ClipPlanes`) must reproduce the **pinned** mechanism: for **B2**, set the back portal's side so our current `CameraOnInteriorSide` returns interior (the bug); for **B1**, place the sweep within 1.75 m so `EyeInsidePortalOpening` fires. Assert each cell's membership across the sweep is a **single contiguous run** (no `present→absent→present`).
```csharp
[Fact]
public void Build_MonotonicEyeSweepThroughDoorway_MembershipIsContiguous_NoFlapCycle()
{
// Two rooms A,B sharing one doorway; B also has a back portal to A (the cycle edge retail culls).
// Sweep the eye monotonically along -Y across the doorway; assert no cell flickers in/out.
const uint A = 0x0001, B = 0x0002;
// (Portal-side fields set per the Phase-1 pin so this reproduces the live cycle. See Task 2 note.)
LoadedCell MakeA() { var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0, 0));
a.PortalPolygons.Add(QuadX(-0.5f, 0.5f, -1f)); return a; }
LoadedCell MakeB() { var b = Cell(B, new CellPortalInfo((ushort)A, 0, 0, 0));
b.PortalPolygons.Add(QuadX(-0.5f, 0.5f, -1f)); return b; }
var seen = new Dictionary<uint, List<bool>>();
int steps = 40;
for (int s = 0; s < steps; s++)
{
var a = MakeA(); var b = MakeB();
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b };
float y = 2.0f - 4.0f * s / (steps - 1); // monotonic sweep through the doorway
var frame = PortalVisibilityBuilder.Build(a, new Vector3(0, y, 0), id => all.GetValueOrDefault(id),
TestViewProj());
var present = new HashSet<uint>(frame.OrderedVisibleCells);
foreach (var c in new[] { A, B })
{
if (!seen.TryGetValue(c, out var run)) { run = new List<bool>(); seen[c] = run; }
run.Add(present.Contains(c));
}
}
foreach (var (cell, run) in seen)
{
int transitions = 0;
for (int i = 1; i < run.Count; i++) if (run[i] != run[i - 1]) transitions++;
Assert.True(transitions <= 1, $"cell 0x{cell:X4} membership flickered ({transitions} transitions) across a monotonic sweep");
}
}
```
- [ ] **Step 2: Run it — expect FAIL (RED)** under the cycle.
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~Build_MonotonicEyeSweepThroughDoorway"`
Expected: FAIL (a cell shows >1 transition — the flap cycle).
> **If the synthetic test cannot be made RED** (the cycle depends on live geometry the fixtures don't capture): do **not** weaken it to pass. Convert it to a **termination/contiguity guard** (assert `OrderedVisibleCells` deduped + contiguous for whatever it does produce), note that the live `[portal-churn] maxPop` + the visual gate (Task 6) are the real acceptance, and proceed — the fix is still validated by Phase 1's pin + Task 6.
### Task 4: Implement the pinned fix
**Apply the branch Phase 1 (Task 2) pinned. Both are exact; pick one.**
#### Branch B1 — drop the `eyeInsideOpening` bypass from the side-cull
**Files:** `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (`Build` ~208-216; `BuildFromExterior` ~472-475)
- [ ] **Step 1 (B1): `Build` side-cull** — remove the bypass so back portals cull like retail. The separate clip-empty rescue (~241-250) still rescues FORWARD (side-test-passing) portals → void fix preserved.
```csharp
// BEFORE:
if (i < cell.ClipPlanes.Count
&& !CameraOnInteriorSide(cell, i, cameraPos)
&& !eyeInsideOpening)
{
sideAllowed = false;
...
continue;
}
// AFTER (retail InitCell side test culls the back portal regardless of eye proximity; :432962):
if (i < cell.ClipPlanes.Count
&& !CameraOnInteriorSide(cell, i, cameraPos))
{
sideAllowed = false;
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side eyeIn={eyeInsideOpening}");
if (dx) Console.WriteLine($"[pv-dump] EXIT-CULLED(side) cell=0x{cell.CellId:X8} p{i} localN={poly.Length} hasClipPlane={(i < cell.ClipPlanes.Count)}");
continue;
}
```
- [ ] **Step 2 (B1): `BuildFromExterior` side-cull** — same removal at the matching site (~472-475):
```csharp
// AFTER:
if (i < cell.ClipPlanes.Count
&& !CameraOnInteriorSide(cell, i, cameraPos))
continue;
```
#### Branch B2 — align `CameraOnInteriorSide` to retail's `InitCell` side test
**Files:** `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (`CameraOnInteriorSide` ~717-724)
- [ ] **Step 1 (B2): correct the convention** per Phase 1's captured `InsideSide`/`Normal`/`D` for the back portal. Retail (`:432962`) traverses iff `(dot > +eps ? front : behind) == portal_side` — a strict front/back classification matched to the stored side, NOT a symmetric ±eps band on both sides. Our current `dot >= -eps` / `dot <= eps` makes the in-plane band interior for *both* conventions and (per the pin) returns interior for the back portal. The exact edit is finalized from the captured back-portal `InsideSide` + `D` sign — the most likely form is to make the test strict and side-correct:
```csharp
// Retail PView::InitCell side test (:432962): viewpoint front/back classified vs the portal plane,
// then traversed iff that equals the portal's stored interior side. Port faithfully:
private static bool CameraOnInteriorSide(LoadedCell cell, int portalIndex, Vector3 cameraPos)
{
var plane = cell.ClipPlanes[portalIndex];
if (plane.Normal.LengthSquared() < 1e-8f) return true; // no usable plane allow
var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
bool front = dot > PortalSideEpsilon; // strict front (retail eax_9), in-plane = behind
return plane.InsideSide == 0 ? front : !front; // traverse iff classification matches the stored side
}
```
> The precise mapping of `InsideSide` (0/1) to `front`/`!front` is confirmed against the captured back-portal data in Task 2 (the back portal MUST end up `!interior`, the forward portal `interior`). Adjust the boolean accordingly if the capture shows the opposite polarity. Do not guess past the captured fact.
#### Step (both branches): run the driver test — expect PASS (GREEN)
- [ ] Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~Build_MonotonicEyeSweepThroughDoorway"`. Expected: PASS.
- [ ] Run the full builder suite — no regression:
`dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PortalVisibilityBuilder"`. Expected: PASS, **including** `Build_EyeStandingInInteriorPortal_FloodsNeighbour`, `Build_CollapsedInteriorPortalNearEyeBeyondHalfMeter_FloodsNeighbour`, `Build_DegeneratePortalToTheSide_NotFlooded_NoOverInclusion` (#95), `Build_ViewGrowthAfterDoneCell_*`, `Build_IsDeterministic_*`.
- If `Build_EyeStandingInInteriorPortal_FloodsNeighbour` FAILS under B1: that test feeds a portal whose side-test fails (a "back" portal) and relied on the bypass. Inspect — if it encodes the non-retail bypass, correct the fixture so the standing-in portal is a FORWARD (side-passing) portal (the void case is a forward portal); do **not** reinstate the bypass. If it represents a genuine forward portal that B1 wrongly culls, B1 is wrong → re-examine the pin.
- [ ] **Step (both): Commit**
```bash
git add src/AcDream.App/Rendering/PortalVisibilityBuilder.cs tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs
git commit -m "fix(render): R-A2b — cull back portal like retail (InitCell side test), kill the indoor flap cycle"
```
---
## PHASE 3 — Remove the now-dead re-enqueue cap
### Task 5: Delete `MaxReprocessPerCell` + `popCounts` (the cycle is gone → the cap is dead)
**Files:** `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (`MaxReprocessPerCell` const ~51; `popCounts` + the cap in the re-enqueue gate in `Build` ~331 and `BuildFromExterior` ~509; the per-pop `popCounts` bookkeeping ~160-161 and ~441-442)
- [ ] **Step 1: Add a termination test first** (diamond + 2-cell cycle) asserting the flood terminates and `OrderedVisibleCells` is deduped with the cap removed:
```csharp
[Fact]
public void Build_DiamondAndCycle_TerminatesAndDedupes_WithoutCap()
{
// A->B, A->C, B->D, C->D (diamond) + D->B (cycle edge). Acyclic-by-side-test now; must terminate.
const uint A = 0x0001, B = 0x0002, C = 0x0003, D = 0x0004;
var a = Cell(A, new CellPortalInfo((ushort)B,0,0,0), new CellPortalInfo((ushort)C,1,0,0));
a.PortalPolygons.Add(QuadX(-0.9f,-0.1f,-2f)); a.PortalPolygons.Add(QuadX(0.1f,0.9f,-2f));
var b = Cell(B, new CellPortalInfo((ushort)D,0,0,0)); b.PortalPolygons.Add(QuadX(-0.9f,-0.1f,-4f));
var c = Cell(C, new CellPortalInfo((ushort)D,0,0,0)); c.PortalPolygons.Add(QuadX(0.1f,0.9f,-4f));
var d = Cell(D, new CellPortalInfo((ushort)B,0,0,0)); d.PortalPolygons.Add(QuadX(-0.9f,-0.1f,-6f));
var all = new Dictionary<uint, LoadedCell> { [A]=a,[B]=b,[C]=c,[D]=d };
var frame = Build(a, all); // must return (no infinite loop) without the cap
Assert.Equal(frame.OrderedVisibleCells.Count, frame.OrderedVisibleCells.Distinct().Count());
}
```
- [ ] **Step 2: Run it — expect PASS** (with the cap still present it already passes; this guards the removal).
- [ ] **Step 3: Remove the cap.** Delete the `MaxReprocessPerCell` const, the `popCounts` dictionaries, the per-pop `popCounts` increments, and simplify the re-enqueue gates:
```csharp
// Build (~331) BEFORE:
if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId))
// AFTER:
if (grew && queued.Add(neighbourId))
```
```csharp
// BuildFromExterior (~509) BEFORE:
if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId))
// AFTER:
if (grew && queued.Add(neighbourId))
```
Also delete `var popCounts = new Dictionary<uint,int>();` (both methods) and the two `popCounts.TryGetValue(...); popCounts[...] = popsSoFar + 1;` blocks, and the `MaxReprocessPerCell` doc-comment block (~40-51).
- [ ] **Step 4: Run the full builder suite** — must stay GREEN (termination now structural via the acyclic side-test). Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PortalVisibilityBuilder"`. Expected: PASS.
- If any cyclic fixture now hangs/fails, the side-cull did **not** make the graph acyclic for that case → STOP, re-examine (a cycle source remains; do not re-add the cap as a band-aid without understanding why).
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/Rendering/PortalVisibilityBuilder.cs tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs
git commit -m "refactor(render): R-A2b — remove dead MaxReprocessPerCell cap (flood is acyclic after the side-cull)"
```
---
## PHASE 4 — Visual gate + cleanup
### Task 6: Visual gate (acceptance) + strip apparatus
- [ ] **Step 1: Full build + test green.** `dotnet build`; `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` and `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` (App `~Rendering` 207+, the physics rest-stability guards green).
- [ ] **Step 2: Visual gate (user).** Launch with `ACDREAM_PROBE_FLAP=1` + `ACDREAM_PROBE_PORTAL_CHURN=1`; walk through the cottage doorway. Acceptance: **no grey flap**; interior rooms render steadily; `[portal-churn] maxPop` ≤ a small constant (no near-16); the eye-standing-in-doorway case still shows the room ahead (void fix intact). **This is the real acceptance — the user confirms.**
- [ ] **Step 3: Strip apparatus.** Remove the Task-1 `sidechk` probe line. Decide whether to keep `[portal-churn]`/`[flap]`/`[pv-trace]` (they are pre-existing R-A diagnostics) or strip per the spec — strip the `sidechk` addition at minimum.
```bash
git add -A && git commit -m "chore(render): R-A2b — strip sidechk pin probe after the visual gate"
```
- [ ] **Step 4: Update memory + docs.**
- `project_indoor_flap_rootcause`: the flap was the flood **cycle** from a non-retail back-portal traversal (B1/B2 per the pin); retail's `InitCell` side test culls the back portal; fix = match it. The "churn refuted (maxPop=1)" was a camera-turn-at-rest sample (overturned: `maxPop=16` on the walk-through).
- Roadmap/milestones: R-A2b shipped (indoor flap fixed); note the §4 camera (eye-pull-in) follow-up is still open (separate, deferred).
- Mark the 2026-06-09 spec + this plan SHIPPED.
---
## Self-Review notes
- **Spec coverage:** Phase 1 pins B1/B2 (spec REVISION "open" item); Phase 2 implements the pinned back-portal cull (spec §4-B / REVISION); Phase 3 removes the dead cap (spec §4); Task 6 = the spec §5 visual gate + the eye-sweep stability test (spec §5.1). Forward-portal void rescue explicitly preserved (spec §6); no camera/rooting/clip/seal change (spec §6).
- **Apparatus-first justification:** Phase 2's exact form is gated on Phase 1's runtime pin (B1 has exact code; B2's convention is finalized from the captured `InsideSide`/`D`). This matches the codebase's established pin-then-fix pattern and the user's explicit "verify first," not a lazy placeholder — both branches' code is concrete.
- **Regression guards:** the void fix (forward clip-empty rescue) is untouched; the #95 over-inclusion guard + eye-standing tests must stay green (Task 4 Step); termination without the cap is guarded (Task 5).
- **Risk:** if Phase 1 shows the cycle isn't a clean back-portal traversal (e.g., the in-plane ±eps band at D≈0), the fix may need both the strict side test (B2) AND care at D≈0 — Task 2's pin surfaces this; do not proceed past an ambiguous pin.

View file

@ -245,6 +245,14 @@ the closest-first order (early-Z) are the mitigations. Measure at the R1 gate; d
> A = camera-collision (walls grey while inside; eye outside the cell) → fold into R4 or a focused phase;
> B = particles through the floor → **R1b** (#104); C = transparent walls from the street → **R2** below.
> Canonical: [`docs/research/2026-06-03-membership-and-bluehole-shipped-handoff.md`](../../research/2026-06-03-membership-and-bluehole-shipped-handoff.md).
>
> **UPDATE 2026-06-05:** Residual **A (camera collision) SHIPPED** as a verbatim `update_viewer` port
> (commits `0ffc3f5`/`5177b54`/`9e70031`). It made the viewer cell (which this pipeline roots on,
> `clipRoot = CameraCell`) **accurate**, which exposed that **R1 is actually INCOMPLETE**: the
> "inside → `DrawInside` only" inversion + the general-case flood were not finished, so the **bleed**
> (other buildings through walls) + the **cellar-floor drop** (PVS flood from the viewer cell doesn't
> reach the player's cell) remain. **The next phase is R1 completion (this §2/§4 work), NOT R2.**
> Canonical: [`docs/research/2026-06-05-render-residual-a-shipped-core-inside-render-handoff.md`](../../research/2026-06-05-render-residual-a-shipped-core-inside-render-handoff.md).
**Retail anchors:** `RenderNormalMode @ 0x453aa0` (binary decision), `PView::DrawInside @ 0x5a5860`,
`ConstructView @ 0x5a57b0`, `DrawCells @ 0x5a4840` (the seal + the three per-cell loops), fact 8

View file

@ -121,8 +121,32 @@ The render rides on stable membership + a stable viewer. Port bottom-up so each
**P0 first** because it's the apparatus that makes "verbatim" checkable.
- **P0 — Conformance apparatus (before any port).** Headless fixtures of the Holtburg cottage neighborhood (cells `0xA9B4003x` + `0xA9B4017x`, the building stab, the cellar) loaded from the real dats. Golden tests that assert *retail* outcomes: `find_cell_list` returns the same cell as a captured retail trace at the threshold; `point_in_cell` matches; the PVS visible-set for a given (cell, eye) matches. Use the existing `ACDREAM_CAPTURE_RESOLVE` + cdb retail traces. **This is how we know a port is verbatim, not vibes.**
- **P1 — Membership (A) + uniform collision (B1).** Port `find_cell_list`/`find_transit_cells`/`find_building_transit_cells`/`add_all_outside_cells` intrinsic; delete `CheckBuildingTransit`. Port uniform `find_env_collisions` (no fork). One `point_in_cell` criterion everywhere. **Gate:** stand in the cottage doorway — the cell does NOT ping-pong (`[cell-transit]` DELTA=0 standing still, no `0031↔0170↔0171`); walk in/out is a clean monotonic cell sequence.
- **P2 — Door/building-shell collision (B3/B4).** Fix the push-back bounce (the 3 failing Core door tests go green). **Gate:** stand in the doorway — no position oscillation (foot Y stable); walk through cleanly; walls block.
- **P1 — Membership (A) — ✅ DONE 2026-06-03 (premise REVERSED).** acdream's membership ALREADY
matches retail; the believed "0/11 lag" was a cdb CAPTURE ARTIFACT (`CPhysicsObj::SetPositionInternal`
calls `change_cell` at :283456 BEFORE `set_frame` writes `m_position` at :283458, so the golden
paired each frame's new cell with the previous frame's position). An aligned re-capture
(`tools/cdb/find-cell-list-capture-aligned.cdb`) makes the production gate read **9/9 with NO code
change**, and the live visual gate is clean (`[cell-transit]` monotonic, no ping-pong). Both retail
and acdream pick with center-only `point_in_cell` on `global_sphere[0]`; commit via
`validate_transition` = the `find_cell_list` pick — structurally identical. See
`docs/research/2026-06-03-p1-membership-swept-advance-handoff.md` (RESOLVED banner) +
`memory/project_retail_membership_criterion.md`. **RE-SCOPED:** the original P1
deletes/unifications — (a) demote/remove `ResolveCellId` (already out of the prod per-frame path),
(b) unify the forked `find_env_collisions`, (c) replace `CheckBuildingTransit` with intrinsic
building stabs in `find_transit_cells`, (d) make the per-cell ObjCell graph the collision authority
— are now **approval-gated refactors of WORKING code, NOT bug fixes**; they wait for explicit user
approval (CLAUDE.md "don't replace working retail-faithful logic without approval"). One soft spot:
outdoor→indoor `0031↔0170` building-entry is live-clean but NOT conformance-locked (rides on the
un-ported `CheckBuildingTransit` bridge).
- **P2 — Door/building-shell collision (B3/B4) — IN PROGRESS, root cause LOCALIZED 2026-06-03.** The
5 failing Core tests localize to **BSP Path 5 grounded step-up**. The wrappers — Path 5 dispatch
(Contact branch), the recursion guard, `DoStepUp` (= retail `CTransition::step_up` pc:273099),
`DoStepDown` (= retail `step_down` pc:272946) — are verified faithful + correctly reached; the
divergence is in the step-up CLIMB itself (`find_walkable`/`step_sphere_down`'s upper-floor find +
sphere-up-adjust when `sp.StepUp=true`; retail `BSPTREE::step_sphere_down` pc:323665). Cleanest
isolation: `B1_GroundedMover_LowStep_StepsUp` (wall-slides a walkable 0.25 m step). Pickup:
`docs/research/2026-06-03-p2-door-stepup-handoff.md`. **Gate:** stand in the doorway — no position
oscillation (foot Y stable); walk through cleanly; walls block; step up a low cottage stair (climbs).
- **P3 — Camera viewer-cell (C1/C3).** Port `find_visible_child_cell` + the faithful `update_viewer` start-cell/fallbacks. **Gate:** `viewerCell` is stable + correct as the camera orbits across boundaries (no `[flap-cam]` thrash).
- **P4 — PView render (D2D9), the core.** Replace `PortalVisibilityBuilder`/`ProjectToNdc`/`ScreenPolygonClip` with `ConstructView`/`InitCell`/`ClipPortals`/`GetClip`/`AddViewToPortals` + `portal_view_type`/`update_count`; re-port `DrawCells`' seal verbatim. **Gate:** cottage interior sealed (opaque walls, no transparent/flap, no void), sky/terrain through the door only.
- **P5 — Outside-looking-in (D8).** `DrawPortal` + `ConstructView(CBldPortal)`. **Gate:** from the street the interior renders through the door (no see-through box).

View file

@ -0,0 +1,114 @@
# Render Residual A — Camera collision: verbatim `SmartBox::update_viewer` completion
**Date:** 2026-06-05 · **Phase:** M1.5 render residual A · **Branch:** `claude/thirsty-goldberg-51bb9b`
## 1. The finding (why this is a faithfulness completion, not a visible-bug fix)
A live `ACDREAM_PROBE_FLAP` capture this session (Holtburg cottage + cellar) proved the
V1 camera spring-arm **already works**:
| Metric | Result | Meaning |
|---|---|---|
| `[flap-cam] eyeInRoot` | 186,349 `Y` / 470 `n` | eye inside the player's cell **99.75%** |
| `viewerCell == 0` (eye in the void) | **0** of ~318k frames | the sweep never lands the eye in invalid space |
| indoor collide rate, cell `0174` | **97.6%** | spring-arm engages cell-BSP walls hard |
The dominant inside-cottage **bluish void** (seeing other buildings / particles / NPCs through
the walls) is the render-**sealing** residual **C** (`PView::DrawPortal`), NOT the camera — the
eye is already in a valid cell, yet the renderer draws the GL clear colour past unsealed geometry.
User-confirmed.
This task therefore **completes Residual A as a faithful verbatim port** and lands
`FindVisibleChildCell`, which **C also needs**. Its one shot at a *visible* win is the
**cellar-corner** (user point 3): there the player's feet are in the cellar but the pivot/head
is up at cottage-floor level, so the pivot-seated start cell genuinely differs from the feet cell —
the only configuration where the faithful start-cell changes the sweep's outcome.
## 2. Retail target (the oracle — port verbatim)
- `SmartBox::update_viewer` `0x453ce0` pc:92761 — start-cell → sweep → fallbacks.
- `CPhysicsObj::AdjustPosition` `0x511d80` pc:280009 — indoor → `find_visible_child_cell`; outdoor → `LandDefs::adjust_to_outside`.
- `CEnvCell::find_visible_child_cell` `0x52dc50` pc:311397 — `this`/portals/stab_list `point_in_cell`.
- `CEnvCell::GetVisible` `0x52dc10` pc:311378 — cell-graph resolve.
- `find_valid_position` pc:273890 = `return find_transitional_position(this)` pc:273613 — **the sweep is already faithful; do NOT re-port it.**
- `init_object(player, 0x5c)` = `IsViewer | PathClipped | FreeRotate | PerfectClip`; `init_sphere(1, viewer_sphere, 1.0)` (ONE sphere, r=0.3 pc:93314).
Decoded `update_viewer` (indoor branch):
```
pivot = head frame · pivot_offset
if player indoor (objcell_id >= 0x100):
if AdjustPosition(pivot, viewer_sphere) -> cell_1: start = cell_1 # seat start at the PIVOT
else: start = player->cell # fallback to feet cell
else: start = player->cell
sweep viewer_sphere pivot -> sought_eye, startCell=start, flags=0x5c # PathClipped = hard stop
if find_valid_position: set_viewer(curr_pos); viewer_cell = curr_cell; return
if AdjustPosition(sought_eye, viewer_sphere) -> var_170: # FALLBACK 1
set_viewer(sought_eye); viewer_cell = var_170; return
set_viewer(player->m_position); viewer_cell = null # FALLBACK 2: snap to player
```
## 3. Design — faithful layering (Core primitives ← App orchestration)
Retail's `update_viewer` is a **`SmartBox` (camera) method** that calls *down* into physics
(`CPhysicsObj::AdjustPosition`, `CTransition`). acdream mirrors that split exactly:
### Core (`AcDream.Core.Physics`) — the physics primitives
- **`CellTransit.FindVisibleChildCell(IDataCache, uint startCellId, Vector3 worldPoint, bool useStabList)`** —
sibling of the existing `FindCellList` (retail `find_cell_list`); both are cell-membership
resolvers. Port of `find_visible_child_cell`:
```
start = cg.GetVisible(startCellId); if start == null: return 0
if PointInsideCellBsp(start, toLocal(start, worldPoint)): return start.Id # point_in_cell
ids = useStabList ? start.VisibleCellIds : start.Portals.Select(OtherCellId)
foreach id in ids:
c = cg.GetVisible(id)
if c != null && PointInsideCellBsp(c, toLocal(c, worldPoint)): return c.Id
return 0
```
Each candidate transforms `worldPoint` through its OWN `InverseWorldTransform` before the
BSP test (matches `CellTransit.cs:520`).
- **`PhysicsEngine.AdjustPosition(uint seedCellId, Vector3 worldPoint) -> (uint cellId, bool found)`** —
port of `CPhysicsObj::AdjustPosition`, indoor branch: `FindVisibleChildCell(seed, point, useStabList:true)`.
Outdoor branch (`seedLow < 0x100`) reuses the existing terrain-grid resolution.
Retail's `seen_outside -> adjust_to_outside` sub-fallback is **deferred** (not on the cottage/cellar
path; adding it unverified would be guessing — see §6).
- **`ResolveResult.Ok` (new `bool`, default `true`)** — surfaces the `ok` already computed at
`PhysicsEngine.cs:718` (`FindTransitionalPosition`), the faithful map of `find_valid_position != 0`.
Default-true → existing callers unaffected.
### App (`AcDream.App.Rendering`) — the camera orchestration
- **`PhysicsCameraCollisionProbe.SweepEye`** gains the verbatim `update_viewer` body:
1. indoor (`cellId >= 0x100`) → `start = AdjustPosition(cellId, pivot)` else `cellId`;
2. sweep `pivot → desiredEye` from `start` (existing `ResolveWithTransition`, viewer flags);
3. `r.Ok` → return `(swept eye, r.CellId)`;
4. `!r.Ok``AdjustPosition(cellId, desiredEye)` → return `(desiredEye, thatCell)` (fallback 1);
5. else → return `(playerPos, 0)` (fallback 2, snap to player).
`SweepEye` needs the player world position for fallback 2 → add a `Vector3 playerPos` parameter
to `ICameraCollisionProbe.SweepEye` (passed by `RetailChaseCamera.Update`).
## 4. Tests
- **Core.Tests (`CellarLipWedgeTests` pattern, RED→GREEN):** load cottage fixtures `0171/0174/0175`.
Seed the captured corner frame — player `(153.55, 9.32, 93.11)` in `0174`, pivot `(153.55, 9.32, 94.61)`.
Assert `AdjustPosition(0174, pivot)` / `FindVisibleChildCell(0174, pivot, true)` resolves the pivot
to its actual (floor-level) cell, not the cellar. <200 ms, iterable.
- **App.Tests:** focused `SweepEye` orchestration test — start-cell seated, fallback-2 snaps to
`playerPos` when the sweep fails. Fixtures loaded by the `SolutionRoot()` path-walk.
## 5. Validation / visual gate
- Core baseline **1317 pass / 4 fail (documented) / 1 skip** maintained (+ the new tests); App green.
- **Visual gate:** stand in the cottage cellar, press into a corner, rotate — the **cellar-corner
void should improve** (point 3). Inside-looking-out must be **unregressed**. The cottage-room
bluish void is **NOT** in scope (Residual C).
## 6. No-shortcuts rules (per master plan §4)
1. Every ported behaviour cites its decomp anchor (address + `pc:line`) in a comment.
2. No suppression flags / grace periods / `if (problem) return` guards. The two fallbacks are
retail's own; fallback 2 (snap-to-player) is the faithful "never leave the eye invalid", not a band-aid.
3. The `seen_outside → adjust_to_outside` sub-fallback inside `AdjustPosition` is deferred, not
stubbed — documented as out-of-path; revisit if a capture shows the camera needs it.
4. Do NOT re-add a `CurrCell` write inside `ResolveWithTransition`/`ResolveCellId` (the blue-hole
clobber — `CurrCell` is player-only via `UpdatePlayerCurrCell`).
5. Do NOT conflate A (eye containment) with C (`DrawPortal` outside-looking-in).

View file

@ -0,0 +1,264 @@
# Verbatim Retail Indoor Render Port (`DrawInside` / `DrawCells`) — Design — 2026-06-06
> **Why this exists.** Two weeks of patching the indoor renderer have not produced
> retail's seamless inside↔outside↔inside behavior. The interior **walls/floor render
> grey** (the clear color shows through) and geometry **bleeds** between cells. Every
> attempt kept an *approximation layer* over retail's membership logic and patched its
> symptoms. This spec stops that: it ports retail's `DrawCells` **verbatim at the
> algorithm level** and **deletes the approximation layer** that keeps reintroducing the
> bug. Scope agreed with the user: **A + B** (indoor seal + look-out, plus look-in from
> outside). The outdoor `LScape` branch is out of scope — it already works.
> **Worktree:** `thirsty-goldberg-51bb9b`, branch `claude/thirsty-goldberg-51bb9b`,
> HEAD `8116d10`. PowerShell on Windows; launch logs are UTF-16. Do NOT branch/worktree,
> push, or `git stash`/`gc`.
---
## 1. The problem, located in the code
The indoor draw lives in `RetailPViewRenderer.DrawInside` (`src/AcDream.App/Rendering/RetailPViewRenderer.cs`).
Its loop *structure* already mirrors retail (landscape → exit masks → shells → objects,
reverse `OrderedVisibleCells`, per-cell, per-slice). **Two things defeat it:**
1. **Dropped shells → grey.** `RetailPViewRenderer.cs:52`
`drawableCells = clipAssembly.CellIdToSlot.Keys`, and every loop does
`if (!drawableCells.Contains(cellId)) continue;`. A visible cell is drawn **only if
`ClipFrameAssembler` assigned it a clip-slot.** Any cell whose view did not yield a
slot is silently skipped → its sealed shell is never drawn → the clear color shows →
**grey**. Retail draws **every** cell in `cell_draw_list`.
2. **No trim → bleed (and the half-character).** `RetailPViewRenderer.cs:237` `UseIndoorMembershipOnlyRouting()`
sets `_envCells.SetClipRouting(null)` — the per-cell trim was **globally disabled** as
an emergency fix for "characters/shells sliced at stair/door boundaries." So cells that
*do* draw are not trimmed to the opening they're seen through → geometry bleeds; and the
reason it had to be disabled is the clip was being applied to **objects/characters**
(which retail never hard-clips), slicing them.
Both failure modes come from the `ClipFrameAssembler` slot-pool + `drawableCells` filter
sitting on top of the (faithful) membership. **That layer is the "bad code."**
## 2. What retail does (the oracle)
From the named decomp (Sept 2013 EoR). `SmartBox::RenderNormalMode` (0x453aa0) branches on
`is_player_outside`: outside → `LScape::draw`; inside → `DrawInside(viewer_cell)`.
`PView::DrawInside` (0x5a5860) seeds the root cell's view to the full screen, then runs two
phases:
- **`ConstructView` (0x5a57b0)** — build membership. Distance-priority flood from the root;
each popped cell is **appended to `cell_draw_list`** (once); `ClipPortals` clips its
portals against its view; `AddViewToPortals` propagates the clipped openings to neighbours
and enqueues new ones. `cell_draw_list` is the **single** membership source.
- **`DrawCells` (0x5a4840)** — draw, three loops over **reverse** `cell_draw_list` (far→near):
- **Landscape:** if `outside_view.view_count > 0``LScape::draw` clipped to `outside_view`,
then `Clear(DEPTH)`.
- **Loop 1 — exit-portal masks:** per cell, per `portal_view` slice: `setup_view(cell, slice)`;
for each exit portal `DrawPortalPolyInternal` (depth mask for the opening).
- **Loop 2 — shells:** per cell, per slice: `setup_view(cell, slice)`; `DrawEnvCell(cell)`
— the **closed cell mesh** (walls/floor/ceiling), hard-clipped to the slice.
- **Loop 3 — objects:** per cell: `Render::PortalList = cell.portal_view[last]`;
`DrawObjCellForDummies(cell)` — the cell's objects, **visibility-gated by the portal view,
not hard-clipped** (so whole creatures are never sliced).
The trim mechanism is `setup_view` + `polyClipFinish` (0x6b6d00): clip geometry to the
slice's **convex screen region** (CPU, every frame). The two facts that make retail seamless:
**(i)** every `cell_draw_list` cell gets its closed shell drawn (it seals); **(ii)** shells are
hard-clipped per-slice, objects are only visibility-gated.
## 3. Goal & success criteria
- Standing **anywhere inside** (room, cellar, on stairs), the interior is **sealed**: no grey,
no see-through walls, cellar floor + stairs present, character whole.
- **Seamless transitions:** room↔room, room↔cellar, outside→inside (walk in), and **look-in**
from outside through an open door (B).
- **Look-out:** windows/doors show the outdoor world through the opening.
- The draw is a **literal translation** of `DrawCells` (every visible cell's shell, per-slice
trim on shells only, objects visibility-gated), with the slot-pool/filter layer **deleted**.
- Acceptance is **visual** (the user's eyes) — pure logic is unit-tested, the draw is GPU.
Non-goals: rewriting the outdoor `LScape` branch; fixing any residual texture-pipeline issue
that survives a correct seal (would be a separate, evidence-led follow-up).
## 4. Architecture
The pipeline is one binary branch (retail `RenderNormalMode`), already in place at
`GameWindow.cs:7343`: `ShouldRenderIndoor` → indoor `DrawInside` vs outdoor `LScape` +
look-in `DrawPortal`. This spec rewrites the **indoor draw body** and the **look-in body**;
it does not change the branch or the outdoor body.
### 4.1 Components
| Unit | Role | Change |
|---|---|---|
| `PortalVisibilityBuilder``PortalVisibilityFrame.OrderedVisibleCells` + per-cell `CellView` | retail `ConstructView` / `cell_draw_list` + `portal_view` | **KEEP** (faithful, unit-tested) |
| `PortalProjection.ProjectToClip` / `ClipToRegion` | retail `GetClip` / `polyClipFinish` (homogeneous) | **KEEP** (ported this session) |
| `ClipPlaneSet.From(CellView)` | NDC convex region → ≤8 `gl_ClipDistance` planes / scissor AABB / nothing-visible | **KEEP**, call **per slice** |
| `EnvCellRenderer.Render(pass, {cellId})` | draw one cell's closed shell | **KEEP**; drive per-cell, per-slice |
| `WbDrawDispatcher.Draw(...)` | draw entity meshes | **KEEP**; drive per-cell, **no clip** |
| `ClipFrame` | upload clip region to the shader (SSBO) + terrain clip UBO | **SIMPLIFY** to one region (the current slice) |
| `RetailPViewRenderer.DrawInside` / `DrawPortal` | the indoor / look-in orchestration | **REWRITE** to the verbatim `DrawCells` loop |
| `ClipFrameAssembler` (+ `ClipFrameAssemblerTests`) | the slot-pool that produces `CellIdToSlot` / `ClipViewSlice[]` | **DELETE** |
| `drawableCells` filter | "draw only cells with a slot" | **DELETE** (draw all `OrderedVisibleCells`) |
| `UseIndoorMembershipOnlyRouting` / clip-off compromise | globally disables the trim | **DELETE** |
| `InteriorEntityPartition` | bucket entities by cell | **KEEP** as the cell→objects map (not as an eligibility filter); call with **all** visible cells |
| `InteriorRenderer` | outdoor entity-bucket wrapper | **KEEP** (outdoor path) — re-evaluate if it becomes dead |
### 4.2 The new `DrawCells` loop (verbatim translation)
`RetailPViewRenderer.DrawInside(viewerCell)` becomes, in pseudocode:
```
frame = PortalVisibilityBuilder.Build(viewerCell, eye, lookup, viewProj) // cell_draw_list + per-cell CellView
cells = frame.OrderedVisibleCells // NO drawableCells filter
objectsByCell = InteriorEntityPartition.Partition(cells, landblockEntries).ByCell
// --- Landscape through outside_view (look-out) ---
if frame.OutsideView not empty:
for slice in frame.OutsideView.Polygons:
setSliceClip(slice) // ClipPlaneSet.From(slice-as-CellView)
drawLandscapeSlice(slice) // GameWindow callback (terrain/sky/scenery clipped to slice)
clearDepth(outsideView bounds)
// --- Loop 1: exit-portal depth masks (only needed with look-out) ---
for cell in reverse(cells) where cell.drawing_bsp:
for slice in cell.CellView.Polygons:
setSliceClip(slice); drawExitPortalMasks(cell) // depth-only, punches the openings
// --- Loop 2: SHELLS (the seal) ---
for cell in reverse(cells) where cell.drawing_bsp:
for slice in cell.CellView.Polygons:
planes = ClipPlaneSet.From(singlePolygonRegion(slice)) // <=8 planes, or scissor, or nothing
if planes.IsNothingVisible: continue
applyShellClip(planes) // gl_ClipDistance (or scissor)
EnvCellRenderer.Render(Opaque, {cell}); Render(Transparent, {cell})
clearShellClip()
// --- Loop 3: OBJECTS (no hard clip) ---
for cell in reverse(cells):
if objectsByCell[cell] empty: continue
WbDrawDispatcher.Draw(objectsByCell[cell], frustum, animatedIds) // depth + frustum + membership; NO clip
drawCellParticles(cell)
```
Two differences from retail are intentional GL adaptations, both faithful in result:
- **Trim is `gl_ClipDistance` (set per slice), not CPU `polyClipFinish`.** Same convex-region
clip; `ClipPlaneSet.From` already produces the planes. A slice that exceeds 8 edges degrades
to its scissor AABB (over-includes a sliver, never drops geometry).
- **Objects are membership-gated, not hard-clipped.** Retail visibility-tests objects against
`PortalList`; we draw the cell's objects (depth + frustum) without clip planes — this is what
prevents the half-character. (A per-object portal-view visibility test is a possible future
refinement if objects visibly poke past a doorway; the cell shells + depth occlude most cases.)
### 4.3 Look-in (B) — `DrawPortal`
Identical loop, seeded by `PortalVisibilityBuilder.BuildFromExterior` (exterior-facing portals)
instead of the root cell. It reuses Loops 13 unchanged; there is no second draw engine. Runs in
the outdoor branch after `LScape`, before scene particles, exactly where it is wired today
(`GameWindow.cs:7552`).
### 4.4 Clip application detail
`setSliceClip` / `applyShellClip` turn one `ClipPlaneSet` into GPU state:
- `Count > 0` → upload the ≤8 planes (one region, slot 0) and enable that many `gl_ClipDistance`
outputs; the existing mesh/terrain shaders already read a clip region and write
`gl_ClipDistance`, so the shader side is unchanged — only the *feed* shrinks from a slot pool
to one region.
- `UseScissorFallback``glScissor` to `ScissorNdcAabb` (mapped to pixels), no clip planes.
- `IsNothingVisible` → draw nothing for that slice.
`clearShellClip` disables all `gl_ClipDistance` + scissor so Loop 3 (objects) and downstream
passes are unclipped.
## 5. Data flow (per indoor frame)
```
ShouldRenderIndoor(player) == true
→ RetailPViewRenderer.DrawInside(viewerCell, eye, viewProj, callbacks)
Build → cells + per-cell CellView + OutsideView
InteriorEntityPartition → objectsByCell
look-out: per OutsideView slice → setSliceClip → DrawLandscapeSlice (GameWindow GL callback) → clearDepth
Loop1 exit masks (reverse cells, per slice)
Loop2 shells (reverse cells, per slice, clip ON) → EnvCellRenderer.Render({cell})
Loop3 objects (reverse cells, clip OFF) → WbDrawDispatcher.Draw + DrawCellParticles
```
GameWindow keeps providing the GL-bound callbacks it already passes today
(`DrawLandscapeSlice`, `ClearDepthSlice`, `DrawCellParticles`, `EmitDiagnostics`); only their
*orchestration* inside `RetailPViewRenderer` changes.
## 6. Error handling / edge cases
- **Empty `CellView` for a visible cell** (`ClipPlaneSet.IsNothingVisible`): skip that slice's
draw, but the cell may still draw via its other slices. (A cell with *no* non-empty slice is
effectively not visible — consistent with retail, where it would not be in `cell_draw_list`.)
- **Slice > 8 edges:** scissor-AABB fallback (over-include, never drop). Expected to be rare
(a single doorway opening is ~46 edges).
- **Eye standing in a portal / behind-eye portal:** handled upstream by the faithful
`ProjectToClip` (eye-plane clip) + the existing `EyeInsidePortalOpening` flood gate in the
builder — unchanged by this spec.
- **No exit portal:** `OutsideView` empty → no landscape/look-out, no depth-clear; interior still
fully sealed by Loop 2.
## 7. Testing
- **Unit (already green, must stay):** `PortalVisibilityBuilderTests` (membership/cell list),
`PortalProjectionTests` (clip math), `ClipPlaneSet` behavior. App suite baseline 205/205.
- **New unit:** the `DrawCells` orchestration is GL-bound, so extract the pure decision —
*"which (cell, slice) pairs are drawn, in what order"* — into a testable function over a
`PortalVisibilityFrame`, and assert: (a) **every** `OrderedVisibleCells` cell with a non-empty
view appears in the shell pass (regression test for the grey: no cell is dropped); (b) reverse
(far→near) order; (c) objects pass has no clip state. This pins the two bugs from §1 as tests.
- **Integration:** visual, with light `[shell]`/`[vis]` probes confirming `draw=[…]` equals the
visible-cell set (no cell dropped) at the cottage + cellar. **Acceptance is the user's eyes**
on a sealed cottage + cellar with seamless transitions.
## 8. Risks & mitigations
- **Per-slice shell clip re-slices shells at boundaries** (the symptom that caused the emergency
clip-off). Mitigation: the slices now come from the *faithful* `ClipToRegion` (not the old
degenerate projection), and **only shells are clipped** (objects never are). If a shell still
gaps, that is a too-small slice — a visible, localized clip-math case to fix, **not** a return
to dropping cells.
- **Texture-pipeline grey could survive a correct seal.** HEAD's commit notes "interior walls
grey." If, after every visible cell's shell draws, walls are still untextured (vs. clear-color
grey), that is a *separate* surface/texture bug (out of scope here) — but the seal must be
correct first to even tell the two apart.
- **Per-cell draw-call count.** Indoor frames have a handful of visible cells × a few slices →
tens of draws, not thousands. Acceptable; matches retail's per-cell-per-slice cadence.
## 9. File-level change list
- **Rewrite:** `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (the `DrawCells` loop;
delete `drawableCells`, `UseIndoorMembershipOnlyRouting`, slot routing).
- **Delete:** `src/AcDream.App/Rendering/ClipFrameAssembler.cs` +
`tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs`; the `ClipViewSlice`/slot types
if unused after the rewrite.
- **Simplify:** `src/AcDream.App/Rendering/ClipFrame.cs` (one region per slice, no slot pool);
`src/AcDream.App/Rendering/ClipPlaneSet.cs` stays as-is (already does the per-slice math);
`EnvCellRenderer` / `WbDrawDispatcher` clip-routing API trimmed to "set one region / clear".
- **Keep, re-purpose:** `InteriorEntityPartition` (cell→objects map for all visible cells).
- **Light touch:** `GameWindow.cs` indoor/look-in call sites (callbacks unchanged; remove
references to deleted types).
- **Untouched:** `PortalVisibilityBuilder`, `PortalProjection`, the outdoor `LScape` branch,
`SkyRenderer`, `TerrainModernRenderer`, `ParticleRenderer`.
## 10. Decomp references
- `SmartBox::RenderNormalMode` 0x453aa0 — the `is_player_outside` branch.
- `PView::DrawInside` 0x5a5860 — seed full-screen view, `ConstructView`, `DrawCells`.
- `PView::ConstructView` 0x5a57b0 — `cell_draw_list` build (= `OrderedVisibleCells`).
- `PView::DrawCells` 0x5a4840 — the three loops (this spec's §4.2).
- `CEnvCell::setup_view` / `ACRender::polyClipFinish` 0x6b6d00 — per-slice convex clip (= `ClipPlaneSet` + `gl_ClipDistance`).
- `RenderDeviceD3D::DrawObjCellForDummies` 0x5a0760 — objects gated by `PortalList`, not hard-clipped.
- `PView::GetClip` 0x5a4320 / `PrimD3DRender::xformStart` 0x59b990 — homogeneous projection (= `ProjectToClip`).
## 11. Prior art / context
- `docs/research/2026-06-05-shell-sealing-cellar-floor-handoff.md` — the grey = shell-sealing /
flood-root, **not** the projection; "draw every visible cell's shell."
- `docs/research/2026-06-06-retail-pview-renderer-replacement-attempts-handoff.md` — the attempt
history (the slot-pool/filter layer this spec deletes).
- `docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md` — the `DrawCells` model.
- This session: `PortalProjection.ProjectToClip`/`ClipToRegion` (homogeneous `GetClip` port) +
`PortalVisibilityBuilder` made faithful — the membership + clip-math this draw builds on.

View file

@ -0,0 +1,274 @@
# Render Unification — Outdoor-as-a-Cell (single DrawInside path) — Design
**Date:** 2026-06-07
**Status:** Design approved (brainstorm), pending spec review → implementation plan
**Branch:** `claude/thirsty-goldberg-51bb9b`
**Supersedes (for the flap):** the 2026-06-05 3-part viewer-cell-stability plan
(boom snap + dead-zone + w-clip) — exhausted; see "Why prior fixes failed".
---
## 1. Context & problem
The indoor render **FLAP**: textures "battle"/oscillate at every transition
(outdoor↔indoor, room↔room, cellar). Walls flash transparent, the world
background covers windows, the doorway appears to "teleport". This has resisted
weeks of incremental fixes.
**Root cause (pinned 2026-06-07 with live `ACDREAM_PROBE_FLAP` render-sig at the
Holtburg/Arcanum cottage):** the renderer chooses one of **two structurally
different branches** per frame, keyed on whether the *viewer (camera-eye) cell* is
indoor or outdoor ([GameWindow.cs:7342-7349](../../../src/AcDream.App/Rendering/GameWindow.cs)):
```
clipRoot = playerIndoorGate && viewerRoot != null ? viewerRoot : null;
renderBranch = clipRoot == null ? "OutdoorRoot" : "RetailPViewInside";
```
The two branches draw the same scene differently:
| Viewer cell | Branch | Terrain | Interior cells | depth-clear |
|---|---|---|---|---|
| outdoor (e.g. `0xA9B40031`) | `OutdoorRoot` | full-screen draw | **4** via 2D "look-in" | no |
| indoor (`0170`/`0171`) | `RetailPViewInside` | skipped (door-clipped only) | **6** via portal flood | yes |
The 3rd-person eye sits 2.63.9 m behind the player and crosses the indoor/outdoor
boundary by **metres** as the player stands at a doorway. Each crossing toggles the
branch → terrain pops, the interior cell set jumps 4↔6, depth-clear toggles → the
flap. When the eye stays indoor (`0170``0171`) **both solves draw the same 6 cells
— no flap** (verified: adjacent frames, eye ~still). So the flap is *specifically*
the indoor/outdoor branch switch — the "two-pipe split" CLAUDE.md's 2026-05-30
banner marked abandoned, still alive as this gate.
## 2. Why prior fixes failed (do not retry)
- **Viewer-cell dead-zone (±0.2 mm in `PointInsideCellBsp`)** — the eye crosses by
metres; a sub-mm dead-zone is irrelevant. Tried 2026-06-07, had **zero** visible
effect and **regressed** the cellar roof (it shifted the flood *root* via the cell
pick). Reverted. The faithful Part-1 (boom snap, `d2212cf`) and Part-3 (w-space
portal clip, `ProjectToClip`/`ClipToRegion`) are *already shipped*. **The 3-part
viewer-cell-stability plan is exhausted and the flap remains.**
- **Gating the branch on the PLAYER cell** — a documented dead-end
([GameWindow.cs:7207-7211](../../../src/AcDream.App/Rendering/GameWindow.cs)):
forcing an indoor draw while the camera is outside "drops the outdoor pass and
leaves clear color around a floating doorway slice." When the eye is genuinely
outside, the outdoor view *is* correct — so a stable branch can't be a pure
function of the player cell either.
- **A render-side debounce/grace on the branch** — forbidden (no-workarounds rule;
2026-06-05 plan §5).
The lesson: the flap is **architectural**, not a stability tweak. Two
structurally-different render paths cannot be made seamless at the boundary by
adjusting *when* you switch between them. They must become **one** path.
## 3. The retail model (the oracle)
Retail has **one** render path. `SmartBox::RenderNormalMode`
(`0x00453aa0`, pc:92635) calls, in the normal case,
`RenderDeviceD3D::DrawInside(viewer_cell)` (`0x0059f0d0`) →
`PView::DrawInside(viewer_cell)` (`0x005a5860`, pc:433793) → `PView::DrawCells`
(`0x005a4840`). It does **not** branch on inside/outside.
`PView::DrawInside(cell)` (pc:433793):
1. `CEnvCell::curr_view_push(cell)`
2. `PView::add_views(cell->num_stabs, cell->stab_list)` — the cell's visible
objects. **For an outdoor cell the stab list includes the landscape.**
3. `ConstructView(cell, 0xffff)` (`0x005a59a0`) — recursive portal-clip flood. Uses
the ±`0.000199999995f` plane side-test (POSITIVE / NEGATIVE / IN_PLANE,
pc:433834) — *this* is where that 0.2 mm constant belongs, not in cell membership.
An exit/portal leads to `CEnvCell::GetVisible(other_cell_id)` and recurses.
4. `DrawCells(view)` — draw every visible cell.
**The outdoor world is a cell** (a landcell / `CObjCell`) with portals to buildings
and a stab list that carries the landscape. There is no outdoor "mode" — outdoors
is just the cell you're standing in, drawn by the same `DrawInside`.
## 4. Goal & non-goals
**Goal:** collapse acdream's two render paths into one, matching retail: a single
flood rooted at the viewer cell (indoor *or* outdoor) and a single draw of every
visible cell. The flap dies **by construction** — there is no branch to flip, and
crossing a doorway is one continuous flood whose output varies continuously.
**Approach chosen (brainstorm 2026-06-07):** "A — make outdoor a cell" (the true
retail model), with a **clean cutover** (no toggle; git revert is the safety net).
**Non-goals (out of scope for this work):**
- Camera collision / viewer-cell resolution behaviour (kept as-is — it is correct;
the flap is not a camera bug).
- `#78` outdoor terrain gating over indoor floor holes (tracked separately).
- L-spotlight point-light artifact (separate).
- Per-landcell outdoor granularity (retail has 64 landcells/landblock for its own
landscape culling; we model the outdoor world as **one** flood node whose shell is
the terrain — acdream's terrain renderer already does its own frustum culling).
## 5. Design overview
One operation per frame: **flood from the viewer's cell; draw every visible cell.**
The only new concept is an **outdoor cell node**: a synthetic cell whose "shell" is
the landscape and whose "doorways" are nearby building entrances. With it, the
outdoor case and the indoor case are the *same* graph problem.
```
resolve viewer cell ─► Build(flood) from viewer cell ─► for each visible cell in
draw order: (outdoor node → terrain+sky+scenery clipped to its region │ interior
cell → shell+objects clipped to its region) ─► entities membership-gated
```
## 6. Components
### 6.1 Outdoor cell node — `CellVisibility` (+ a small new type)
A synthetic node representing the outdoor world near the player. **One node per
frame**, keyed by the viewer's current outdoor landcell id (the id the camera already
produces, e.g. `0xA9B40031`). All building exit portals collapse to this single node
(the landscape is global, so we do not model 64 landcells/landblock — the node's shell
is the whole visible landscape, drawn by the terrain renderer's own frustum cull).
- `SeenOutside = true`, world transform = identity.
- **Portals = the entrances of nearby buildings** — the reverse of each building's
exit portal (`OtherCellId == 0xFFFF`) polygon, with its clip plane reversed so the
flood can traverse outdoor→building. Built per-frame from the **same nearby-building
enumeration the current exterior look-in already does**
([GameWindow.cs:~7538-7565](../../../src/AcDream.App/Rendering/GameWindow.cs)).
- Marked so the draw path knows "this cell's shell is the terrain, not EnvCell
geometry."
- `CellVisibility.TryGetCell` / the viewer-cell resolution at
[GameWindow.cs:7201-7204](../../../src/AcDream.App/Rendering/GameWindow.cs) returns
this node when the camera's viewer-cell id is an outdoor id, so `viewerRoot` is
**non-null outdoors** and `ComputeVisibilityFromRoot` runs the flood.
**Interface:** `what` — represents the outdoor world as a flood graph node;
`how to use``CellVisibility` builds/refreshes it per frame and returns it from the
viewer-cell lookup when outdoors; `depends on` — the nearby-building portal
enumeration and the loaded landblock set.
### 6.2 One flood — `PortalVisibilityBuilder.Build`
- `Build` roots at the viewer cell — interior `EnvCell` **or** the outdoor node
(today it requires a non-null interior `LoadedCell`,
[PortalVisibilityBuilder.cs:63](../../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs)).
- Building exit portals (`OtherCellId == 0xFFFF`,
[PortalVisibilityBuilder.cs:234](../../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs))
now **lead to the outdoor node** and enqueue it (clipped to the door opening),
instead of terminating into a 2D `OutsideView`.
- The outdoor↔building cycle is bounded by the existing visited-set / per-cell
reprocess guard (`MaxReprocessPerCell`).
- **`BuildFromExterior` is deleted** — the exterior look-in becomes "the flood,
rooted at the outdoor node."
**Interface:** `what` — given any root cell + eye, returns the set of visible cells
(interior + outdoor node) with per-cell screen-space clip regions; `how to use`
called once per frame from the viewer cell; `depends on` — the cell graph (now
including the outdoor node) and `PortalProjection`.
### 6.3 One draw path — `RetailPViewRenderer` + `GameWindow`
- Delete the `OutdoorRoot`/`RetailPViewInside` branch
([GameWindow.cs:7342-7349](../../../src/AcDream.App/Rendering/GameWindow.cs)) and
its two blocks.
- Walk the flood's visible cells in draw order; for each:
- **Outdoor node** → draw terrain + sky + outdoor scenery, clipped to that node's
view region.
- **Interior cell** → draw shell + objects, clipped to its region (today's
`IndoorDrawPlan.ShellPass`/`ObjectPass`).
- Entities membership-gated as today (`InteriorEntityPartition`).
- Delete the separate `OutsideView` mechanism +
`DrawLandscapeThroughOutsideView`/`DrawRetailPViewLandscapeSlice`
([GameWindow.cs:~9239](../../../src/AcDream.App/Rendering/GameWindow.cs)) and
`RetailPViewRenderer.DrawPortal` — terrain-through-door is now "the outdoor node
drawn clipped to its doorway region," produced by the unified flood.
### 6.4 Terrain-clipped-to-region — `TerrainModernRenderer` (reused)
No new terrain machinery. Terrain already clips to the `OutsideView` doorway planes
in-shader via `gl_ClipDistance` (binding=2 clip UBO,
[TerrainModernRenderer.cs:206](../../../src/AcDream.App/Rendering/TerrainModernRenderer.cs)).
We drive that clip from the **outdoor node's view region** instead: full-screen (no
clip — byte-identical to today's open-world draw) when the outdoor node is the root,
the doorway region when it is reached through a portal.
## 7. Per-frame data flow
1. Resolve the viewer cell: interior `EnvCell` if the eye is indoors, else the
outdoor node (`CellVisibility`).
2. `Build` the visibility flood from the viewer cell (one call).
3. Draw every visible cell in order: outdoor node → terrain/sky/scenery clipped to
its region; interior cell → shell/objects clipped to its region.
4. Draw entities, membership-gated to visible cells.
5. Sky draws when the outdoor node is in the visible set, clipped to its region.
No branch anywhere in steps 25.
## 8. What gets deleted (clean cutover)
- The two-branch gate + blocks ([GameWindow.cs:7342-7349](../../../src/AcDream.App/Rendering/GameWindow.cs) and the outdoor/indoor draw blocks).
- `PortalVisibilityBuilder.BuildFromExterior`.
- `RetailPViewRenderer.DrawPortal` (the exterior look-in).
- The `OutsideView` 2D-region path + `DrawLandscapeThroughOutsideView` /
`DrawRetailPViewLandscapeSlice`.
Net result: less code, one path.
## 9. Phasing (implementation order)
1. **Outdoor cell node** — construct it + wire building-entrance portals; resolve
`viewerRoot` to it outdoors. *Unit-tested* (node has the right portals; viewer
resolution returns it for outdoor ids). Additive — not yet consumed by the draw.
2. **Outdoor-root flood capability**`Build` *can* root at the outdoor node and
flood into buildings through their entrances; cycle-safe. This is a **new,
additive** path: the existing indoor `Build` and its exit-portal→`OutsideView`
behaviour are left untouched so the live draw stays correct. *Unit-tested* (flood
from the outdoor node reaches buildings; termination on the outdoor↔building cycle).
3. **Cutover (the one risky, visual-gated step)** — switch the draw to the single
unified path; repoint building exit portals from `OutsideView` to the outdoor node
(so indoor→outdoor floods into the node through the door); delete the
`OutdoorRoot`/`RetailPViewInside` branch, `BuildFromExterior`,
`RetailPViewRenderer.DrawPortal`, and the `OutsideView` /
`DrawLandscapeThroughOutsideView` path. **Visual gate (user's eyes)** at the
cottage doorway/cellar/look-in-from-outside.
4. **Cleanup** — remove any remaining dead code; reconcile the `[render-sig]` probe
to the single path.
Phases 12 are **purely additive** — the new node + outdoor-root flood are not yet
consumed by the draw, and the exit-portal→`OutsideView` behaviour is unchanged — so
the build stays green and the game renders exactly as today. The disruptive change
(repointing exit portals + switching the draw + deleting the old paths) is isolated to
phase 3 and is git-revertible as a unit.
## 10. Testing strategy
- **Unit (TDD):** outdoor-node portal wiring; viewer-cell resolution to the node;
`Build` rooted at the outdoor node returns the expected cell set; indoor→outdoor
flood through a door; cycle termination. New tests in
`tests/AcDream.App.Tests/Rendering/`.
- **Regression guard:** the **pure-outdoor case** (no building in view) must stay
byte-for-byte today's behaviour — full-screen terrain, no clip — so open-world
rendering cannot regress. Assert the outdoor node's root region is full-screen and
the terrain clip is the no-clip UBO in that case.
- **Visual gate (acceptance):** user walks in/out of the cottage, pans the camera at
the threshold, drops to the cellar and back, looks at the cottage from outside —
no flap, no missing textures, terrain/sky correct, no see-through walls.
## 11. Risks & mitigations
- **Occlusion/ordering when terrain + interiors share one flood** — keep the draw
order retail-faithful (far→near, exit-portal masks as today); mitigate by keeping
the pure-outdoor path identical (regression guard above).
- **Cycle blow-up (outdoor→building→outdoor)** — reuse the existing visited-set +
`MaxReprocessPerCell` cap; unit-test termination at the cottage.
- **Performance** — the outdoor node is a single flood node (terrain is its shell,
drawn once with its own frustum cull), not millions of cells, so the flood does not
become combinatorial. Watch outdoor FPS at the visual gate.
- **Clean cutover (no toggle)** — phases 12 are additive and green; phase 3 is the
only risky step and is git-revertible as a unit.
## 12. References / decomp anchors
- Retail: `SmartBox::RenderNormalMode` `0x00453aa0` (pc:92635);
`RenderDeviceD3D::DrawInside` `0x0059f0d0`; `PView::DrawInside` `0x005a5860`
(pc:433793); `PView::ConstructView` `0x005a59a0` (side-test pc:433834);
`PView::DrawCells` `0x005a4840`; `PView::GetClip` `0x005a4320`.
- acdream: `GameWindow.cs` 7183-7204 (viewer/player root), 7342-7349 (branch),
~7538-7601 (look-in), ~9239 (landscape-through-OutsideView);
`PortalVisibilityBuilder.cs` 63 (Build), 234 (exit portal), 339 (BuildFromExterior),
664 (side-test); `PortalProjection.cs` (w-space clip); `TerrainModernRenderer.cs`
206 (Draw/clip); `CellVisibility.cs` 276 (TryGetCell), 338 (ComputeVisibilityFromRoot).
- Memory: `project_indoor_flap_rootcause`, `reference_render_pipeline_state`,
`feedback_render_one_gate`, `feedback_render_downstream_of_membership`,
`project_camera_visibility_coupling`.

View file

@ -0,0 +1,278 @@
> **✅ REVIVED 2026-06-09 — the REVISION banner (bounded propagation) is the live design; see
> [`2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md`](2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md).**
> The `⛔` banner below was wrong: the `maxPop=1` "refutation" was a **camera-turn-at-rest** capture (the
> calm position, root `0172`), NOT a doorway crossing. A 2026-06-09 walk-through re-capture
> (`launch-churn-confirm.log`) measured `maxPop=16` on 44 % of frames — the churn is confirmed at
> flap-time. The "enqueue-once" half (§4/§5) stays dead (re-processing IS retail-faithful); the
> **bounded-propagation** half (REVISION banner) is what ships, as R-A2b.
>
> ---
>
> **⛔ (HISTORICAL — corrected above) SUPERSEDED / REFUTED 2026-06-08 (evening).** Both this spec's directions
> (enqueue-once AND the revised bounded-propagation) were called dead: live measurement found ZERO portal
> re-enqueue churn at the flap (`maxPop=1`) — **but that sample was the wrong reproduction (see the revival
> note above).** The flap is a STRUCTURAL divergence — retail renders
> inside+outside through ONE `DrawInside(viewer_cell)` path with no inside/outside branch, and is
> robust to its own ~36 µm eye jitter via many small per-building floods. Decision: full retail port
> (Option A). Canonical:
> [`docs/research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md`](../../research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md).
# Portal-Flood Bounded-Propagation Port — the indoor "flap" fix (verified design)
**Date:** 2026-06-08 (revised — enqueue-once superseded)
**Branch:** `claude/thirsty-goldberg-51bb9b`
**Status:** Design revised after the writing-plans decomp pass; pending re-review.
> ## ⚠️ REVISION (2026-06-08 PM): "enqueue-once" REFUTED — corrected to "bounded propagation"
> The original approach below (§4 enqueue-once, §5 correct-the-test) is **WRONG** and is retained only
> for the audit trail. The writing-plans decomp pass read `FixCellList` (decomp 433407) →
> `AdjustCellView` (433741) → `ClipPortals(update_count)` + `AddViewToPortals`, which proves **retail
> DOES re-process a grown-after-drawn cell**. So:
> - **Re-processing on growth is retail-faithful and STAYS.** Pure enqueue-once is wrong; it would break
> `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` for the *right* reason (the test is correct
> — do NOT "correct" it; §5 below is VOID).
> - **The real divergence is BOUNDING.** Retail's re-processing terminates *structurally* — each view
> slice is processed once (the `update_count` watermark is monotonic) and a redundant **reciprocal**
> back-contribution clips to **empty** (`OtherPortalClip` → no `copy_view` → no new slice; decomp
> 433654/433711-712). acdream's reciprocal (`ApplyReciprocalClip`+`ClipToRegion`) instead yields a
> **drifted non-empty sliver**`grew` → re-enqueue → churn, bounded only by the
> `MaxReprocessPerCell=16` **hack**. The churn's fixpoint is eye-sensitive → the flap.
> - **Corrected fix:** port retail's **bounded propagation** — make redundant reciprocal/re-clip
> contributions NOT generate new propagatable slices (match retail's empty-reciprocal + monotonic
> `update_count` watermark), and remove the `MaxReprocessPerCell` cap. Keep re-processing.
> - **Scope:** still `PortalVisibilityBuilder` only; no rooting/camera/clip-math-rewrite/seal change. The
> user approved this corrected "faithful moderate port" direction (over a non-faithful epsilon-dedup
> band-aid) on 2026-06-08.
> - **One open precision (→ plan Task 1):** exactly *where* acdream's reciprocal sliver becomes non-empty
> is float-drift-dependent on real doorway geometry — a runtime fact. The implementation plan's first
> task **instruments `PortalVisibilityBuilder` (per-pop re-pop count + reciprocal-clip in/out + `grew`),
> captures at the doorway, and pins the exact line** before the fix, rather than guessing from decomp.
> - **Corrected retail grounding (the full traversal):** `ConstructView` 433750 (pop-once → draw-list →
> `ClipPortals(cell,0)``AddViewToPortals`); `ClipPortals` 433572 (slices `[update_count,view_count)`,
> `GetClip` per portal, exit→`copy_view`/OutsideView, neighbour→`OtherPortalClip`); `AddViewToPortals`
> 433446 (first-discovery→`InitCell`+`InsCellTodoList`; growth→`AddToCell`+`FixCellList`);
> `FixCellList` 433407 = `AdjustCellPlace` + `AdjustCellView` 433741 (=`ClipPortals(update_count)` +
> `AddViewToPortals`**the re-process**); `OtherPortalClip` 433524 (reciprocal, empty-for-redundant).
>
> Everything below this banner is the ORIGINAL (superseded) enqueue-once design — kept for the record.
> ---
# (SUPERSEDED) Portal-Flood Enqueue-Once Port — original design
**Date:** 2026-06-08
**Branch:** `claude/thirsty-goldberg-51bb9b`
**Status:** SUPERSEDED by the revision banner above.
> **Supersedes** the enqueue logic in `docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md`
> (whose §4 enqueue-once was an *incomplete* attempt — it dropped the `AddToCell` growth half) and the
> physics-rest direction in `docs/research/2026-06-08-flap-rootcause-physics-rest-handoff.md` (refuted).
> **Diagnosis evidence:** `docs/research/2026-06-08-flap-physics-diagnosis-REFUTED-its-render-membership.md`
> + this session's two adversarial verification agents (retail decomp + acdream code/data).
---
## 1. Summary
The indoor render **flap** (interior textures battling / popping in and out at a building doorway) is a
**render-side portal-flood membership instability**: as the camera **eye** moves (turning the camera, or
the camera's smoothing-glide after a turn), the set of cells the flood deems visible **oscillates**
(e.g. `8↔3`) even though the eye sweeps **monotonically**. The root is acdream's **re-enqueue-on-growth
"drift"** in `PortalVisibilityBuilder.Build` (`cs:322`, `MaxReprocessPerCell = 16`): a cell whose view
grows is re-enqueued and its portals **re-clipped from the grown (drifted) view** each round; under
sub-cm eye motion each frame re-clips slightly differently → the visible set flips.
The fix is a **verbatim port of retail's enqueue-once portal traversal** (`PView::ConstructView` +
`AddViewToPortals`): a cell is enqueued **only on first discovery**; its portals are clipped **exactly
once** (at pop); later growth into an already-discovered cell is unioned **incrementally in place**
(`AddToCell`) and its draw-list slot re-ordered (`FixCellList`) — **never re-enqueued, never re-clipped
from scratch**. This makes the visible set a deterministic function of the **root + geometry**, so it no
longer drifts with eye jitter. Localized to `PortalVisibilityBuilder`. No camera, physics, rooting, clip-
math, or seal change.
---
## 2. Root cause — verified this session
### 2.1 What the flap is NOT (refuted with primary evidence)
- **Not physics.** `door-recheck-capture.jsonl`: **216,300 standstill physics records, 0 position
re-snaps** — the body is byte-stable at rest. Deterministic tests (flat terrain + indoor cell, resolver
+ full controller) confirm: a resting body holds a byte-identical position. The 2026-06-08 AM
"physics rest µm-jitter" diagnosis is refuted.
- **Not the camera rooting or the inside/outside toggle.** Verified against retail (agent 1):
`SmartBox::RenderNormalMode` (0x453aa0) calls **`DrawInside(viewer_cell)`** (decomp 92675), and
`SmartBox::update_viewer` (0x453ce0) sets `viewer_cell` from a **swept `CTransition`** seeded at the
**player's cell** (`init_path(cell_1, …)` 92866 → `viewer_cell = sphere_path.curr_cell` 92871). So
rooting at the camera's `viewer_cell` and toggling `DrawInside`/`LScape::draw` are **retail-faithful**.
The locked-design claim "root at the player cell" (`2026-06-02 …redesign-design.md` §1.5) is **wrong**;
acdream's current `clipRoot = viewerRoot ?? _outdoorNode` (eye-cell rooting) is correct and stays.
- **Not camera drift at rest.** When the eye is byte-stable (hands-off idle), the flood is rock-stable
(203/181/178-frame byte-identical-eye runs hold a single flood value). The camera settles; the flap
fires **only while the eye moves**.
### 2.2 What the flap IS (verified — agent 2 + live capture)
- The flood oscillates **only when the eye moves**: across ~7,800 flood flips, **3** had a byte-identical
eye (all startup/streaming); **~87 %** of eye-motion flips have a **byte-identical player** position.
A clean burst (yaw byte-constant, eye gliding monotonically 18→5 mm/frame as the camera settles) shows
flood `8→3→8…`**non-monotonic membership under a monotonic eye sweep**.
- The mechanism is the **re-enqueue/re-clip drift**: `PortalVisibilityBuilder.cs:322`
`if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId))
todo.Insert(neighbour, dist);` re-enqueues a grown neighbour up to 16×; each re-process re-clips the
cell's portals from its grown view, so sub-cm eye jitter walks `ClipToRegion`'s surviving-vertex count
across the empty/non-empty boundary → the deep cluster `{0172-0175}` drops/returns → the flap.
- **Sub-issue "C" (indoor flood=2 / "missing textures") is mostly a *symptom* of this drift**, not a
missing seal: the landscape-through-the-door seal **is** present in the indoor path
(`RetailPViewRenderer.DrawInside``DrawLandscapeThroughOutsideView`). When the flood drops `8→3`,
the `OutsideView`/terrain/cell clip shrinks → things vanish. Fixing the drift removes the symptom.
---
## 3. Retail grounding (the traversal being ported)
All from `docs/research/named-retail/acclient_2013_pseudo_c.txt`:
- **`PView::ConstructView`** (0x5a57b0, :433750): `InitCell(root)` + `InsCellTodoList(root)`, then a loop
that **pops one cell at a time** from the todo list, **appends it to the draw list** (← that is
membership), sets `cell_view_done = 1` (:433784), runs `ClipPortals` once, then `AddViewToPortals`.
- **`PView::AddViewToPortals`** (0x5a52d0, :433446): for each visible portal to a neighbour, three cases
keyed on the neighbour's stamps (`processed_stamp` = `*(view+0x44)`, `view_stamp` = `*(view+0x38)`):
- **First discovery** (`processed_stamp == 0`, :433478): `InitCell(neighbour)` + `InsCellTodoList`
(**enqueue once**).
- **Growth** (`processed_stamp != view_stamp`, :433492): `AddToCell(neighbour)` + if already drawn
`FixCellList`; then `processed_stamp = view_stamp`. **No re-enqueue. No re-clip from scratch.**
- **Already current** (`processed_stamp == view_stamp`): **nothing**.
- **`PView::AddToCell`** (0x5a4d90, :433050): clips the cell's portals against **only the newly-added
view slices** (`for i = esi[0x11]; i < esi[0xe]`) — an **incremental** union, not a full re-clip; it
does **not** re-contribute to `OutsideView`.
- **`PView::FixCellList`** (0x5a5250, :433407) → `AdjustDrawList` (:433107): **re-orders** the grown cell
in the draw list to preserve draw order. No re-flood.
- **`PView::InitCell`** (0x5a4b70, :432896): seeds the cell's view, clips its portals against the full
incoming view, stamps with `master_timestamp`; returns whether the cell is non-empty (→ enqueue).
So retail clips each cell's portals **exactly once** (at pop). Late growth refines a cell's own view +
draw order, never its downstream flood. This is the `cell_view_done` "process each cell once" guarantee.
---
## 4. The fix (design)
**Scope: `PortalVisibilityBuilder.Build` only.** Replace the re-enqueue-on-growth fixpoint with retail's
enqueue-once traversal. Concretely:
**Change A — enqueue-once (`Build` ~308-328).** Today:
var nview = GetOrCreate(frame.CellViews, neighbourId);
bool grew = AddRegion(nview, clippedRegion); // union in place (= retail AddToCell)
if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId))
todo.Insert(neighbour, dist); // RE-ENQUEUE on growth ← the drift
New: enqueue a neighbour into `todo` **only on first discovery** — i.e. when it has **no `CellViews`
entry yet** (retail `processed_stamp == 0``InitCell` + `InsCellTodoList`). On growth into an
already-discovered neighbour, **keep `AddRegion`** (incremental union = `AddToCell`) and re-order it in
the draw list if already present (`FixCellList`, §Change C), but **do not** re-insert into `todo`.
**Change B — remove the re-enqueue machinery.** Delete `MaxReprocessPerCell`, `popCounts`, and the
per-pop re-enqueue / `queued`-reset logic in the pop loop. Termination is now by construction (each cell
enqueued ≤1, popped ≤1; ≤N cells total), matching retail `cell_view_done`. The `MaxReprocessPerCell` cap
existed **only** as a termination band-aid for the re-enqueue — with enqueue-once it is dead.
**Change C — draw-list re-order on growth (`FixCellList`).** When growth unions into an
already-discovered cell that is **already in `OrderedVisibleCells`**, re-position it to preserve
closest-first draw order (retail `AdjustDrawList` :433107). If acdream's `OrderedVisibleCells` is already
distance-sorted at assembly time and order is not load-bearing for correctness, this degrades to a no-op
— confirm during implementation; do **not** add ordering machinery the renderer doesn't consume.
**Unchanged (explicitly):** the per-portal clip (`ProjectToClip`/`ClipToRegion`), the
`EyeInsidePortalOpening` degenerate-portal guard (`Build:235-244`), the reciprocal `OtherPortalClip`, the
`OutsideView` exit contribution, the rooting (`clipRoot = viewerRoot ?? _outdoorNode`), the camera, and
the landscape-through-door seal. No new predicate, no robustness heuristic, no hysteresis.
**Why this is the flap fix, not a band-aid:** with each cell's portals clipped once, the visible set is a
deterministic function of `(root, geometry)` — independent of the per-round re-clip path. Sub-cm eye
jitter changes the *projection* (and thus what's drawn within each clipped cell, correctly) but no longer
changes *which cells are members*. The membership stops oscillating; the textures stop battling.
---
## 5. The `Build_ViewGrowthAfterDoneCell` question (open item, resolve during implementation)
The re-enqueue was added 2026-06-07 "to propagate late-discovered slices to exit portals," and
`PortalVisibilityBuilderTests.Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` encodes that. But
the decomp shows retail's `AddToCell` (:433050) only clips the cell's **own** portals against new slices
+ re-orders draw position — it does **not** re-contribute to `OutsideView` (the exit slice is emitted by
`ClipPortals` at pop, once). So "late growth reaches the exit/OutsideView" appears to be **non-retail**.
**Action:** read `PView::ClipPortals` (the OutsideView contribution site) during implementation to
confirm. If confirmed, this test encodes the non-faithful re-enqueue behavior and is **corrected to
match retail** (late growth refines the cell's view + draw order, not the OutsideView). It will **not**
be satisfied by reinstating the re-enqueue. If the OutsideView tests
(`Builder_Cellar_WindowClippedToStairwell`, look-in tests) shrink, that is the retail behavior, handled
retail's way — not by re-adding the drift.
---
## 6. Testing (TDD)
The flap manifests only under live µm/mm eye motion at a specific grazing geometry, so the **visual gate
is acceptance**; the unit layer pins determinism + guards regressions.
1. **Deterministic eye-sweep stability (new, the RED→GREEN driver).** In `AcDream.App.Tests`
(alongside `PortalVisibilityBuilderTests`, since `PortalVisibilityBuilder` is an App-layer type), build
the flood at a sequence of eye positions stepping across the grazing door angle (sub-cm steps
reproducing the live sweep). **Assert each cell's membership across the sweep is a single contiguous
run** — no `present→absent→present` (or `absent→present→absent`) flicker for any cell. That is the
precise anti-flap property (the live capture showed `8→3→8→3`, multiple transitions per cell). RED
under the re-enqueue drift; GREEN after enqueue-once. *Fixture note:* the captured dumps live at
`tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B4017{0..5}.json`; the test must reach them
(shared path or copied into `AcDream.App.Tests/Fixtures`) and the cells must carry the portal graph +
clip planes `Build` consumes. If the cell-dump format omits portals/clip-planes, the impl plan either
extends the dump or synthesizes a minimal doorway portal topology reproducing the grazing geometry —
surface this as the first implementation step, do not silently weaken the test.
2. **Enqueue-once termination + dedup (new).** Diamond (a cell reachable from two parents) + cycle
fixtures: assert the flood terminates with `MaxReprocessPerCell` removed, `OrderedVisibleCells` is
deduped, each reachable cell present exactly once, and (if a per-cell pop counter is cheap to surface)
each cell popped ≤1.
3. **No membership regression.** `Build_IsDeterministic_*`, `Build_EyeStandingInInteriorPortal_FloodsNeighbour`,
`Build_CollapsedInteriorPortalNearEyeBeyondHalfMeter_FloodsNeighbour`,
`Build_DegeneratePortalToTheSide_NotFlooded_NoOverInclusion` (#95 guard), and the cellar/window/look-in
tests stay **green**. `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` is handled per §5.
4. **Visual gate (user) — acceptance.** At the cottage doorway: turn the camera back and forth and walk
through — the interior rooms render steadily, no battling/popping; the `[pv-input]` flood is stable
for a given eye pose. Re-run with `launch-flap-capture.ps1`.
`dotnet build` + `dotnet test` green before the visual gate.
---
## 7. Scope / non-goals
- **In scope:** `PortalVisibilityBuilder.Build` enqueue logic (enqueue-once; remove
`MaxReprocessPerCell`/`popCounts`/re-enqueue; incremental union on growth; draw-order re-position) + the
new/updated tests; reading `ClipPortals` to settle §5.
- **Non-goals (deferred / untouched):**
- **No rooting change** — eye-cell rooting (`clipRoot = viewerRoot ?? _outdoorNode`) is retail-faithful
(§2.1). The locked design's "root at player cell" is refuted, not implemented.
- **No clip-math change** (`ProjectToClip`/`ClipToRegion`), no `EyeInsidePortalOpening` change, no
overlap predicate, no hysteresis/robustness heuristic.
- **No camera, physics, or seal change.** The landscape-through-door seal already exists; C is a symptom
of the drift and resolves with it.
- The 4 GREEN physics rest-stability tests added this session stay as regression guards (they document
that physics rest is bit-stable → the flap is not physics).
---
## 8. Apparatus + references
- **Diagnosis + verification:** `docs/research/2026-06-08-flap-physics-diagnosis-REFUTED-its-render-membership.md`;
this session's two adversarial verification agents (retail decomp CONFIRMED rooting/seal; acdream
code/data CONFIRMED physics-out + eye-driven + the `cs:322` drift).
- **Captured fixtures:** `tests/AcDream.Core.Tests/Fixtures/flap-doorway/0xA9B4017{0..5}.json`;
`flap-doorway-resolve.jsonl`. Apparatus: `launch-flap-capture.ps1`, `analyze_flap_live.py`,
`find_burst.py`, the `[pv-input]` probe (`ACDREAM_PROBE_PVINPUT`, now logs eye/player/rawPlayer/yaw).
- **Retail decomp anchors:** `ConstructView` :433750, `AddViewToPortals` :433446, `InitCell` :432896,
`AddToCell` :433050, `FixCellList` :433407 / `AdjustDrawList` :433107, `InsCellTodoList` :433183,
`SmartBox::update_viewer` :92761, `SmartBox::RenderNormalMode` :92635.
- **Superseded:** `2026-06-08-portal-flood-membership-stability-design.md` §4 (incomplete enqueue-once);
`2026-06-08-flap-rootcause-physics-rest-handoff.md` (physics direction, refuted).
- **Memory to correct after ship:** `project_indoor_flap_rootcause` (root = the `PortalVisibilityBuilder`
re-enqueue/re-clip **drift** under a moving eye; rooting/toggle is retail-faithful; physics + camera
exonerated).

View file

@ -0,0 +1,231 @@
# Portal-Flood Membership Stability — the indoor "flap" root-cause fix
**Date:** 2026-06-08
**Branch:** `claude/thirsty-goldberg-51bb9b`
**Status:** ⚠️ **§4 (enqueue-once) REFUTED 2026-06-08** — retail propagates late slices via `AddToCell`
(decomp :433494); the existing `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` test encodes
that and enqueue-once broke it (reverted). The flap's confirmed root is the **physics resting position
µm-jitter** (§6 contingency, now the active direction). **CANONICAL PICKUP:**
`docs/research/2026-06-08-flap-rootcause-physics-rest-handoff.md`. Keep §1§3 (mechanism + retail
grounding) as accurate diagnosis; treat §4§5 as a refuted approach.
---
## 1. Summary
The indoor render **flap** (textures "battling" at the doorway threshold) is **portal-flood
set-membership instability**: from a *stable* viewer cell, the PView BFS includes or excludes a
deeper cell cluster frame-to-frame, redrawing a different set each frame. The fix is a **verbatim
port of retail's enqueue-once traversal** (`PView::ConstructView`/`AddViewToPortals`): a cell is
enqueued **only on first discovery**; later view-growth into an already-discovered cell is unioned
**in place** (retail `AddToCell`/`FixCellList`) and **never re-enqueues or re-clips** that cell's
portals. This removes acdream's `MaxReprocessPerCell` **re-enqueue fixpoint** — the documented
per-round `ProjectToClip` **drift** that lets µm viewpoint jitter re-discover/undiscover the deep
cluster. Localized to `PortalVisibilityBuilder`; no overlap-predicate, no added robustness, no
camera/movement/physics/clip-math change. (Contingency: if a residual flap survives — the deep
portal's *first* clip being knife-edge under µm jitter independent of drift — the next
retail-faithful step is bit-stabilizing the viewpoint at rest; see §6.)
---
## 2. Root cause — confirmed with primary evidence
### 2.1 What the flap actually is
Live `[render-sig]` + `[pv-input]` capture at the Holtburg cottage threshold (landblock `0xA9B4`),
standing at the doorway:
- The render root is **stable** (`root=0xA9B40170`, `outRoot=n`, i.e. an interior viewer cell — NOT
the outdoor node, NOT a root toggle).
- The flood cell set **oscillates frame-to-frame**: `ids=[0170,0171,0172,0173,0174,0175]` (6) ↔
`ids=[0170,0171]` (2). The deeper cluster `{0172,0173,0174,0175}` pops in/out.
- The oscillation occurs **at a byte-identical (to cm) eye AND player position** — e.g. three
consecutive frames at eye `(155.55,15.45,96.05)`, player `(155.40,13.20,94.00)` with flood
`6,2,6`.
### 2.2 Why it flips — the mechanism
1. `PortalVisibilityBuilder.Build` is a **pure** static function with all-fresh per-call state
(new `frame`/`todo`/`queued`/`popCounts` every call). Proven deterministic by
`PortalVisibilityBuilderTests.Build_IsDeterministic_IdenticalInputsGiveIdenticalVisibleSet`
(passes). **So for identical inputs the output cannot flip** → the flip requires a varying input.
2. The high-precision `[pv-input]` probe (6 dp) shows the camera eye and the **player
`RenderPosition` carry perpetual ~18 µm float jitter every frame** even "standing still"
(e.g. player `94.000000 ↔ 94.000008`, eye `96.248863 ↔ 96.248871`). At most poses this is
harmless; the flood is stable.
3. The per-portal clip is a faithful homogeneous port of retail's `polyClipFinish`
(`PortalProjection.ProjectToClip``ClipToRegion`, w-aware SutherlandHodgman). But the
**re-enqueue fixpoint** (`MaxReprocessPerCell`) re-clips a cell's view each round, and the
codebase documents that this **drifts per round** (`PortalVisibilityBuilder.cs:43,151,732`:
"ProjectToClip drift keeps a view growing forever").
4. At the threshold pose a deeper portal is **grazing** (oblique / near the eye) → it projects to a
thin sliver. The per-round drift + the µm viewpoint jitter flip `ClipToRegion`'s surviving-vertex
count across the `<3` boundary (PortalProjection.cs:118/121) → `clippedRegion.Count` flips
`0 ↔ N` → the cull at **`PortalVisibilityBuilder.cs:235`**
(`if (clippedRegion.Count == 0 && !EyeInsidePortalOpening) continue;`) drops the deeper cluster
on the empty-clip frames → flood `2 ↔ 6` → the flap.
### 2.3 Why prior fixes did not work
- **boom-snap** (camera stabilization, shipped): the jitter is sub-cm and **perpetual** (it is in the
player `RenderPosition`, propagating to the camera); snapping the boom distance did not make the
viewpoint bit-exact, so the knife-edge still flips.
- **w-space clip** (`ProjectToClip`/`ClipToRegion`, shipped): this made the *single* clip robust, but
the instability is in the **re-clip drift across rounds** + the membership gate's dependence on the
surviving-vertex count, not in a single clip.
- **viewer-cell dead-zone** (tried, reverted): the root does not toggle here (`root=0170` stable), so
a root-resolution dead-zone is irrelevant to this symptom.
### 2.4 What this REFUTES (the 2026-06-07 handoff diagnosis)
The predecessor handoff
(`docs/research/2026-06-07-cutover-flip-render-residuals-diagnosis-handoff.md`) is **wrong** on its
load-bearing claims; do not act on its F1/F2:
- "See-through walls from outside" — **not reproduced**: standing outside with the door closed is
**stable** (user visual gate, 2026-06-08).
- "The walls ARE the EnvCell shells; the ModelId is a partial frame" — **refuted**: the cottage
ModelId GfxObj `0x01000A2B` is a full closed exterior (76 render polys, bbox 20×18×10.4 m, 46
outward-facing walls + roof; cross-checked vs the physics BSP + retail `DrawBuilding`). The EnvCell
shells are interior-facing room surfaces. **F2 (build EnvCell back faces / double-side) targets the
wrong geometry.**
- "Oscillation = outdoor-node flood instability (1↔13)" — **corrected**: it is the *indoor* flood
(`outRoot=n`, stable root) swinging **2↔6**. F1 targeted the wrong root.
- "branch=RetailPViewInside every frame proves the flap is gone" — **tautological**: post-flip
`clipRoot = viewerRoot ?? _outdoorNode` is essentially never null, so the `branch` label can no
longer report `OutdoorRoot`. It proves nothing.
---
## 3. Retail grounding
Retail `PView::ConstructView` (decomp `acclient_2013_pseudo_c.txt:433750`): a cell becomes a draw-set
member the moment it is popped from the todo list (`:433783`). A neighbour is enqueued only if the
per-portal `ConstructView` (`:433827`) passes: the **side-test** (`:433832-433849`, `dot(viewpoint,
planeN)+d` vs a 0.2 mm epsilon → POSITIVE/IN_PLANE/NEGATIVE) **AND** `GetClip` (`:432344`) returns a
**non-empty** clip (`:433858` `if (arg3 != 0)`). `GetClip` projects via `xformStart` and clips via
`ACRender::polyClipFinish` (`:702749`).
So retail gates membership on a non-empty clip **too** — it never flaps because (a) it processes each
cell **once** (enqueue-once; no re-clip drift) and (b) its viewpoint is **bit-stable at rest** (the
authoritative local position does not move). acdream diverges on **both** (re-enqueue drift + µm
viewpoint jitter), and the two combine at the grazing portal.
The fix restores retail's traversal **verbatim** — enqueue-once on first discovery, union-in-place on
growth — so acdream stops diverging from `AddViewToPortals` and the per-round re-clip drift disappears.
No new predicate, no added robustness.
---
## 4. The fix (design)
**Principle:** membership is set by **first discovery** in distance-priority order (retail
`InsCellTodoList` in the `AddViewToPortals` `update_count == 0` branch, decomp `:433478`). A cell
already discovered is **never re-enqueued and never re-clipped**; later view-growth into it is unioned
**in place** and only refines that cell's own draw clip / draw-list position (retail `AddToCell` +
`FixCellList`, `:433492-433502`). The drift-prone re-clip loop is deleted, so µm viewpoint jitter can
no longer re-discover/undiscover a cell.
**Change A — enqueue-once (the core fix), `PortalVisibilityBuilder.cs` ~308-327.**
Today a neighbour is RE-enqueued whenever its view `grew`, capped by `MaxReprocessPerCell`:
bool grew = AddRegion(nview, clippedRegion); // union in place (= retail AddToCell)
if (grew && popCounts[neighbourId] < MaxReprocessPerCell // RE-ENQUEUE on growth the divergence
&& queued.Add(neighbourId))
todo.Insert(neighbour, dist);
New: enqueue a neighbour **only on first discovery** (no `CellViews` / `processedViewCounts` entry
yet). On growth into an already-discovered neighbour, union in place (keep `AddRegion`) and update its
draw-list position if already drawn (port `FixCellList`), but **do not** re-insert it into the todo
list. Remove `MaxReprocessPerCell`, `popCounts`, and the per-pop cap — enqueue-once terminates by
construction (≤ N cells), matching retail's `cell_view_done` guarantee (`:433784`).
**Change B — exit-portal / `OutsideView` contribution stays first-process.** Retail contributes a
cell's exit-portal slice to `OutsideView` once, when the cell is processed; there is no re-enqueue
path in `AddViewToPortals` to re-contribute a grown view. acdream's `OutsideView` contribution
(line 256) already happens at process time, so removing the re-enqueue makes it match retail.
**Regression watch:** the re-enqueue was added 2026-06-07 "to propagate late-discovered slices to exit
portals" — which retail does **not** do, so dropping it is faithful, but a look-in / outside-view
slice could shrink. The existing OutsideView tests (`Builder_Cellar_WindowClippedToStairwell`, the
look-in tests) must stay green; if one shrinks, the fix is retail's `AddToCell`/`FixCellList` ordering,
**not** reinstating the re-enqueue.
**`EyeInsidePortalOpening` (line 235-244) is unchanged by this fix.** It is a separate near-degenerate
single-clip guard (eye standing in a doorway), orthogonal to the re-enqueue, and stays as-is. **No
overlap predicate is introduced.**
**Why this is the flap fix, not a band-aid:** the re-enqueue re-clips a popped cell's portals from its
*grown* (drifted) view and can therefore **add or drop** the deep `0172-0175` cluster as the drift
walks across the clip boundary under µm jitter. Enqueue-once decides the cluster's membership **once**,
at first discovery, from the cell's clean first-accumulated view — the same decision retail makes.
---
## 5. Verification (TDD)
The flap itself is float-drift-dependent (it manifests only under live µm jitter at a specific grazing
geometry), so the **visual gate is the acceptance**; the unit layer pins enqueue-once correctness and
guards regressions.
1. **Enqueue-once correctness + termination (new).** A multi-path fixture in
`PortalVisibilityBuilderTests`: a **diamond** (a cell reachable from two parents, so its view grows
after first discovery) and a **cycle** (portals looping back). Assert the flood (a) **terminates
with `MaxReprocessPerCell` removed**, (b) yields a **deduped** `OrderedVisibleCells`, and (c) each
reachable cell is present exactly once. This is the property the re-enqueue cap was protecting;
enqueue-once provides it by construction. If a per-cell pop counter is cheap to surface, also assert
**each cell is popped ≤ 1** (RED under the re-enqueue, GREEN after) — the direct enqueue-once signal.
2. **No membership regression on known geometries.** `Build_EyeStandingInInteriorPortal_FloodsNeighbour`,
`Build_CollapsedInteriorPortalNearEyeBeyondHalfMeter_FloodsNeighbour`,
`Build_DegeneratePortalToTheSide_NotFlooded_NoOverInclusion` (#95 guard), `Build_IsDeterministic_*`,
and the cellar/window/look-in tests stay **green** (re-enqueue and enqueue-once agree on
non-drifting geometry; if one changes, that is the §4 Change-B regression to handle retail's way,
NOT by reinstating the re-enqueue).
3. **Visual gate (user) — the acceptance.** At the cottage doorway threshold, hold still: the 2↔6
oscillation is gone; the deeper rooms render steadily through the door; walking in/out stays
seamless. Re-run the `[pv-input]`/`[render-sig]` probes to confirm `ids=`/flood is stable while
standing still.
`dotnet build` + `dotnet test` green before the visual gate.
---
## 6. Scope / non-goals
- **In scope:** `PortalVisibilityBuilder` enqueue logic — enqueue-once on first discovery; remove the
`MaxReprocessPerCell` re-enqueue, `popCounts`, and the per-pop cap; union-in-place + draw-list
re-position on growth (port retail `AddToCell`/`FixCellList`); the new + existing tests.
- **Non-goals (explicitly deferred):**
- **No overlap predicate / no added robustness** — this is a verbatim retail port, not a new
membership rule. `EyeInsidePortalOpening` (line 235) is untouched.
- **No clip-math rewrite** (`ProjectToClip`/`ClipToRegion` stay).
- **No camera / movement / interpolation / physics changes** in this step.
- **Contingency (next retail-faithful step, only if a residual flap survives the visual gate):**
bit-stabilize the viewpoint at rest. The live `[pv-input]` probe shows the player `RenderPosition`
carries ~18 µm float noise at rest (e.g. Z `94.000000 ↔ 94.000008`), which retail's authoritative
local position does not. If enqueue-once leaves a residual flicker (the deep portal's *first* clip is
knife-edge under that jitter), trace the jitter to its source (interpolation residual vs physics
contact-settling) and make the local-player viewpoint bit-stable at rest, matching retail. Scoped as
a separate step because it touches the movement/physics path; do it only if measured necessary.
---
## 7. Apparatus (diagnostic probes added this session)
- **Keep:** `PortalVisibilityBuilderTests.Build_IsDeterministic_*` (regression value);
`tools/A8CellAudit` `gfxobj` dump mode (reusable).
- **Strip after the fix is visually verified:** the `[pv-input]` probe + `RenderingDiagnostics.ProbePvInputEnabled`
(GameWindow.cs / RenderingDiagnostics.cs), the `outRoot=`/`bshell=` fields added to `[render-sig]`,
and `launch-bshell-probe.ps1` / `launch-pvinput.ps1`. All env-var-gated and inert when off; safe to
leave until the visual gate passes, then remove.
---
## 8. References
- Diagnosis evidence + refutation: this session's `[render-sig]`/`[pv-input]` captures (cottage
threshold), the `Build_IsDeterministic` test, the GfxObj `0x01000A2B` render-geometry dump.
- Retail decomp: `PView::ConstructView` `:433750`/`:433827`, `PView::GetClip` `:432344`,
`ACRender::polyClipFinish` `:702749` (`docs/research/named-retail/acclient_2013_pseudo_c.txt`).
- Superseded: `docs/research/2026-06-07-cutover-flip-render-residuals-diagnosis-handoff.md` (wrong on
see-through / EnvCell-walls / outdoor-node — see §2.4).
- Memory to correct: `project_indoor_flap_rootcause`, `reference_render_pipeline_state`.

View file

@ -0,0 +1,228 @@
# R-A2b — Portal-Flood Bounded Propagation (the indoor "flap" fix)
**Date:** 2026-06-09
**Branch:** `claude/thirsty-goldberg-51bb9b`
**Phase:** full retail render port (Option A) → R-A2b
**Status:** design — approved direction (Option A, the faithful clip), pending written-spec review.
> **Revives** `docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md` (REVISION
> banner = "bounded propagation") and `docs/superpowers/plans/2026-06-08-portal-flood-bounded-propagation.md`.
> Both were marked `⛔ SUPERSEDED` on the strength of a single `maxPop=1` capture that turned out to be the
> **wrong reproduction** (camera-turn at rest, not a doorway crossing). This session re-ran the pin with a
> slow walk-through and measured `maxPop=16` on a fifth of frames — the churn is real. Their banners are
> corrected to redirect here.
---
> ## ⚠️ REVISION (2026-06-09, writing-plans decomp pass): approach changed A → B (back-portal side-cull)
> Reading retail `PView::InitCell` (`:432896`; side test at `:432962`) + `AddToCell` (`:433050`) during
> plan-writing showed WHY retail never churns: its per-portal **side test culls the "back" portal** (the
> doorway just flooded through — the viewpoint is on its exit side), so retail's flood is an acyclic tree
> and the `0171↔0173` mutual cycle **cannot form**. Retail has **no** eye-in-opening bypass of that cull.
>
> Our flood forms the cycle because the back portal `0173→0171` **is traversed** where retail culls it
> (`[pv-trace]`: `pop 0173 p0->0171 grew=True`). The re-enqueue churn (what §4 Option A targeted) is a
> *consequence* of that non-retail cycle. The user chose the more-faithful **Option B**: cull the back
> portal like retail (kill the cycle at its source), **keep** the forward-portal clip-empty void rescue,
> and remove the now-dead `MaxReprocessPerCell` cap. **§4 (Option A coverage test) is superseded by §4-B
> below.**
>
> **Open — WHY is the back portal traversed (this pins the exact fix; plan Phase 1 verifies before code):**
> - **(B1) the bypass:** `EyeInsidePortalOpening` switches off the side-cull (`Build` lines ~208-216:
> `!CameraOnInteriorSide(...) && !eyeInsideOpening`) when the eye is within 1.75 m of a doorway → fix:
> drop `&& !eyeInsideOpening` from the side-cull (back portals cull; the *separate* clip-empty rescue at
> `Build` ~241-250 still rescues FORWARD portals, so the 2026-06-05 void fix is preserved).
> - **(B2) the side test itself:** `CameraOnInteriorSide` (`PortalVisibilityBuilder.cs:717-724`) returns
> true for the back portal where retail's `InitCell` test (`eax_9 == portal_side`, `:432962`) culls it →
> fix: align our side test to retail's convention.
> - **Discriminator:** the back portal's signed distance `D` to the doorway plane at the churn frames —
> `> 1.75 m ⇒ B2` (bypass is off; the side test passed on its own); `≤ 1.75 m ⇒ B1` (bypass in play).
> At `root=0171`, `p1->0173` was measured at `D=-2.73 m` (bypass off) — *indicating B2* — but the churn
> cluster was at a different eye pose with no captured `D`, so Phase 1 confirms before the fix.
---
## 1. Summary
The indoor **flap** (grey/background flashing through doorways while *moving*) is a portal-flood
**re-enqueue churn** in `PortalVisibilityBuilder.Build`. When the camera crosses an interior doorway, the
two rooms sharing that doorway (`0171``0173` at the Holtburg cottage) mutually re-contribute through the
shared portal. Each pass, the near-side re-clip produces a **drifted near-duplicate** region; the
reciprocal leaves it non-empty; `AddRegion`'s exact-polygon dedup doesn't recognize it → `grew=true`
the neighbour re-enqueues. It ping-pongs to the `MaxReprocessPerCell=16` cap, which cuts the flood at an
**arbitrary depth**. Because the cut depth depends on the exact eye position, sub-cm eye creep makes the
visible cell set swing (2↔4 cells) frame-to-frame → the grey flap.
**The fix — see the REVISION banner above: Option B (back-portal side-cull), not the Option A coverage
test described in this paragraph.** Retail's flood is acyclic because its per-portal side test culls the
back portal; our flood cycles because the back portal is traversed (sub-mechanism B1/B2 pinned by plan
Phase 1). Fix: cull the back portal like retail (kill the cycle), keep the forward-portal clip-empty void
rescue, remove the now-dead `MaxReprocessPerCell` + `popCounts` cap. Scope: `PortalVisibilityBuilder` only
— no camera, rooting, clip-math, or seal change.
>
> _(Original Option A text, superseded — kept for the record:)_ port retail's *bounded* propagation: a
> candidate contribution already covered by the neighbour's accumulated view does not count as growth; only
> the uncovered remainder propagates. Mirrors retail's "redundant → empty before `copy_view`". This is a
> non-retail mechanism bounding a cycle retail never forms — Option B removes the cycle instead.
---
## 2. Diagnosis — verified this session (the verify-first gate)
The 2026-06-08 handoff gated the fix on a measurement gate (`docs/research/2026-06-08-indoor-flap-edgeon-vs-camera-position-handoff.md` §5). Results:
### 2.1 §5.3 — retail's clip collapses at edge-on (the "port clip robustness" idea is dead)
`PView::GetClip` (`:432344`) → `ACRender::polyClipFinish` (`:702749`) bails when the clipped polygon drops
below 3 vertices (`:702863`, no guard band). `ClipPortals` (`:433654`) only propagates `if (ecx_8 != 0)`.
`ConstructView` (`:433750`) rebuilds the flood every frame, no cross-frame hysteresis. Our
`PortalProjection.ClipToRegion` collapses identically. **Edge-on area-collapse is geometric — there is no
retail clip robustness to port.** That option is off the table.
### 2.2 §5.1 — the flap is a same-root flood oscillation, not a root-swap
`analyze_flap_vis.py` over `launch-camprobe.log`: of ~4,009 `vis` transitions, **3,984 are same-root vs
25 root-changes (99.4 % same-root)**. The flap is a flood-membership oscillation *inside* room `0171`, not
the "going-outside" root swap, and not the root doorway's D5 rescue flip (3,836/3,984 transitions had no
change in the root doorway's clip/D-band/side).
### 2.3 The mechanism — `[pv-trace]` in `launch-camprobe.log`
At a near-stationary eye (`157.30, 7.8x, 96.25`, ~1 cm creep), one `Build` call shows `0171` popped ~19×
and `0173` ~20×, each round `p1->0173 addCell polys=1 clipVerts=4 recip=1->1 grew=True queued=True`, the
`processed` watermark climbing 0→1→…→19 until the cap binds at 16. The mutual contribution does **not**
shrink (constant `clipVerts=4`, `polys=1`) — it is the same doorway aperture, drifting. Per-cell view
counts swing 1↔53 and cells `016F`/`0172` flicker in/out → the flap.
### 2.4 Confirmation — `launch-churn-confirm.log` (live walk-through, this session)
`analyze_churn_confirm.py`: **44.4 % of frames `maxPop ≥ 2`; worst `maxPop = 16`** (cap saturated, 3,745
frames); root `0171` `maxPopMax=16`; `[flap]` vis oscillation reproduced (187 transitions, vis `2/3/4`).
The calm baseline (player at rest, root `0172`) sits at `maxPop=1` — **that is exactly the position the
2026-06-08 "refuted (maxPop=1)" capture sampled.** The DO-NOT was an unrepresentative sample; the churn is
confirmed at flap-time.
### 2.5 Why retail doesn't churn (termination primitive)
`Render::copy_view` (`:344784`) — the slice-adder — **just appends** (with internal consecutive-vertex
cleanup); it has **no redundancy check** (confirmed by reading it). So retail's termination is **upstream**:
a redundant re-contribution does **not generate a new propagatable slice** — via the clip going empty
(`GetClip`/`OtherPortalClip` < 3 verts) and/or the monotonic `update_count` watermark (each slice processed
once). The exact primitive (empty-clip vs watermark vs both) is confirmed in the plan by tracing the
`ClipPortals`/`AddToCell`/`AdjustCellView` mutual-cycle in full. Either way the *observable* contract is:
**a redundant contribution adds no new visible area, so it does not grow the view.** Our
`ApplyReciprocalClip``AddRegion` path violates that — it leaves the redundant contribution non-empty
(`recip=1->1`) and `AddRegion`'s polygon-equality dedup can't catch the drifted near-duplicate → spurious
`grew`.
---
## 3. Retail grounding (the traversal being matched)
From `docs/research/named-retail/acclient_2013_pseudo_c.txt`:
- `PView::ConstructView` (`:433750`): per-frame flood — `cell_todo_num=0`, seed root, pop one cell at a
time, append to `cell_draw_list` (= membership), `ClipPortals(cell, 0)`, then `AddViewToPortals`.
- `PView::ClipPortals` (`:433572`): processes the cell's view slices `[update_count, view_count)`;
per portal `GetClip`; exit portal → `copy_view`/landscape; neighbour → `OtherPortalClip`. Propagates
**only when the clipped result is non-empty** (`ecx_8 != 0` / `eax_16 != 0`).
- `PView::AddViewToPortals` (`:433446`): first discovery (`processed_stamp==0`) → `InitCell` +
`InsCellTodoList` (enqueue once); growth (`processed_stamp != view_stamp`) → `AddToCell` + `FixCellList`,
then `processed_stamp = view_stamp` (**no re-enqueue**).
- `PView::AddToCell` (`:433050`): incremental — clips the cell's portals against **only the newly-added
slices**; does not re-contribute to `OutsideView`.
- `PView::OtherPortalClip` (`:433524`): reciprocal back-clip; yields empty for a redundant back-contribution.
- `Render::copy_view` (`:344784`): appends a slice; **no dedup** (confirms the empty-for-redundant
decision is upstream, in the clip).
**Takeaway:** retail re-processes growth (faithful — keep it), but a redundant re-contribution adds **no
new visible area** → no new propagatable slice → termination (via empty clip and/or the monotonic
watermark; §2.5). Our divergence is purely that redundant re-contributions stay non-empty and grow the view.
---
## 4. The fix (design — Option A)
**Scope: `PortalVisibilityBuilder` only.**
**4.1 Bounded growth (the core change).** A candidate contribution to a neighbour grows the neighbour's
view (and may re-enqueue) **only by the area not already covered by that neighbour's accumulated view**.
Concretely, before unioning a candidate region into `frame.CellViews[neighbour]`, intersect/subtract it
against the neighbour's existing accumulated regions and keep only the **uncovered remainder**; `grew` is
true iff that remainder is non-empty. A drifted near-duplicate of an already-covered region has
~zero uncovered area → `grew=false` → no re-enqueue → the mutual cycle terminates. This is retail's
"redundant → empty," expressed on our region representation, and it is **drift-tolerant by construction**
(it tests *coverage*, not polygon equality — so it is NOT the rejected epsilon-dedup band-aid).
**4.2 Remove the band-aid.** Delete `MaxReprocessPerCell` and `popCounts` and the per-pop re-enqueue cap
logic in both `Build` and `BuildFromExterior`. With redundant contributions no longer growing the view,
termination is structural (each cell's genuinely-new slices process a bounded number of times; the flood
converges as the aperture is covered).
**4.3 Keep re-processing of genuinely-new slices.** A contribution that *does* add uncovered area still
grows the view and re-enqueues, so late-discovered slices still reach exit portals
(`Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` stays GREEN).
**Exact code form (→ implementation plan, Task 1).** Whether 4.1 is implemented as (i) a polygon
coverage test in `AddRegion` (candidate ⊆ union(existing) → no growth), (ii) an uncovered-remainder
set-difference before the union, or (iii) matching retail's `ClipPortals` slice-watermark + `AddToCell`
in-place growth, is finalized in the plan by reading the retail `ClipPortals`/`AddToCell`/`AdjustCellView`
slice loop in full and choosing the smallest faithful form. The **principle** (redundant/covered →
no growth; uncovered remainder propagates; cap removed; genuine re-processing kept) is fixed here.
**Unchanged (explicit):** `ProjectToClip`/`ClipToRegion`, `EyeInsidePortalOpening`, the reciprocal
`ApplyReciprocalClip`, the `OutsideView` exit contribution, rooting (`clipRoot = viewerRoot ?? _outdoorNode`),
the camera, and the landscape-through-door seal. No new heuristic, hysteresis, or epsilon.
---
## 5. Testing (TDD)
1. **Eye-sweep membership stability (new, the RED→GREEN driver).** In `AcDream.App.Tests`, build the flood
at a sequence of eye positions stepping monotonically across a grazing doorway (synthetic two-room +
shared-portal topology reproducing the `0171↔0173` mutual aperture). **Assert each cell's membership
across the sweep is a single contiguous run** — no `present→absent→present` flicker — and, if surfaced,
per-cell pop count ≤ a small constant. RED under the churn, GREEN after the bound.
2. **Termination without the cap.** Diamond + cycle fixtures: assert the flood terminates with
`MaxReprocessPerCell` removed, `OrderedVisibleCells` deduped, each reachable cell present once.
3. **No membership regression.** `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit`,
`Build_IsDeterministic_*`, `Build_EyeStandingInInteriorPortal_FloodsNeighbour`,
`Build_DegeneratePortalToTheSide_NotFlooded_NoOverInclusion` (#95 over-inclusion guard), and the
cellar/window/look-in tests stay GREEN. The 4 physics rest-stability guards stay GREEN.
4. **Visual gate (user) — acceptance.** At the cottage doorway: walk through and turn the camera — interior
rooms render steadily, no battling/popping; `[pv-input]` flood stable per eye pose; `[portal-churn]`
`maxPop` ≤ a small constant (no near-16 churn). Then strip the `[portal-churn]`/`[flap]`/`[pv-trace]`
apparatus.
`dotnet build` + `dotnet test` green before the visual gate.
---
## 6. Scope / non-goals / risks
- **In scope:** `PortalVisibilityBuilder` bounded-growth (4.1) + cap removal (4.2) in both `Build` and
`BuildFromExterior`; the new tests.
- **Under-inclusion risk + mitigation:** an over-aggressive "covered" test could drop a genuinely-visible
cell (a hole). Mitigation: "covered" is conservative (drop a candidate's growth only when fully covered);
the #95 over-inclusion guard, the eye-standing/look-in/cellar tests, and the new eye-sweep test (must not
drop a cell mid-sweep) bound both directions. Surface any test tension during implementation; do not
weaken a test to pass.
- **§4 camera (deferred, separate divergence):** the eye floating edge-on (retail's eye is pulled in,
collided 93 % at the doorway — `flap-cam-measure.log`) can make the churn fire more often, but is **not**
required for this fix — the churn is a real flood bug at any eye position. Revisit as a follow-up only if
a residual remains after R-A2b.
- **No** rooting / clip-math-rewrite / seal / physics change.
---
## 7. Apparatus + references
- **Captures (untracked, large):** `launch-churn-confirm.log` (this session's walk-through —
`maxPop=16`, 44 % churn); `launch-camprobe.log` (`[pv-trace]` `0171↔0173` churn detail);
`flap-churn.log` (the `maxPop=1` camera-turn-at-rest = the wrong reproduction that mis-shelved the spec).
- **Analyzers (throwaway):** `analyze_flap_vis.py` (same-root vs root-swap split), `analyze_churn_confirm.py`
(maxPop distribution + flap reproduction).
- **Probes:** `ACDREAM_PROBE_FLAP=1` (`[flap]` / `[pv-trace]`), `ACDREAM_PROBE_PORTAL_CHURN=1`
(`[portal-churn]` per-Build maxPop + reciprocal pre→post). Strip after the visual gate.
- **Retail anchors:** `ConstructView` `:433750`, `ClipPortals` `:433572`, `AddViewToPortals` `:433446`,
`AddToCell` `:433050`, `FixCellList` `:433407`, `AdjustCellView` `:433741`, `OtherPortalClip` `:433524`,
`copy_view` `:344784`, `GetClip` `:432344`, `polyClipFinish` `:702749`.
- **Revived (banners redirected here):** `2026-06-08-portal-flood-enqueue-once-port-design.md` (REVISION =
bounded propagation), `2026-06-08-portal-flood-bounded-propagation.md` (Phase 1 done; Phase 2 = this).
- **Memory to correct after ship:** `project_indoor_flap_rootcause` — the churn is confirmed at flap-time
(`maxPop=16`); the "churn refuted (maxPop=1)" verdict was a non-flapping (camera-turn-at-rest) sample.

30
find_burst.py Normal file
View file

@ -0,0 +1,30 @@
import sys, re
path = sys.argv[1]
pat = re.compile(r'flood=(\d+) eye=\(([^)]+)\) player=\(([^)]+)\) rawPlayer=\(([^)]+)\) yaw=([-\d.]+)')
rows = []
with open(path, encoding='utf-8', errors='ignore') as fh:
for l in fh:
m = pat.search(l)
if m:
rows.append((int(m.group(1)),
tuple(float(x) for x in m.group(2).split(',')),
float(m.group(5))))
print("total pv-input rows:", len(rows))
# find first window of 25 frames containing >=4 flood changes (an oscillation burst)
def changes(seg): return sum(1 for i in range(1, len(seg)) if seg[i][0] != seg[i-1][0])
W = 25
start = None
for i in range(len(rows)-W):
if changes(rows[i:i+W]) >= 4:
start = i; break
if start is None:
print("no oscillation burst found"); sys.exit()
print(f"burst at row {start}; dumping {W+8} frames (flood, eyeX,eyeY,eyeZ, dEyeX_mm,dEyeY_mm, yaw):")
prev = None
for r in rows[start:start+W+8]:
fl, e, yaw = r
dx = (e[0]-prev[0])*1000 if prev else 0.0
dy = (e[1]-prev[1])*1000 if prev else 0.0
mark = " <" if (prev and fl != prevfl) else ""
print(f" flood={fl} eye=({e[0]:.6f},{e[1]:.6f},{e[2]:.6f}) dX={dx:+7.3f}mm dY={dy:+7.3f}mm yaw={yaw:.6f}{mark}")
prev = e; prevfl = fl

23
launch-flap-capture.ps1 Normal file
View file

@ -0,0 +1,23 @@
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
# Targeted flap capture (2026-06-08). Goal: pin what VARIES at the grazing
# doorway pose when the deep rooms flicker — especially the IDLE case.
#
# 1. [pv-input] : per frame — eye + player(RenderPos) + rawPlayer(physics) + yaw + flood.
# If the flood flickers, exactly one of those is the varying input.
# 2. CAPTURE_RESOLVE : full physics body JSONL per resolve (confirm body bit-stable at the doorway).
# 3. DUMP_CELLS : dump doorway cell geometry as fixtures so a deterministic
# eye-sweep builder test can be written without the live client.
Remove-Item Env:\ACDREAM_PROBE_FLAP -ErrorAction SilentlyContinue
$env:ACDREAM_PROBE_PVINPUT = "1"
$env:ACDREAM_CAPTURE_RESOLVE = "flap-doorway-resolve.jsonl"
$env:ACDREAM_DUMP_CELLS = "0xA9B40170,0xA9B40171,0xA9B40172,0xA9B40173,0xA9B40174,0xA9B40175,0xA9B40031"
$env:ACDREAM_DUMP_CELLS_DIR = "tests\AcDream.Core.Tests\Fixtures\flap-doorway"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Tee-Object -FilePath "flap-doorway-pvinput.log"

23
launch-flap-churn.ps1 Normal file
View file

@ -0,0 +1,23 @@
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
# Phase 1 portal-flood bounded-propagation PIN (2026-06-08).
# Goal: on the doorway flap frames, show whether PortalVisibilityBuilder.Build's churn is
# driven by redundant reciprocal back-contributions staying NON-EMPTY (the hypothesis).
#
# 1. [pv-input] : per indoor-Build frame — outRoot + flood count + eye/player/yaw. The
# flood-flip frames (e.g. 8<->3) are the flap; correlate with churn below.
# 2. [portal-churn]: per Build call — cells, reEnqueues, rePoppedCells, maxPop=0x<cell>:<n>,
# then per reciprocal call: recip[0x<neighbour> pre->post]. A back-contribution
# that stays pre>0 AND post>0 on a cell that already contributed is the pin.
# FLAP probe is intentionally OFF (its per-portal [flap] dump would drown the log).
Remove-Item Env:\ACDREAM_PROBE_FLAP -ErrorAction SilentlyContinue
$env:ACDREAM_PROBE_PVINPUT = "1"
$env:ACDREAM_PROBE_PORTAL_CHURN = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Tee-Object -FilePath "flap-churn.log"

17
launch-flap-verify.ps1 Normal file
View file

@ -0,0 +1,17 @@
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
# Verify the render-position rest-snap fix (cd974b2). LIGHT probe only (no portal-churn
# spam, which lagged the client) so the visual gate is smooth. [pv-input] logs eye +
# player(RenderPosition) + rawPlayer per frame; at the doorway, STANDING STILL, the fix
# must make RenderPosition + eye byte-stable (was 15 / 17 distinct before; expect ~1 now).
Remove-Item Env:\ACDREAM_PROBE_FLAP -ErrorAction SilentlyContinue
Remove-Item Env:\ACDREAM_PROBE_PORTAL_CHURN -ErrorAction SilentlyContinue
$env:ACDREAM_PROBE_PVINPUT = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Tee-Object -FilePath "flap-verify.log"

View file

@ -8,12 +8,13 @@ namespace AcDream.App.Input;
/// <c>GameWindow</c> and ticked each frame from <c>OnUpdate</c>.
///
/// <para>
/// Why is this its own class? The auto-entry has three independent
/// Why is this its own class? The auto-entry has four independent
/// preconditions (live session reaches <c>InWorld</c>, the player
/// entity has been streamed into the world dictionary, and the player
/// movement controller is constructible) plus a manual-override path
/// entity has been streamed into the world dictionary, the player
/// movement controller is constructible, and the terrain under the
/// spawn position has streamed in) plus a manual-override path
/// (the user can flip into fly mode before the auto-entry fires —
/// their choice wins). All four interact with each other in a way
/// their choice wins). All five interact with each other in a way
/// that's painful to test through GameWindow but trivial here against
/// fakes.
/// </para>
@ -43,6 +44,7 @@ public sealed class PlayerModeAutoEntry
private readonly Func<bool> _isLiveInWorld;
private readonly Func<bool> _isPlayerEntityPresent;
private readonly Func<bool> _isPlayerControllerReady;
private readonly Func<bool> _isSpawnGroundReady;
private readonly Action _enterPlayerMode;
private bool _armed;
@ -60,6 +62,12 @@ public sealed class PlayerModeAutoEntry
/// PlayerMovementController is set up. Stays true once player mode
/// is established; the auto-entry's job is to flip it from false
/// to true exactly once.</param>
/// <param name="isSpawnGroundReady">True iff the terrain under the
/// player's spawn position has streamed into the physics engine.
/// #106 gate-2 (2026-06-09): entering player mode earlier integrates
/// gravity against an empty world and free-falls the player into the
/// void. Retail never has this state — it loads cells synchronously;
/// this hold is the async-streaming equivalent of that invariant.</param>
/// <param name="enterPlayerMode">Action invoked on the firing
/// tick. The same routine the manual Tab handler invokes (fly →
/// player transition). Must construct the controller + chase
@ -69,11 +77,13 @@ public sealed class PlayerModeAutoEntry
Func<bool> isLiveInWorld,
Func<bool> isPlayerEntityPresent,
Func<bool> isPlayerControllerReady,
Func<bool> isSpawnGroundReady,
Action enterPlayerMode)
{
_isLiveInWorld = isLiveInWorld ?? throw new ArgumentNullException(nameof(isLiveInWorld));
_isPlayerEntityPresent = isPlayerEntityPresent ?? throw new ArgumentNullException(nameof(isPlayerEntityPresent));
_isPlayerControllerReady = isPlayerControllerReady ?? throw new ArgumentNullException(nameof(isPlayerControllerReady));
_isSpawnGroundReady = isSpawnGroundReady ?? throw new ArgumentNullException(nameof(isSpawnGroundReady));
_enterPlayerMode = enterPlayerMode ?? throw new ArgumentNullException(nameof(enterPlayerMode));
}
@ -107,6 +117,7 @@ public sealed class PlayerModeAutoEntry
if (!_isLiveInWorld()) return false;
if (!_isPlayerEntityPresent()) return false;
if (!_isPlayerControllerReady()) return false;
if (!_isSpawnGroundReady()) return false;
_armed = false;
_enterPlayerMode();

View file

@ -104,6 +104,16 @@ public sealed class LoadedCell
/// grab_visible_cells decomp:311878). The stable anchor for the terrain-draw test.
/// </summary>
public bool SeenOutside;
/// <summary>
/// Render unification (2026-06-07): true for the synthetic OUTDOOR cell node built by
/// <see cref="OutdoorCellNode.Build"/> — the outdoor world modelled as a flood-graph cell whose
/// shell is the landscape. <see cref="PortalVisibilityBuilder.Build"/> seeds OutsideView
/// full-screen when the root carries this flag (so terrain/sky/scenery draw as the node's shell).
/// An explicit flag, not a cell-id heuristic: interior EnvCell ids are >= 0x100 in production but
/// test fixtures use low ids for interior cells, so keying on the id would misfire.
/// </summary>
public bool IsOutdoorNode;
}
/// <summary>

View file

@ -59,8 +59,9 @@ public sealed class ChaseCamera : ICamera
public Matrix4x4 View =>
Matrix4x4.CreateLookAt(Position, _lookAt, Vector3.UnitZ);
// Near plane 0.1 m = retail Render::znear (see RetailChaseCamera.Projection).
public Matrix4x4 Projection =>
Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f);
Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 0.1f, 5000f);
/// <summary>
/// Update the camera position to follow the player. <paramref name="isOnGround"/>

View file

@ -1,251 +1,224 @@
// ClipFrameAssembler.cs
//
// Phase U.4: assemble a per-frame ClipFrame (the GPU-side shared clip data) +
// a cellId→slot map from a PortalVisibilityFrame. This is the CPU policy that
// turns the portal-visibility BFS result into the slot indices the mesh shader
// (binding=2 CellClip + binding=3 per-instance slot) and the terrain UBO read.
// Retail PView assembly policy. PortalVisibilityBuilder produces a retail-like
// view graph: one portal_view list per visible cell plus an outside_view list.
// This assembler packs each visible polygon as an individual GPU clip slot so
// the renderer can draw the exact PView order:
//
// GL-free: ClipFrame's CPU byte-packing (AppendSlot / SetTerrainClip) runs here;
// the GL upload (ClipFrame.UploadShared) happens at the call site. That keeps the
// whole slot/gate policy unit-testable without a GPU context — see
// ClipFrameAssemblerTests.
// outside_view landscape slices
// reverse cell_draw_list exit masks
// reverse cell_draw_list EnvCell shells
// reverse cell_draw_list object lists
//
// === The slot/gate policy (implemented EXACTLY as the U.4 spec dictates) ======
// slot 0 = no-clip (count 0). ALWAYS present (ClipFrame.NoClip seeds it).
//
// Per visible interior cell (PortalVisibilityFrame.OrderedVisibleCells):
// ClipPlaneSet.From(CellView) has THREE Count==0 meanings (see ClipPlaneSet):
// • IsNothingVisible ⇒ DO NOT map the cell. Its instances/shell won't draw
// (the cull is deliberate — retail culls it too).
// • Count > 0 ⇒ append a real planes slot; cellIdToSlot[cell] = slot.
// • UseScissorFallback⇒ cellIdToSlot[cell] = 0 (no-clip / over-include).
// Per-cell glScissor would break MDI batching, and
// over-inclusion is the SAFE direction; counted in
// ScissorFallbacks for the probe.
//
// OutsideView feeds TWO consumers:
// • mesh "outdoor slot" (outdoor scenery / building shells drawn while the
// camera is indoors): Count>0 ⇒ planes slot (OutdoorSlot); scissor ⇒ slot 0
// (no-clip, counted); IsNothingVisible ⇒ OutdoorVisible=false (CULL these
// instances — the camera can't see outdoors through any portal chain).
// • terrain UBO: Count>0 ⇒ SetTerrainClip(planes); scissor ⇒ TerrainScissor
// (the call site sets glScissor around ONLY the terrain draw) + UBO count 0;
// IsNothingVisible ⇒ SKIP the terrain draw entirely (THIS is the bleed fix).
//
// Outdoor root (pvFrame == null) is handled by the caller, not here: terrain
// draws normally (UBO count 0, no scissor), every instance is slot 0. The caller
// only invokes Assemble when there IS an indoor root.
// Slot 0 is always no-clip. A slice whose polygon cannot be represented by the
// <=8 plane budget uses slot 0 and its NDC AABB; the renderer uses scissor for
// passes that need that fallback. Empty regions are omitted entirely.
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// How the terrain (single OutsideView region) should be drawn this frame.
/// How the landscape-through-outside_view pass should be interpreted.
/// </summary>
public enum TerrainClipMode
{
/// <summary>OutsideView reduced to convex planes — terrain gated via the UBO
/// (<see cref="ClipFrame.SetTerrainClip"/> already applied by the assembler).</summary>
/// <summary>All outside_view slices have convex plane clips.</summary>
Planes,
/// <summary>OutsideView exceeded the convex budget — the call site sets a
/// glScissor to <see cref="ClipFrameAssembly.TerrainScissorNdcAabb"/> around ONLY
/// the terrain draw; the UBO is left at count 0 (ungated).</summary>
/// <summary>At least one outside_view slice requires scissor fallback.</summary>
Scissor,
/// <summary>OutsideView is empty (no exit portal visible through any chain) —
/// the call site SKIPS the terrain draw entirely. This is the bleed fix: an
/// interior with no view outdoors draws no terrain.</summary>
/// <summary>No outside_view slice is visible; skip landscape indoors.</summary>
Skip,
}
/// <summary>
/// Result of <see cref="ClipFrameAssembler.Assemble"/>: the populated
/// <see cref="ClipFrame"/> (CPU bytes ready; caller does <c>UploadShared</c>) plus
/// the per-instance routing data the renderers + the terrain draw consume.
/// One retail portal_view slice mapped to a GPU clip slot. The AABB is retained
/// for passes that cannot write gl_ClipDistance and must use scissor.
/// </summary>
public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes);
/// <summary>
/// Result of <see cref="ClipFrameAssembler.Assemble"/>: populated clip buffers
/// plus routing data consumed by the render orchestration.
/// </summary>
public sealed class ClipFrameAssembly
{
/// <summary>The per-frame clip data. Caller uploads it via
/// <see cref="ClipFrame.UploadShared"/> then hands its
/// <see cref="ClipFrame.RegionSsbo"/> / <see cref="ClipFrame.TerrainUbo"/> to the
/// renderers.</summary>
public required ClipFrame Frame { get; init; }
/// <summary>Maps a visible cell id to its CellClip slot index. A cell that is
/// NOT a key (IsNothingVisible, or never visible) must NOT be drawn — its mesh
/// instances / shell are culled. A scissor-fallback cell maps to slot 0.</summary>
/// <summary>First drawable slice slot per visible cell. Compatibility map
/// for renderer APIs that can accept only one slot at a time.</summary>
public required Dictionary<uint, int> CellIdToSlot { get; init; }
/// <summary>Slot for outdoor scenery / building-shell instances (ParentCellId
/// == null) while the camera is indoors. Meaningful only when
/// <see cref="OutdoorVisible"/> is true. 0 ⇒ no-clip (scissor fallback or trivial).</summary>
/// <summary>Slot-only cell slices, retained for older renderer APIs.</summary>
public required Dictionary<uint, int[]> CellIdToViewSlots { get; init; }
/// <summary>Full retail portal_view slices per visible cell.</summary>
public required Dictionary<uint, ClipViewSlice[]> CellIdToViewSlices { get; init; }
/// <summary>Full retail outside_view slices.</summary>
public required ClipViewSlice[] OutsideViewSlices { get; init; }
public required int OutdoorSlot { get; init; }
/// <summary>False ⇒ the OutsideView is empty; outdoor scenery / shells are
/// CULLED this frame (camera sees no outdoors through any portal chain).</summary>
public required bool OutdoorVisible { get; init; }
/// <summary>How to draw terrain (planes already applied to the UBO / scissor /
/// skip). See <see cref="TerrainClipMode"/>.</summary>
public required TerrainClipMode TerrainMode { get; init; }
/// <summary>NDC AABB (minX,minY,maxX,maxY) for the terrain glScissor when
/// <see cref="TerrainMode"/> is <see cref="TerrainClipMode.Scissor"/>. Unused otherwise.</summary>
public required Vector4 TerrainScissorNdcAabb { get; init; }
/// <summary>True ⇒ the OutsideView (the exit-portal screen region) is meaningfully visible this
/// frame — the camera can see outdoors through a portal chain (<see cref="TerrainMode"/> is
/// <see cref="TerrainClipMode.Planes"/> or <see cref="TerrainClipMode.Scissor"/>). False ⇒ a
/// sealed interior with no exit portal in view (<see cref="TerrainClipMode.Skip"/>). Drives the
/// Stage 4 sky/weather draw + the conditional doorway Z-clear. Always false on the outdoor root
/// (the caller does not invoke <see cref="ClipFrameAssembler.Assemble"/> there).</summary>
public required bool HasOutsideView { get; init; }
/// <summary>NDC AABB (minX,minY,maxX,maxY) of the OutsideView screen region — the doorway
/// opening's bounding box. Computed whenever <see cref="HasOutsideView"/> is true, for BOTH the
/// Planes and Scissor terrain modes (unlike <see cref="TerrainScissorNdcAabb"/>, which is valid
/// only in Scissor mode). Stage 4 scissors the conditional doorway depth-only Z-clear (retail
/// PView::DrawCells:432731) and the sky/weather particle passes to this region. Degenerate
/// (<see cref="Vector4.Zero"/>) when <see cref="HasOutsideView"/> is false.</summary>
public required Vector4 OutsideViewNdcAabb { get; init; }
// ---- Probe data (ACDREAM_PROBE_VIS / RenderingDiagnostics.EmitVis) --------
/// <summary>Plane count the OutsideView reduced to (0 ⇒ scissor or empty).</summary>
// Probe data.
public required int OutsidePlaneCount { get; init; }
/// <summary>Per-cell clip-plane count (cell id → plane count) for the probe.
/// A scissor-fallback cell records 0 here (it maps to slot 0).</summary>
public required Dictionary<uint, int> PerCellPlaneCounts { get; init; }
/// <summary>Number of regions (cells + OutsideView) that fell back to a scissor
/// AABB → no-clip this frame.</summary>
public required int ScissorFallbacks { get; init; }
}
/// <summary>
/// Builds a <see cref="ClipFrameAssembly"/> from a <see cref="PortalVisibilityFrame"/>.
/// Pure CPU; no GL. The single entry point <see cref="Assemble"/> implements the U.4
/// slot/gate policy (file header).
/// </summary>
public static class ClipFrameAssembler
{
/// <summary>
/// Assemble the per-frame clip data + routing from a portal-visibility frame
/// INTO an existing <see cref="ClipFrame"/> — the long-lived GameWindow frame is
/// <see cref="ClipFrame.Reset"/>-and-repacked here every frame so its GL buffers
/// are reused (no per-frame buffer churn). The returned assembly's
/// <see cref="ClipFrameAssembly.Frame"/> is the same instance passed in.
/// </summary>
public static ClipFrameAssembly Assemble(ClipFrame frame, PortalVisibilityFrame pvFrame)
{
System.ArgumentNullException.ThrowIfNull(frame);
System.ArgumentNullException.ThrowIfNull(pvFrame);
frame.Reset(); // slot 0 = no-clip
frame.Reset();
var cellIdToSlot = new Dictionary<uint, int>();
var cellIdToViewSlots = new Dictionary<uint, int[]>();
var cellIdToViewSlices = new Dictionary<uint, ClipViewSlice[]>();
var perCellPlaneCounts = new Dictionary<uint, int>();
int scissorFallbacks = 0;
// ── Interior cells ───────────────────────────────────────────────────
foreach (uint cellId in pvFrame.OrderedVisibleCells)
{
if (!pvFrame.CellViews.TryGetValue(cellId, out var view))
continue; // defensive — OrderedVisibleCells is derived from CellViews
var cps = ClipPlaneSet.From(view);
if (cps.IsNothingVisible)
{
// Cell culled — do NOT map it; its instances/shell won't draw.
continue;
var slices = new List<ClipViewSlice>(view.Polygons.Count);
int maxPlaneCount = 0;
foreach (var poly in view.Polygons)
{
var cps = ClipPlaneSet.From(ViewOf(poly));
if (cps.IsNothingVisible)
continue;
int slot;
Vector4[] planes;
if (cps.Count > 0)
{
planes = ToPlaneSpan(cps);
slot = frame.AppendSlot(planes);
if (cps.Count > maxPlaneCount)
maxPlaneCount = cps.Count;
}
else
{
planes = System.Array.Empty<Vector4>();
slot = 0;
scissorFallbacks++;
}
slices.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
}
if (slices.Count == 0)
continue;
var sliceArray = slices.ToArray();
cellIdToViewSlices[cellId] = sliceArray;
cellIdToViewSlots[cellId] = ToSlots(sliceArray);
cellIdToSlot[cellId] = sliceArray[0].Slot;
perCellPlaneCounts[cellId] = maxPlaneCount;
}
var outsideSlicesList = new List<ClipViewSlice>(pvFrame.OutsideView.Polygons.Count);
int outsideMaxPlaneCount = 0;
bool outsideHasScissorFallback = false;
foreach (var poly in pvFrame.OutsideView.Polygons)
{
var cps = ClipPlaneSet.From(ViewOf(poly));
if (cps.IsNothingVisible)
continue;
int slot;
Vector4[] planes;
if (cps.Count > 0)
{
int slot = frame.AppendSlot(cps);
cellIdToSlot[cellId] = slot;
perCellPlaneCounts[cellId] = cps.Count;
planes = ToPlaneSpan(cps);
slot = frame.AppendSlot(planes);
if (cps.Count > outsideMaxPlaneCount)
outsideMaxPlaneCount = cps.Count;
}
else // UseScissorFallback (Count == 0, not nothing-visible)
else
{
// Over-include via no-clip (slot 0). Per-cell glScissor would break
// MDI batching; over-inclusion is the safe direction for M1.5.
cellIdToSlot[cellId] = 0;
perCellPlaneCounts[cellId] = 0;
planes = System.Array.Empty<Vector4>();
slot = 0;
outsideHasScissorFallback = true;
scissorFallbacks++;
}
outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
}
// ── OutsideView ──────────────────────────────────────────────────────
var ov = ClipPlaneSet.From(pvFrame.OutsideView);
var outsideViewSlices = outsideSlicesList.ToArray();
bool outdoorVisible = outsideViewSlices.Length > 0;
int outdoorSlot = outdoorVisible ? outsideViewSlices[0].Slot : 0;
TerrainClipMode terrainMode = !outdoorVisible
? TerrainClipMode.Skip
: (outsideHasScissorFallback ? TerrainClipMode.Scissor : TerrainClipMode.Planes);
int outdoorSlot;
bool outdoorVisible;
TerrainClipMode terrainMode;
Vector4 terrainScissor = Vector4.Zero;
if (ov.IsNothingVisible)
{
// No outdoors visible through any portal chain.
outdoorSlot = 0;
outdoorVisible = false; // mesh: CULL outdoor scenery / shells.
terrainMode = TerrainClipMode.Skip; // terrain: the bleed fix.
}
else if (ov.Count > 0)
{
// Convex planes — gate both the outdoor mesh slot and the terrain UBO.
outdoorSlot = frame.AppendSlot(ov);
outdoorVisible = true;
frame.SetTerrainClip(ToPlaneSpan(ov));
terrainMode = TerrainClipMode.Planes;
}
else // UseScissorFallback
{
// Mesh: no-clip over-include (slot 0), still visible. Terrain: scissor
// around the single terrain batch + UBO ungated (count 0 left as-is).
outdoorSlot = 0;
outdoorVisible = true;
terrainMode = TerrainClipMode.Scissor;
terrainScissor = ov.ScissorNdcAabb;
scissorFallbacks++;
}
// Stage 4: the doorway screen-space AABB (the OutsideView union bounds), available for
// BOTH Planes and Scissor modes — the sky/weather particle scissor + the conditional
// doorway Z-clear need it regardless of how the OutsideView reduced to a gate.
// TerrainScissorNdcAabb above is only valid in Scissor mode; the OutsideView CellView
// always tracks its Min/Max as polygons accumulate, so it is the single source here.
bool hasOutsideView = terrainMode != TerrainClipMode.Skip;
Vector4 outsideViewNdcAabb = (hasOutsideView && !pvFrame.OutsideView.IsEmpty)
Vector4 outsideViewNdcAabb = outdoorVisible
? new Vector4(pvFrame.OutsideView.MinX, pvFrame.OutsideView.MinY,
pvFrame.OutsideView.MaxX, pvFrame.OutsideView.MaxY)
: Vector4.Zero;
Vector4 terrainScissor = terrainMode == TerrainClipMode.Scissor
? outsideViewNdcAabb
: Vector4.Zero;
return new ClipFrameAssembly
{
Frame = frame,
CellIdToSlot = cellIdToSlot,
CellIdToViewSlots = cellIdToViewSlots,
CellIdToViewSlices = cellIdToViewSlices,
OutsideViewSlices = outsideViewSlices,
OutdoorSlot = outdoorSlot,
OutdoorVisible = outdoorVisible,
TerrainMode = terrainMode,
TerrainScissorNdcAabb = terrainScissor,
HasOutsideView = hasOutsideView,
HasOutsideView = outdoorVisible,
OutsideViewNdcAabb = outsideViewNdcAabb,
OutsidePlaneCount = ov.Count,
OutsidePlaneCount = terrainMode == TerrainClipMode.Planes ? outsideMaxPlaneCount : 0,
PerCellPlaneCounts = perCellPlaneCounts,
ScissorFallbacks = scissorFallbacks,
};
}
// Copy a ClipPlaneSet's planes into a heap array for SetTerrainClip's span
// parameter (the set exposes IReadOnlyList, not a contiguous span).
private static CellView ViewOf(ViewPolygon poly)
{
var view = new CellView();
view.Add(poly);
return view;
}
private static Vector4 AabbOf(ViewPolygon poly) =>
new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY);
private static int[] ToSlots(ClipViewSlice[] slices)
{
var slots = new int[slices.Length];
for (int i = 0; i < slices.Length; i++)
slots[i] = slices[i].Slot;
return slots;
}
private static Vector4[] ToPlaneSpan(ClipPlaneSet set)
{
int n = set.Count;
var planes = new Vector4[n];
for (int i = 0; i < n; i++) planes[i] = set.Planes[i];
for (int i = 0; i < n; i++)
planes[i] = set.Planes[i];
return planes;
}
}

View file

@ -66,7 +66,6 @@ public readonly struct ClipPlaneSet
// or point — zero screen coverage ⇒ nothing visible. A real portal opening has area far
// above this (e.g. the sliver-clip test region is 0.4); only an edge-on projection gets here.
private const float MinPolygonArea = 1e-7f;
private readonly Vector4[] _planes;
private ClipPlaneSet(Vector4[] planes, bool useScissorFallback, bool isNothingVisible, Vector4 scissorNdcAabb)

View file

@ -32,8 +32,9 @@ public sealed class FlyCamera : ICamera
}
}
// Near plane 0.1 m = retail Render::znear (see RetailChaseCamera.Projection).
public Matrix4x4 Projection
=> Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f);
=> Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 0.1f, 5000f);
/// <summary>
/// Integrate position for one frame based on WASD + vertical keys.

File diff suppressed because it is too large Load diff

View file

@ -21,9 +21,11 @@ public interface ICameraCollisionProbe
/// <summary>
/// Roll a collision sphere from <paramref name="pivot"/> to
/// <paramref name="desiredEye"/>; return the position it reaches without
/// penetrating geometry AND the cell it ended in. Returns
/// <paramref name="desiredEye"/> + <paramref name="cellId"/> unchanged
/// when nothing blocks the path or when <paramref name="cellId"/> is 0.
/// penetrating geometry AND the cell it ended in. Mirrors retail
/// <c>SmartBox::update_viewer</c>: when <paramref name="cellId"/> is indoor the
/// sweep's start cell is seated at the pivot, and when there is no start cell or
/// the sweep fails the eye snaps to <paramref name="playerPos"/> (retail
/// <c>set_viewer(player_pos)</c>, viewer cell null).
/// </summary>
CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId);
CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos);
}

View file

@ -0,0 +1,30 @@
// IndoorDrawPlan.cs
//
// Pure (GL-free) port of the membership half of retail PView::DrawCells (0x5a4840):
// the reverse cell_draw_list iterated per portal_view slice. EVERY visible cell with a
// non-empty view is included — there is NO "drawable" filter. Dropping cells without a
// clip-slot was the grey-walls bug (the cell's sealed shell never drew → clear color showed).
using System.Collections.Generic;
namespace AcDream.App.Rendering;
public readonly record struct CellDrawEntry(uint CellId, IReadOnlyList<ViewPolygon> Slices);
public static class IndoorDrawPlan
{
/// <summary>Reverse OrderedVisibleCells (far→near), each visible cell with its view
/// slices. Mirrors DrawCells' shell/object loops. Cells whose view is empty are skipped
/// (they are not actually visible); no other cell is ever dropped.</summary>
public static List<CellDrawEntry> ShellPass(PortalVisibilityFrame frame)
{
var result = new List<CellDrawEntry>(frame.OrderedVisibleCells.Count);
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = frame.OrderedVisibleCells[i];
if (!frame.CellViews.TryGetValue(cellId, out var view) || view.IsEmpty)
continue;
result.Add(new CellDrawEntry(cellId, view.Polygons));
}
return result;
}
}

View file

@ -5,26 +5,36 @@ using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// Splits a frame's landblock entities into the three draw buckets the per-cell
/// <see cref="InteriorRenderer"/> needs, using the SAME precedence as
/// <see cref="Wb.WbDrawDispatcher.ResolveEntitySlot"/>:
/// <list type="number">
/// <item><b>ServerGuid != 0</b> (player / NPCs / items / doors) ⇒ <see cref="Result.LiveDynamic"/>
/// — drawn unclipped (depth only). These have no <c>ParentCellId</c> so they MUST be tested first.</item>
/// <item><b>ParentCellId</b> in the visible set ⇒ <see cref="Result.ByCell"/>[cell] — per-cell, portal-clipped.</item>
/// <item><b>ParentCellId == null</b> (outdoor scenery / building shell) ⇒ <see cref="Result.Outdoor"/>
/// — drawn through the doorway, clipped to OutsideView.</item>
/// Splits a frame's landblock entities into the draw buckets used by the
/// retail-style DrawInside flood.
///
/// <para>T1 (fused BR-2/3, 2026-06-11) — retail draw-order contract: the
/// frame draws STATIC world first (terrain, building shells, scenery, then
/// flooded interior cells + their static object lists), and every DYNAMIC
/// (server-spawned: player, NPCs, doors, items) draws LAST, depth-tested,
/// never hard-clipped. This is what makes the aperture depth punch safe —
/// when the punch erases depth inside a doorway, no dynamic has been drawn
/// yet, so nothing visible is destroyed (retail: objects draw per cell AFTER
/// cells, PView::DrawCells epilogue Ghidra 0x005a4840; the first BR-2 attempt
/// punched after dynamics and erased the player, reverted 88be519).</para>
///
/// <list type="bullet">
/// <item><see cref="Result.ByCell"/> — indoor STATICS (dat-baked, ServerGuid==0)
/// per visible cell, drawn with their cell.</item>
/// <item><see cref="Result.OutdoorStatic"/> — outdoor statics (building
/// shells, scenery stabs), drawn with the world/landscape pass.</item>
/// <item><see cref="Result.Dynamics"/> — ALL server-spawned entities
/// (ServerGuid != 0) regardless of cell, plus unresolved-cell live entities;
/// drawn in the frame's single LAST entity pass.</item>
/// </list>
/// A static whose <c>ParentCellId</c> is NOT in <paramref name="visibleCells"/> is dropped (its cell
/// isn't drawn this frame). Entities with no <c>MeshRefs</c> are skipped. Pure; GL-free; unit-tested.
/// </summary>
public static class InteriorEntityPartition
{
public sealed class Result
{
public Dictionary<uint, List<WorldEntity>> ByCell { get; } = new();
public List<WorldEntity> Outdoor { get; } = new();
public List<WorldEntity> LiveDynamic { get; } = new();
public List<WorldEntity> OutdoorStatic { get; } = new();
public List<WorldEntity> Dynamics { get; } = new();
}
public static Result Partition(
@ -40,23 +50,37 @@ public static class InteriorEntityPartition
{
if (e.MeshRefs.Count == 0) continue;
if (e.ServerGuid != 0) // live-dynamic — precedence first (no ParentCellId)
// Retail contract: every server-spawned entity is a DYNAMIC
// and draws in the last pass — indoor, outdoor, or unresolved.
if (e.ServerGuid != 0)
{
result.LiveDynamic.Add(e);
result.Dynamics.Add(e);
}
else if (e.ParentCellId is uint cell)
else if (e.ParentCellId is uint cell && IsIndoorCellId(cell))
{
if (!visibleCells.Contains(cell)) continue; // its cell isn't drawn this frame
if (!visibleCells.Contains(cell))
continue;
if (!result.ByCell.TryGetValue(cell, out var list))
result.ByCell[cell] = list = new List<WorldEntity>();
list.Add(e);
}
else // outdoor scenery / building shell
else
{
result.Outdoor.Add(e);
result.OutdoorStatic.Add(e);
}
}
}
return result;
}
/// <summary>Shared indoor classification — keep DrawDynamicsLast, the
/// outside-stage assignment (#118), and the partition in lockstep.</summary>
public static bool IsIndoorCellId(uint cellId)
{
uint low = cellId & 0xFFFFu;
return low >= 0x0100u && low != 0xFFFFu;
}
/// <inheritdoc cref="IsIndoorCellId(uint)"/>
public static bool IsIndoorCellId(uint? cellId) => cellId is uint c && IsIndoorCellId(c);
}

View file

@ -1,110 +0,0 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>Per-frame inputs for one <see cref="InteriorRenderer.DrawInside"/> flood.</summary>
public sealed class InteriorRenderContext
{
/// <summary>Visible cells, closest-first (retail cell_draw_list). From PortalVisibilityFrame.</summary>
public required IReadOnlyList<uint> OrderedVisibleCells { get; init; }
/// <summary>The cells the assembler mapped a clip slot for (ClipFrameAssembly.CellIdToSlot.Keys =
/// the GameWindow envCellShellFilter). A cell may appear in <see cref="OrderedVisibleCells"/> but
/// reduce to IsNothingVisible in the assembler (no slot) — those are skipped. This is the
/// membership filter; <see cref="OrderedVisibleCells"/> supplies the draw ORDER.</summary>
public required IReadOnlySet<uint> DrawableCells { get; init; }
/// <summary>The 3-bucket entity split (<see cref="InteriorEntityPartition"/>). Only ByCell +
/// LiveDynamic are used here; Outdoor scenery is drawn by the caller's landscape-through-door
/// step (clipped to OutsideView).</summary>
public required InteriorEntityPartition.Result Partition { get; init; }
public required ICamera Camera { get; init; }
public required FrustumPlanes? Frustum { get; init; }
/// <summary>The full FFFF-suffixed landblock id of the player. Used as BOTH the synthetic
/// per-cell entry id AND neverCullLandblockId so the degenerate (zero) synthetic AABB is never
/// landblock-culled — per-entity frustum culling inside Draw still applies.</summary>
public required uint? PlayerLandblockId { get; init; }
public required HashSet<uint>? AnimatedEntityIds { get; init; }
}
/// <summary>
/// The per-cell interior render flood — a faithful port of retail PView::DrawCells' per-cell loops
/// (decomp 0x5a4840). Iterates the visible cells closest-first; per cell draws the closed shell +
/// that cell's static objects (portal-clipped via the clip routing the caller installed), then the
/// live-dynamics unclipped, then the transparent shells. The landscape-through-door (sky/terrain/
/// scenery) + the conditional Z-clear are the caller's responsibility, run BEFORE this. GL state is
/// self-contained inside each renderer (EnvCellRenderer / WbDrawDispatcher set their own).
/// </summary>
public sealed class InteriorRenderer
{
private readonly EnvCellRenderer _envCells;
private readonly WbDrawDispatcher _entities;
// Reused single-cell filter set — cleared + repopulated per cell to avoid per-frame allocs.
private readonly HashSet<uint> _oneCell = new(1);
public InteriorRenderer(EnvCellRenderer envCells, WbDrawDispatcher entities)
{
_envCells = envCells;
_entities = entities;
}
public void DrawInside(InteriorRenderContext ctx)
{
// Loop A — per-cell OPAQUE shell + that cell's static objects (closest-first).
foreach (uint cellId in ctx.OrderedVisibleCells)
{
if (!ctx.DrawableCells.Contains(cellId)) continue; // no clip slot ⇒ assembler culled it
_oneCell.Clear();
_oneCell.Add(cellId);
_envCells.Render(WbRenderPass.Opaque, _oneCell);
if (ctx.Partition.ByCell.TryGetValue(cellId, out var cellEntities) && cellEntities.Count > 0)
DrawEntityBucket(ctx, cellEntities, visibleCellIds: _oneCell);
}
// Live-dynamics (player / NPCs): unclipped (serverGuid != 0 → clip slot 0), depth-tested.
// Drawn AFTER opaque shells so wall depth occludes them correctly.
if (ctx.Partition.LiveDynamic.Count > 0)
DrawEntityBucket(ctx, ctx.Partition.LiveDynamic, visibleCellIds: null);
// Loop B — per-cell TRANSPARENT shells (stained glass / additive cell surfaces).
foreach (uint cellId in ctx.OrderedVisibleCells)
{
if (!ctx.DrawableCells.Contains(cellId)) continue;
_oneCell.Clear();
_oneCell.Add(cellId);
_envCells.Render(WbRenderPass.Transparent, _oneCell);
}
}
// Draws one bucket of entities via the existing dispatcher, scoped to a synthetic single-entry
// landblock list. visibleCellIds gates which entities pass the cell-membership walk (a single-cell
// set for per-cell statics; null for live-dynamics — they pass the gate and resolve to slot 0).
// The clip slot per entity comes from the SetClipRouting the caller installed (cellIdToSlot +
// outdoorSlot + outdoorVisible) via ResolveEntitySlot.
private void DrawEntityBucket(
InteriorRenderContext ctx, IReadOnlyList<WorldEntity> bucket, HashSet<uint>? visibleCellIds)
{
// LandblockId == neverCullLandblockId (PlayerLandblockId) ⇒ the degenerate (zero) AABB is
// never landblock-frustum-culled; per-entity AABB culling inside Draw still applies.
uint lbId = ctx.PlayerLandblockId ?? 0u;
var entry = (lbId, Vector3.Zero, Vector3.Zero,
(IReadOnlyList<WorldEntity>)bucket,
(IReadOnlyDictionary<uint, WorldEntity>?)null);
_entities.Draw(
ctx.Camera,
new[] { entry },
ctx.Frustum,
neverCullLandblockId: ctx.PlayerLandblockId,
visibleCellIds: visibleCellIds,
animatedEntityIds: ctx.AnimatedEntityIds);
}
}

View file

@ -1,4 +1,4 @@
using System.Numerics;
using System.Numerics;
namespace AcDream.App.Rendering;
@ -23,6 +23,7 @@ public sealed class OrbitCamera : ICamera
}
}
// Near plane 0.1 m = retail Render::znear (see RetailChaseCamera.Projection).
public Matrix4x4 Projection
=> Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f);
=> Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 0.1f, 5000f);
}

View file

@ -0,0 +1,31 @@
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// Factory for the OUTDOOR render root — the cell the render roots at when the camera eye is outdoors.
/// Retail roots every in-world frame at <c>viewer_cell</c> (SmartBox::RenderNormalMode →
/// DrawInside(viewer_cell), decomp:92635); when outdoors that is a <c>CLandCell</c>. acdream models it
/// as a portal-less <see cref="LoadedCell"/> carrying only <see cref="LoadedCell.IsOutdoorNode"/> (so
/// <see cref="PortalVisibilityBuilder.Build"/> seeds OutsideView FULL-SCREEN → terrain/sky/scenery draw
/// as the root's shell) and <see cref="LoadedCell.SeenOutside"/>.
///
/// <para>R-A2 (2026-06-08): the node no longer carries reverse portals into nearby buildings. Retail
/// does NOT flood buildings from the land root — buildings flood SEPARATELY, per-building, during the
/// landscape draw (terrain BSP → DrawPortal → ConstructView(CBldPortal), decomp:326881/433895/433827).
/// acdream issues those via <see cref="PortalVisibilityBuilder.ConstructViewBuilding"/> per nearby
/// building inside <see cref="RetailPViewRenderer.DrawInside"/>. The pre-R-A2 design flooded all
/// buildings from one root through reverse portals, coupling their interior membership to a single
/// root-level portal-side test that oscillated as the chase eye grazed a doorway — the indoor flap.</para>
/// </summary>
public static class OutdoorCellNode
{
public static LoadedCell Build(uint outdoorCellId) => new LoadedCell
{
CellId = outdoorCellId,
SeenOutside = true,
IsOutdoorNode = true,
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
};
}

View file

@ -120,7 +120,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
ParticleSystem particles,
ICamera camera,
Vector3 cameraWorldPos,
ParticleRenderPass renderPass = ParticleRenderPass.Scene)
ParticleRenderPass renderPass = ParticleRenderPass.Scene,
Func<AcDream.Core.Vfx.ParticleEmitter, bool>? emitterFilter = null)
{
if (particles is null || camera is null)
return;
@ -128,7 +129,7 @@ public sealed unsafe class ParticleRenderer : IDisposable
Matrix4x4.Invert(camera.View, out var invView);
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
var draws = BuildDrawList(particles, cameraWorldPos, renderPass, cameraRight, cameraUp);
var draws = BuildDrawList(particles, cameraWorldPos, renderPass, cameraRight, cameraUp, emitterFilter);
if (draws.Count == 0)
return;
draws.Sort(static (a, b) => b.Instance.DistanceSq.CompareTo(a.Instance.DistanceSq));
@ -174,13 +175,16 @@ public sealed unsafe class ParticleRenderer : IDisposable
Vector3 cameraWorldPos,
ParticleRenderPass renderPass,
Vector3 cameraRight,
Vector3 cameraUp)
Vector3 cameraUp,
Func<AcDream.Core.Vfx.ParticleEmitter, bool>? emitterFilter)
{
var draws = new List<ParticleDraw>(Math.Max(64, particles.ActiveParticleCount));
foreach (var (em, idx) in particles.EnumerateLive())
{
if (em.RenderPass != renderPass)
continue;
if (emitterFilter is not null && !emitterFilter(em))
continue;
ref var p = ref em.Particles[idx];
// `p.Position` is already in world coordinates: AttachLocal

View file

@ -21,22 +21,35 @@ public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
public PhysicsCameraCollisionProbe(PhysicsEngine physics) => _physics = physics;
public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos)
{
// No starting cell → nothing to sweep against; keep the desired eye + cell.
if (cellId == 0) return new CameraSweepResult(desiredEye, cellId);
// update_viewer: player->cell == 0 → set_viewer(player_pos, 1), viewer_cell = null
// (acclient_2013_pseudo_c.txt:92775). No cell to sweep against → snap to the player.
if (cellId == 0) return new CameraSweepResult(playerPos, 0u);
// SpherePath.InitPath puts sphere0's center at pathPos + (0,0,radius)
// (the player foot-capsule convention). Retail's viewer_sphere center is
// (0,0,0), so shift the path DOWN by the radius to make the SPHERE CENTER
// travel pivot→eye, then add it back to the swept stop position.
// === Start cell (pc:92824-92844) ===
// Indoor (objcell_id >= 0x100): seat the sweep's start cell at the head-PIVOT via
// CPhysicsObj::AdjustPosition (pc:92832) — the head can sit in a different cell than
// the feet (the cellar lip: feet in the low connector, head up at floor level). On
// failure retail falls back to player->cell. Outdoor: cell = player->cell (no AdjustPosition).
uint startCell = cellId;
if ((cellId & 0xFFFFu) >= 0x0100u)
{
var (pivotCell, found) = _physics.AdjustPosition(cellId, pivot);
if (found) startCell = pivotCell;
}
// === Sweep the viewer_sphere pivot → sought-eye from the start cell (pc:92860-92868) ===
// SpherePath.InitPath puts sphere0's center at pathPos + (0,0,radius) (the player
// foot-capsule convention). Retail's viewer_sphere center is (0,0,0), so shift the
// path DOWN by the radius to make the SPHERE CENTER travel pivot→eye, then add it back.
Vector3 begin = ToSpherePath(pivot, ViewerSphereRadius);
Vector3 end = ToSpherePath(desiredEye, ViewerSphereRadius);
var r = _physics.ResolveWithTransition(
currentPos: begin,
targetPos: end,
cellId: cellId,
cellId: startCell,
sphereRadius: ViewerSphereRadius,
sphereHeight: 0f, // single sphere (no head sphere)
stepUpHeight: 0f, // no step-up for a camera
@ -58,32 +71,35 @@ public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
Vector3 eye = FromSpherePath(r.Position, ViewerSphereRadius);
// Phase U.4c spike apparatus (THROWAWAY — strip with ACDREAM_PROBE_FLAP).
// The post-fix [flap-cam] capture shows the eye flying to full chase distance
// (eyeInRoot=n ~90%) in cells like 0xA9B40174/0175 — i.e. this sweep is not
// stopping it. This line answers WHY, the fork that picks the primary residual
// fix: pulledIn≈0 with resolved=Y bsp=ok ⇒ the sweep ran but found NOTHING in
// that cell (space genuinely open, or wall geometry the per-cell sweep can't
// reach → clip-robustness is primary); resolved=n / bsp=nobsp/noroot ⇒ collision
// can't even run there (cell/BSP not loaded → camera-collision reliability is
// primary); pulledIn large ⇒ collision IS engaging (eye leaving is then expected
// through an opening). Paired per-frame with the builder's [flap]/[flap-cam].
// [flap-sweep] camera-collision probe (ACDREAM_PROBE_FLAP), paired with the
// builder's [flap]/[flap-cam]. start = the pivot-seated start cell (vs cell = the
// player feet cell); ok = the sweep found a valid position (find_valid_position != 0).
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
{
var cp = _physics.DataCache?.GetCellStruct(cellId);
var cp = _physics.DataCache?.GetCellStruct(startCell);
string bsp = cp?.BSP is null ? "nobsp" : (cp.BSP.Root is null ? "noroot" : "ok");
float desiredBack = Vector3.Distance(pivot, desiredEye);
float eyeBack = Vector3.Distance(pivot, eye);
System.Console.WriteLine(
$"[flap-sweep] cell=0x{cellId:X8} resolved={(cp is not null ? "Y" : "n")} bsp={bsp} " +
$"[flap-sweep] cell=0x{cellId:X8} start=0x{startCell:X8} ok={r.Ok} resolved={(cp is not null ? "Y" : "n")} bsp={bsp} " +
$"desiredBack={desiredBack:F2} eyeBack={eyeBack:F2} pulledIn={desiredBack - eyeBack:F2} " +
$"collNormValid={r.CollisionNormalValid}");
$"in=({desiredEye.X:F6},{desiredEye.Y:F6},{desiredEye.Z:F6}) out=({eye.X:F6},{eye.Y:F6},{eye.Z:F6}) " +
$"viewerCell=0x{r.CellId:X8} collNormValid={r.CollisionNormalValid}");
}
// Phase W single-viewpoint V1 (2026-06-03): surface the swept cell (r.CellId =
// sp.CurCellId) as the viewer cell — retail viewer_cell = sphere_path.curr_cell
// (update_viewer pc:92871). Graph-tracked, no AABB/grace → the U.4c flap source is gone.
return new CameraSweepResult(eye, r.CellId);
// success: set_viewer(curr_pos, 0); viewer_cell = sphere_path.curr_cell (pc:92870-92871).
// Graph-tracked, no AABB/grace.
if (r.Ok) return new CameraSweepResult(eye, r.CellId);
// === Fallback 1 (pc:92878-92883): AdjustPosition at the sought eye ===
// The sweep found no valid position; try to seat the eye at its own cell.
// (Seed with the player cell — acdream's camera doesn't track the sought-eye's
// cell separately; the eye is near the player so its stab-list is the right one.)
var (eyeCell, eyeFound) = _physics.AdjustPosition(cellId, desiredEye);
if (eyeFound) return new CameraSweepResult(desiredEye, eyeCell);
// === Fallback 2 (pc:92886-92887): set_viewer(player_pos), viewer_cell = null ===
return new CameraSweepResult(playerPos, 0u);
}
/// <summary>Eye/pivot point → InitPath path point (subtract the sphere-center offset).</summary>

View file

@ -0,0 +1,277 @@
using System;
using System.Numerics;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// BR-2 (holistic building-render port): retail's invisible portal depth
/// writes — the port of <c>D3DPolyRender::DrawPortalPolyInternal</c>
/// (Ghidra 0x0059bc90, pc:424490).
///
/// <para><b>Wired by T1 (BR-3, `579c8b0`):</b> seal on interior roots, punch
/// on outdoor / look-in roots, via <c>GameWindow.DrawRetailPViewPortalDepthWrite</c>
/// (the <c>DrawExitPortalMasks</c> slice callback) — safe alongside the
/// dynamics-drawn-LAST frame order (the first BR-2 attempt punched after
/// dynamics and erased the player; reverted 88be519). #117 (2026-06-11)
/// added the two-pass stencil depth gate on the punch side — see
/// <see cref="DrawDepthFan"/>.</para>
///
/// <para>Retail projects a portal polygon, software-clips it against the
/// installed portal view (<c>polyClipFinish</c>), and draws the survivor as a
/// COLOR-INVISIBLE triangle fan with depth-test ALWAYS + depth-write ON:</para>
/// <list type="bullet">
/// <item><b>Seal</b> (retail <c>maxZ2=6</c>, bit0 clear, data 0x00820e14):
/// z = the polygon's true projected depth. Drawn on portals leading OUTSIDE
/// (<c>other_cell_id==0xFFFF</c>) after the landscape pass — terrain seen
/// through a doorway keeps its pixels because farther interior geometry
/// z-fails inside the aperture (PView::DrawCells loop 1, Ghidra 0x005a4840,
/// pc:432783-432786).</item>
/// <item><b>Punch</b> (retail <c>maxZ1=7</c>, bit0 set, data 0x00820e18):
/// z forced to the far plane (0.99999988) — erases depth inside a building
/// aperture so the interior cells drawn next land cleanly
/// (ConstructView(CBldPortal) mode-1, pc:433827). BR-2 commit 2 wires this
/// side.</item>
/// </list>
///
/// <para>Where retail clips the polygon on the CPU against the view, we apply
/// the SAME view region via <c>gl_ClipDistance</c> from the slice's clip-space
/// half-planes (≤8, the validated <see cref="ClipPlaneSet"/> output) — the
/// depth write lands only inside the slice region, matching retail's clipped
/// fan.</para>
///
/// <para>Self-contained GL state (feedback_render_self_contained_gl_state):
/// sets everything it depends on, restores the frame-global convention on
/// exit, no early-outs between set and restore.</para>
/// </summary>
public sealed class PortalDepthMaskRenderer : IDisposable
{
private const string VertSrc = @"#version 430 core
layout(location = 0) in vec3 aPos;
uniform mat4 uViewProjection;
uniform int uPlaneCount;
uniform vec4 uPlanes[8];
uniform int uForceFarZ;
uniform float uDepthBias; // NDC bias toward the viewer (mark pass only)
out float gl_ClipDistance[8];
void main()
{
vec4 clipPos = uViewProjection * vec4(aPos, 1.0);
for (int i = 0; i < 8; i++)
gl_ClipDistance[i] = (i < uPlaneCount) ? dot(uPlanes[i], clipPos) : 1.0;
if (uForceFarZ == 1)
clipPos.z = clipPos.w * 0.99999988; // retail far-z punch constant (0x0059bc90 tail)
else if (uDepthBias > 0.0)
clipPos.z -= uDepthBias * clipPos.w; // #117 mark-pass bias (see DrawDepthFan)
gl_Position = clipPos;
}";
private const string FragSrc = @"#version 430 core
void main() { } // depth-only: color writes are masked off by the caller state
";
private readonly GL _gl;
private readonly uint _program;
private readonly uint _vao;
private readonly uint _vbo;
private readonly int _locViewProjection;
private readonly int _locPlaneCount;
private readonly int _locPlanes;
private readonly int _locForceFarZ;
private readonly int _locDepthBias;
private const int MaxFanVerts = 32;
private readonly float[] _scratch = new float[MaxFanVerts * 3];
public PortalDepthMaskRenderer(GL gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
uint vs = Compile(ShaderType.VertexShader, VertSrc);
uint fs = Compile(ShaderType.FragmentShader, FragSrc);
_program = _gl.CreateProgram();
_gl.AttachShader(_program, vs);
_gl.AttachShader(_program, fs);
_gl.LinkProgram(_program);
_gl.GetProgram(_program, ProgramPropertyARB.LinkStatus, out int linked);
if (linked == 0)
throw new InvalidOperationException($"PortalDepthMask link failed: {_gl.GetProgramInfoLog(_program)}");
_gl.DeleteShader(vs);
_gl.DeleteShader(fs);
_locViewProjection = _gl.GetUniformLocation(_program, "uViewProjection");
_locPlaneCount = _gl.GetUniformLocation(_program, "uPlaneCount");
_locPlanes = _gl.GetUniformLocation(_program, "uPlanes");
_locForceFarZ = _gl.GetUniformLocation(_program, "uForceFarZ");
_locDepthBias = _gl.GetUniformLocation(_program, "uDepthBias");
_vao = _gl.GenVertexArray();
_vbo = _gl.GenBuffer();
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
unsafe
{
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(MaxFanVerts * 3 * sizeof(float)), null, BufferUsageARB.DynamicDraw);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 3 * sizeof(float), (void*)0);
}
_gl.EnableVertexAttribArray(0);
_gl.BindVertexArray(0);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
}
private uint Compile(ShaderType type, string src)
{
uint s = _gl.CreateShader(type);
_gl.ShaderSource(s, src);
_gl.CompileShader(s);
_gl.GetShader(s, ShaderParameterName.CompileStatus, out int ok);
if (ok == 0)
throw new InvalidOperationException($"PortalDepthMask {type} compile failed: {_gl.GetShaderInfoLog(s)}");
return s;
}
/// <summary>
/// #117 (2026-06-11): the mark-pass depth bias, in NDC, toward the
/// viewer. Retail's punch is DEPTHTEST_ALWAYS and is safe only because
/// retail's outdoor pass is painter's-ordered far→near (anything nearer
/// redraws AFTER the punch and re-covers it). Our z-buffered MDI frame
/// has no such order, so an unconditional far-Z punch erased the depth
/// of NEARER occluders (terrain hills, closer buildings) at aperture
/// pixels — doors/interiors painted through them (the T5 #117 report).
/// The z-buffer-correct equivalent: punch ONLY where the aperture
/// polygon itself wins a depth test at its true depth (two-pass
/// stencil below). The bias keeps the #108 case covered — terrain
/// hugging the door plane (centimeters in front of the aperture) must
/// still be punched; a hill or another house meters nearer must not.
/// 0.0005 NDC ≈ 6 cm at 5 m / ≈ 1 m at 20 m with znear=0.1.
/// </summary>
private const float PunchMarkDepthBias = 0.0005f;
/// <summary>
/// Draw one portal polygon as an invisible depth write, clipped to the
/// slice's clip-space half-planes. <paramref name="forceFarZ"/> selects
/// punch (true, retail maxZ1) vs seal (false, retail maxZ2 true depth).
///
/// <para><b>Seal</b> (interior root): one pass, retail-verbatim —
/// depth ALWAYS + true projected depth. It runs immediately after the
/// gated full depth clear, so there is no nearer content to stomp.</para>
///
/// <para><b>Punch</b> (outdoor root / look-in): two passes (#117).
/// Pass A marks stencil where the aperture fan passes a LEQUAL depth
/// test at its (biased) true depth — i.e. where the aperture is
/// actually visible against everything drawn so far. Pass B writes the
/// far-Z punch with depth ALWAYS but stencil-gated to the marked
/// pixels, and zeroes the stencil as it goes (self-cleaning). This is
/// the z-buffered equivalent of retail's painter's-order safety.</para>
/// </summary>
public void DrawDepthFan(
ReadOnlySpan<Vector3> worldVerts,
in Matrix4x4 viewProjection,
ReadOnlySpan<Vector4> planes,
bool forceFarZ)
{
if (worldVerts.Length < 3)
return;
int n = Math.Min(worldVerts.Length, MaxFanVerts);
int planeCount = Math.Min(planes.Length, 8);
for (int i = 0; i < n; i++)
{
_scratch[i * 3 + 0] = worldVerts[i].X;
_scratch[i * 3 + 1] = worldVerts[i].Y;
_scratch[i * 3 + 2] = worldVerts[i].Z;
}
// ---- set state (everything this draw depends on) ----
_gl.UseProgram(_program);
_gl.Disable(EnableCap.Blend);
_gl.Disable(EnableCap.CullFace); // portal fans face either way
_gl.Disable(EnableCap.ScissorTest);
_gl.Enable(EnableCap.DepthTest);
_gl.ColorMask(false, false, false, false); // alpha-0 fan ≙ no color
for (int i = 0; i < planeCount; i++)
_gl.Enable(EnableCap.ClipDistance0 + i);
unsafe
{
var m = viewProjection;
_gl.UniformMatrix4(_locViewProjection, 1, false, (float*)&m);
_gl.Uniform1(_locPlaneCount, planeCount);
if (planeCount > 0)
{
Span<float> p = stackalloc float[planeCount * 4];
for (int i = 0; i < planeCount; i++)
{
p[i * 4 + 0] = planes[i].X;
p[i * 4 + 1] = planes[i].Y;
p[i * 4 + 2] = planes[i].Z;
p[i * 4 + 3] = planes[i].W;
}
fixed (float* pp = p)
_gl.Uniform4(_locPlanes, (uint)planeCount, pp);
}
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
fixed (float* v = _scratch)
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)(n * 3 * sizeof(float)), v);
if (!forceFarZ)
{
// ── SEAL: retail-verbatim single pass ──
_gl.DepthFunc(DepthFunction.Always);
_gl.DepthMask(true);
_gl.Uniform1(_locForceFarZ, 0);
_gl.Uniform1(_locDepthBias, 0f);
_gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
}
else
{
// ── PUNCH pass A: stencil-mark visible aperture pixels ──
_gl.Enable(EnableCap.StencilTest);
_gl.StencilFunc(StencilFunction.Always, 1, 0xFF);
_gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace);
_gl.StencilMask(0xFF);
_gl.DepthFunc(DepthFunction.Lequal);
_gl.DepthMask(false);
_gl.Uniform1(_locForceFarZ, 0);
_gl.Uniform1(_locDepthBias, PunchMarkDepthBias);
_gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
// ── PUNCH pass B: far-Z write on marked pixels only;
// zero the stencil as we go (self-cleaning) ──
_gl.StencilFunc(StencilFunction.Equal, 1, 0xFF);
_gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Zero);
_gl.DepthFunc(DepthFunction.Always);
_gl.DepthMask(true);
_gl.Uniform1(_locForceFarZ, 1);
_gl.Uniform1(_locDepthBias, 0f);
_gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
_gl.Disable(EnableCap.StencilTest);
}
_gl.BindVertexArray(0);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
}
// ---- restore the frame-global convention ----
for (int i = 0; i < planeCount; i++)
_gl.Disable(EnableCap.ClipDistance0 + i);
_gl.ColorMask(true, true, true, true);
_gl.DepthMask(true);
_gl.DepthFunc(DepthFunction.Less);
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.CW);
_gl.UseProgram(0);
}
public void Dispose()
{
_gl.DeleteProgram(_program);
_gl.DeleteVertexArray(_vao);
_gl.DeleteBuffer(_vbo);
}
}

View file

@ -1,9 +1,11 @@
// PortalProjection.cs
//
// Phase A8.F: project a cell-local portal polygon to NDC screen space, clipping against the
// IN-FRONT-OF-EYE half-space (keep where w > MinW) so a portal straddling the camera does not
// invert under the perspective divide, and the divide stays bounded away from the w=0 eye
// singularity.
// Phase A8.F: project a cell-local portal polygon to NDC screen space. Homogeneous frustum clip
// in CLIP SPACE (before the perspective divide): first the IN-FRONT-OF-EYE half-space (keep where
// w > MinW) so a portal straddling the camera does not invert under the divide and the divide
// stays bounded away from the w=0 eye singularity, then the 4 SIDE planes (x,y within ±w) so every
// surviving vertex lands on the screen [-1,1] by construction. The side-plane clip is the R1
// void-flap fix (2026-06-05) — see ProjectToNdc.
//
// The clip is NEAR-INDEPENDENT on purpose. We only use the projected x/y for the visibility clip
// REGION, so a vertex in front of the eye is meaningful even if it is closer than the projection's
@ -38,10 +40,24 @@ public static class PortalProjection
foreach (var lp in localPoly)
clip.Add(Vector4.Transform(new Vector4(lp, 1f), m));
// Clip against the in-front-of-eye half-space (keep where w > MinW). Near-independent:
// see the file header — clipping at the projection's near plane culls portals the camera
// is standing in (the doorway "void").
clip = ClipBehindEye(clip);
// Homogeneous frustum clip in CLIP SPACE, before the perspective divide. First the
// in-front-of-eye half-space (w > MinW) — near-INDEPENDENT, so a portal the camera is
// standing in still projects (see header); then the 4 SIDE planes (x,y within ±w). The
// side clip is the R1 void-flap fix (2026-06-05): without it, a portal WITHIN the near
// plane projected small-w verts to wildly off-screen NDC (the probe saw (10.2,-67.4)),
// which corrupted the downstream 2D ScreenPolygonClip into an EMPTY region -> OutsideView
// empty -> terrain Skip -> the bluish doorway "void". Clipping the side planes here bounds
// every surviving vertex to the screen [-1,1] by construction, so a screen-covering doorway
// clips to the screen (non-empty) instead of collapsing. The eye plane is clipped FIRST so
// all survivors have w > 0, making the side-plane functionals (w ± x, w ± y) well defined.
// Near/far are intentionally NOT clipped (near-independence). Retail PView::GetClip
// (decomp:0x005a4320) projects + frustum-clips the portal poly likewise (research doc A §3.5).
clip = ClipPlane(clip, v => v.W - MinW); // in front of eye (near-independent)
if (clip.Count < 3) return System.Array.Empty<Vector2>();
clip = ClipPlane(clip, v => v.W + v.X); // left: x/w >= -1 <=> w + x >= 0
clip = ClipPlane(clip, v => v.W - v.X); // right: x/w <= 1 <=> w - x >= 0
clip = ClipPlane(clip, v => v.W + v.Y); // bottom: y/w >= -1 <=> w + y >= 0
clip = ClipPlane(clip, v => v.W - v.Y); // top: y/w <= 1 <=> w - y >= 0
if (clip.Count < 3) return System.Array.Empty<Vector2>();
// Perspective divide → NDC xy.
@ -54,22 +70,206 @@ public static class PortalProjection
return ndc;
}
/// <summary>Faithful homogeneous projection (retail PrimD3DRender::xformStart + the W=0 clip of
/// ACRender::polyClipFinish, decomp 424310 / 702749): transform the portal to clip space and clip
/// ONLY the eye plane (w &gt;= 0, EXACT), keeping homogeneous coords — NO perspective divide, NO
/// frustum side-plane clamp. The screen bound is applied later by <see cref="ClipToRegion"/>
/// against the view region (the root region is the full screen), exactly as retail clips the portal
/// against the accumulated portal_view rather than fixed side planes.
///
/// <para>The W=0 clip is exact on purpose (the knife-edge port, 2026-06-11; pseudocode at
/// docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md): boundary intersections land
/// at w == 0 — homogeneous DIRECTIONS — so a portal the eye is crossing (stair openings, decks)
/// yields the correct UNBOUNDED half-region, which the bounded view-region clip then cuts to the
/// screen. The previous EyePlaneW = 1e-4 produced finite ~1e4-NDC boundary verts whose region
/// intersections sat at the dedup/merge degeneracy threshold — the climb-strobe class. A w=0
/// vertex can never survive ClipToRegion into its divide (a nonzero direction fails at least one
/// edge test of any BOUNDED convex region), so no divide-by-zero path exists; the measure-zero
/// corner case is guarded in ClipToRegion. Matches polyClipFinish part 1: clip pass runs only
/// when some vertex has w &lt; 0; &lt;3 survivors → reject (empty).</para></summary>
public static Vector4[] ProjectToClip(IReadOnlyList<Vector3> localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj)
{
if (localPoly == null || localPoly.Count < 3) return System.Array.Empty<Vector4>();
Matrix4x4 m = cellToWorld * viewProj;
var clip = new List<Vector4>(localPoly.Count);
bool anyBehind = false;
foreach (var lp in localPoly)
{
var v = Vector4.Transform(new Vector4(lp, 1f), m);
if (v.W < 0f) anyBehind = true;
clip.Add(v);
}
// polyClipFinish part 1 (0x006b6d5d): the W pass runs only when some vertex sits behind
// the eye plane (w < 0); an all-in-front polygon passes through untouched (and an
// all-behind one clips to empty inside the pass).
if (anyBehind)
clip = ClipPlane(clip, v => v.W);
return clip.Count >= 3 ? clip.ToArray() : System.Array.Empty<Vector4>();
}
/// <summary>Clip a homogeneous (clip-space) portal polygon against an NDC view region
/// (CCW convex) with w-aware Sutherland-Hodgman edge tests, then divide the survivors to NDC and
/// normalize to CCW. Ports retail ACRender::polyClipFinish's view-region clip (decomp 702749): the
/// edge test multiplies through w (which is &gt; 0 after the eye-plane clip) so it never divides a
/// near-eye vertex, and the final divide runs only on survivors already bounded to the region —
/// stable by construction. Returns &lt;3 verts when the portal does not intersect the region.</summary>
public static Vector2[] ClipToRegion(IReadOnlyList<Vector4> subjectClip, IReadOnlyList<Vector2> regionCcwNdc)
{
if (subjectClip == null || regionCcwNdc == null || subjectClip.Count < 3 || regionCcwNdc.Count < 3)
return System.Array.Empty<Vector2>();
// Homogeneous Sutherland-Hodgman: clip the (w > 0) subject against each CCW edge of the NDC
// region. f(P) below is the NDC inside test cross(edge, P_ndc - a) multiplied through P.W,
// which is > 0 after the eye-plane clip — so the sign is the NDC sign yet no near-eye vertex
// is ever divided (retail polyClipFinish, decomp 702749).
var poly = new List<Vector4>(subjectClip);
int n = regionCcwNdc.Count;
for (int e = 0; e < n; e++)
{
if (poly.Count < 3) return System.Array.Empty<Vector2>();
poly = ClipHomogeneousEdge(poly, regionCcwNdc[e], regionCcwNdc[(e + 1) % n]);
}
if (poly.Count < 3) return System.Array.Empty<Vector2>();
// Divide survivors → NDC. They are inside the region now, so |x| ≤ |w| and |y| ≤ |w|: the
// divide is bounded by construction (this is why the homogeneous clip avoids the early-divide
// blow-up). Normalize to CCW so the result is a valid clip region for the next portal hop.
//
// W=0 port (2026-06-11): with ProjectToClip clipping at exactly w >= 0, a w == 0 vertex
// (a direction) cannot survive the bounded region clip above — a nonzero direction fails at
// least one edge's inside test of any bounded convex region — EXCEPT the measure-zero case
// of a direction lying exactly on a region corner with d == 0 on the adjoining edges. That
// case divides to ±Inf/NaN; treat it as the degenerate knife-edge sliver it is and return
// empty (retail's effective result for the same input: a <1 px degenerate region).
var ndc = new Vector2[poly.Count];
for (int i = 0; i < poly.Count; i++)
{
float w = poly[i].W;
var v = new Vector2(poly[i].X / w, poly[i].Y / w);
if (!float.IsFinite(v.X) || !float.IsFinite(v.Y))
return System.Array.Empty<Vector2>();
ndc[i] = v;
}
// T2 (BR-4): retail's post-divide vertex merge — Render::copy_view
// (Ghidra 0x0054dfc0) collapses consecutive vertices closer than ~1
// PIXEL (|dx|<=1 && |dy|<=1 screen units) after the perspective divide.
// This is the flood's physical fixpoint floor: re-clipping a view can
// only insert sub-pixel sliver vertices, which this merge removes, so
// accumulated views converge instead of drifting (the drift is what
// forced the MaxReprocessPerCell=16 cap). Unit approximation: the
// builder has no viewport, so 1 px is expressed in NDC at a reference
// 1080p (2/1080 ≈ 0.00185); at higher resolutions the merge is merely
// slightly coarser than retail's, which only strengthens convergence.
var merged = MergeSubPixelVertices(ndc);
if (merged.Length < 3)
return System.Array.Empty<Vector2>();
EnsureCcw(merged);
return merged;
}
// Retail copy_view's ~1-pixel vertex merge (see ClipToRegion). Collapses
// runs of consecutive near-identical vertices, including across the
// wrap-around. A polygon that collapses below 3 distinct vertices is
// degenerate (sub-pixel sliver) and returns empty — exactly retail's
// "<3 surviving verts → output count 0".
private const float VertexMergeEpsilonNdc = 2f / 1080f;
private static Vector2[] MergeSubPixelVertices(Vector2[] poly)
{
if (poly.Length < 3) return poly;
var kept = new List<Vector2>(poly.Length);
foreach (var v in poly)
{
if (kept.Count > 0)
{
var prev = kept[^1];
if (MathF.Abs(v.X - prev.X) <= VertexMergeEpsilonNdc
&& MathF.Abs(v.Y - prev.Y) <= VertexMergeEpsilonNdc)
continue;
}
kept.Add(v);
}
// Wrap-around: last ≈ first.
while (kept.Count >= 2)
{
var first = kept[0];
var last = kept[^1];
if (MathF.Abs(first.X - last.X) <= VertexMergeEpsilonNdc
&& MathF.Abs(first.Y - last.Y) <= VertexMergeEpsilonNdc)
kept.RemoveAt(kept.Count - 1);
else
break;
}
return kept.Count == poly.Length ? poly : kept.ToArray();
}
// One Sutherland-Hodgman half-plane against the directed NDC edge a→b, keeping the CCW-inside
// (left) part of a HOMOGENEOUS polygon. Inside test for vertex P (clip space): the NDC cross
// product cross(b-a, P/P.W - a) scaled by P.W (> 0): ex·(P.Y - P.W·a.Y) - ey·(P.X - P.W·a.X) ≥ 0.
// Crossings interpolate in homogeneous coords (perspective-correct), via the shared Lerp.
private static List<Vector4> ClipHomogeneousEdge(List<Vector4> poly, Vector2 a, Vector2 b)
{
var result = new List<Vector4>(poly.Count + 1);
float ex = b.X - a.X, ey = b.Y - a.Y;
for (int i = 0; i < poly.Count; i++)
{
Vector4 cur = poly[i];
Vector4 prev = poly[(i + poly.Count - 1) % poly.Count];
float dCur = ex * (cur.Y - cur.W * a.Y) - ey * (cur.X - cur.W * a.X);
float dPrev = ex * (prev.Y - prev.W * a.Y) - ey * (prev.X - prev.W * a.X);
bool curIn = dCur >= 0f;
bool prevIn = dPrev >= 0f;
if (curIn)
{
if (!prevIn) result.Add(Lerp(prev, cur, dPrev, dCur));
result.Add(cur);
}
else if (prevIn)
{
result.Add(Lerp(prev, cur, dPrev, dCur));
}
}
return result;
}
// Reverse vertex order in place if wound clockwise (signed area < 0). Mirrors the builder's
// EnsureCcw so a clipped region is always CCW for the next hop's ClipToRegion edge test.
private static void EnsureCcw(Vector2[] poly)
{
float area2 = 0f;
for (int i = 0; i < poly.Length; i++)
{
var p = poly[i]; var q = poly[(i + 1) % poly.Length];
area2 += p.X * q.Y - q.X * p.Y;
}
if (area2 < 0f) System.Array.Reverse(poly);
}
// Minimum clip-space w (≈ metres in front of the eye) to keep a vertex. Excludes the eye
// (w=0) singularity and the ~5 cm right at it (bounding the perspective divide), but is
// INTENTIONALLY far closer than the projection's 1.0 m near plane so a doorway the camera is
// standing in still projects and the cell behind it stays visible. See the file header.
private const float MinW = 0.05f;
// Sutherland-Hodgman against the in-front-of-eye half-space: keep where w > MinW.
private static List<Vector4> ClipBehindEye(List<Vector4> poly)
// Sutherland-Hodgman against one half-space of the homogeneous view frustum, in CLIP SPACE.
// `dist` is the signed plane functional (>= 0 keeps the vertex); crossings are interpolated in
// homogeneous coords (perspective-correct). Callers apply the eye plane first so every survivor
// has w > 0, making the side-plane functionals (w ± x, w ± y) well defined.
private static List<Vector4> ClipPlane(List<Vector4> poly, System.Func<Vector4, float> dist)
{
if (poly.Count == 0) return poly;
var result = new List<Vector4>(poly.Count + 1);
for (int i = 0; i < poly.Count; i++)
{
Vector4 cur = poly[i];
Vector4 prev = poly[(i + poly.Count - 1) % poly.Count];
float dCur = cur.W - MinW;
float dPrev = prev.W - MinW;
float dCur = dist(cur);
float dPrev = dist(prev);
bool curIn = dCur >= 0f;
bool prevIn = dPrev >= 0f;

View file

@ -5,6 +5,7 @@
// a cell's clip region is a SET of convex polygons in normalized device coords.
using System.Collections.Generic;
using System.Numerics;
using System.Text;
namespace AcDream.App.Rendering;
@ -40,6 +41,10 @@ public readonly struct ViewPolygon
public sealed class CellView
{
public readonly List<ViewPolygon> Polygons = new();
// Canonical (snapped) keys of the polygons in <see cref="Polygons"/>, backing the drift-tolerant
// dedup in <see cref="Add"/>. One entry per stored polygon; HashSet membership IS the dedup.
private readonly HashSet<string> _polygonKeys = new();
public float MinX { get; private set; } = float.MaxValue;
public float MinY { get; private set; } = float.MaxValue;
public float MaxX { get; private set; } = float.MinValue;
@ -59,13 +64,215 @@ public sealed class CellView
return v;
}
public void Add(ViewPolygon p)
public bool Add(ViewPolygon p)
{
if (p.IsEmpty) return;
if (p.IsEmpty) return false;
// Drift-tolerant, rotation-invariant dedup (2026-06-06 hang fix). PortalVisibilityBuilder.Build
// re-queues a cell every time its CellView GROWS, so the flood only terminates when Add
// recognises a re-clipped region as a duplicate. Across BFS rounds the SAME region returns
// float-drifted, vertex-rotated, and/or with a ±1 vertex count (homogeneous Sutherland-Hodgman +
// EnsureCcw); the old exact index-by-index match (eps 1e-4) caught none of those, so the region
// grew without bound -> O(n^2) CPU-spin hang in this method. We instead key each polygon by its
// vertices SNAPPED to a small NDC grid, consecutive snap-duplicates removed, rotated to a
// canonical start. The snapped key space is finite, so a monotonically-growing CellView is
// bounded and the flood is GUARANTEED to converge. The stored polygon keeps full precision (only
// the key is snapped), so downstream clip geometry is unchanged, and the grid (1e-3 NDC ~ sub-
// pixel) is far finer than the gap between genuinely distinct openings, so real regions never merge.
string? key = CanonicalKey(p.Vertices);
if (key is null) return false; // degenerate after snap (< 3 distinct vertices)
if (!_polygonKeys.Add(key)) return false; // duplicate region (drift / rotation / count tolerant)
// #120 convergence (2026-06-11): reject a polygon CONTAINED in one already
// stored. The reciprocal ping-pong (eye within PortalSideEpsilon of a
// portal plane → BOTH side tests pass → views lap A→B→A…) re-emits, each
// lap, a region that is — in exact arithmetic — a SUBSET of the polygon
// that originated it; near-edge-on apertures make the re-clip wobble by
// more than the 1e-3 key grid, so every lap keyed as "new" and the
// in-place growth recursed to the depth-128 tripwire (chain dumps:
// 0xA9B4015C↔0x0162, 0xA9B30103↔0x010F; Issue120ReciprocalPingPongTests
// reproduces deterministically). Containment rejection makes growth
// strictly area-increasing — no new visible area, no propagation. The
// key stays recorded so the exact emission also short-circuits later.
// Bonus: back-emission into a full-screen view (the root cell) is now
// always rejected outright.
if (ContainedInExisting(p)) return false;
Polygons.Add(p);
if (p.MinX < MinX) MinX = p.MinX;
if (p.MinY < MinY) MinY = p.MinY;
if (p.MaxX > MaxX) MaxX = p.MaxX;
if (p.MaxY > MaxY) MaxY = p.MaxY;
return true;
}
// #120: is polygon p entirely inside ONE stored polygon (with DedupGridNdc
// slack)? Single-polygon containment is sufficient for the ping-pong class —
// a round-trip re-emission descends from exactly one originator. Stored
// polygons are convex (Sutherland-Hodgman / full-screen seed outputs); the
// edge test adapts to either winding via the polygon's signed area.
private bool ContainedInExisting(in ViewPolygon p)
{
const float eps = DedupGridNdc;
for (int i = 0; i < Polygons.Count; i++)
{
var e = Polygons[i];
// bounding-rect quick reject (with slack)
if (p.MinX < e.MinX - eps || p.MaxX > e.MaxX + eps
|| p.MinY < e.MinY - eps || p.MaxY > e.MaxY + eps)
continue;
if (ContainsAllVertices(e.Vertices, p.Vertices, eps))
return true;
}
return false;
}
private static bool ContainsAllVertices(Vector2[] convex, Vector2[] pts, float eps)
{
if (convex.Length < 3) return false;
// signed area → winding (CCW positive); inside = left of every CCW edge.
float area2 = 0f;
for (int i = 0; i < convex.Length; i++)
{
var a = convex[i];
var b = convex[(i + 1) % convex.Length];
area2 += a.X * b.Y - b.X * a.Y;
}
float sign = area2 >= 0f ? 1f : -1f;
for (int i = 0; i < convex.Length; i++)
{
var a = convex[i];
var b = convex[(i + 1) % convex.Length];
var ab = b - a;
float len = ab.Length();
if (len < 1e-9f) continue; // degenerate edge — no constraint
foreach (var pt in pts)
{
// signed perpendicular distance of pt from edge a→b (positive = inside for CCW)
float cross = sign * (ab.X * (pt.Y - a.Y) - ab.Y * (pt.X - a.X));
if (cross < -eps * len)
return false; // a vertex lies outside this edge by more than eps
}
}
return true;
}
// NDC dedup grid. 1e-3 is ~0.5 px at 1080p — finer than the gap between distinct portal openings
// (so real regions stay distinct) yet far coarser than the per-round float drift of a re-clipped
// region (so a drifted duplicate snaps onto its predecessor). The finite grid is what bounds growth.
private const float DedupGridNdc = 1e-3f;
// Canonical key for a view polygon: vertices snapped to the NDC grid, consecutive snap-duplicates
// removed (including wrap-around), COLLINEAR points removed (exact integer cross-products on the
// snapped grid), then rotated to start at the lexicographically smallest vertex so a rotated
// emission of the same cycle yields the same key. Winding is already CCW for every
// builder input (ClipToRegion / EnsureCcw), so the cyclic order is canonical without a reversal step.
//
// W=0 port (2026-06-11): an ALL-COLLINEAR polygon (zero area) keys as its snapped segment
// ("L:" + extreme points) instead of null. A portal whose plane contains the eye projects to
// exactly this — and retail PROPAGATES it: PView::ClipPortals (decomp:433651-433711) forwards
// any GetClip output with count != 0 to copy_view/OtherPortalClip with no area gate anywhere,
// so the neighbour cell stays in the draw list (cells draw whole; onward floods die naturally
// against the zero-area region). Rejecting these views dropped the whole chain behind an
// exactly-in-plane portal for the frame — the parked-eye knife-edge band (tower deck, spiral
// landings). The segment key space is finite like the area-key space, so dedup + the strict
// growth convergence invariant are unchanged. Returns null only when fewer than 2 distinct
// snapped points survive (a true sub-grid point — not a real region OR segment).
//
// §4 corner/doorway fix (2026-06-10) — the collinear pass: the homogeneous region clipper
// (PortalProjection.ClipToRegion, used by the forward AND — as of today — the reciprocal hop)
// legitimately inserts intersection vertices ON a subject edge when a region edge grazes it, so
// BFS re-clip rounds re-emit the SAME geometric region with 1-2 extra collinear edge vertices.
// Without collinear canonicalization those re-emissions key as distinct, defeating the dedup and
// accumulating duplicate polygons (the pre-2026-06-06 unbounded-growth hang in miniature, and the
// exact reason the reciprocal clip was previously parked on the unstable divide-first path).
// Dropping collinear snapped points makes the key purely a function of the region's CORNERS, so
// any re-emission of the same shape — drifted, rotated, vertex-count-inflated — deduplicates.
private static string? CanonicalKey(Vector2[]? verts)
{
if (verts is null || verts.Length < 3) return null;
var pts = new List<(int X, int Y)>(verts.Length);
foreach (var v in verts)
{
var q = ((int)System.MathF.Round(v.X / DedupGridNdc), (int)System.MathF.Round(v.Y / DedupGridNdc));
if (pts.Count == 0 || pts[^1] != q) pts.Add(q);
}
if (pts.Count >= 2 && pts[^1] == pts[0]) pts.RemoveAt(pts.Count - 1);
// Snapshot the distinct snapped points BEFORE collinear removal — the all-collinear
// fallback keys off the segment EXTREMES of the full point set (stable across
// re-emissions regardless of the removal loop's order).
List<(int X, int Y)>? preCollinear = pts.Count >= 2 ? new List<(int, int)>(pts) : null;
// Remove collinear points: for consecutive (prev, cur, next) around the cycle, drop cur when
// cross(cur-prev, next-cur) == 0 — exact in integer grid coordinates (deltas ≤ ~4000, products
// ≤ ~1.6e7, no overflow). Loop to a fixpoint: removing one point can make its neighbour
// collinear. All-collinear inputs reduce below 3 → the segment-key fallback below.
bool removed = true;
while (removed && pts.Count >= 3)
{
removed = false;
for (int i = 0; i < pts.Count && pts.Count >= 3; i++)
{
var prev = pts[(i + pts.Count - 1) % pts.Count];
var cur = pts[i];
var next = pts[(i + 1) % pts.Count];
long cross = (long)(cur.X - prev.X) * (next.Y - cur.Y)
- (long)(cur.Y - prev.Y) * (next.X - cur.X);
if (cross == 0)
{
pts.RemoveAt(i);
removed = true;
i--;
}
}
}
if (pts.Count < 3)
{
// Zero-area (all-collinear) view — key as its snapped segment so retail's
// degenerate-view propagation works (see method doc). Extremes are the
// lexicographic min/max of the full snapped point set.
if (preCollinear is null) return null;
var lo = preCollinear[0];
var hi = preCollinear[0];
foreach (var q in preCollinear)
{
if (q.X < lo.X || (q.X == lo.X && q.Y < lo.Y)) lo = q;
if (q.X > hi.X || (q.X == hi.X && q.Y > hi.Y)) hi = q;
}
if (lo == hi) return null; // a sub-grid point — not a region or a segment
return $"L:{lo.X},{lo.Y};{hi.X},{hi.Y};";
}
int n = pts.Count;
int best = 0;
for (int s = 1; s < n; s++)
if (RotationLess(pts, s, best, n)) best = s;
var sb = new StringBuilder(n * 10);
for (int i = 0; i < n; i++)
{
var q = pts[(best + i) % n];
sb.Append(q.X).Append(',').Append(q.Y).Append(';');
}
return sb.ToString();
}
// True when the rotation of `pts` starting at index a is lexicographically less than the rotation
// starting at b (compare X then Y, vertex by vertex around the cycle). Gives a unique canonical
// start even when two vertices share the minimum snapped coordinate.
private static bool RotationLess(List<(int X, int Y)> pts, int a, int b, int n)
{
for (int i = 0; i < n; i++)
{
var pa = pts[(a + i) % n];
var pb = pts[(b + i) % n];
if (pa.X != pb.X) return pa.X < pb.X;
if (pa.Y != pb.Y) return pa.Y < pb.Y;
}
return false;
}
}

View file

@ -35,7 +35,25 @@ public sealed class PortalVisibilityFrame
public static class PortalVisibilityBuilder
{
private const float PortalSideEpsilon = 0.01f; // matches CellVisibility.PointInCellEpsilon
// Side-classification epsilon. Retail's is F_EPSILON = 0.000199999995
// (const @0x007c8c70; PView::InitCell Ghidra 0x005a4b70). T2 (BR-4)
// attempted the retail value and the CornerFloodReplay gate REFUTED it:
// retail's tight epsilon works because retail's viewer cell transits the
// INSTANT the eye crosses a portal plane (the sweep's curr_cell), so the
// side test never sees a stale root more than F_EPSILON past a plane. Our
// root can lag the eye by up to ~1 cm at pressed corners (the harness's
// fixed-root sweep models this), and 0.01 is that documented root-lag
// tolerance — NOT a retail constant. Tighten to F_EPSILON only together
// with eye-exact viewer-cell tracking verification (the #108-membership
// family) + the cdstW near-clip pin.
private const float PortalSideEpsilon = 0.01f;
// Retail F_EPSILON proper — used where the semantic is knife-edge
// REJECTION (ConstructView(CBldPortal) Sidedness IN_PLANE → return 0,
// Ghidra 0x005a59a0), which must NOT inherit the root-lag tolerance above
// (a 1 cm-wide in-plane band would reject look-in seeds whenever the eye
// stands near a doorway plane).
private const float SeedInPlaneEpsilon = 0.0002f;
// TEMP diagnostic (Phase A8.F visual-gate triage; strip after): ACDREAM_A8_DUMP_PV=1 dumps the
// local→NDC→clipped portal geometry for the first 2 Build calls per distinct camera cell.
@ -43,6 +61,42 @@ public static class PortalVisibilityBuilder
Environment.GetEnvironmentVariable("ACDREAM_A8_DUMP_PV") == "1";
private static readonly Dictionary<uint, int> s_pvDumpCount = new();
/// <summary>
/// #120 observable: total convergence-tripwire firings across both the
/// interior <see cref="Build"/> and the exterior look-in propagation.
/// The tripwire firing means the in-place growth's fixpoint invariant
/// broke (T2/BR-4) — tests reset this and assert it stays 0.
/// </summary>
public static int ConvergenceTripwireCount;
/// <summary>
/// #120 self-attribution dump: the growth-recursion path that exceeded
/// the tripwire, as a per-cell frequency summary plus the chain tail —
/// the cycle's structure (e.g. 0174↔0175 ping-pong vs a 3-cycle lap)
/// reads directly off the output.
/// </summary>
private static void DumpPropagationChain(uint[] chain, int depth, uint rootCellId, Vector3 eye)
{
int n = Math.Min(depth, chain.Length);
var freq = new Dictionary<uint, int>();
for (int i = 0; i < n; i++)
{
freq.TryGetValue(chain[i], out int c);
freq[chain[i]] = c + 1;
}
var summary = new System.Text.StringBuilder(256);
foreach (var kvp in freq)
summary.Append(System.FormattableString.Invariant($" 0x{kvp.Key:X8}x{kvp.Value}"));
var tail = new System.Text.StringBuilder(256);
for (int i = Math.Max(0, n - 24); i < n; i++)
tail.Append(System.FormattableString.Invariant($" 0x{chain[i] & 0xFFFFu:X4}"));
Console.WriteLine(System.FormattableString.Invariant(
$"[pv-ERROR] chain root=0x{rootCellId:X8} eye=({eye.X:F3},{eye.Y:F3},{eye.Z:F3}) cells:{summary}"));
Console.WriteLine($"[pv-ERROR] chain tail(24):{tail}");
}
/// <param name="lookup">Resolve a full cell id to its LoadedCell, or null if not loaded.</param>
/// <param name="buildingMembership">Optional: true if a cell id is in the camera building's cell
/// set. When provided, a neighbour OUTSIDE the set routes to CrossBuildingViews instead of
@ -63,6 +117,18 @@ public static class PortalVisibilityBuilder
frame.CellViews[cameraCell.CellId] = CellView.FullScreen();
// Render unification (outdoor-as-cell, 2026-06-07): when the root IS the synthetic outdoor
// node, the landscape is visible FULL-SCREEN, so seed OutsideView with the full-screen NDC
// quad. ClipFrameAssembler turns that into a full-screen OutsideView slice, so DrawInside's
// DrawLandscapeThroughOutsideView draws terrain/sky/scenery/weather as the node's "shell" —
// the very same callback that already draws the doorway slice when an INTERIOR root reaches
// outdoors. Keyed on the explicit IsOutdoorNode flag (set by OutdoorCellNode.Build), NOT a
// cell-id heuristic: production EnvCell ids are >= 0x100 but test fixtures use low interior
// ids, so an id test would misfire. An interior root never sets this flag, so the indoor
// exit-portal path (OtherCellId==0xFFFF below) still owns the doorway OutsideView region.
if (cameraCell.IsOutdoorNode)
frame.OutsideView.Add(new ViewPolygon((Vector2[])FullScreenQuad.Clone()));
// Distance-priority work list (retail PView::cell_todo_list). Cells pop closest-first;
// each cell carries the camera→nearest-portal-vertex distance that put it on the list
// (retail keys on InitCell's per-portal min-vertex distance, decomp 432988-433004). The
@ -81,7 +147,16 @@ public static class PortalVisibilityBuilder
// the instant a cell is popped). Enqueue-once across the cell set is the hard termination
// guarantee for cyclic / hub / diamond graphs: at most N cells are ever processed. The
// camera cell is pre-marked so a portal looping back to it can never re-enqueue it.
var seen = new HashSet<uint> { cameraCell.CellId };
var queued = new HashSet<uint> { cameraCell.CellId };
var drawListed = new HashSet<uint>();
var processedViewCounts = new Dictionary<uint, int>();
var trace = PortalBuildTrace.Start(cameraCell, cameraPos);
// [portal-churn] apparatus (2026-06-08): when ProbePortalChurnEnabled, accumulate re-enqueue churn
// + reciprocal pre/post region counts, emitted as one summary line at end of Build. Inert when off.
bool churnProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbePortalChurnEnabled;
int churnReenqueues = 0;
var churnReciprocal = churnProbe ? new System.Text.StringBuilder(256) : null;
bool pvDump = false;
if (s_pvDump)
@ -113,65 +188,138 @@ public static class PortalVisibilityBuilder
}
}
while (todo.Count > 0)
{
var cell = todo.PopNearest();
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
continue;
// T2 (BR-4): retail's growth propagation is IN PLACE, never by re-enqueue
// — PView::AddViewToPortals (Ghidra 0x005a52d0, pc:433446): first
// discovery enqueues via InsCellTodoList; growth into a cell whose
// cell_view_done is set calls AdjustCellView (pc:433741-433745), which
// re-clips ONLY the new views (the update_count watermark) through that
// cell's portals immediately. Our processedViewCounts IS that watermark,
// so in-place propagation = call ProcessCellPortals on the grown
// neighbour; it processes exactly the new tail and recurses further
// growth. Termination is physical: recursion fires only when AddRegion
// added a DISTINCT polygon (CanonicalKey dedup) that survived the 1-px
// vertex merge — the finite fixpoint floor that replaced the old
// MaxReprocessPerCell=16 drift cap (deleted). The depth tripwire below
// is a loud failsafe, not control flow: it firing means the convergence
// invariant broke and must be fixed, not tuned.
const int RecursionTripwire = 128;
// #120 self-attribution: the recursion path (cell id per depth), so a
// tripwire firing names the growth CYCLE instead of just the tip.
// Harness sweeps (CornerFloodReplayTests *Converges tests) could not
// reproduce the T5 firing — production-only ingredients (full lookup
// graph / real camera path) are suspected; this dump pins them on the
// next natural occurrence.
var propagationChain = new uint[RecursionTripwire];
// `seen` guarantees each cell is inserted into the todo list exactly once, so this single
// pop IS the cell's closest-first draw position (retail appends to cell_draw_list once per
// pop, 433783) — no per-pop dedup needed, OrderedVisibleCells stays distinct by construction.
frame.OrderedVisibleCells.Add(cell.CellId);
void ProcessCellPortals(LoadedCell cell, int depth)
{
if (depth >= RecursionTripwire)
{
System.Threading.Interlocked.Increment(ref ConvergenceTripwireCount);
Console.WriteLine($"[pv-ERROR] in-place propagation tripwire at depth {depth} on cell=0x{cell.CellId:X8} — convergence invariant broken, investigate");
DumpPropagationChain(propagationChain, depth, cameraCell.CellId, cameraPos);
return;
}
propagationChain[depth] = cell.CellId;
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
{
trace?.Add($"proc cell=0x{cell.CellId:X8} skip=no-view");
return;
}
processedViewCounts.TryGetValue(cell.CellId, out int processedCount);
int endCount = currentView.Polygons.Count;
if (processedCount >= endCount)
{
trace?.Add($"proc cell=0x{cell.CellId:X8} skip=processed processed={processedCount} views={endCount}");
return;
}
trace?.Add($"proc cell=0x{cell.CellId:X8} processed={processedCount}->{endCount} depth={depth}");
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
processedViewCounts[cell.CellId] = endCount;
for (int i = 0; i < cell.Portals.Count; i++)
{
if (i >= cell.PortalPolygons.Count) continue;
var portal = cell.Portals[i];
if (i >= cell.PortalPolygons.Count)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=no-poly-slot");
continue;
}
var poly = cell.PortalPolygons[i];
if (poly == null || poly.Length < 3) continue;
if (poly == null || poly.Length < 3)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=degenerate-poly len={(poly?.Length ?? -1)}");
continue;
}
bool dx = pvDump && cell.Portals[i].OtherCellId == 0xFFFF;
// Portal-side test: only traverse a portal the camera is on the interior side of
// (mirrors CellVisibility.GetVisibleCells + retail's 'seen' flag). Culls back-facing
// portals so we never feed a degenerate/wrong-facing projection downstream.
if (i < cell.ClipPlanes.Count && !CameraOnInteriorSide(cell, i, cameraPos))
// (R-A2b Phase 1 pin, throwaway) Log the side-test inputs for EVERY portal so a back-portal
// traversal (cell=0x..0173 p->0x0171) can be attributed to the side test.
// Strip with the rest of the [pv-trace] apparatus.
if (trace != null)
{
bool camInterior = i >= cell.ClipPlanes.Count || CameraOnInteriorSide(cell, i, cameraPos);
float sideD = (i < cell.ClipPlanes.Count && cell.ClipPlanes[i].Normal.LengthSquared() >= 1e-8f)
? Vector3.Dot(cell.ClipPlanes[i].Normal, Vector3.Transform(cameraPos, cell.InverseWorldTransform)) + cell.ClipPlanes[i].D
: float.NaN;
trace.Add($"sidechk cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} camInterior={camInterior} D={(float.IsNaN(sideD) ? "na" : sideD.ToString("F2"))}");
}
// Portal-side test (retail PView::InitCell side test, decomp:432962): only traverse a portal
// the camera is on the INTERIOR side of. Retail culls the back-facing portal (the doorway just
// flooded through) by this test ALONE — there is NO eye-in-opening bypass. R-A2b: the old
// `&& !eyeInsideOpening` bypass let a back portal within 1.75 m through, forming the
// 0171<->0173 flood cycle -> re-enqueue churn -> the doorway flap (pinned in flap-sidechk.log:
// back portals show camInterior=False eyeIn=True).
if (i < cell.ClipPlanes.Count
&& !CameraOnInteriorSide(cell, i, cameraPos))
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side");
if (dx) Console.WriteLine($"[pv-dump] EXIT-CULLED(side) cell=0x{cell.CellId:X8} p{i} localN={poly.Length} hasClipPlane={(i < cell.ClipPlanes.Count)}");
continue;
}
// Project to NDC, then normalize to CCW for the CCW-only ScreenPolygonClip
// (ProjectToNdc preserves input winding; portal dat polygons may be CW).
Vector2[] portalNdc = PortalProjection.ProjectToNdc(poly, cell.WorldTransform, viewProj);
if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2}) ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F2},{v.Y:F2})"))}]");
if (portalNdc.Length < 3) continue;
EnsureCcw(portalNdc);
// Intersect the portal opening with every polygon of the current cell's view.
var clippedRegion = new List<ViewPolygon>();
foreach (var vp in currentView.Polygons)
{
var clipped = ScreenPolygonClip.Intersect(portalNdc, vp.Vertices);
if (clipped.Length >= 3) clippedRegion.Add(new ViewPolygon(clipped));
}
// Retail PView::ClipPortals calls GetClip(..., finish=1): transform to
// homogeneous clip space, clip at the eye, then clip against the current
// portal_view region before the divide. Do the same here; the old early
// ProjectToNdc + 2D intersect path is too unstable for near/grazing doorways.
var clippedRegion = ClipPortalAgainstView(
poly,
cell.WorldTransform,
viewProj,
activeViewPolygons,
out int clipVerts);
if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipN={clipVerts} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2})");
if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}");
if (clippedRegion.Count == 0) continue; // portal not visible through this chain
var portal = cell.Portals[i];
// Empty clip = no flood through this portal, period — retail's empty-GetClip rule
// (polyClipFinish <3 survivors → reject; ClipPortals adds no view). The
// EyeInsidePortalOpening rescue that used to substitute the current view here was
// the documented compensation for ProjectToClip's old EyePlaneW=1e-4 divergence
// from polyClipFinish's exact W=0 clip; with the W=0 port (2026-06-11, pseudocode
// at docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md) an eye-crossing
// portal projects to its true half-region and the rescue is DELETED.
if (clippedRegion.Count == 0)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=clip-empty clipVerts={clipVerts}");
continue;
}
if (portal.OtherCellId == 0xFFFF)
{
if (pvDump)
{
Console.WriteLine($"[pv-dump] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} clipPolys={clippedRegion.Count}");
Console.WriteLine($"[pv-dump] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipVerts={clipVerts} clipPolys={clippedRegion.Count}");
Console.WriteLine($"[pv-dump] local=[{string.Join(" ", System.Array.ConvertAll(poly, v => $"({v.X:F2},{v.Y:F2},{v.Z:F2})"))}]");
Console.WriteLine($"[pv-dump] ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F3},{v.Y:F3})"))}]");
foreach (var cp in clippedRegion)
Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]");
}
// Exit portal -> outdoors visible through this (clipped) opening.
foreach (var cp in clippedRegion) frame.OutsideView.Add(cp);
AddRegion(frame.OutsideView, clippedRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts}");
continue;
}
@ -184,12 +332,17 @@ public static class PortalVisibilityBuilder
if (buildingMembership != null && !buildingMembership(neighbourId))
{
var xview = GetOrCreate(frame.CrossBuildingViews, neighbourId);
foreach (var cp in clippedRegion) xview.Add(cp);
bool grewCross = AddRegion(xview, clippedRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} crossBldg polys={clippedRegion.Count} grew={grewCross}");
continue;
}
var neighbour = lookup(neighbourId);
if (neighbour == null) continue;
if (neighbour == null)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=lookup-miss polys={clippedRegion.Count}");
continue;
}
// Phase U.2b — neighbour-side OtherPortalClip (retail PView::OtherPortalClip
// decomp:433524). The portal opening seen from THIS cell may be wider than the
@ -204,29 +357,73 @@ public static class PortalVisibilityBuilder
// direct index is what lets a cell with TWO portals to the same neighbour clip each
// opening against its OWN reciprocal instead of the first one. Mutates clippedRegion
// in place before the union below.
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj);
if (clippedRegion.Count == 0) continue; // reciprocal opening doesn't overlap → not visible
// T2 (BR-4): reciprocal-empty culls — retail OtherPortalClip
// returning nothing means the opening is invisible from the
// neighbour's side; the old eye-in-opening restore was part of
// the deleted rescue.
int preReciprocalCount = clippedRegion.Count;
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
if (churnProbe)
churnReciprocal!.Append(System.FormattableString.Invariant(
$" recip[0x{neighbourId:X8} {preReciprocalCount}->{clippedRegion.Count}]"));
if (clippedRegion.Count == 0)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=reciprocal-empty pre={preReciprocalCount} otherPortal={portal.OtherPortalId}");
continue;
}
// Union the clipped region into the neighbour's accumulated view.
var nview = GetOrCreate(frame.CellViews, neighbourId);
foreach (var cp in clippedRegion) nview.Add(cp);
bool grew = AddRegion(nview, clippedRegion);
bool inserted = false;
bool inPlace = false;
float dist = float.NaN;
// Insert the neighbour into the distance-priority list — but ONLY on first discovery
// (retail enqueues via InsCellTodoList solely in the ecx_5==0 branch; growth into an
// already-seen cell is handled in place, never by re-enqueue). `seen` is the
// enqueue-once / `cell_view_done` gate: a neighbour already discovered is never
// re-enqueued, which is what bounds cyclic & hub graphs. Distance = camera→nearest
// portal-opening vertex in world space (retail InitCell min-vertex distance,
// 432988-433004); derived from the portal geometry, so it works even when the cell's
// WorldPosition was never populated.
if (seen.Add(neighbourId))
if (grew)
{
float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
todo.Insert(neighbour, dist);
// First discovery → enqueue once (retail InsCellTodoList in
// the ecx_5==0 branch). Distance = camera→nearest portal-
// opening vertex (retail InitCell min-vertex distance,
// pc:432988-433004).
if (queued.Add(neighbourId))
{
dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
todo.Insert(neighbour, dist);
inserted = true;
}
// Growth into an already-POPPED cell → retail AdjustCellView:
// process only the new views, immediately, in place. A cell
// discovered but still pending in the todo list needs nothing
// — its pop processes everything to date via the watermark.
else if (drawListed.Contains(neighbourId))
{
inPlace = true;
if (churnProbe) churnReenqueues++;
ProcessCellPortals(neighbour, depth + 1);
}
}
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} addCell polys={clippedRegion.Count} clipVerts={clipVerts} recip={preReciprocalCount}->{clippedRegion.Count} grew={grew} queued={inserted} inPlace={inPlace} dist={(float.IsNaN(dist) ? "na" : dist.ToString("F2"))}");
}
}
while (todo.Count > 0)
{
var cell = todo.PopNearest();
// Single pop per cell (enqueue-once) IS the cell's closest-first
// draw position (retail appends to cell_draw_list once per pop,
// pc:433783). Note: retail also RE-SORTS the draw list when a
// late-grown cell's dependency order changes (AdjustCellPlace,
// pc:433247); we keep first-pop order — under T1's whole-cell
// far→near draws + depth testing, order affects only transparent-
// pass compositing in exotic chains (documented residual for T5).
if (drawListed.Add(cell.CellId))
frame.OrderedVisibleCells.Add(cell.CellId);
trace?.Add($"pop cell=0x{cell.CellId:X8} drawPos={frame.OrderedVisibleCells.Count - 1}");
ProcessCellPortals(cell, 0);
}
if (pvDump)
Console.WriteLine($"[pv-dump] OUTSIDEVIEW polys={frame.OutsideView.Polygons.Count} bfsCellViews={frame.CellViews.Count} crossBldg={frame.CrossBuildingViews.Count}");
@ -234,14 +431,326 @@ public static class PortalVisibilityBuilder
// root cell's per-portal side-test + projection + the frame's exit/visible counts.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
EmitFlapProbe(cameraCell, cameraPos, viewProj, frame);
trace?.Emit(frame);
if (churnProbe)
{
// T2: pops are enqueue-once now; churnReenqueues counts retail-style
// IN-PLACE propagations (AdjustCellView equivalents) instead.
Console.WriteLine(System.FormattableString.Invariant(
$"[portal-churn] root=0x{cameraCell.CellId:X8} cells={frame.OrderedVisibleCells.Count} inPlaceProps={churnReenqueues}") + churnReciprocal);
}
return frame;
}
/// <summary>
/// Build a portal visibility frame for an OUTDOOR viewer looking into one or more
/// outside-facing cell portals. This is the reciprocal of <see cref="Build"/>:
/// the seed view is the projected exit-portal opening instead of a full-screen
/// camera cell. It keeps the same retail distance-priority traversal and
/// neighbour reciprocal clipping once inside the building.
/// </summary>
public static PortalVisibilityFrame BuildFromExterior(
IEnumerable<LoadedCell> candidateCells,
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
float maxSeedDistance = float.PositiveInfinity)
{
var frame = new PortalVisibilityFrame();
var todo = new CellTodoList();
var queued = new HashSet<uint>();
var drawListed = new HashSet<uint>();
var processedViewCounts = new Dictionary<uint, int>();
foreach (var cell in candidateCells)
{
if (cell is null) continue;
for (int i = 0; i < cell.Portals.Count; i++)
{
var portal = cell.Portals[i];
if (portal.OtherCellId != 0xFFFF)
continue;
if (i >= cell.PortalPolygons.Count)
continue;
var poly = cell.PortalPolygons[i];
if (poly == null || poly.Length < 3)
continue;
// Exterior peering starts from the OUTSIDE face of an exit portal.
// If the camera is on the cell-interior side, the normal indoor
// DrawInside path owns this portal instead. T2 (BR-4): a seed
// portal the eye is IN-PLANE with (|dist| <= F_EPSILON) rejects
// OUTRIGHT — retail ConstructView(CBldPortal) returns 0 on
// Sidedness IN_PLANE (Ghidra 0x005a59a0); no degenerate view is
// ever built from a knife-edge aperture.
if (i < cell.ClipPlanes.Count)
{
if (CameraOnInteriorSide(cell, i, cameraPos))
continue;
if (EyeInPlaneOfPortal(cell, i, cameraPos))
continue;
}
float seedDistance = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
if (seedDistance > maxSeedDistance)
continue;
var clippedRegion = ClipPortalAgainstView(
poly,
cell.WorldTransform,
viewProj,
FullScreenRegion,
out _);
// T2 (BR-4): empty clip = no seed, no exceptions (retail's
// empty-GetClip rule; the full-screen substitute rescue is
// deleted — see Build()).
if (clippedRegion.Count == 0)
continue;
var seedView = GetOrCreate(frame.CellViews, cell.CellId);
bool grew = AddRegion(seedView, clippedRegion);
if (grew && queued.Add(cell.CellId))
todo.Insert(cell, seedDistance);
}
}
// T2 (BR-4): in-place growth propagation — mirrors Build()'s
// ProcessCellPortals (retail AdjustCellView via the watermark); the
// re-enqueue + MaxReprocessPerCell cap and the eye-in-opening rescues
// are deleted (empty clip culls, period).
const int RecursionTripwire = 128;
var propagationChain = new uint[RecursionTripwire]; // #120 self-attribution — see Build()
void ProcessCellPortals(LoadedCell cell, int depth)
{
if (depth >= RecursionTripwire)
{
System.Threading.Interlocked.Increment(ref ConvergenceTripwireCount);
Console.WriteLine($"[pv-ERROR] look-in in-place propagation tripwire at depth {depth} on cell=0x{cell.CellId:X8} — convergence invariant broken, investigate");
DumpPropagationChain(propagationChain, depth, 0u, cameraPos);
return;
}
propagationChain[depth] = cell.CellId;
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
return;
processedViewCounts.TryGetValue(cell.CellId, out int processedCount);
int endCount = currentView.Polygons.Count;
if (processedCount >= endCount)
return;
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
processedViewCounts[cell.CellId] = endCount;
uint lbMask = cell.CellId & 0xFFFF0000u;
for (int i = 0; i < cell.Portals.Count; i++)
{
if (i >= cell.PortalPolygons.Count)
continue;
var poly = cell.PortalPolygons[i];
if (poly == null || poly.Length < 3)
continue;
var portal = cell.Portals[i];
if (portal.OtherCellId == 0xFFFF)
continue; // already outdoors; exterior terrain was drawn by the caller.
// R-A2b: cull back portals by the side test alone — see Build().
if (i < cell.ClipPlanes.Count
&& !CameraOnInteriorSide(cell, i, cameraPos))
continue;
var clippedRegion = ClipPortalAgainstView(
poly,
cell.WorldTransform,
viewProj,
activeViewPolygons,
out _);
if (clippedRegion.Count == 0)
continue;
uint neighbourId = lbMask | portal.OtherCellId;
var neighbour = lookup(neighbourId);
if (neighbour == null)
continue;
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
if (clippedRegion.Count == 0)
continue;
var nview = GetOrCreate(frame.CellViews, neighbourId);
bool grew = AddRegion(nview, clippedRegion);
if (grew)
{
if (queued.Add(neighbourId))
{
float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
todo.Insert(neighbour, dist);
}
else if (drawListed.Contains(neighbourId))
{
ProcessCellPortals(neighbour, depth + 1);
}
}
}
}
while (todo.Count > 0)
{
var cell = todo.PopNearest();
if (drawListed.Add(cell.CellId))
frame.OrderedVisibleCells.Add(cell.CellId);
ProcessCellPortals(cell, 0);
}
return frame;
}
/// <summary>
/// Retail per-building flood — <c>PView::ConstructView(CBldPortal*, …)</c> (decomp:433827),
/// reached from <c>BSPPORTAL::portal_draw_portals_only</c> (0x53d870) → <c>DrawPortal</c>
/// (0x5a5ab0) during the terrain BSP walk. Floods ONE building's cells from its outside-facing
/// entrance portal(s). Identical machinery to <see cref="BuildFromExterior"/>, but the CONTRACT is
/// per-building: the caller passes exactly one building's cells, so the seed is that building's
/// FINITE entrance opening (bounded flood depth → the stable ~2-cell view retail draws per visible
/// building, measured live §3.4). This differs from the synthetic outdoor node's single unified
/// flood whose full-screen-ish seed reaches variable depth into a building as the eye moves — the
/// 2↔6 oscillation. Robustness is validated by the conformance test, not assumed.
/// </summary>
public static PortalVisibilityFrame ConstructViewBuilding(
IEnumerable<LoadedCell> buildingCells,
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
float maxSeedDistance = float.PositiveInfinity)
=> BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance);
// The NDC [-1,1] viewport quad (CCW), reused by the flap probe's clip recompute.
private static readonly Vector2[] FullScreenQuad =
{ new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) };
private static readonly ViewPolygon[] FullScreenRegion =
{ new ViewPolygon(FullScreenQuad) };
private static List<ViewPolygon> ClipPortalAgainstView(
Vector3[] localPoly,
Matrix4x4 cellToWorld,
Matrix4x4 viewProj,
IReadOnlyList<ViewPolygon> viewPolygons,
out int clipVertexCount)
{
var portalClip = PortalProjection.ProjectToClip(localPoly, cellToWorld, viewProj);
clipVertexCount = portalClip.Length;
var clippedRegion = new List<ViewPolygon>();
if (portalClip.Length < 3)
return clippedRegion;
foreach (var vp in viewPolygons)
{
if (vp.IsEmpty)
continue;
var clipped = PortalProjection.ClipToRegion(portalClip, vp.Vertices);
if (clipped.Length >= 3)
clippedRegion.Add(new ViewPolygon(clipped));
}
return clippedRegion;
}
private const int PortalTraceEmitLimit = 160;
private static readonly object s_portalTraceLock = new();
private static readonly Dictionary<uint, string> s_portalTraceLastSignature = new();
private static int s_portalTraceEmits;
private sealed class PortalBuildTrace
{
private readonly uint _rootCellId;
private readonly Vector3 _eye;
private readonly List<string> _lines = new();
private PortalBuildTrace(uint rootCellId, Vector3 eye)
{
_rootCellId = rootCellId;
_eye = eye;
}
public static PortalBuildTrace? Start(LoadedCell root, Vector3 eye)
{
if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
return null;
if (!IsHoltburgIndoorProbeCell(root.CellId))
return null;
return new PortalBuildTrace(root.CellId, eye);
}
public void Add(string line)
{
if (_lines.Count < 96)
_lines.Add(line);
}
public void Emit(PortalVisibilityFrame frame)
{
string signature = BuildSignature(frame);
lock (s_portalTraceLock)
{
if (s_portalTraceEmits >= PortalTraceEmitLimit)
return;
if (s_portalTraceLastSignature.TryGetValue(_rootCellId, out var last) &&
string.Equals(last, signature, StringComparison.Ordinal))
return;
s_portalTraceLastSignature[_rootCellId] = signature;
s_portalTraceEmits++;
}
Console.WriteLine($"[pv-trace] root=0x{_rootCellId:X8} eye=({_eye.X:F2},{_eye.Y:F2},{_eye.Z:F2}) {signature}");
foreach (var line in _lines)
Console.WriteLine("[pv-trace] " + line);
}
}
private static bool IsHoltburgIndoorProbeCell(uint cellId)
{
if ((cellId & 0xFFFF0000u) != 0xA9B40000u)
return false;
uint low = cellId & 0xFFFFu;
return low >= 0x016F && low <= 0x0175;
}
private static string BuildSignature(PortalVisibilityFrame frame)
{
var sb = new System.Text.StringBuilder(160);
sb.Append("outPolys=").Append(frame.OutsideView.Polygons.Count);
sb.Append(" cells=[");
for (int i = 0; i < frame.OrderedVisibleCells.Count; i++)
{
if (i != 0) sb.Append(',');
sb.Append("0x").Append((frame.OrderedVisibleCells[i] & 0xFFFFu).ToString("X4"));
}
sb.Append("] views=[");
bool first = true;
foreach (var kvp in frame.CellViews)
{
if (!first) sb.Append(',');
first = false;
sb.Append("0x").Append((kvp.Key & 0xFFFFu).ToString("X4")).Append(':').Append(kvp.Value.Polygons.Count);
}
sb.Append(']');
return sb.ToString();
}
// Phase U.4c flap probe. One [flap] line per Build: the root cell's per-portal
// signed distance D (eye→portal plane), traverse/cull decision, and NDC projection
// vertex count, plus the frame's OutsideView polygon count + visible-cell count.
@ -270,10 +779,10 @@ public static class PortalVisibilityBuilder
d = Vector3.Dot(pl.Normal, localEye) + pl.D;
side = CameraOnInteriorSide(cameraCell, i, cameraPos);
}
// Replicate the walk's project → EnsureCcw → Intersect(FullScreen) exactly, so a
// portal that PROJECTS (proj>=3) but still fails to ADD its neighbour shows WHY:
// clip=0 with ndc inside [-1,1] ⇒ winding/self-intersection degeneracy; clip=0 with
// ndc outside [-1,1] ⇒ genuinely off-screen. The ndc coords expose a near-plane bowtie.
// Replicate the walk's faithful path exactly (ProjectToClip → ClipToRegion(FullScreen)) so
// proj/clip mean the same as production: proj = clip-space verts in front of the eye,
// clip = verts surviving the screen-region clip. clip=0 with proj>=3 ⇒ the portal is
// genuinely off-screen; the ndc coords (post-clip, bounded) show where on screen it lands.
int projN = -1, clipN = -1;
string ndcText = "";
if (i < cameraCell.PortalPolygons.Count)
@ -281,12 +790,12 @@ public static class PortalVisibilityBuilder
var poly = cameraCell.PortalPolygons[i];
if (poly != null && poly.Length >= 3)
{
var ndc = PortalProjection.ProjectToNdc(poly, cameraCell.WorldTransform, viewProj);
projN = ndc.Length;
if (ndc.Length >= 3)
var clip = PortalProjection.ProjectToClip(poly, cameraCell.WorldTransform, viewProj);
projN = clip.Length;
if (clip.Length >= 3)
{
EnsureCcw(ndc);
clipN = ScreenPolygonClip.Intersect(ndc, FullScreenQuad).Length;
var ndc = PortalProjection.ClipToRegion(clip, FullScreenQuad);
clipN = ndc.Length;
var ns = new System.Text.StringBuilder(48);
foreach (var v in ndc) ns.Append('(').Append(v.X.ToString("F1")).Append(',').Append(v.Y.ToString("F1")).Append(')');
ndcText = ns.ToString();
@ -305,6 +814,10 @@ public static class PortalVisibilityBuilder
}
// Mirrors CellVisibility's portal-side test (InsideSide convention).
// In-plane (|dot| <= PortalSideEpsilon) counts as interior-side — retail
// InitCell leaves the in-plane case a CANDIDATE for cell portals (Ghidra
// 0x005a4b70); building/exterior SEED portals additionally reject in-plane
// via EyeInPlaneOfPortal (retail ConstructView(CBldPortal) IN_PLANE → 0).
private static bool CameraOnInteriorSide(LoadedCell cell, int portalIndex, Vector3 cameraPos)
{
var plane = cell.ClipPlanes[portalIndex];
@ -314,6 +827,19 @@ public static class PortalVisibilityBuilder
return plane.InsideSide == 0 ? dot >= -PortalSideEpsilon : dot <= PortalSideEpsilon;
}
// T2 (BR-4): retail ConstructView(CBldPortal)'s Sidedness IN_PLANE reject
// (Ghidra 0x005a59a0): |eye·N + d| <= F_EPSILON → the building/exterior
// portal contributes nothing this frame (knife-edge aperture). Uses the
// true retail epsilon, NOT the side test's root-lag tolerance.
private static bool EyeInPlaneOfPortal(LoadedCell cell, int portalIndex, Vector3 cameraPos)
{
var plane = cell.ClipPlanes[portalIndex];
if (plane.Normal.LengthSquared() < 1e-8f) return false;
var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
return MathF.Abs(dot) <= SeedInPlaneEpsilon;
}
// Reverse vertex order in place if the polygon is wound clockwise (signed area < 0).
private static void EnsureCcw(Vector2[] poly)
{
@ -346,26 +872,51 @@ public static class PortalVisibilityBuilder
// (< 3 verts), OR it projects entirely behind the camera. Over-inclusion is the safe default;
// mis-resolution is the bug this method exists to remove. PortalPolygons is in lockstep with
// Portals, so index `otherPortalId` selects the reciprocal polygon. NEVER throws.
// Dat CellPortal flags bit 0 (DatReaderWriter.Enums.PortalFlags.ExactMatch; retail
// CCellPortal.exact_match at +0x14, acclient.h:32300).
private const ushort PortalFlagExactMatch = 0x0001;
private static void ApplyReciprocalClip(
List<ViewPolygon> clippedRegion, ushort otherPortalId, LoadedCell neighbour, Matrix4x4 viewProj)
List<ViewPolygon> clippedRegion, ushort otherPortalId, ushort portalFlags,
LoadedCell neighbour, Matrix4x4 viewProj)
{
if (clippedRegion.Count == 0) return;
// Retail skips OtherPortalClip entirely for exact-match portals — both cells share
// the SAME opening polygon, so re-clipping against the reciprocal can only re-derive
// the near-side clip: PView::ClipPortals decomp:433689
// `if (exact_match != 0 || other_portal_id < 0) goto propagate-without-reciprocal`.
if ((portalFlags & PortalFlagExactMatch) != 0) return;
// Direct back-link index (retail arg2->other_portal_id). Out-of-range → over-include.
if (otherPortalId >= neighbour.PortalPolygons.Count) return;
Vector3[]? reciprocalPoly = neighbour.PortalPolygons[otherPortalId];
if (reciprocalPoly == null || reciprocalPoly.Length < 3) return; // missing/degenerate → over-include
// Project the reciprocal opening through the NEIGHBOUR's transform (retail positionPush(3,
// &other_cell_ptr->pos) at 005a54d2), then normalize winding for the CCW-only clipper.
Vector2[] reciprocalNdc = PortalProjection.ProjectToNdc(reciprocalPoly, neighbour.WorldTransform, viewProj);
if (reciprocalNdc.Length < 3) return; // reciprocal entirely behind camera / degenerate → no-op
EnsureCcw(reciprocalNdc);
// §4 corner/doorway fix (2026-06-10): the reciprocal clip now runs the SAME homogeneous
// pipeline as the forward clip — retail PView::OtherPortalClip (decomp:433524-433563) routes
// the reciprocal polygon through the very same GetClip(finish=1) → ACRender::polyClipFinish
// homogeneous clipper as the near-side portal; there is no divide-first special case.
//
// HISTORY: this used to be ProjectToNdc + 2D ScreenPolygonClip.Intersect, justified by "the
// reciprocal is a back-portal one hop away — never near the eye". That assumption is FALSE
// exactly at doorways/corners: the reciprocal IS the same opening whose plane the eye presses
// against (2-60 cm). ProjectToNdc's MinW=0.05 eye-clip + side-plane clip + divide is knife-edge
// there — 2 cm eye moves flipped its output between "covers the region" and a duplicated-vertex
// hairline, which CellView.Add's snap-dedup then rejected → the neighbour room dropped from the
// flood for isolated frames → the corner/transition background strobe (CornerFloodReplayTests
// pins this deterministically; the glitch steps die with this change). The old path's other
// rationale — per-round float drift defeating the exact-match CellView dedup — is obsolete:
// CanonicalKey's 1e-3-grid snap dedup (2026-06-06) absorbs re-clip drift by construction.
var reciprocalClip = PortalProjection.ProjectToClip(reciprocalPoly, neighbour.WorldTransform, viewProj);
if (reciprocalClip.Length < 3) return; // reciprocal entirely behind the eye → no constraint (over-include)
// Intersect the reciprocal opening into each near-side polygon; drop any that fall away.
// ClipToRegion(subject=homogeneous reciprocal, region=near-side NDC polygon) = the same
// region-edge homogeneous Sutherland-Hodgman the forward hop uses (polyClipFinish port).
for (int k = clippedRegion.Count - 1; k >= 0; k--)
{
var tightened = ScreenPolygonClip.Intersect(reciprocalNdc, clippedRegion[k].Vertices);
var tightened = PortalProjection.ClipToRegion(reciprocalClip, clippedRegion[k].Vertices);
if (tightened.Length >= 3) clippedRegion[k] = new ViewPolygon(tightened);
else clippedRegion.RemoveAt(k);
}
@ -377,6 +928,14 @@ public static class PortalVisibilityBuilder
return v;
}
private static bool AddRegion(CellView view, List<ViewPolygon> region)
{
bool grew = false;
foreach (var poly in region)
grew |= view.Add(poly);
return grew;
}
// Camera→nearest-vertex distance for a portal polygon, in world space. Mirrors the per-portal
// min-distance loop retail runs in PView::InitCell (decomp:432988-433004) to key the todo list:
// it walks the portal's vertices, transforms each to world space, and keeps the smallest

View file

@ -38,8 +38,18 @@ public sealed class RetailChaseCamera : ICamera
public float Aspect { get; set; } = 16f / 9f;
public float FovY { get; set; } = MathF.PI / 3f;
public Matrix4x4 View { get; private set; } = Matrix4x4.Identity;
// Near plane = retail Render::znear = 0.1 m (decomp :342130/:342173/:1101867 —
// Render::SetFOVRad sets 0.1 flat; the legacy set_vdst variant is max(0.1, vdst·0.25)).
// MUST be smaller than the 0.3 m camera-collision sphere (PhysicsCameraCollisionProbe.
// ViewerSphereRadius): with a 1.0 m near, a wall the collided eye sits 0.3 m from
// falls INSIDE the near plane and is clipped away — pressing the camera into a corner
// let you see straight through the wall (§4 corner residual). History: 0.1 landed
// (137b4f2), was reverted (8bd3492) after correlating with missing indoor textures,
// and re-landed once #110 resolved: the textures were the pre-existing #105
// staged-texture-flush drop (WbMeshAdapter.Tick), and 0.1 merely raised its trigger
// probability by making more close-up geometry visible (more uploads in flight).
public Matrix4x4 Projection =>
Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f);
Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 0.1f, 5000f);
// ── Public tunables (per-instance) ──────────────────────────────
@ -77,6 +87,16 @@ public sealed class RetailChaseCamera : ICamera
public const float PitchMin = -0.7f;
public const float PitchMax = 1.4f;
// Retail CameraManager::UpdateCamera convergence-snap thresholds (decomp
// acclient_2013_pseudo_c.txt, 0x00456fcd0x00457035). SnapEpsilon = 2 ×
// 0.000199999995 m ≈ 0.0004 m — the per-frame translation step below which retail
// freezes the boom at an exact fixed point (0x00456fe1). RotCloseEpsilon =
// 0.000199999995 — the Frame::close_rotation tolerance (0x00456fdd). Without the
// snap, Vector3.Lerp asymptotes forever and the boom drifts at rest, walking the eye
// across a portal plane and flipping the viewer cell → the indoor flicker.
private const float SnapEpsilon = 0.000199999995f * 2f;
private const float RotCloseEpsilon = 0.000199999995f;
// ── Damped state ────────────────────────────────────────────────
private readonly Vector3[] _velocityRing = new Vector3[5];
@ -146,8 +166,14 @@ public sealed class RetailChaseCamera : ICamera
{
float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, dt);
float rAlpha = ComputeDampingAlpha(CameraDiagnostics.RotationStiffness, dt);
_dampedEye = Vector3.Lerp(_dampedEye, targetEye, tAlpha);
_dampedForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha));
Vector3 candidateEye = Vector3.Lerp(_dampedEye, targetEye, tAlpha);
Vector3 candidateForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha));
// Retail UpdateCamera convergence snap (0x00456fcd): freeze at an exact fixed
// point once the lerp step is sub-epsilon, instead of dithering forever. This is
// the at-rest flicker fix — see ApplyConvergenceSnap + SnapEpsilon.
(_dampedEye, _dampedForward, _) =
ApplyConvergenceSnap(_dampedEye, _dampedForward, candidateEye, candidateForward);
}
// 5b. Spring-arm collision (A8.F). Retail SmartBox::update_viewer
@ -166,7 +192,7 @@ public sealed class RetailChaseCamera : ICamera
ViewerCellId = cellId;
if (CameraDiagnostics.CollideCamera && CollisionProbe is not null)
{
var swept = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId);
var swept = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId, playerPosition);
publishedEye = swept.Eye;
ViewerCellId = swept.ViewerCellId;
}
@ -369,6 +395,26 @@ public sealed class RetailChaseCamera : ICamera
return a;
}
/// <summary>
/// Retail <c>CameraManager::UpdateCamera</c> convergence snap (decomp 0x00456fcd).
/// After the per-frame lerp, if the translation step from <paramref name="dampedEye"/>
/// to <paramref name="candidateEye"/> is below <see cref="SnapEpsilon"/> AND the
/// rotation step is below <see cref="RotCloseEpsilon"/>, retail returns the input
/// position unchanged — an exact fixed point. Returns <c>frozen=true</c> with the
/// current state in that case; otherwise <c>frozen=false</c> with the candidate.
/// Both conditions are required (retail couples origin + rotation in the snap test),
/// so the boom keeps converging while the heading is still turning.
/// </summary>
internal static (Vector3 eye, Vector3 forward, bool frozen) ApplyConvergenceSnap(
Vector3 dampedEye, Vector3 dampedForward, Vector3 candidateEye, Vector3 candidateForward)
{
bool translationConverged = Vector3.Distance(candidateEye, dampedEye) < SnapEpsilon;
bool rotationConverged = Vector3.Distance(candidateForward, dampedForward) < RotCloseEpsilon;
if (translationConverged && rotationConverged)
return (dampedEye, dampedForward, true); // freeze: exact fixed point
return (candidateEye, candidateForward, false);
}
/// <summary>
/// Low-pass filter for a single mouse axis. Mirrors retail's
/// <c>CameraSet::FilterMouseInput</c>: if last sample was within

View file

@ -0,0 +1,733 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// App-layer port of the retail indoor render orchestration:
/// SmartBox::RenderNormalMode -> RenderDeviceD3D::DrawInside ->
/// PView::DrawInside -> ConstructView -> DrawCells.
/// </summary>
public sealed class RetailPViewRenderer
{
private readonly GL _gl;
private readonly ClipFrame _clipFrame;
private readonly EnvCellRenderer _envCells;
private readonly WbDrawDispatcher _entities;
private static readonly ClipViewSlice NoClipSlice =
new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty<Vector4>());
private readonly HashSet<uint> _oneCell = new(1);
// R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame).
private readonly Dictionary<uint, List<LoadedCell>> _buildingGroups = new();
// T2 (BR-4): retail has NO distance constant on the flood-admission chain
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
// GetClip + GetVisible only). The old 48 m seed cap is replaced by the
// caller's per-building frustum pre-gate on aperture bounds (GameWindow's
// gather); seeds themselves are unbounded.
private const float OutdoorBuildingSeedDistance = float.PositiveInfinity;
public RetailPViewRenderer(
GL gl,
ClipFrame clipFrame,
EnvCellRenderer envCells,
WbDrawDispatcher entities)
{
_gl = gl;
_clipFrame = clipFrame;
_envCells = envCells;
_entities = entities;
}
public RetailPViewFrameResult DrawInside(RetailPViewDrawContext ctx)
{
ArgumentNullException.ThrowIfNull(ctx);
var pvFrame = PortalVisibilityBuilder.Build(
ctx.RootCell,
ctx.ViewerEyePos,
ctx.CellLookup,
ctx.ViewProjection);
// R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge
// the small (~2-cell) per-building views into the frame. Retail reaches building interiors via
// the terrain BSP -> DrawPortal -> ConstructView(CBldPortal) (decomp:326881/433895/433827); the
// land root itself has no portals (it floods nothing into buildings). Per-building seeding is
// robust to the eye's ~36 µm rest jitter where the pre-R-A2 single reverse-portal flood
// oscillated as the chase eye grazed a doorway (the indoor flap).
if (ctx.RootCell.IsOutdoorNode && ctx.NearbyBuildingCells is not null)
MergeNearbyBuildingFloods(ctx, pvFrame);
var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame);
UploadClipFrame(ctx.SetTerrainClipUbo);
// R1: draw EVERY visible cell (retail cell_draw_list), not only the cells the
// assembler handed a clip-slot. This feeds the Prepare filter + entity partition,
// so every visible cell's shell has a prepared batch and seals — killing the grey
// (the old clipAssembly.CellIdToSlot.Keys filter silently dropped slot-less cells).
// Per-slice trim still applies in DrawEnvCellShells (Task 4 makes it self-contained).
var drawableCells = new HashSet<uint>(pvFrame.OrderedVisibleCells);
UseIndoorMembershipOnlyRouting();
_envCells.PrepareRenderBatches(
ctx.ViewProjection,
ctx.CameraWorldPosition,
filter: drawableCells,
centerLbX: ctx.RenderCenterLbX,
centerLbY: ctx.RenderCenterLbY,
renderRadius: ctx.RenderRadius);
var partition = InteriorEntityPartition.Partition(drawableCells, ctx.LandblockEntries);
var result = new RetailPViewFrameResult
{
PortalFrame = pvFrame,
ClipAssembly = clipAssembly,
DrawableCells = drawableCells,
Partition = partition,
};
ctx.EmitDiagnostics?.Invoke(result);
// T1 (fused BR-2/3): retail's frame order — static world, then the
// aperture depth writes, then interior cells WHOLE far→near, then
// per-cell statics, then ALL dynamics last (retail draws objects after
// cells: PView::DrawCells Ghidra 0x005a4840; DrawBuilding 0x0059f2a0).
// The geometric shell chop (gl_ClipDistance crop, 927fd8f/9ce335e) is
// DELETED — retail never clips cell geometry; aperture exactness comes
// from the punch/seal depth writes + the z-buffer, and the dynamics-
// last order is what makes the punch safe (the first BR-2 attempt
// punched after dynamics and erased the player, reverted 88be519).
// T3 (BR-5): retail viewconeCheck — meshes are sphere-CULLED per view,
// never clipped (Ghidra 0x0054c250). Built once per frame from the
// assembled slices + this frame's view-projection.
var viewcone = ViewconeCuller.Build(clipAssembly, ctx.ViewProjection);
// #118: stage assignment for dynamics under an INTERIOR root. Retail
// draws the OUTSIDE world's objects inside the landscape stage —
// PView::DrawCells runs LScape::draw FIRST (pc:432719), then the gated
// full depth clear (pc:432731-432732) and the exit-portal SEALS
// (pc:432785-432786); DrawBlock draws every landcell's objects via
// DrawSortCell (0x005a17c0, pc:430124). A dynamic deferred to our
// single last pass instead z-fails against the seal's true-depth stamp
// the moment it stands beyond the door plane — the house-exit
// clip+vanish (pinned by HouseExitWalkReplayTests). So under an
// interior root: outdoor-classified dynamics draw in the outside
// stage; an indoor dynamic whose sphere STRADDLES an exit portal
// draws in BOTH stages (retail's per-overlapped-cell shadow-part
// draw, DrawBlock pc:430056-430064) so neither body half clips at the
// plane. Outdoor roots keep ALL dynamics in the last pass — our
// z-buffered equivalent of retail's painter-ordered outdoor pass (the
// BR-2 punch-after-dynamics lesson, reverted 88be519).
_outsideStageDynamics.Clear();
if (!ctx.RootCell.IsOutdoorNode)
{
foreach (var e in partition.Dynamics)
{
EntitySphere(e, out var c, out float r);
if (DynamicDrawsInOutsideStage(e.ParentCellId, c, r, drawableCells, ctx.CellLookup))
_outsideStageDynamics.Add(e);
}
}
DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition, viewcone);
UseIndoorMembershipOnlyRouting();
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
DrawEnvCellShells(pvFrame);
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, viewcone);
DrawDynamicsLast(ctx, partition, viewcone, ctx.RootCell.IsOutdoorNode);
return result;
}
// R-A2: group the nearby building cells by BuildingId and run one per-building flood per group
// (retail's per-building ConstructView(CBldPortal)), merging each small view into the frame. The
// grouping dict is reused across frames; inner lists are cleared each frame so a building that left
// the near set simply contributes an empty (skipped) group.
private void MergeNearbyBuildingFloods(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame)
{
foreach (var group in _buildingGroups.Values)
group.Clear();
foreach (var cell in ctx.NearbyBuildingCells!)
{
// R-A2 seam fix: a cell without a BuildingId (unstamped, or outdoor-adjacent with an exit
// portal) must STILL flood — the pre-R-A2 node flood reached it via a reverse portal, so
// dropping it (the original `continue`) left holes at building/terrain seams. Key it by its
// own CellId → a singleton per-entrance flood: a cell with an exit portal seeds from it, a
// cell with none contributes nothing (same as before). BuildingId/CellId key collisions are
// harmless — BuildFromExterior seeds each exit-portal cell in a group independently.
uint groupKey = cell.BuildingId ?? cell.CellId;
if (!_buildingGroups.TryGetValue(groupKey, out var group))
{
group = new List<LoadedCell>();
_buildingGroups[groupKey] = group;
}
group.Add(cell);
}
foreach (var group in _buildingGroups.Values)
{
if (group.Count == 0)
continue;
var buildingFrame = PortalVisibilityBuilder.ConstructViewBuilding(
group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection, OutdoorBuildingSeedDistance);
MergeBuildingFrame(pvFrame, buildingFrame);
}
}
// T2 (BR-4): merge a per-building flood's cells + views into the frame as a
// UNION. Retail accumulates EVERY clipped portal polygon as a new view_poly
// on the cell (Render::copy_view appends + view_count++, Ghidra 0x0054dfc0;
// a cell visible through two apertures holds two views, all consumed
// downstream). The old first-wins (`ContainsKey -> continue`) dropped the
// second building flood's views whenever a cell was already in the frame —
// the multiview-loss-first-wins divergence (a named #109 suspect: per-frame
// winner flips between apertures). CellView.Add dedups exact/collinear
// re-emissions (the dac8f6a CanonicalKey), so unioning is convergent.
// OutsideView is NOT merged — the outdoor root already seeds full-screen
// terrain, and ConstructViewBuilding (BuildFromExterior) leaves OutsideView
// empty (it stops at exit portals once inside the building).
private static void MergeBuildingFrame(PortalVisibilityFrame target, PortalVisibilityFrame src)
{
foreach (uint cellId in src.OrderedVisibleCells)
{
if (!src.CellViews.TryGetValue(cellId, out var srcView))
continue;
if (target.CellViews.TryGetValue(cellId, out var existing))
{
foreach (var p in srcView.Polygons)
existing.Add(p);
continue;
}
target.CellViews[cellId] = srcView;
target.OrderedVisibleCells.Add(cellId);
}
}
private void DrawLandscapeThroughOutsideView(
RetailPViewDrawContext ctx,
ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result partition,
ViewconeCuller viewcone)
{
if (clipAssembly.OutsideViewSlices.Length == 0)
return;
int probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
_clipFrame.SetTerrainClip(slice.Planes);
UploadClipFrame(ctx.SetTerrainClipUbo);
// T3 (BR-5): entities are never hard-clipped — retail viewcone-
// CHECKS each mesh's sphere against the view (Ghidra 0x0054c250)
// and draws it whole. The old per-slice entity clip routing
// (gl_ClipDistance via SetClipRouting) is replaced by the sphere
// pre-filter below; terrain/sky keep their per-slice plane clip.
_entities.ClearClipRouting();
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled)
EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex);
_outdoorStaticScratch.Clear();
foreach (var e in partition.OutdoorStatic)
{
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
_outdoorStaticScratch.Add(e);
}
// #118: outside-stage dynamics ride the landscape pass like retail's
// per-landcell DrawSortCell (DrawBlock 0x005a17c0, pc:430124) — drawn
// BEFORE the depth clear + seals so the seal PROTECTS their pixels in
// the aperture instead of z-killing them. Same per-slice cone test as
// the statics above. Empty under outdoor roots (see DrawInside).
foreach (var e in _outsideStageDynamics)
{
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
_outdoorStaticScratch.Add(e);
}
probeSliceIndex++;
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch));
}
// T1: retail clears the FULL depth buffer ONCE between the outside
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
// Clear gated on portalsDrawnCount; exact gate semantics is a plan
// open question, staged as "any outside slice drawn"), then re-stamps
// every outside-leading portal's TRUE depth (the seals,
// DrawExitPortalMasks). Replaces the old per-slice scissored AABB
// clear (wrong shape, no seal after it).
if (clipAssembly.OutsideViewSlices.Length > 0)
ctx.ClearDepthForInterior?.Invoke();
UseIndoorMembershipOnlyRouting();
}
// §4 flap [clip-route] probe state (2026-06-10, throwaway): print-on-change signature +
// monotonic sequence so held-flap vs healthy frames diff cleanly in one capture.
private string? _lastClipRouteSig;
private long _clipRouteSeq;
private readonly List<uint> _clipRouteCellKeys = new();
// §4 flap apparatus (2026-06-10): the decisive probe between the surviving suspects
// (handoff 2026-06-09 §1). Emits the EXACT clip inputs the landscape pass draws under:
// the outside slice's slot + NDC AABB + planes (CPU side), the region-SSBO bytes decoded
// at that slot (what mesh_modern.vert reads for routed instances), the terrain-UBO head
// (what terrain/sky gate against), and the CellIdToSlot routing table. Fires AFTER
// SetTerrainClip + UploadClipFrame + SetClipRouting, BEFORE DrawLandscapeSlice — so the
// printed bytes are exactly what this slice's draws consume.
private void EmitClipRouteProbe(ClipFrameAssembly clipAssembly, ClipViewSlice slice, int sliceIndex)
{
var sb = new System.Text.StringBuilder(256);
sb.Append(System.FormattableString.Invariant(
$"slice={sliceIndex}/{clipAssembly.OutsideViewSlices.Length} slot={slice.Slot}"));
sb.Append(System.FormattableString.Invariant(
$" ndc=({slice.NdcAabb.X:F3},{slice.NdcAabb.Y:F3},{slice.NdcAabb.Z:F3},{slice.NdcAabb.W:F3})"));
sb.Append(System.FormattableString.Invariant($" planes={slice.Planes.Length}["));
for (int i = 0; i < slice.Planes.Length; i++)
{
var p = slice.Planes[i];
if (i > 0) sb.Append(' ');
sb.Append(System.FormattableString.Invariant($"({p.X:F3},{p.Y:F3},{p.Z:F3},{p.W:F3})"));
}
// CellIdToSlot sorted by cell id so dictionary enumeration order can't fake a change.
sb.Append("] cells={");
_clipRouteCellKeys.Clear();
foreach (uint key in clipAssembly.CellIdToSlot.Keys)
_clipRouteCellKeys.Add(key);
_clipRouteCellKeys.Sort();
for (int i = 0; i < _clipRouteCellKeys.Count; i++)
{
if (i > 0) sb.Append(',');
sb.Append(System.FormattableString.Invariant(
$"0x{_clipRouteCellKeys[i]:X8}:{clipAssembly.CellIdToSlot[_clipRouteCellKeys[i]]}"));
}
sb.Append('}');
// Region-SSBO content decoded at the routed slot, from the packed bytes UploadClipFrame
// just uploaded — slot stride 144: count uint at +0, planes[8] at +16.
var rb = _clipFrame.RegionBytesForTest;
int off = slice.Slot * ClipFrame.CellClipStrideBytes;
if (off >= 0 && off + ClipFrame.CellClipStrideBytes <= rb.Length)
{
uint ssboCount = System.BitConverter.ToUInt32(rb.Slice(off, 4));
sb.Append(System.FormattableString.Invariant($" ssbo[{slice.Slot}]: n={ssboCount}"));
int planeN = (int)System.Math.Min(ssboCount, (uint)ClipFrame.MaxPlanes);
for (int i = 0; i < planeN; i++)
{
int po = off + ClipFrame.CellClipPlanesOffset + i * 16;
float px = System.BitConverter.ToSingle(rb.Slice(po, 4));
float py = System.BitConverter.ToSingle(rb.Slice(po + 4, 4));
float pz = System.BitConverter.ToSingle(rb.Slice(po + 8, 4));
float pw = System.BitConverter.ToSingle(rb.Slice(po + 12, 4));
sb.Append(System.FormattableString.Invariant($" ({px:F3},{py:F3},{pz:F3},{pw:F3})"));
}
}
else
{
sb.Append(System.FormattableString.Invariant(
$" ssbo[{slice.Slot}]: OUT-OF-RANGE len={rb.Length}"));
}
// Terrain-UBO head as uploaded (std140: int count at +0, planes[8] at +16).
var tb = _clipFrame.TerrainBytesForTest;
int uboCount = System.BitConverter.ToInt32(tb.Slice(0, 4));
float u0 = System.BitConverter.ToSingle(tb.Slice(16, 4));
float u1 = System.BitConverter.ToSingle(tb.Slice(20, 4));
float u2 = System.BitConverter.ToSingle(tb.Slice(24, 4));
float u3 = System.BitConverter.ToSingle(tb.Slice(28, 4));
sb.Append(System.FormattableString.Invariant(
$" ubo: n={uboCount} p0=({u0:F3},{u1:F3},{u2:F3},{u3:F3})"));
string sig = sb.ToString();
_clipRouteSeq++;
if (sig == _lastClipRouteSig)
return;
_lastClipRouteSig = sig;
Console.WriteLine($"[clip-route] n={_clipRouteSeq} {sig}");
}
private void DrawExitPortalMasks(
IRetailPViewCellDrawCallbacks ctx,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells)
{
if (ctx.DrawExitPortalMasks is null)
return;
for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = pvFrame.OrderedVisibleCells[i];
if (!drawableCells.Contains(cellId))
continue;
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
ctx.DrawExitPortalMasks(new RetailPViewCellSliceContext(cellId, slice, Array.Empty<WorldEntity>()));
}
}
private void DrawEnvCellShells(PortalVisibilityFrame pvFrame)
{
// T1 (fused BR-2/3): retail DrawCells Loop 2 — every visible cell's
// shell drawn WHOLE, reverse cell_draw_list (far→near), drawn once.
// Retail NEVER clips cell geometry: the production path is the
// prebuilt mesh (DrawEnvCell use_built_mesh, pc:427905; the
// planeMask=0xffffffff legacy submit means skip-all-edges), and
// aperture exactness comes from the punch/seal depth writes + the
// z-buffer + this order. The former gl_ClipDistance chop
// (927fd8f/9ce335e, #114) is deleted with this rewrite.
// Per-cell opaque+transparent keeps the far→near transparent
// compositing the per-cell loop already provided.
UseIndoorMembershipOnlyRouting();
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
{
_oneCell.Clear();
_oneCell.Add(entry.CellId);
_envCells.Render(WbRenderPass.Opaque, _oneCell);
_envCells.Render(WbRenderPass.Transparent, _oneCell);
}
}
// T1: the frame's single LAST entity pass — ALL server-spawned dynamics
// (player, NPCs, doors, items), indoor or out, drawn after the static
// world + punches + interior cells. Depth-tested, never hard-clipped
// (retail draws objects per cell AFTER cells and viewcone-culls them —
// PView::DrawCells epilogue Ghidra 0x005a4840; the sphere-vs-view cull is
// T3). Drawing dynamics last is what makes the aperture punch safe.
// T3 (BR-5): each dynamic is viewcone-culled like retail — sphere vs its
// cell's views; outdoor/unresolved vs the outside views (pass-all under
// the outdoor root's full-screen outside view). A dynamic in a NON-flooded
// room culls HERE — retail never reaches an object whose cell is not in
// the draw list; the partition keeps routing it so the CULL (not the
// visibility set) drops it, exactly retail's shape.
private void DrawDynamicsLast(
IRetailPViewCellDrawContext ctx,
InteriorEntityPartition.Result partition,
ViewconeCuller viewcone,
bool rootIsOutdoor)
{
if (partition.Dynamics.Count == 0)
return;
_dynamicsScratch.Clear();
foreach (var e in partition.Dynamics)
{
EntitySphere(e, out var c, out float r);
bool indoor = InteriorEntityPartition.IsIndoorCellId(e.ParentCellId);
// #118: under an interior root, outdoor-classified dynamics drew in
// the outside stage (pre-clear, seal-protected) — retail draws them
// via LScape::draw's per-landcell DrawSortCell, never in the
// post-seal cell-object epilogue (PView::DrawCells pc:432719 vs
// pc:432878). Drawing them here instead z-fails them against the
// seal. Indoor dynamics (incl. exit-portal straddlers, which drew
// in BOTH stages) stay — this pass is retail's loop C.
if (!rootIsOutdoor && !indoor)
continue;
bool visible = indoor
? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r)
: viewcone.SphereVisibleOutside(c, r);
if (visible)
_dynamicsScratch.Add(e);
}
if (_dynamicsScratch.Count == 0)
return;
UseIndoorMembershipOnlyRouting();
DrawEntityBucket(ctx, _dynamicsScratch, visibleCellIds: null);
// #121: dynamics' attached emitters (portal swirls, creature effects)
// gate through the SAME cone-surviving owner set as their meshes —
// retail draws emitters with the owner object. Before this callback,
// dynamics' emitters fell through EVERY particle filter under the pview
// path (the landscape slice carries outdoor statics + #118 outside-
// stage dynamics; the cell callback carries cell statics; T4 deleted
// the old clipRoot==null global pass from normal frames) — all world
// portals went invisible. Outside-stage dynamics are excluded here:
// their emitters already drew in the landscape slice (alpha-blended
// particles must not double-draw, unlike the depth-idempotent meshes).
if (ctx.DrawDynamicsParticles is not null)
{
_dynamicsParticleScratch.Clear();
foreach (var e in _dynamicsScratch)
if (!_outsideStageDynamics.Contains(e))
_dynamicsParticleScratch.Add(e);
if (_dynamicsParticleScratch.Count > 0)
ctx.DrawDynamicsParticles(_dynamicsParticleScratch);
}
}
private void DrawCellObjectLists(
IRetailPViewCellDrawContext ctx,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells,
InteriorEntityPartition.Result partition,
ViewconeCuller viewcone)
{
// T1: per-cell STATIC object lists only (dat-baked 0x40 statics) —
// dynamics moved to DrawDynamicsLast. Far→near with the cells, after
// the shells (retail DrawCells epilogue: PortalList = cell's views →
// DrawObjCell, Ghidra 0x005a4840). T3 (BR-5): each static's sphere is
// tested against ITS CELL's views (retail viewconeCheck) — the
// statics-through-walls fix: a static whose sphere is outside every
// view of its cell no longer paints through the wall (the cottage
// phantom staircase's draw path).
for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = pvFrame.OrderedVisibleCells[i];
if (!drawableCells.Contains(cellId))
continue;
if (!partition.ByCell.TryGetValue(cellId, out var bucket) || bucket.Count == 0)
continue;
_cellStaticScratch.Clear();
foreach (var e in bucket)
{
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInCell(cellId, c, r))
_cellStaticScratch.Add(e);
}
// BR-2 phantom-site probe (T3-updated): post-viewcone survivors.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled)
EmitPhantomObjsProbe(cellId, _cellStaticScratch.Count);
if (_cellStaticScratch.Count > 0)
{
_oneCell.Clear();
_oneCell.Add(cellId);
UseIndoorMembershipOnlyRouting();
DrawEntityBucket(ctx, _cellStaticScratch, _oneCell);
}
// T3 (BR-5): particles gate through the SAME viewcone as their
// owners — the callback receives the cone-surviving entity set, so
// an emitter attached to a culled static no longer draws through
// the wall (the candle-flames-through-walls fix). Consumed
// synchronously within this iteration (scratch list reuse).
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, _cellStaticScratch));
}
}
// T3 scratch lists (render thread only; cleared per use).
private readonly List<WorldEntity> _outdoorStaticScratch = new();
private readonly List<WorldEntity> _cellStaticScratch = new();
private readonly List<WorldEntity> _dynamicsScratch = new();
// #118: dynamics assigned to the OUTSIDE stage this frame (interior roots
// only) — outdoor-classified + exit-portal straddlers. Cleared per frame.
private readonly List<WorldEntity> _outsideStageDynamics = new();
// #121: cone-surviving dynamics whose emitters draw in the dynamics
// particle pass (survivors minus outside-stage). Cleared per use.
private readonly List<WorldEntity> _dynamicsParticleScratch = new();
/// <summary>
/// #118 stage assignment for a dynamic under an INTERIOR root: does it draw
/// in the OUTSIDE (landscape) stage — before the gated depth clear and the
/// exit-portal seals — like retail's per-landcell object draw
/// (LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell pc:430124, run at
/// the top of PView::DrawCells pc:432719)?
///
/// True for outdoor-classified dynamics (their fragments lie beyond the
/// door plane and would z-fail the seal in the last pass), and for INDOOR
/// dynamics whose sphere straddles an exit-portal plane of their flood-
/// visible cell — retail draws an object once per overlapped shadow cell
/// (DrawBlock pc:430056-430064), so a threshold-straddling body draws in
/// both stages and neither half clips at the plane. Pure — also driven
/// headlessly by HouseExitWalkReplayTests as the ordering contract.
/// </summary>
public static bool DynamicDrawsInOutsideStage(
uint? parentCellId,
Vector3 sphereCenter,
float sphereRadius,
HashSet<uint> drawableCells,
Func<uint, LoadedCell?> cellLookup)
{
if (!InteriorEntityPartition.IsIndoorCellId(parentCellId))
return true;
uint cellId = parentCellId!.Value;
if (!drawableCells.Contains(cellId))
return false; // not in the flood — the last-pass cone cull owns it
var cell = cellLookup(cellId);
if (cell is null)
return false;
var localC = Vector3.Transform(sphereCenter, cell.InverseWorldTransform);
int n = Math.Min(cell.Portals.Count, cell.ClipPlanes.Count);
for (int i = 0; i < n; i++)
{
if (cell.Portals[i].OtherCellId != 0xFFFF)
continue;
var plane = cell.ClipPlanes[i];
if (plane.Normal.LengthSquared() < 1e-8f)
continue;
float dist = Vector3.Dot(plane.Normal, localC) + plane.D;
if (MathF.Abs(dist) < sphereRadius)
return true; // sphere straddles the exit-portal plane
}
return false;
}
// Conservative bounding sphere from the entity's cached AABB — the same
// bounds source the dispatcher's frustum cull uses.
private static void EntitySphere(WorldEntity e, out Vector3 center, out float radius)
{
if (e.AabbDirty)
e.RefreshAabb();
center = (e.AabbMin + e.AabbMax) * 0.5f;
radius = (e.AabbMax - e.AabbMin).Length() * 0.5f;
}
// BR-2 phantom-site probe state: print-on-change per cell so the log stays
// diffable while the condition persists. Throwaway apparatus — strip when
// the #113 phantom residual closes. (The [phantom-shell] half died with
// the T1 chop deletion — shells draw whole, there is no slice state left
// to report.)
private readonly Dictionary<uint, int> _phantomObjsSig = new();
private void EmitPhantomObjsProbe(uint cellId, int bucketCount)
{
if (_phantomObjsSig.TryGetValue(cellId, out var prev) && prev == bucketCount) return;
_phantomObjsSig[cellId] = bucketCount;
Console.WriteLine($"[phantom-objs] cell=0x{cellId:X8} entities={bucketCount} (drawn unclipped, no viewcone)");
}
private static ClipViewSlice[] GetCellSlicesOrNoClip(
ClipFrameAssembly clipAssembly,
uint cellId)
{
if (clipAssembly.CellIdToViewSlices.TryGetValue(cellId, out var slices)
&& slices.Length > 0)
return slices;
return new[] { NoClipSlice };
}
private void UseIndoorMembershipOnlyRouting()
{
// T1: NOTHING in the world passes hard-clips geometry anymore — retail
// viewcone-CHECKS meshes (sphere vs view planes, T3) and never clips
// cell shells (DrawEnvCell draws the whole prebuilt mesh, pc:427905).
// This clears any clip routing left by the landscape slices.
_envCells.SetClipRouting(null);
_entities.ClearClipRouting();
}
private void DrawEntityBucket(
IRetailPViewCellDrawContext ctx,
IReadOnlyList<WorldEntity> bucket,
HashSet<uint>? visibleCellIds)
{
uint lbId = ctx.PlayerLandblockId ?? 0u;
var entry = (lbId, Vector3.Zero, Vector3.Zero,
(IReadOnlyList<WorldEntity>)bucket,
(IReadOnlyDictionary<uint, WorldEntity>?)null);
_entities.Draw(
ctx.Camera,
new[] { entry },
ctx.Frustum,
neverCullLandblockId: ctx.PlayerLandblockId,
visibleCellIds: visibleCellIds,
animatedEntityIds: ctx.AnimatedEntityIds);
}
private void RestoreNoClip(Action<uint> setTerrainClipUbo)
{
_clipFrame.Reset();
UploadClipFrame(setTerrainClipUbo);
UseIndoorMembershipOnlyRouting();
}
private void UploadClipFrame(Action<uint> setTerrainClipUbo)
{
_clipFrame.UploadShared(_gl);
_entities.SetClipRegionSsbo(_clipFrame.RegionSsbo);
_envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo);
setTerrainClipUbo(_clipFrame.TerrainUbo);
}
}
public interface IRetailPViewCellDrawCallbacks
{
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; }
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; }
}
public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks
{
public ICamera Camera { get; }
public FrustumPlanes? Frustum { get; }
public uint? PlayerLandblockId { get; }
public HashSet<uint>? AnimatedEntityIds { get; }
/// <summary>#121: draw the Scene-pass emitters attached to the frame's
/// cone-surviving dynamics (portal swirls, creature effects). Invoked once
/// per frame after the last entity pass with the survivor list.</summary>
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; }
}
public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
{
public required LoadedCell RootCell { get; init; }
/// <summary>R-A2: nearby building cells (BuildingId-tagged) flooded per-building when the root is the
/// outdoor node. Null for interior roots. Grouped by BuildingId inside <see cref="DrawInside"/>.</summary>
public IReadOnlyList<LoadedCell>? NearbyBuildingCells { get; init; }
public required Vector3 ViewerEyePos { get; init; }
public required Matrix4x4 ViewProjection { get; init; }
public required Func<uint, LoadedCell?> CellLookup { get; init; }
public required ICamera Camera { get; init; }
public required Vector3 CameraWorldPosition { get; init; }
public required FrustumPlanes? Frustum { get; init; }
public required uint? PlayerLandblockId { get; init; }
public required HashSet<uint>? AnimatedEntityIds { get; init; }
public required int RenderCenterLbX { get; init; }
public required int RenderCenterLbY { get; init; }
public required int RenderRadius { get; init; }
public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
public required Action<uint> SetTerrainClipUbo { get; init; }
public required Action<RetailPViewLandscapeSliceContext> DrawLandscapeSlice { get; init; }
/// <summary>T1: one full-buffer depth clear between the outside stage and the
/// interior stage (retail PView::DrawCells, Ghidra 0x005a4840). Null for outdoor
/// roots — outdoors the interiors must depth-test against terrain + exteriors and
/// appear only through punched apertures.</summary>
public Action? ClearDepthForInterior { get; init; }
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; }
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; }
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
}
public sealed class RetailPViewFrameResult
{
public required PortalVisibilityFrame PortalFrame { get; init; }
public required ClipFrameAssembly ClipAssembly { get; init; }
public required HashSet<uint> DrawableCells { get; init; }
public required InteriorEntityPartition.Result Partition { get; init; }
}
public readonly record struct RetailPViewLandscapeSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> OutdoorEntities);
public readonly record struct RetailPViewCellSliceContext(
uint CellId,
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> CellEntities);

View file

@ -385,7 +385,11 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
{
var surface = _dats.Get<Surface>(surfaceId);
if (surface is null)
{
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
Console.WriteLine($"[tex-miss] Surface 0x{surfaceId:X8} -> magenta (thread={System.Environment.CurrentManagedThreadId})");
return DecodedTexture.Magenta;
}
// Base1Solid surfaces (and any with OrigTextureId==0) carry a ColorValue
// instead of a texture chain. Overrides are irrelevant here — there's
@ -401,12 +405,20 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
uint surfaceTextureId = origTextureOverride ?? (uint)surface.OrigTextureId;
var surfaceTexture = _dats.Get<SurfaceTexture>(surfaceTextureId);
if (surfaceTexture is null || surfaceTexture.Textures.Count == 0)
{
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
Console.WriteLine($"[tex-miss] SurfaceTexture 0x{surfaceTextureId:X8} (surface 0x{surfaceId:X8}) -> magenta (thread={System.Environment.CurrentManagedThreadId})");
return DecodedTexture.Magenta;
}
uint renderSurfaceId = (uint)surfaceTexture.Textures[0];
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs)
&& !_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
{
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
Console.WriteLine($"[tex-miss] RenderSurface 0x{renderSurfaceId:X8} (surface 0x{surfaceId:X8}) -> magenta (thread={System.Environment.CurrentManagedThreadId})");
return DecodedTexture.Magenta;
}
// Start with the texture's default palette, then apply overlays.
// ACViewer's Render/TextureCache.IndexToColor does the same and never

View file

@ -0,0 +1,135 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// T3 (BR-5): the port of retail's <c>Render::viewconeCheck</c> (Ghidra
/// 0x0054c250) — meshes (characters, statics, emitters) are CULLED per portal
/// view by a bounding-sphere test against the view's edge planes, never
/// clipped. Retail stores each view vertex with its 3D eye-edge plane
/// (<c>view_vertex { Vec2D pt; Plane plane }</c>, acclient.h:32483) and tests
/// the object's drawing sphere against the installed view's plane set;
/// OUTSIDE → skipped (RenderDeviceD3D::DrawMesh per-view loop pc:429290-429310,
/// and the DrawCells per-cell object epilogue, Ghidra 0x005a4840).
///
/// <para>Our views are clip-space half-planes (≤8 per slice,
/// <see cref="ClipPlaneSet"/> output: (nx,ny,0,d) satisfied when
/// nx·Cx + ny·Cy + d·Cw ≥ 0 for clip-space C). Lifting one to world space —
/// the view_vertex.plane analog, a plane through the EYE and the view edge —
/// is one matrix fold: with row-vector convention (System.Numerics),
/// C = world·VP, so C·P = world·(VP·P); L = VP·P (rows of VP dotted with P)
/// is the world-space homogeneous half-plane. Sphere-vs-half-plane keeps the
/// sphere when L.xyz·c + L.w ≥ r·|L.xyz| (not entirely outside).</para>
///
/// <para>A sphere is visible through a SLICE when it is not entirely outside
/// any of the slice's planes (convex region); visible for a CELL when any of
/// the cell's slices passes. A slice with zero planes is pass-all (the
/// NoClipSlice / full-screen outdoor case). A cell with no views culls — in
/// retail an object whose cell is not in the draw list is simply never
/// reached.</para>
/// </summary>
public sealed class ViewconeCuller
{
private readonly Dictionary<uint, Vector4[][]> _cellPlanes = new();
private Vector4[][] _outsidePlanes = Array.Empty<Vector4[]>();
/// <summary>True when the outside view is a full-screen pass-all (the
/// synthetic outdoor root) — every outside-test passes.</summary>
public bool OutsideIsFullScreen { get; private set; }
public static ViewconeCuller Build(ClipFrameAssembly assembly, in Matrix4x4 viewProjection)
{
ArgumentNullException.ThrowIfNull(assembly);
var culler = new ViewconeCuller();
foreach (var (cellId, slices) in assembly.CellIdToViewSlices)
{
var lifted = new Vector4[slices.Length][];
for (int s = 0; s < slices.Length; s++)
lifted[s] = LiftPlanes(slices[s].Planes, viewProjection);
culler._cellPlanes[cellId] = lifted;
}
var outside = assembly.OutsideViewSlices;
var outsideLifted = new Vector4[outside.Length][];
bool fullScreen = false;
for (int s = 0; s < outside.Length; s++)
{
outsideLifted[s] = LiftPlanes(outside[s].Planes, viewProjection);
if (outside[s].Planes.Length == 0)
fullScreen = true;
}
culler._outsidePlanes = outsideLifted;
culler.OutsideIsFullScreen = fullScreen;
return culler;
}
private static Vector4[] LiftPlanes(Vector4[] clipPlanes, in Matrix4x4 m)
{
if (clipPlanes.Length == 0)
return Array.Empty<Vector4>();
var world = new Vector4[clipPlanes.Length];
for (int i = 0; i < clipPlanes.Length; i++)
{
var p = clipPlanes[i];
world[i] = new Vector4(
m.M11 * p.X + m.M12 * p.Y + m.M13 * p.Z + m.M14 * p.W,
m.M21 * p.X + m.M22 * p.Y + m.M23 * p.Z + m.M24 * p.W,
m.M31 * p.X + m.M32 * p.Y + m.M33 * p.Z + m.M34 * p.W,
m.M41 * p.X + m.M42 * p.Y + m.M43 * p.Z + m.M44 * p.W);
}
return world;
}
private static bool SphereInsidePlanes(Vector4[] planes, in Vector3 center, float radius)
{
for (int i = 0; i < planes.Length; i++)
{
var l = planes[i];
float nLen = MathF.Sqrt(l.X * l.X + l.Y * l.Y + l.Z * l.Z);
if (nLen < 1e-12f)
continue; // degenerate plane — no constraint
float dist = l.X * center.X + l.Y * center.Y + l.Z * center.Z + l.W;
if (dist < -radius * nLen)
return false; // entirely outside this edge plane
}
return true;
}
/// <summary>Sphere-vs-the-cell's-views: visible when any slice passes.
/// A cell with no views culls (not in the draw list ⇒ never reached in
/// retail). A zero-plane slice is pass-all.</summary>
public bool SphereVisibleInCell(uint cellId, in Vector3 center, float radius)
{
if (!_cellPlanes.TryGetValue(cellId, out var slices))
return false;
for (int s = 0; s < slices.Length; s++)
if (SphereInsidePlanes(slices[s], center, radius))
return true;
return false;
}
/// <summary>Sphere-vs-the-outside-views (objects in outdoor space seen
/// from an interior root through doorways; pass-all under the outdoor
/// root's full-screen outside view).</summary>
public bool SphereVisibleOutside(in Vector3 center, float radius)
{
if (OutsideIsFullScreen)
return true;
for (int s = 0; s < _outsidePlanes.Length; s++)
if (SphereInsidePlanes(_outsidePlanes[s], center, radius))
return true;
return false;
}
/// <summary>Sphere vs ONE outside slice (the landscape pass draws per
/// slice; its statics pre-filter tests against exactly that slice).</summary>
public bool SphereVisibleInOutsideSlice(int sliceIndex, in Vector3 center, float radius)
{
if ((uint)sliceIndex >= (uint)_outsidePlanes.Length)
return false;
return SphereInsidePlanes(_outsidePlanes[sliceIndex], center, radius);
}
}

View file

@ -149,6 +149,17 @@ internal sealed class DatDatabaseWrapper : IDatDatabase
_cache.TryAdd((typeof(T), fileId), value);
return true;
}
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix):
// a miss for an id whose BTree entry EXISTS is always an anomaly —
// either Unpack returned false or the lookup flickered transiently.
// Legit not-found probes (e.g. Portal→HighRes fallback) stay silent.
if (_db.Tree.TryGetFile(fileId, out _))
{
Console.WriteLine(
$"[dat-miss] {typeof(T).Name} 0x{fileId:X8} entry EXISTS but TryGet failed " +
$"(thread={Environment.CurrentManagedThreadId})");
}
}
return false;

View file

@ -12,12 +12,19 @@ namespace AcDream.App.Rendering.Wb;
/// <b>Key composition:</b> entries are keyed by the tuple
/// <c>(EntityId, LandblockHint)</c>, NOT by <c>EntityId</c> alone. Issue #53
/// uncovered that <c>entity.Id</c> is NOT globally unique across all
/// static-entity hydration paths: scenery (<c>0x80LLBB00 + localIndex</c>)
/// and interior cells (<c>0x40LLBB00 + localCounter</c>) overflow at >256
/// static-entity hydration paths: scenery (<c>0x80XXYY00 + localIndex</c>)
/// and interior cells (<c>0x40XXYY00 + localCounter</c>, X-byte fixed
/// 2026-06-11 — it used to be discarded entirely, #119) overflow at >256
/// items per landblock, wrapping into the <c>lbY</c> byte and producing
/// cross-LB collisions in dense forest/urban LBs outside Holtburg. Keying
/// by the tuple is correct-by-construction regardless of any hydration
/// path's id strategy.
/// by the tuple is correct-by-construction ONLY when the hint identifies the
/// entity's OWNING landblock — callers must derive it via
/// <c>WbDrawDispatcher.ResolveCacheLandblockHint</c> (the entity's
/// ParentCellId landblock when present, canonicalized <c>0xXXYYFFFF</c>),
/// never a call-site landblock. The #119 "broken stairs + water barrel" was
/// exactly this: the bucket draw path hinted every entity with the PLAYER's
/// landblock, so colliding ids from different landblocks shared a key and
/// served each other's batches.
/// </para>
///
/// <para>

View file

@ -349,6 +349,12 @@ public sealed unsafe class EnvCellRenderer : IDisposable
lock (lb.Lock)
{
// TEMP diagnostic #105 (strip with fix): a registration landing AFTER
// this landblock was already finalized starts a fresh pending list that
// only commits if ANOTHER finalize arrives — and that one will REPLACE
// (not merge) the committed set. One-shot per landblock per pending list.
if (lb.InstancesReady && lb.PendingInstances is null)
Console.WriteLine($"[late-register] lb=0x{landblockId:X8} cell=0x{envCellId:X8} registered AFTER finalize — starting a new pending list ({(lb.Instances?.Count ?? 0)} already committed)");
lb.PendingInstances ??= new List<EnvCellSceneryInstance>(capacity: 32);
lb.PendingInstances.Add(cellInstance);
lb.PendingEnvCellBounds ??= new Dictionary<uint, WbBoundingBox>();
@ -403,6 +409,13 @@ public sealed unsafe class EnvCellRenderer : IDisposable
{
if (lb.PendingInstances is not null)
{
// TEMP diagnostic #105 (strip with fix): REPLACE semantics — if a
// previous finalize already committed instances for this landblock,
// this swap DISCARDS them in favor of the new pending set. A partial
// pending set (finalize racing a still-registering promote job)
// silently loses buildings.
if (lb.Instances is { Count: > 0 })
Console.WriteLine($"[finalize-replace] lb=0x{landblockId:X8} DISCARDING {lb.Instances.Count} committed instances, replacing with {lb.PendingInstances.Count} pending");
lb.Instances = lb.PendingInstances;
lb.PendingInstances = null;
}
@ -1003,31 +1016,17 @@ public sealed unsafe class EnvCellRenderer : IDisposable
int passIdx = (int)renderPass;
if (passIdx < 0 || passIdx > 2) return;
// §4 outdoor full-world flap (2026-06-10): hoisted from below the SSBO uploads.
// Without the global VAO nothing can draw, and returning AFTER the pass state
// was established leaked it (same early-out shape as the totalDraws==0 leak —
// see the comment on the state-establish block below).
var globalVao = _meshManager.GlobalBuffer?.VAO ?? 0u;
if (globalVao == 0) return;
// WB BaseObjectRenderManager.cs:715-716:
shader.Use();
shader.SetInt("uFilterByCell", 0);
// Phase U.4 ROOT-CAUSE FIX (cell-shell "transparent walls / only bluish
// background, flickering when moving"): establish this pass's BLEND + DepthMask
// state OURSELVES rather than inheriting it. Render(Opaque) runs right after the
// terrain draw (which sets neither) and after particles / last frame's transparent
// pass — so whatever left GL_BLEND enabled made the OPAQUE shells composite their
// (often sub-1.0 alpha) wall textures against the bluish clear color (terrain is
// Skip'd indoors), toggling with per-frame ordering → flicker. Mirror the working
// WbDrawDispatcher passes (Disable(Blend)+DepthMask(true) opaque;
// Enable(Blend)+DepthMask(false) transparent). Restored to opaque defaults at the
// end of the draw loop so a Transparent pass can't leak into later draws.
if (renderPass == WbRenderPass.Transparent)
{
_gl.Enable(EnableCap.Blend);
_gl.DepthMask(false);
}
else
{
_gl.Disable(EnableCap.Blend);
_gl.DepthMask(true);
}
// WB BaseObjectRenderManager.cs:718-740: group batches by CullMode + additive flag.
var batchesByCullMode = new Dictionary<int, List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)>>();
int totalDraws = 0;
@ -1065,6 +1064,35 @@ public sealed unsafe class EnvCellRenderer : IDisposable
// WB BaseObjectRenderManager.cs:743:
if (totalDraws == 0) return;
// Phase U.4 ROOT-CAUSE FIX (cell-shell "transparent walls / only bluish
// background, flickering when moving"): establish this pass's BLEND + DepthMask
// state OURSELVES rather than inheriting it. Mirror the working WbDrawDispatcher
// passes (Disable(Blend)+DepthMask(true) opaque; Enable(Blend)+DepthMask(false)
// transparent). Restored to opaque defaults at the end of the draw loop so a
// Transparent pass can't leak into later draws.
//
// §4 outdoor full-world flap fix (2026-06-10): this block MOVED below the
// totalDraws==0 early-out above. It used to run before the batch grouping, so a
// Transparent pass over a cell whose batches are ALL opaque (a plain cottage
// interior) set Blend-on/DepthMask-off and then returned at the count check
// WITHOUT reaching the restore. The frame ended with dmask=0; the NEXT frame's
// glClear(DEPTH) silently no-oped (depth clears honor glDepthMask), every world
// fragment failed GL_LESS against its own previous-frame depth ghost, and the
// whole screen dropped to the fog-tinted clear color — onset-locked to the
// building-flood merge (the first frame a flooded building shell draws), holding
// until camera rotation dropped the cell from the flood. From here down every
// path reaches the end-of-pass restore.
if (renderPass == WbRenderPass.Transparent)
{
_gl.Enable(EnableCap.Blend);
_gl.DepthMask(false);
}
else
{
_gl.Disable(EnableCap.Blend);
_gl.DepthMask(true);
}
// WB BaseObjectRenderManager.cs:745-759: resize buffers if needed.
if (totalDraws > _mdiCommandCapacity)
{
@ -1186,8 +1214,8 @@ public sealed unsafe class EnvCellRenderer : IDisposable
}
// WB BaseObjectRenderManager.cs:807-818: bind VAO + SSBOs + barrier.
var globalVao = _meshManager.GlobalBuffer?.VAO ?? 0u;
if (globalVao == 0) return;
// (globalVao validated at the top of the method — a return here would leak the
// pass state established above.)
if (_currentVao != globalVao)
{
_gl.BindVertexArray(globalVao);

View file

@ -41,6 +41,16 @@ namespace AcDream.App.Rendering.Wb {
public ulong BindlessClampHandle { get; private set; }
public long TotalSizeInBytes => CalculateTotalSize();
/// <summary>
/// #105 diagnostic: staged layer updates (PBO writes + pending list) not yet
/// applied to the GL texture by <see cref="ProcessDirtyUpdates"/>. Layers with
/// a pending update sample UNDEFINED content (TexStorage3D contents) until the
/// flush runs — a stuck non-zero count at standstill is the white-walls mechanism.
/// </summary>
public int PendingUpdateCount {
get { lock (_mipmapLock) { return _pendingUpdates.Count; } }
}
public ManagedGLTextureArray(OpenGLGraphicsDevice graphicsDevice, TextureFormat format, int width, int height,
int size, ILogger logger, TextureParameters? texParams = null) {
var p = texParams ?? TextureParameters.Default;

View file

@ -301,6 +301,23 @@ namespace AcDream.App.Rendering.Wb {
}
}
/// <summary>
/// #105 diagnostic: counts staged-but-unflushed texture layer updates across all
/// shared atlases (see <see cref="ManagedGLTextureArray.PendingUpdateCount"/>).
/// Render thread only — <c>_globalAtlases</c> is render-thread-owned.
/// </summary>
public (int PendingUpdates, int ArraysWithPending, int TotalArrays) GetPendingTextureUpdateStats() {
int pending = 0, arraysWith = 0, total = 0;
foreach (var atlasList in _globalAtlases.Values) {
foreach (var atlas in atlasList) {
total++;
int p = atlas.TextureArray.PendingUpdateCount;
if (p > 0) { arraysWith++; pending += p; }
}
}
return (pending, arraysWith, total);
}
/// <summary>
/// Decrement reference count and unload GPU resources if no longer needed.
/// </summary>
@ -382,7 +399,7 @@ namespace AcDream.App.Rendering.Wb {
/// Phase 1 (Background Thread): Prepare CPU-side mesh data for deduplicated EnvCell geometry.
/// </summary>
public Task<ObjectMeshData?> PrepareEnvCellGeomMeshDataAsync(ulong geomId, uint environmentId, ushort cellStructure, List<ushort> surfaces, CancellationToken ct = default) {
if (HasRenderData(geomId)) return Task.FromResult<ObjectMeshData?>(null);
if (IsDisposed || HasRenderData(geomId)) return Task.FromResult<ObjectMeshData?>(null);
// Check CPU cache first
lock (_cpuMeshCache) {
@ -403,6 +420,11 @@ namespace AcDream.App.Rendering.Wb {
_preparationTasks[geomId] = task;
lock (_pendingRequests) {
if (IsDisposed) {
tcs.TrySetCanceled();
_preparationTasks.TryRemove(geomId, out _);
return task;
}
// Special handling for EnvCell geometry - we need to store the cell data for the worker
_pendingEnvCellRequests[geomId] = new EnvCellGeomRequest {
EnvironmentId = environmentId,
@ -420,7 +442,7 @@ namespace AcDream.App.Rendering.Wb {
}
public Task<ObjectMeshData?> PrepareMeshDataAsync(ulong id, bool isSetup, CancellationToken ct = default) {
if (HasRenderData(id)) return Task.FromResult<ObjectMeshData?>(null);
if (IsDisposed || HasRenderData(id)) return Task.FromResult<ObjectMeshData?>(null);
// Check CPU cache first
lock (_cpuMeshCache) {
@ -452,6 +474,11 @@ namespace AcDream.App.Rendering.Wb {
_preparationTasks[id] = task;
lock (_pendingRequests) {
if (IsDisposed) {
tcs.TrySetCanceled();
_preparationTasks.TryRemove(id, out _);
return task;
}
_pendingRequests.Add((id, isSetup, tcs, ct));
if (_activeWorkers < MaxParallelLoads) {
_activeWorkers++;
@ -471,7 +498,9 @@ namespace AcDream.App.Rendering.Wb {
CancellationToken ct;
lock (_pendingRequests) {
if (_pendingRequests.Count == 0) {
// IsDisposed re-check: lets Dispose() drain the queue and
// observe _activeWorkers reach 0 before the dats unmap.
if (IsDisposed || _pendingRequests.Count == 0) {
return;
}
@ -488,10 +517,25 @@ namespace AcDream.App.Rendering.Wb {
if (_dats.Portal.TryGet<DatReaderWriter.DBObjs.Environment>(envId, out var environment)) {
if (environment.Cells.TryGetValue(req.CellStructure, out var cellStruct)) {
data = PrepareCellStructMeshData(id, cellStruct, req.Surfaces, Matrix4x4.Identity, CancellationToken.None);
// TEMP diagnostic #105 (strip with fix): a null prep here means
// this deduplicated cell geometry will NEVER render anywhere.
if (data == null)
Console.WriteLine($"[geom-null] prepare-null geom=0x{id:X10} env=0x{envId:X8} cs=0x{req.CellStructure:X4}");
}
else {
Console.WriteLine($"[geom-null] cellstruct-missing geom=0x{id:X10} env=0x{envId:X8} cs=0x{req.CellStructure:X4}");
}
}
else {
Console.WriteLine($"[geom-null] env-read-failed geom=0x{id:X10} env=0x{envId:X8}");
}
}
else {
// TEMP diagnostic #105 (strip with fix): an EnvCell geom id (bit 33)
// whose pending request vanished gets misrouted to the generic path,
// where its hash-derived low bits resolve to nothing -> silent null.
if ((id & 0x2_0000_0000UL) != 0)
Console.WriteLine($"[geom-misroute] envcell geom 0x{id:X10} had no pending request — generic path will null it");
// If it's a direct setup or gfxobj, make sure background loads don't abort half-way
data = PrepareMeshData(id, isSetup, CancellationToken.None);
}
@ -671,6 +715,16 @@ namespace AcDream.App.Rendering.Wb {
var renderData = UploadGfxObjMeshData(meshData);
if (renderData == null) {
// 0-vertex mesh: every polygon was gated out at extraction. #119
// (2026-06-11) dat-verified this is LEGITIMATE for all-no-draw
// models (all polys NoPos + Base1Solid surfaces — retail's
// skipNoTexture never draws them either; 0x010002B4/0x010008A8
// are this class, Issue119UpNullGfxObjDumpTests). The empty
// cache is the correct terminal state for those. The line stays
// as a tripwire for the OTHER way to get here (extraction
// dropped textured polys — a real defect; dat-verify with the
// dump test before treating as one).
Console.WriteLine($"[up-null] 0x{meshData.ObjectId:X10} produced a 0-vertex mesh — caching empty render data (legitimate for all-no-draw models; dat-verify via Issue119UpNullGfxObjDumpTests)");
renderData = new ObjectRenderData();
}
@ -941,6 +995,27 @@ namespace AcDream.App.Rendering.Wb {
}
}
/// <summary>
/// #113: the set of polygon ids referenced by the GfxObj's drawing BSP —
/// the polys retail actually renders (D3DPolyRender traverses the BSP;
/// dictionary-orphaned polys are physics/no-draw geometry). Returns null
/// when the model has no drawing BSP (caller draws everything).
/// </summary>
internal static HashSet<ushort>? CollectDrawingBspPolygonIds(GfxObj gfxObj) {
if (gfxObj.DrawingBSP?.Root is null) return null;
var ids = new HashSet<ushort>();
CollectDrawingBspPolygonIds(gfxObj.DrawingBSP.Root, ids);
return ids;
}
private static void CollectDrawingBspPolygonIds(DatReaderWriter.Types.DrawingBSPNode node, HashSet<ushort> ids) {
if (node.Polygons is not null)
foreach (var pid in node.Polygons)
ids.Add((ushort)pid);
if (node.PosNode is not null) CollectDrawingBspPolygonIds(node.PosNode, ids);
if (node.NegNode is not null) CollectDrawingBspPolygonIds(node.NegNode, ids);
}
private ObjectMeshData? PrepareGfxObjMeshData(ulong id, GfxObj gfxObj, Vector3 scale, CancellationToken ct) {
var vertices = new List<VertexPositionNormalTexture>();
var UVLookup = new Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort>();
@ -949,8 +1024,29 @@ namespace AcDream.App.Rendering.Wb {
var (min, max) = ComputeBounds(gfxObj, scale);
var boundingBox = new BoundingBox(min, max);
foreach (var poly in gfxObj.Polygons.Values) {
// #113 (2026-06-11): retail draws a GfxObj by TRAVERSING its drawing
// BSP — a polygon present in the Polygons dictionary but referenced by
// no DrawingBSP node is never rendered (physics/no-draw geometry).
// The Holtburg meeting hall (0x010014C3) keeps its walkable exterior
// stair-ramp as dictionary polys {0,1}: in the PhysicsBSP (NPCs walk
// it) but absent from every DrawingBSP node — retail shows a plain
// wall; iterating the dictionary draws the "phantom staircase"
// (invisible-but-walkable in retail, visible in acdream). The hill
// cottage (0x01000827) carries 8 such orphans.
//
// ⚠️ FILTER NOT APPLIED (e46d3d9 un-applied same day): naively
// filtering to CollectDrawingBspPolygonIds(gfxObj) made DOORS
// disappear across Holtburg (user gate 2026-06-11) — the naive
// PosNode/NegNode walk evidently misses polys some models reference
// another way (portal-type nodes? leaf indexing? DatReaderWriter
// parse gap?). Diagnose with the histogram fact in
// Issue113PhantomStairsDumpTests on a door GfxObj BEFORE re-landing.
// The full retail draw is BSP-TRAVERSAL ORDER drawing, not a
// dictionary iteration with a filter — see the holistic port handoff
// docs/research/2026-06-11-building-render-holistic-port-handoff.md.
foreach (var polyEntry in gfxObj.Polygons) {
ct.ThrowIfCancellationRequested();
var poly = polyEntry.Value;
if (poly.VertexIds.Count < 3) continue;
// Handle Positive Surface
@ -972,7 +1068,11 @@ namespace AcDream.App.Rendering.Wb {
if (surfaceIdx < 0 || surfaceIdx >= gfxObj.Surfaces.Count) return;
var surfaceId = gfxObj.Surfaces[surfaceIdx];
if (!_dats.Portal.TryGet<Surface>(surfaceId, out var surface)) return;
if (!_dats.Portal.TryGet<Surface>(surfaceId, out var surface)) {
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
Console.WriteLine($"[tex-skip] gfxobj Surface 0x{surfaceId:X8} miss -> poly batch dropped (obj 0x{gfxObj.Id:X8})");
return;
}
int texWidth, texHeight;
byte[] textureData;
@ -1133,6 +1233,8 @@ namespace AcDream.App.Rendering.Wb {
sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT5)));
}
else {
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
Console.WriteLine($"[tex-skip] gfxobj SurfaceTexture 0x{surface.OrigTextureId:X8} miss -> poly batch dropped (surface 0x{surfaceId:X8})");
return;
}
@ -1291,21 +1393,24 @@ namespace AcDream.App.Rendering.Wb {
ct.ThrowIfCancellationRequested();
if (poly.VertexIds.Count < 3) continue;
// Handle Positive Surface
if (!poly.Stippling.HasFlag(StipplingType.NoPos)) {
AddSurfaceToBatch(poly, poly.PosSurface, false);
// Retail D3DPolyRender::ConstructMesh (0x0059dfa0) treats this
// DatReaderWriter "CullMode" as CPolygon::sides_type, not as a
// GL cull enum: 0 = pos, 1 = pos twice with reversed winding,
// 2 = pos + neg surface. The DAT-side NoPos/NoNeg flags still
// suppress hidden portal/cap faces before they reach our mesh.
bool hasPos = !poly.Stippling.HasFlag(StipplingType.NoPos);
bool hasNeg = !poly.Stippling.HasFlag(StipplingType.NoNeg);
if (hasPos)
AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: false, reverseWinding: false);
if (hasPos && poly.SidesType == CullMode.None) {
AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: true, reverseWinding: true);
}
else if (hasNeg && poly.SidesType == CullMode.Clockwise) {
AddSurfaceToBatch(poly, poly.NegSurface, useNegUv: true, invertNormal: true, reverseWinding: false);
}
// Handle Negative Surface
bool hasNeg = poly.Stippling.HasFlag(StipplingType.Negative) ||
poly.Stippling.HasFlag(StipplingType.Both) ||
(!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise);
if (hasNeg) {
AddSurfaceToBatch(poly, poly.NegSurface, true);
}
void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool isNeg) {
void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool useNegUv, bool invertNormal, bool reverseWinding) {
if (surfaceIdx < 0) return;
uint surfaceId;
@ -1317,7 +1422,11 @@ namespace AcDream.App.Rendering.Wb {
return;
}
if (!_dats.Portal.TryGet<Surface>(surfaceId, out var surface)) return;
if (!_dats.Portal.TryGet<Surface>(surfaceId, out var surface)) {
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
Console.WriteLine($"[tex-skip] cellstruct Surface 0x{surfaceId:X8} miss -> WALL poly batch dropped (cellstruct 0x{cellStruct:X4})");
return;
}
int texWidth, texHeight;
byte[] textureData;
@ -1342,6 +1451,8 @@ namespace AcDream.App.Rendering.Wb {
var renderSurfaceId = surfaceTexture.Textures.First();
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var renderSurface)) {
if (!_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out var hrRenderSurface)) {
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
Console.WriteLine($"[tex-skip] cellstruct RenderSurface 0x{renderSurfaceId:X8} miss (portal+highres) -> WALL poly batch dropped");
return;
}
renderSurface = hrRenderSurface;
@ -1451,6 +1562,8 @@ namespace AcDream.App.Rendering.Wb {
}
}
else {
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
Console.WriteLine($"[tex-skip] cellstruct SurfaceTexture 0x{surface.OrigTextureId:X8} miss -> WALL poly batch dropped (surface 0x{surfaceId:X8})");
return;
}
@ -1499,7 +1612,17 @@ namespace AcDream.App.Rendering.Wb {
// Helper for CellStruct vertices
bool batchHasWrappingUVs = batch.HasWrappingUVs;
BuildCellStructPolygonIndices(poly, cellStruct, UVLookup, vertices, batch.Indices, isNeg, transform, ref batchHasWrappingUVs);
BuildCellStructPolygonIndices(
poly,
cellStruct,
UVLookup,
vertices,
batch.Indices,
useNegUv,
invertNormal,
reverseWinding,
transform,
ref batchHasWrappingUVs);
batch.HasWrappingUVs = batchHasWrappingUVs;
}
}
@ -1516,8 +1639,10 @@ namespace AcDream.App.Rendering.Wb {
}
private void BuildCellStructPolygonIndices(Polygon poly, CellStruct cellStruct,
Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort> UVLookup,
List<VertexPositionNormalTexture> vertices, List<ushort> indices, bool useNegSurface, Matrix4x4 transform, ref bool hasWrappingUVs) {
Dictionary<(ushort vertId, ushort uvIdx, bool invertNormal), ushort> UVLookup,
List<VertexPositionNormalTexture> vertices, List<ushort> indices,
bool useNegUv, bool invertNormal, bool reverseWinding,
Matrix4x4 transform, ref bool hasWrappingUVs) {
var polyIndices = new List<ushort>();
@ -1525,9 +1650,9 @@ namespace AcDream.App.Rendering.Wb {
ushort vertId = (ushort)poly.VertexIds[i];
ushort uvIdx = 0;
if (useNegSurface && poly.NegUVIndices != null && i < poly.NegUVIndices.Count)
if (useNegUv && poly.NegUVIndices != null && i < poly.NegUVIndices.Count)
uvIdx = poly.NegUVIndices[i];
else if (!useNegSurface && poly.PosUVIndices != null && i < poly.PosUVIndices.Count)
else if (poly.PosUVIndices != null && i < poly.PosUVIndices.Count)
uvIdx = poly.PosUVIndices[i];
if (!cellStruct.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue;
@ -1536,7 +1661,7 @@ namespace AcDream.App.Rendering.Wb {
uvIdx = 0;
}
var key = (vertId, uvIdx, useNegSurface);
var key = (vertId, uvIdx, invertNormal);
if (!hasWrappingUVs) {
var uvCheck = vertex.UVs.Count > 0
@ -1553,7 +1678,7 @@ namespace AcDream.App.Rendering.Wb {
: Vector2.Zero;
var normal = Vector3.Normalize(Vector3.TransformNormal(vertex.Normal, transform));
if (useNegSurface) {
if (invertNormal) {
normal = -normal;
}
@ -1568,18 +1693,18 @@ namespace AcDream.App.Rendering.Wb {
polyIndices.Add(idx);
}
if (useNegSurface) {
if (reverseWinding) {
for (int i = 2; i < polyIndices.Count; i++) {
indices.Add(polyIndices[0]);
indices.Add(polyIndices[i - 1]);
indices.Add(polyIndices[i]);
indices.Add(polyIndices[i - 1]);
indices.Add(polyIndices[0]);
}
}
else {
for (int i = 2; i < polyIndices.Count; i++) {
indices.Add(polyIndices[i]);
indices.Add(polyIndices[i - 1]);
indices.Add(polyIndices[0]);
indices.Add(polyIndices[i - 1]);
indices.Add(polyIndices[i]);
}
}
}
@ -1945,7 +2070,39 @@ namespace AcDream.App.Rendering.Wb {
public void Dispose() {
if (IsDisposed) return;
IsDisposed = true;
// Quiesce the background decode workers BEFORE returning: the owner
// disposes the DatCollection right after this adapter chain, which
// unmaps the dats' memory-mapped views. A worker still inside
// MemoryMappedBlockAllocator.ReadBlock at that point dereferences the
// dead view pointer — an uncatchable, process-fatal AccessViolation
// (dat-race investigation 2026-06-09). Setting IsDisposed under the
// queue lock publishes it to workers, which re-check it before every
// dequeue; draining the queue means each worker exits after at most
// its current (millisecond-scale) item.
lock (_pendingRequests) {
IsDisposed = true;
foreach (var (id, _, tcs, _) in _pendingRequests) {
tcs.TrySetCanceled();
_preparationTasks.TryRemove(id, out _);
}
_pendingRequests.Clear();
_pendingEnvCellRequests.Clear();
}
var deadline = System.Environment.TickCount64 + 10_000;
while (System.Environment.TickCount64 < deadline) {
lock (_pendingRequests) {
if (_activeWorkers == 0) break;
}
Thread.Sleep(5);
}
lock (_pendingRequests) {
if (_activeWorkers > 0)
_logger.LogError(
"Dispose: {Count} mesh-decode workers still active after 10s — dat teardown may race in-flight reads",
_activeWorkers);
}
_graphicsDevice.QueueGLAction(gl => {
foreach (var data in _renderData.Values) {
if (!_useModernRendering) {

View file

@ -144,8 +144,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// each Draw. When _clipRoutingActive is false (the U.3 path / outdoor root /
// no portal frame), every instance maps to slot 0 (no-clip) and no instance is
// culled — identical to U.3. When active, each instance's slot is resolved by
// ResolveEntitySlot per the U.4 policy (live-dynamic unclipped; cell statics to
// their cell slot; outdoor scenery to the OutsideView slot; non-visible culled).
// ResolveEntitySlot per the U.4 policy (cell-owned entities to their cell slot;
// outdoor-owned entities to OutsideView; non-visible/unresolved indoors culled).
private bool _clipRoutingActive;
private IReadOnlyDictionary<uint, int>? _cellIdToSlot;
private int _outdoorSlot;
@ -244,6 +244,28 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private int _instancesIssued;
private long _lastLogTick;
// #128 self-heal: per-Draw dedup of point-of-use load re-requests
// (PrepareMeshDataAsync is idempotent while pending — the dedup just
// avoids redundant dictionary probes within one pass) + the once-per-id
// [mesh-miss] diagnostic set (never cleared; diag-gated emission).
private readonly HashSet<ulong> _missRequested = new();
private readonly HashSet<ulong> _missLogged = new();
// #119 decisive probe (2026-06-11): ACDREAM_DUMP_ENTITY one-shot entity
// dump. Keyed by entity Id; the stored signature re-emits the header line
// whenever (MeshRefs count, cache batch count, zero-translation count,
// culled) changes — e.g. the Tier-1 populate landing one frame after the
// first slow-path draw. The full per-part listing prints only on first
// sight. Inert (one Count==0 check per new entity) when the env var is
// unset. Render-thread only.
private readonly Dictionary<uint, (int MeshRefCount, int CacheBatches, int ZeroT, bool Culled)> _entityDumpSig = new();
// Rate limiter for [dump-entity] WALK-REJECT lines: a rejected entity
// re-tests every frame; emit the first rejection per entity then every
// 300th (~5 s at 60 fps). Static because WalkEntitiesInto is static;
// render-thread only like the walk itself.
private static readonly Dictionary<uint, int> _walkRejectCounts = new();
// CPU + GPU timing for [WB-DIAG] under ACDREAM_WB_DIAG=1.
private readonly System.Diagnostics.Stopwatch _cpuStopwatch = new();
private readonly long[] _cpuSamples = new long[256]; // microseconds
@ -257,6 +279,19 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private const int GpuQueryRingDepth = 3;
private readonly uint[] _gpuQueryOpaque = new uint[GpuQueryRingDepth];
private readonly uint[] _gpuQueryTransparent = new uint[GpuQueryRingDepth];
// #125: a glGenQueries name does not become a QUERY OBJECT until its first
// glBeginQuery — GetQueryObject on a never-begun name is GL_INVALID_OPERATION.
// The N.6 ring assumed ONE Draw per frame with both passes always non-empty;
// the pview pipeline issues MANY small Draws per frame (landscape slices,
// per-cell buckets, dynamics), where zero-draw passes routinely skip
// BeginQuery. Under ACDREAM_WB_DIAG=1 the slot read then queued an
// InvalidOperation EVERY frame — silently, until WB's diligent texture-path
// glGetError checks ate the stale errors and treated their own successful
// uploads as failures ([wb-error] + sticky drop) and ProcessDirtyUpdates'
// check threw (process death; tower-wbdiag3.log). Track which slots were
// actually begun and only read those.
private readonly bool[] _gpuQueryOpaqueBegun = new bool[GpuQueryRingDepth];
private readonly bool[] _gpuQueryTransparentBegun = new bool[GpuQueryRingDepth];
private int _gpuQueryFrameIndex;
private readonly long[] _gpuSamples = new long[256]; // microseconds
private int _gpuSampleCursor;
@ -310,8 +345,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// Phase U.4: install the per-frame clip-slot routing for an INDOOR root.
/// Call once per frame BEFORE <see cref="Draw"/> when the camera's root cell is
/// non-null; the next <see cref="Draw"/> resolves each instance's binding=3
/// clip slot via the U.4 policy (live-dynamic unclipped, cell statics to their
/// cell slot, outdoor scenery to the OutsideView slot, non-visible culled).
/// clip slot via the U.4 policy (cell-owned entities to their cell slot,
/// outdoor-owned entities to OutsideView, non-visible/unresolved indoors culled).
/// Pair with <see cref="ClearClipRouting"/> on outdoor-root frames so the
/// dispatcher reverts to the U.3 no-clip-everything behavior.
/// </summary>
@ -343,6 +378,52 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_outdoorVisible = false;
}
// §4 flap [clip-route-disp] probe state (2026-06-10, throwaway): print-on-change
// signature + monotonic sequence + reusable histogram. See RenderingDiagnostics
// .ProbeClipRouteEnabled for the full probe contract.
private string? _lastClipRouteDispSig;
private long _clipRouteDispSeq;
private readonly SortedDictionary<uint, int> _clipRouteHist = new();
// §4 flap apparatus (2026-06-10): per-slot instance histogram as staged for binding=3.
// grp.Slots is laid out 1:1 with grp.Matrices (binding=0), so this IS the slot content
// the GPU reads per instance — if outdoor instances land on the wrong slot (or vanish
// into cullEnt) when the building flood merges, this line shows it directly.
private void EmitClipRouteDispatchProbe(int culledEntities)
{
_clipRouteHist.Clear();
int total = 0;
foreach (var grp in _groups.Values)
{
var slots = grp.Slots;
for (int i = 0; i < slots.Count; i++)
{
_clipRouteHist.TryGetValue(slots[i], out int c);
_clipRouteHist[slots[i]] = c + 1;
total++;
}
}
var sb = new System.Text.StringBuilder(128);
sb.Append(System.FormattableString.Invariant(
$"outdoorSlot={_outdoorSlot} outdoorVis={(_outdoorVisible ? 'Y' : 'n')} inst={total} cullEnt={culledEntities} slots={{"));
bool first = true;
foreach (var kv in _clipRouteHist)
{
if (!first) sb.Append(',');
first = false;
sb.Append(System.FormattableString.Invariant($"{kv.Key}:{kv.Value}"));
}
sb.Append('}');
string sig = sb.ToString();
_clipRouteDispSeq++;
if (sig == _lastClipRouteDispSig)
return;
_lastClipRouteDispSig = sig;
Console.WriteLine($"[clip-route-disp] n={_clipRouteDispSeq} {sig}");
}
// Phase U.4 CULL sentinel returned by ResolveEntitySlot: the entity's instances
// are dropped entirely (not emitted into the binding=0 instance buffer NOR the
// binding=3 slot buffer), matching the existing frustum / visible-cell cull.
@ -354,12 +435,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// Phase U.4: resolve the clip slot for one entity per the slot/gate policy.
/// Returns <see cref="ClipSlotCull"/> to drop the entity's instances entirely.
/// <list type="bullet">
/// <item>ServerGuid != 0 (live dynamic: player / NPC / items / doors) ⇒ slot 0
/// (UNCLIPPED — retail draws live-dynamic unclipped; depth only).</item>
/// <item>ParentCellId != null (cell static) ⇒ the cell's slot, or CULL when the
/// cell isn't in <paramref name="cellIdToSlot"/> (not visible / nothing-visible).</item>
/// <item>ParentCellId == null (outdoor scenery / building shell) ⇒ the OutsideView
/// slot when <paramref name="outdoorVisible"/>, else CULL.</item>
/// <item>Indoor ParentCellId: the cell's slot, or CULL when hidden.</item>
/// <item>Outdoor ParentCellId or ParentCellId == null static scenery: the OutsideView slot
/// when <paramref name="outdoorVisible"/>, else CULL.</item>
/// <item>ServerGuid != 0 with ParentCellId == null: CULL while routing is active.</item>
/// </list>
/// Only called when <c>_clipRoutingActive</c> (indoor root). On the U.3 / outdoor
/// path every instance is slot 0 and nothing is culled — see
@ -385,20 +464,37 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
int outdoorSlot,
bool outdoorVisible)
{
// Live-dynamic entities render unclipped regardless of cell — retail draws
// the player / NPCs / dropped items through the depth buffer without portal
// clipping. ServerGuid is the live-dynamic marker (0 for dat-hydrated).
if (serverGuid != 0)
return 0;
// Live-dynamic entities are not a global indoor overlay. When they
// have current cell ownership, route them through the same visible
// cell/OutsideView graph as every other object. Parentless live objects
// are unresolved indoors, so cull them while clip routing is active.
if (parentCellId is uint parentCell)
return cellIdToSlot.TryGetValue(parentCell, out int slot) ? slot : ClipSlotCull;
{
if (IsIndoorCellId(parentCell))
{
if (!cellIdToSlot.ContainsKey(parentCell))
return ClipSlotCull;
return cellIdToSlot[parentCell];
}
return outdoorVisible ? outdoorSlot : ClipSlotCull;
}
if (serverGuid != 0)
return ClipSlotCull;
// Outdoor scenery / building shell (no ParentCellId). Indoor root: gate to
// the OutsideView slot, or cull when nothing outdoors is visible.
return outdoorVisible ? outdoorSlot : ClipSlotCull;
}
private static bool IsIndoorCellId(uint cellId)
{
uint low = cellId & 0xFFFFu;
return low >= 0x0100u && low != 0xFFFFu;
}
/// <summary>
/// Phase U.4: the call-site clip-slot decision for one entity, returning the
/// <c>(Slot, Culled)</c> pair the per-entity loop body consumes. Wraps
@ -581,6 +677,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (!cellInVis)
{
if (shellScoped) result.BuildingShellAnchorReject++;
MaybeEmitWalkRejectDump(entity, "visibleCellIds-miss");
if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
&& indoorProbeState!.ShouldEmit(cellProbeId))
{
@ -606,6 +703,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (!aabbVisible)
{
MaybeEmitWalkRejectDump(entity, "frustum");
if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
&& indoorProbeState!.ShouldEmit(cellProbeId))
{
@ -638,6 +736,116 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
}
}
/// <summary>
/// #119 ROOT-CAUSE FIX (2026-06-11): the Tier-1 cache hint must identify the
/// entity's OWNING landblock, not the Draw call's tuple landblock.
/// <c>RetailPViewRenderer.DrawEntityBucket</c> fabricates its tuple with the
/// PLAYER's landblock id, so every bucket entity that frame shared one hint —
/// and colliding entity ids from different landblocks (the pre-fix
/// <c>0x40YYFF00</c> interior namespace discarded the landblock X byte) mapped
/// to the SAME cache key and served each other's batches: the AAB3 tower's
/// 43-part staircase drew a 1-part entity's 3 zero-RestPose batches
/// (captured live, tower-dump-launch1.log) — the session-sticky "broken
/// stairs + water barrel". Interior statics carry their owning cell; derive
/// the hint from it, canonicalized to the same <c>0xXXYYFFFF</c> key format
/// the streaming entries and <see cref="EntityClassificationCache.InvalidateLandblock"/>
/// use — which also makes owner-unload invalidation actually hit these
/// entries (bucket-hinted entries were previously orphaned forever).
/// Entities without a ParentCellId (outdoor stabs / scenery / building
/// shells via GpuWorldState entries) keep the tuple id, which IS their
/// owner on those paths.
/// </summary>
internal static uint ResolveCacheLandblockHint(WorldEntity entity, uint tupleLandblockId)
=> entity.ParentCellId is uint pc ? ((pc & 0xFFFF0000u) | 0xFFFFu) : tupleLandblockId;
/// <summary>
/// #119 decisive probe: rate-limited <c>[dump-entity] WALK-REJECT</c> line
/// for an <c>ACDREAM_DUMP_ENTITY</c>-targeted entity that the walk filtered
/// out (visibleCellIds gate / per-entity frustum). Absence of any DRAW dump
/// plus presence of these lines attributes "entity exists but never reaches
/// the draw loop" to the specific gate. Inert when the target set is empty.
/// </summary>
private static void MaybeEmitWalkRejectDump(WorldEntity entity, string reason)
{
var targets = RenderingDiagnostics.DumpEntitySourceIds;
if (targets.Count == 0 || !targets.Contains(entity.SourceGfxObjOrSetupId)) return;
_walkRejectCounts.TryGetValue(entity.Id, out int n);
_walkRejectCounts[entity.Id] = n + 1;
if (n % 300 != 0) return;
Console.WriteLine(
$"[dump-entity] WALK-REJECT id=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} " +
$"reason={reason} parentCell=0x{(entity.ParentCellId ?? 0u):X8} " +
$"pos=({entity.Position.X:F2},{entity.Position.Y:F2},{entity.Position.Z:F2}) n={n + 1}");
}
/// <summary>
/// #119 decisive probe: per-entity state dump at draw time for
/// <c>ACDREAM_DUMP_ENTITY</c>-targeted entities. First sight prints a
/// header + every MeshRef's GfxObj id, part-transform translation, and
/// loaded flag; afterwards a compact header re-emits only when the
/// (meshRefs, cacheBatches, zeroTranslations, culled) signature changes.
/// Discriminates H-A (hydration-time MeshRef corruption: translations
/// collapsed to ~zero / missing parts) from H-B (Tier-1 cache holding a
/// partial or stale batch set) from H-C (both healthy ⇒ draw-side compose).
/// </summary>
private void MaybeEmitEntityDump(WorldEntity entity, uint landblockId, bool culled)
{
var targets = RenderingDiagnostics.DumpEntitySourceIds;
if (targets.Count == 0 || !targets.Contains(entity.SourceGfxObjOrSetupId)) return;
var refs = entity.MeshRefs;
int zeroT = 0;
float tzMin = float.MaxValue, tzMax = float.MinValue;
for (int i = 0; i < refs.Count; i++)
{
var t = refs[i].PartTransform.Translation;
if (t.LengthSquared() < 1e-9f) zeroT++;
if (t.Z < tzMin) tzMin = t.Z;
if (t.Z > tzMax) tzMax = t.Z;
}
int cacheBatches = -1;
int restZero = 0;
float rzMin = float.MaxValue, rzMax = float.MinValue;
if (_cache.TryGet(entity.Id, landblockId, out var cacheEntry))
{
cacheBatches = cacheEntry!.Batches.Length;
foreach (var b in cacheEntry.Batches)
{
var t = b.RestPose.Translation;
if (t.LengthSquared() < 1e-9f) restZero++;
if (t.Z < rzMin) rzMin = t.Z;
if (t.Z > rzMax) rzMax = t.Z;
}
}
var sig = (refs.Count, cacheBatches, zeroT, culled);
bool first = !_entityDumpSig.TryGetValue(entity.Id, out var prev);
if (!first && prev == sig) return;
_entityDumpSig[entity.Id] = sig;
string cacheStr = cacheBatches < 0
? (_tier1CacheDisabled ? "disabled" : "miss")
: $"hit:{cacheBatches} restZero={restZero} restZ=[{rzMin:F2}..{rzMax:F2}]";
Console.WriteLine(
$"[dump-entity] DRAW{(first ? "" : "-CHANGED")} id=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} " +
$"lb=0x{landblockId:X8} cell=0x{(entity.ParentCellId ?? 0u):X8} " +
$"pos=({entity.Position.X:F2},{entity.Position.Y:F2},{entity.Position.Z:F2}) scale={entity.Scale:F2} " +
$"meshRefs={refs.Count} tZero={zeroT} tZ=[{tzMin:F2}..{tzMax:F2}] cache={cacheStr} culled={culled}");
if (first)
{
for (int i = 0; i < refs.Count; i++)
{
var mr = refs[i];
var t = mr.PartTransform.Translation;
bool loaded = _meshAdapter.TryGetRenderData(mr.GfxObjId) is not null;
Console.WriteLine(
$"[dump-entity] part[{i:D2}] gfx=0x{mr.GfxObjId:X8} t=({t.X:F3},{t.Y:F3},{t.Z:F3}) loaded={loaded}");
}
}
}
public void Draw(
ICamera camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
@ -654,6 +862,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
var vp = camera.View * camera.Projection;
_shader.SetMatrix4("uViewProjection", vp);
// #128 self-heal: fresh re-request dedup per Draw pass.
_missRequested.Clear();
bool diag = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal);
if (diag && !_gpuQueriesInitialized)
@ -737,6 +948,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
uint? populateEntityId = null;
uint populateLandblockId = 0;
// §4 flap [clip-route-disp] probe (2026-06-10, throwaway): entities dropped by
// ResolveSlotForFrame's CULL sentinel this Draw. One increment per culled entity —
// cheap enough to count unconditionally; emission below is probe-gated.
int probeCulledEntities = 0;
// Tier 1 cache (#53) — fast-path one-shot tracker. The cache stores a
// FLAT list of batches across all MeshRefs of an entity, so a single
// ApplyCacheHit call already drew every batch. _walkScratch yields
@ -811,6 +1027,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// re-mark the entity "complete" and let partial data populate
// the cache. Trees with [trunk valid, branches null, leaves
// valid] hit this exactly — branches never recover.
// #119 root-cause fix: cache operations key on the entity's OWNING
// landblock, never the Draw call's tuple landblock (which is the
// PLAYER's landblock on the bucket path). See ResolveCacheLandblockHint.
uint cacheLb = ResolveCacheLandblockHint(entity, landblockId);
bool isNewEntity = !prevTupleEntityId.HasValue || prevTupleEntityId.Value != entity.Id;
if (isNewEntity)
{
@ -829,6 +1050,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
(_currentEntitySlot, _currentEntityCulled) = ResolveSlotForFrame(
_clipRoutingActive, entity.ServerGuid, entity.ParentCellId,
_cellIdToSlot, _outdoorSlot, _outdoorVisible);
if (_currentEntityCulled)
probeCulledEntities++;
// #119 decisive probe: one-shot dump (+ change re-emission) for
// ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue
// so a routed-out entity still reports its state.
MaybeEmitEntityDump(entity, cacheLb, _currentEntityCulled);
}
prevTupleEntityId = entity.Id;
@ -871,7 +1099,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// ApplyCacheHit, sets lastHitEntityId, and continues. Subsequent
// tuples of the same entity short-circuit at the top of the loop
// body via the lastHitEntityId == entity.Id check above.
if (!isAnimated && !_tier1CacheDisabled && _cache.TryGet(entity.Id, landblockId, out var cachedEntry))
if (!isAnimated && !_tier1CacheDisabled && _cache.TryGet(entity.Id, cacheLb, out var cachedEntry))
{
ApplyCacheHit(cachedEntry!, entityWorld, AppendInstanceToGroup);
@ -973,6 +1201,17 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// the populate fires with the complete batch set.
currentEntityIncomplete = true;
if (diag) _meshesMissing++;
// #128 self-heal: a missing-but-referenced mesh re-requests
// its load HERE — the one site that touches it every frame —
// so a preparation lost to landblock churn (cancelled after
// the last registration event) can never stay lost. Deduped
// per Draw; PrepareMeshDataAsync is idempotent while pending.
if (_missRequested.Add(gfxObjId))
{
_meshAdapter.EnsureLoaded(gfxObjId);
if (diag && _missLogged.Add(gfxObjId))
Console.WriteLine($"[mesh-miss] 0x{gfxObjId:X10} re-requested at point of use");
}
continue;
}
if (anyVao == 0) anyVao = renderData.VAO;
@ -989,7 +1228,23 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
{
var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
if (partData is null) continue;
if (partData is null)
{
// #128 self-heal + #53: a missing Setup PART must mark
// the entity incomplete (else a partial batch set
// caches permanently — the same bug class one level
// deeper) and re-request its load like the MeshRef
// path above.
currentEntityIncomplete = true;
if (diag) _meshesMissing++;
if (_missRequested.Add(partGfxObjId))
{
_meshAdapter.EnsureLoaded(partGfxObjId);
if (diag && _missLogged.Add(partGfxObjId))
Console.WriteLine($"[mesh-miss] 0x{partGfxObjId:X10} (setup part) re-requested at point of use");
}
continue;
}
var model = ComposePartWorldMatrix(
entityWorld, meshRef.PartTransform, partTransform);
@ -1026,10 +1281,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// Track THIS entity for the next iteration's flush check. Only
// when collector is non-null (entity is static); animated entities
// leave the tracker null so we don't try to flush them.
// #119: the populate commits under the OWNER-derived hint so the
// entry is found by the same key on the next frame's TryGet and
// swept by InvalidateLandblock when the OWNING landblock unloads.
if (collector is not null)
{
populateEntityId = entity.Id;
populateLandblockId = landblockId;
populateLandblockId = cacheLb;
}
if (diag && drewAny) _entitiesDrawn++;
@ -1052,6 +1310,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// null) or when no entities walked at all.
FinalFlushPopulate(populateEntityId, populateLandblockId, _cache, _populateScratch);
// §4 flap [clip-route-disp] probe (2026-06-10, throwaway): the per-slot instance
// histogram exactly as it will be uploaded to binding=3 (grp.Slots) plus the
// culled-entity count. Routed draws only (the landscape pass under DrawInside) so the
// unrouted per-cell bucket draws don't oscillate the print-on-change signature.
// Emitted BEFORE the anyVao / totalInstances early-outs so an all-culled frame still
// reports (inst=0).
if (RenderingDiagnostics.ProbeClipRouteEnabled && _clipRoutingActive)
EmitClipRouteDispatchProbe(probeCulledEntities);
// Nothing visible — skip the GL pass entirely.
if (anyVao == 0)
{
@ -1226,18 +1493,40 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// re-reading the same slot, producing duplicate stale samples.
if (diag && _gpuQueriesInitialized && _gpuQueryFrameIndex >= GpuQueryRingDepth)
{
_gl.GetQueryObject(_gpuQueryOpaque[gpuQuerySlot], QueryObjectParameterName.ResultAvailable, out int avail);
if (avail != 0)
// #125: only read slots whose query objects were actually BEGUN (a
// zero-draw pass skips BeginQuery; reading a never-begun name is
// GL_INVALID_OPERATION). A pass that never ran contributes 0 ns.
ulong opaqueNs = 0, transNs = 0;
bool anyRead = false, allAvailable = true;
if (_gpuQueryOpaqueBegun[gpuQuerySlot])
{
_gl.GetQueryObject(_gpuQueryOpaque[gpuQuerySlot], QueryObjectParameterName.ResultAvailable, out int availO);
if (availO != 0)
{
_gl.GetQueryObject(_gpuQueryOpaque[gpuQuerySlot], QueryObjectParameterName.Result, out opaqueNs);
anyRead = true;
}
else allAvailable = false;
}
if (_gpuQueryTransparentBegun[gpuQuerySlot])
{
_gl.GetQueryObject(_gpuQueryTransparent[gpuQuerySlot], QueryObjectParameterName.ResultAvailable, out int availT);
if (availT != 0)
{
_gl.GetQueryObject(_gpuQueryTransparent[gpuQuerySlot], QueryObjectParameterName.Result, out transNs);
anyRead = true;
}
else allAvailable = false;
}
// If a begun query isn't available yet the sample is dropped
// silently. MedianMicros computes over the non-zero subset, so
// dropped samples don't poison the median.
if (anyRead && allAvailable)
{
_gl.GetQueryObject(_gpuQueryOpaque[gpuQuerySlot], QueryObjectParameterName.Result, out ulong opaqueNs);
_gl.GetQueryObject(_gpuQueryTransparent[gpuQuerySlot], QueryObjectParameterName.Result, out ulong transNs);
long gpuUs = (long)((opaqueNs + transNs) / 1000UL);
_gpuSamples[_gpuSampleCursor] = gpuUs;
_gpuSampleCursor = (_gpuSampleCursor + 1) % _gpuSamples.Length;
}
// If avail==0 the sample is dropped silently. MedianMicros
// computes over the non-zero subset, so dropped samples don't
// poison the median.
}
// ── Phase 7: opaque pass ─────────────────────────────────────────────
@ -1257,7 +1546,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// mesh_modern.vert for why this is needed.
_shader.SetInt("uDrawIDOffset", 0);
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryOpaque[gpuQuerySlot]);
if (diag && _gpuQueriesInitialized)
{
_gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryOpaque[gpuQuerySlot]);
_gpuQueryOpaqueBegun[gpuQuerySlot] = true; // #125
}
DrawIndirectRange(0, _opaqueDrawCount);
if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed);
if (AlphaToCoverage) _gl.Disable(EnableCap.SampleAlphaToCoverage);
@ -1281,7 +1574,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// section. BuildIndirectArrays preserves CullMode in _drawCullModes.
_gl.FrontFace(FrontFaceDirection.CW);
_shader.SetInt("uRenderPass", 1);
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryTransparent[gpuQuerySlot]);
if (diag && _gpuQueriesInitialized)
{
_gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryTransparent[gpuQuerySlot]);
_gpuQueryTransparentBegun[gpuQuerySlot] = true; // #125
}
DrawIndirectRange(_opaqueDrawCount, _transparentDrawCount);
if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed);
_gl.DepthMask(true);
@ -1506,7 +1803,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
int nz = 0;
foreach (var v in copy) if (v > 0) nz++;
if (nz == 0) return 0;
return copy[copy.Length - nz / 2];
// Sorted ascending: zero-padding front, samples at the back. (nz - 1) / 2
// from the end keeps the offset >= 0 for all nz >= 1 — the original
// nz / 2 form indexed copy[copy.Length] (crash) on the first diag flush
// when exactly 1 sample was recorded. Same fix as GameWindow's
// TerrainDiagMedianMicros twin.
return copy[copy.Length - 1 - (nz - 1) / 2];
}
private static long Percentile95Micros(long[] samples)

View file

@ -156,23 +156,39 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
if (_isUninitialized || _meshManager is null) return;
_meshManager.IncrementRefCount(id);
if (_metadataPopulated.Add(id))
{
bool firstEver = _metadataPopulated.Add(id);
if (firstEver)
PopulateMetadata(id);
// WB's IncrementRefCount alone only bumps a usage counter; it does
// NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync
// so the background workers actually decode the GfxObj. The result
// auto-enqueues into _stagedMeshData (ObjectMeshManager line 510),
// which Tick() drains onto the GPU. Until that completes,
// TryGetRenderData(id) returns null and the dispatcher silently
// skips the entity — standard streaming flicker.
//
// isSetup: false — acdream's MeshRefs already carry expanded
// per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
// unused.
// WB's IncrementRefCount alone only bumps a usage counter; it does
// NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync
// so the background workers actually decode the GfxObj. The result
// auto-enqueues into _stagedMeshData (ObjectMeshManager line 510),
// which Tick() drains onto the GPU. Until that completes,
// TryGetRenderData(id) returns null and the dispatcher silently
// skips the entity — standard streaming flicker.
//
// #128 (2026-06-11): Prepare must RE-ARM whenever the id has no render
// data — NOT only on the first-ever registration. The old
// first-ever-only gate (`if (_metadataPopulated.Add(id))`) permanently
// lost any id whose initial decode was cancelled before completing
// (landblock unload → CancelStagedUploads during login/teleport
// churn) or whose upload was later LRU-evicted: every subsequent
// registration skipped Prepare, so the mesh stayed invisible for the
// session with zero log output — the dispatcher's slow path just
// counted meshMissing forever (issue #55's 1.45M/5s mountain was this
// bug's heartbeat). User-visible: the AAB3 tower staircase rendering
// partially or not at all depending on the session's landblock
// load/unload interleaving (#119/#128 "broken stairs"). Safe to call
// unconditionally when data is absent: PrepareMeshDataAsync early-outs
// on existing render data, returns the in-flight task when already
// pending, and dedups via _preparationTasks.
//
// isSetup: false — acdream's MeshRefs already carry expanded
// per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
// unused.
if (firstEver || _meshManager.TryGetRenderData(id) is null)
_meshManager.PrepareMeshDataAsync(id, isSetup: false);
}
}
/// <inheritdoc/>
@ -182,6 +198,26 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
_meshManager.DecrementRefCount(id);
}
/// <summary>
/// #128 self-heal (2026-06-11): re-request a mesh load at the POINT OF
/// USE. Registration-time re-arming was insufficient — a preparation
/// cancelled by landblock churn AFTER the last registration event
/// (running across blocks loads/unloads them repeatedly) left the mesh
/// permanently unloadable with no later event to re-fire it. The draw
/// dispatcher touches every missing-but-referenced mesh every frame (the
/// meshMissing slow path) — that is the one place a retry can never be
/// missed. Cheap and idempotent: PrepareMeshDataAsync early-outs on
/// existing render data and returns the in-flight task when pending.
/// Retail-equivalence: retail loads content synchronously — geometry is
/// never permanently absent; this converges our async pipeline to the
/// same guarantee.
/// </summary>
public void EnsureLoaded(ulong id)
{
if (_isUninitialized || _meshManager is null) return;
_meshManager.PrepareMeshDataAsync(id, isSetup: false);
}
/// <summary>
/// Per-frame drain of the WB pipeline's main-thread work queues. MUST be
/// called once per frame from the render thread. Without this, the staged
@ -212,6 +248,56 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
{
_meshManager.UploadMeshData(meshData);
}
bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled;
var pendingBefore = texProbe
? _meshManager.GetPendingTextureUpdateStats()
: default;
// #105 root cause (2026-06-10): TextureAtlasManager.AddTexture only STAGES
// texture content (PBO write + ManagedGLTextureArray._pendingUpdates) — the
// actual TexSubImage3D copies + mipmap regeneration happen in
// ProcessDirtyUpdates, which WB drives ONCE PER FRAME from its render loop
// (WB GameScene.cs:975 `_meshManager?.GenerateMipmaps()`, just before the
// opaque pass). That call site lived in the GameScene file the N.4/O-T4
// extraction replaced with GameWindow, so the driver was silently dropped:
// staged updates only ever reached the GPU as a side effect of PBO growth,
// and every layer staged after an array's LAST growth kept undefined
// TexStorage3D content behind a valid resident bindless handle — the
// intermittent white indoor walls (#105). Pre-fix evidence: 126 updates
// stuck across 34/34 arrays at standstill (texflush-prefix.log). Tick()
// runs before all draw passes (GameWindow OnRender), so this is the exact
// WB-equivalent position.
_meshManager.GenerateMipmaps();
if (texProbe)
EmitTexFlushProbe(pendingBefore);
}
// #105 apparatus state — see RenderingDiagnostics.ProbeTexFlushEnabled.
private int _lastTexFlushBefore = -1;
private int _texFlushHeartbeat;
/// <summary>
/// #105 apparatus: one <c>[tex-flush]</c> line on change of the staged-texture
/// pending picture (plus a ~10 s heartbeat while anything is stuck). A healthy
/// frame ends with <c>after=0</c>; <c>before==after&gt;0</c> persisting at
/// standstill is the white-walls mechanism live (staged uploads never applied).
/// </summary>
private void EmitTexFlushProbe((int PendingUpdates, int ArraysWithPending, int TotalArrays) before)
{
var after = _meshManager!.GetPendingTextureUpdateStats();
bool changed = before.PendingUpdates != _lastTexFlushBefore;
bool flushed = after.PendingUpdates != before.PendingUpdates;
bool heartbeat = after.PendingUpdates > 0 && ++_texFlushHeartbeat >= 600;
if (!changed && !flushed && !heartbeat) return;
_texFlushHeartbeat = 0;
_lastTexFlushBefore = before.PendingUpdates;
Console.WriteLine(
$"[tex-flush] before={before.PendingUpdates} after={after.PendingUpdates}" +
$" arrays={after.ArraysWithPending}/{after.TotalArrays}" +
$" (arraysBefore={before.ArraysWithPending})");
}
private void PopulateMetadata(ulong id)

View file

@ -329,7 +329,15 @@ public sealed class LandblockStreamer : IDisposable
if (System.Threading.Interlocked.Exchange(ref _disposed, 1) != 0) return;
_cancel.Cancel();
_inbox.Writer.TryComplete();
_worker?.Join(TimeSpan.FromSeconds(2));
// Generous join: the owner disposes the DatCollection after this, which
// unmaps the dats' memory-mapped views — an abandoned worker mid-dat-read
// would take the process down with an AccessViolation in
// MemoryMappedBlockAllocator.ReadBlock (dat-race investigation 2026-06-09).
// Cancellation is honored between jobs, so the wait is bounded by one
// landblock load; 15s only ever elapses if the worker is genuinely hung.
if (_worker is not null && !_worker.Join(TimeSpan.FromSeconds(15)))
Console.Error.WriteLine(
"[streamer] worker did not stop within 15s — dat teardown may race an in-flight load");
_cancel.Dispose();
}
}

View file

@ -0,0 +1,95 @@
using System.Collections.Concurrent;
using System.Numerics;
using DatReaderWriter.DBObjs;
namespace AcDream.Core.Meshing;
/// <summary>
/// Per-GfxObj vertex-space AABB, cached by id. The ground-truth source for entity
/// visibility bounds (#119 follow-up, 2026-06-11): derived from the SAME dat vertex
/// data that gets drawn, so the bound can never disagree with the mesh — unlike the
/// previous synthetic constants (anchor ± 5 m, then ± max-part-offset) whose
/// containment was a promise nothing enforced. Retail needs no equivalent because it
/// culls per part with dat-authored spheres (CGfxObj.drawing_sphere, viewconeCheck at
/// 0x005a09a4); our per-ENTITY culling granularity is a deliberate batching-era
/// divergence and is visually safe exactly as long as the entity volume CONTAINS the
/// mesh — which vertex-derived bounds guarantee by construction.
///
/// Thread-safe: hydration runs on the streaming worker AND live spawns on the render
/// thread. Parts repeat heavily (shared body parts, repeated stair steps, fence
/// segments), so the cache hit rate is high and the vertex scan runs once per
/// distinct GfxObj id per session.
/// </summary>
public static class GfxObjBounds
{
private static readonly ConcurrentDictionary<uint, (Vector3 Min, Vector3 Max)> _cache = new();
/// <summary>
/// Vertex-space AABB of <paramref name="gfx"/>, or null for a vertex-less model
/// (the legitimate all-no-draw class — see Issue119UpNullGfxObjDumpTests).
/// </summary>
public static (Vector3 Min, Vector3 Max)? Get(GfxObj? gfx)
{
if (gfx is null) return null;
if (_cache.TryGetValue(gfx.Id, out var hit)) return hit;
var min = new Vector3(float.MaxValue);
var max = new Vector3(float.MinValue);
foreach (var v in gfx.VertexArray.Vertices.Values)
{
var o = new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z);
min = Vector3.Min(min, o);
max = Vector3.Max(max, o);
}
if (min.X == float.MaxValue) return null;
_cache[gfx.Id] = (min, max);
return (min, max);
}
}
/// <summary>
/// Accumulates an entity's ROOT-LOCAL geometry bounds during hydration: the union
/// over its MeshRefs of each part's vertex AABB transformed by the part's FINAL
/// MeshRef transform (placement frame, including any baked scenery scale). The
/// result feeds <c>WorldEntity.SetLocalBounds</c>; <c>WorldEntity.RefreshAabb</c>
/// rotates it into world axes per frame. Transforming the 8 corners and re-boxing
/// is conservative-correct under any affine transform (the re-box of transformed
/// corners contains the transformed contents).
/// </summary>
public struct LocalBoundsAccumulator
{
private Vector3 _min;
private Vector3 _max;
private bool _any;
public void Add(Matrix4x4 partTransform, (Vector3 Min, Vector3 Max) partBounds)
{
Vector3 lo = partBounds.Min, hi = partBounds.Max;
for (int c = 0; c < 8; c++)
{
var corner = new Vector3(
(c & 1) == 0 ? lo.X : hi.X,
(c & 2) == 0 ? lo.Y : hi.Y,
(c & 4) == 0 ? lo.Z : hi.Z);
var t = Vector3.Transform(corner, partTransform);
if (!_any)
{
_min = _max = t;
_any = true;
}
else
{
_min = Vector3.Min(_min, t);
_max = Vector3.Max(_max, t);
}
}
}
public readonly bool TryGet(out Vector3 min, out Vector3 max)
{
min = _min;
max = _max;
return _any;
}
}

Some files were not shown because too many files have changed in this diff Show more