Merge branch 'main' into claude/eloquent-hugle-42119e

# Conflicts:
#	.gitignore
This commit is contained in:
Erik 2026-07-06 00:47:09 +02:00
commit 093cdb6d57
38 changed files with 5852 additions and 216 deletions

View file

@ -0,0 +1,236 @@
# CCylSphere collision family — retail pseudocode (port prep)
**Date:** 2026-07-05 · **Trigger:** the Holtburg town-network portal platform
(stab `0xC0A9B465`, Setup `0x020019E3`, CylSphere r=2.597 m h=0.256 m) blocks
the player with an endless rim slide instead of the retail step-up-onto-top.
Surfaced the moment #149 (`4cf6eeb`) started registering BSP-less stab
CylSpheres — the collision SHAPE is right; the RESPONSE family was never
ported. Feeds #137 (dungeon door feet flow through the same dispatcher).
**Sources:** named-retail pseudo-C (addresses below) = ground truth;
`references/ACE/Source/ACE.Server/Physics/CylSphere.cs` = cross-reference
(settles BN x87 garbles; one ACE bug found, noted in §8).
## Retail function inventory
| Function | Address | pseudo-C line |
|---|---|---|
| `CCylSphere::intersects_sphere(CTransition*)` — dispatcher | `0x0053b440` | :324558 |
| `CCylSphere::intersects_sphere(Position*, float scale, CTransition*)` — wrapper | `0x0053b8f0` | :324744 |
| `CCylSphere::collides_with_sphere` | `0x0053a880` | :323943 |
| `CCylSphere::normal_of_collision` | `0x0053ab50` | :324102 |
| `CCylSphere::collide_with_point` | `0x0053acb0` | :324173 |
| `CCylSphere::slide_sphere` | `0x0053b2a0` | :324502 |
| `CCylSphere::step_sphere_up` | `0x0053b310` | :324516 |
| `CCylSphere::land_on_cylinder` | `0x0053b3d0` | :324542 |
| `CCylSphere::step_sphere_down` | `0x0053a9b0` | :324032 |
| `COLLISIONINFO::set_contact_plane(plane, is_water)` | `0x00509d80` | :271925 |
## 1. Wrapper (0x0053b8f0) — globalize the cylinder
```
intersects_sphere(cyl, Position* objPos, float scale, CTransition* t):
SPHEREPATH::cache_localspace_sphere(&t->sphere_path, objPos, 1f)
world_cyl = { low_pt: objPos.localtoglobal(cyl.low_pt * scale),
radius: cyl.radius * scale,
height: cyl.height * scale }
return world_cyl.intersects_sphere(t) // axis stays world-Z
```
**acdream mapping:** `ShadowEntry` already stores the globalized base point
(`Position` = entity pos + rotated scaled local offset, registration sites in
`GameWindow.cs`) and pre-scaled Radius/CylHeight — the wrapper's work is done
at registration. `cache_localspace_sphere` matters only for
`localspace_pos` (used by step_sphere_up's normal rotation, §6).
## 2. collides_with_sphere (0x0053a880) — pure overlap test
```
collides_with_sphere(cyl, CSphere* sphere, Vector3* disp, float radsum):
// disp = sphere.center cyl.low_pt (caller computes)
if (disp.x² + disp.y² <= radsum²) // XY overlap
halfH = cyl.height * 0.5
if (|halfH disp.z| <= sphere.radius F_EPSILON + halfH) // Z band
return 1
return 0
```
`radsum` at every call site = `cyl.radius F_EPSILON + sphere.radius`
(ε shaved ONCE, in the dispatcher preamble). The ε is what makes "resting
exactly on the top" a non-overlap, so landings settle instead of re-colliding.
## 3. Dispatcher (0x0053b440)
```
intersects_sphere(cyl, CTransition* t): // cyl in world frame
sp = t.sphere_path; oi = t.object_info
s0 = sp.global_sphere[0]; disp0 = s0.center low_pt
if sp.num_sphere > 1: s1 = sp.global_sphere[1]; disp1 = s1.center low_pt
radsum = cyl.radius F_EPSILON + s0.radius
// ── branch 1: placement / ethereal — detection only ──
if (sp.insert_type == PLACEMENT_INSERT || sp.obstruction_ethereal):
if collides(s0, disp0) → COLLIDED
if num_sphere>1 && collides(s1, disp1) → COLLIDED
return OK
// ── branch 2: step-down probe — land on the top ──
if (sp.step_down): return step_sphere_down(t, s0, disp0, radsum)
// ── branch 3: walkable probe — cylinder occupancy blocks ──
if (sp.check_walkable):
if collides(s0, disp0) → COLLIDED
if num_sphere>1 && collides(s1, disp1) → COLLIDED
return OK
// ── branch 4: normal sweep (collide flag clear) ──
if (!sp.collide):
if (oi.state & (CONTACT|ON_WALKABLE)): // grounded
if collides(s0, disp0) → step_sphere_up(t, s0, disp0, radsum)
if num_sphere>1 && collides(s1, disp1)
→ slide_sphere(t, s1, disp1, radsum, sphereNum=1) // §8: retail passes disp1
elif (oi.state & PATH_CLIPPED):
if collides(s0, disp0) → collide_with_point(t, s0, disp0, radsum, 0)
else: // airborne
if collides(s0, disp0) → land_on_cylinder(t, s0, disp0, radsum)
if num_sphere>1 && collides(s1, disp1)
→ collide_with_point(t, s1, disp1, radsum, 1)
return OK
// ── branch 5: collide-flag re-test — exact-TOI cap landing ──
if collides(s0,disp0) || (num_sphere>1 && collides(s1,disp1)):
movement = sp.global_curr_center[0] s0.center block_offset(cur→check)
if |movement.z| < F_EPSILON COLLIDED
timecheck = (height + s0.radius disp0.z) / movement.z
offset = movement * timecheck
if radsum² < |xy(offset + disp0)|² → OK // rewound off the cap
t2 = (1 timecheck) * sp.walk_interp
if t2 >= sp.walk_interp || t2 < 0.1 → COLLIDED
pt = s0.center + offset; pt.z = s0.radius
ci.set_contact_plane(Plane(n=(0,0,1), d=pt.z), is_water=1) // literal 1, §7
ci.contact_plane_cell_id = sp.check_pos.objcell_id
sp.walk_interp = t2
sp.add_offset_to_check_pos(offset)
return ADJUSTED
return OK
```
State bits (verified against our `ObjectInfoState`): CONTACT=0x1,
ON_WALKABLE=0x2, PATH_CLIPPED=0x8, PERFECT_CLIP=0x40.
## 4. step_sphere_down (0x0053a9b0) — land on the top during a step-down probe
```
step_sphere_down(t, s0, disp0, radsum):
if !collides(s0,disp0) && !(num_sphere>1 && collides(s1,disp1)) → OK
stepScale = sp.step_down_amt * sp.walk_interp
if |stepScale| < F_EPSILON COLLIDED
deltaz = height + s0.radius disp0.z // lift so bottom rests on top
interp = (1 deltaz / stepScale) * sp.walk_interp // divisor = stepScale (BN garbled; ACE)
if interp >= sp.walk_interp || interp < 0.1 → COLLIDED
contactPt = (s0.center.x, s0.center.y, s0.center.z + deltaz s0.radius)
ci.set_contact_plane(Plane(n=(0,0,1), d=contactPt.z), is_water=1) // §7
ci.contact_plane_cell_id = sp.check_pos.objcell_id
sp.walk_interp = interp
sp.add_offset_to_check_pos((0,0,deltaz))
return ADJUSTED
```
This is THE missing piece that made step-up-onto-a-wide-cylinder impossible:
`CTransition::step_up`'s internal step-down probe needs branch 2 to produce a
walkable contact plane ON the cylinder top.
## 5. normal_of_collision (0x0053ab50)
```
normal_of_collision(cyl, sp, sphere, dispCheck, radsum, sphereNum, out n) → bool definite:
dispCurr = sp.global_curr_center[sphereNum] low_pt
if (radsum² < dispCurr.x² + dispCurr.y²): // curr was XY-OUTSIDE side hit
n = (dispCurr.x, dispCurr.y, 0) // radial, horizontal
// definite unless the contact could actually be a diagonal cap hit:
zBandOverlapAtCurr = |halfH dispCurr.z| <= sphere.radius F_EPSILON + halfH
noZMovement = |dispCurr.z dispCheck.z| <= F_EPSILON
return zBandOverlapAtCurr || noZMovement
// curr was XY-INSIDE the footprint → cap hit
n = (0, 0, (dispCheck.z dispCurr.z <= 0) ? +1 : 1) // descending → top (+1)
return true
```
Cap polarity settled by ACE + geometry (BN's x87 branch rendering is
untrustworthy here — [[feedback_bn_decomp_field_names]] class 2).
## 6. step_sphere_up (0x0053b310) / land_on_cylinder (0x0053b3d0) / slide_sphere (0x0053b2a0)
```
step_sphere_up(t, s0, disp0, radsum):
if (oi.step_up_height < s0.radius + height disp0.z) // too tall
→ slide_sphere(t, s0, disp0, radsum, 0)
definite = normal_of_collision(..., 0, out n)
if normalize_check_small(n) → COLLIDED
nWorld = localspace_pos.localtoglobalvec(n) // rotate by the OBJECT's frame
if CTransition::step_up(t, nWorld) → OK
else → sp.step_up_slide(t)
land_on_cylinder(t, s0, disp0, radsum): // airborne foot hit
normal_of_collision(..., 0, out n)
if normalize_check_small(n) → COLLIDED
sp.set_collide(n) // backup + Collide flag
sp.walkable_allowance = LANDING_Z (0.0871557)
return ADJUSTED
slide_sphere(t, sphere, disp, radsum, sphereNum):
normal_of_collision(..., sphereNum, out n)
if normalize_check_small(n) → COLLIDED
return CSphere::slide_sphere(sphere, sp, ci, n, sp.global_curr_center[sphereNum])
```
The airborne landing closes through the retry loop: land_on_cylinder
(ADJUSTED, sets `sp.collide`) → next attempt → branch 5 exact-TOI rests the
sphere on the top + CP → next attempt → ε-shaved overlap now misses → OK →
TransitionalInsert Phase 3 `sp.Collide` placement re-test validates on the
CP → landing completes.
## 7. collide_with_point (0x0053acb0) — PathClipped / head-sphere hits
Port per ACE `CylSphere.CollideWithPoint` verbatim (self-contained TOI math):
non-PerfectClip movers → `set_collision_normal` + COLLIDED. PerfectClip →
exact time-of-impact reposition (`add_offset_to_check_pos`) + ADJUSTED, with
the not-definite branch deriving cap-vs-side from the movement.
## 8. Divergences + settled ambiguities (register-relevant)
1. **`is_water=1` on cylinder-top contact planes is RETAIL** (literal 1 at
0x0053aae2 and the branch-5 site; `set_contact_plane` 0x00509d80 stores
arg3 → `contact_plane_is_water`). Port verbatim; do not "fix".
2. **ACE bug (do NOT copy):** ACE's grounded head-sphere leg passes the FOOT
disp to `SlideSphere`; retail 0x0053b843 passes the HEAD disp (`x_2`).
Retail wins. (Class: [[feedback_bn_decomp_field_names]] #3 — ACE decode
wrong in a branch ACE rarely exercises.)
3. **Block offset in branch 5:** retail subtracts the cur→check landblock
offset; acdream's physics frame is continuous world-space → offset = 0.
Standing frame adaptation (same as SlideSphere's gDelta note).
4. **Ethereal targets:** branch 1 returns COLLIDED on overlap even for
ethereal; passability comes from the caller's Layer-2 override
(pc:276961-276989, non-static + !step_down → forced OK) plus the #150
step-down skip. The previous port consumed ObstructionEthereal with an
early OK before any test — response-equivalent for non-static targets,
but branch 1 is the faithful shape and also gives placement inserts the
retail blocked-by-cylinder semantics. Ported faithfully now.
5. **`normalize_check_small`** = normalize; returns true (fail) when |v| < ε
before normalizing — maps to `LengthSquared() < EpsilonSq` guard.
6. **step_sphere_up normal rotation:** retail rotates the collision normal by
the target OBJECT's frame (`localspace_pos` = the object's Position cached
by the wrapper) before `CTransition::step_up`. For yaw-only AC objects
this only affects yawed radial normals; ported faithfully via
`Vector3.Transform(n, obj.Rotation)`.
## 9. acdream port surface
`Transition.CylinderCollision` (TransitionTypes.cs) becomes the branch-4/5
dispatcher body; new private siblings `CylCollidesWithSphere`,
`CylNormalOfCollision`, `CylStepSphereUp`, `CylStepSphereDown`,
`CylSlideSphere`, `CylLandOnCylinder`, `CylCollideWithPoint`. Callers
unchanged (`FindObjCollisionsInCell` Cylinder branch; the BspOnlyDispatch
gate and the #150 ethereal step-down skip sit ABOVE this dispatch and are
unaffected). `DoStepUp` (= CTransition::step_up, A6.P6) and
`SpherePath.StepUpSlide` are reused as-is.

View file

@ -0,0 +1,92 @@
# Pickup prompt — post-#137-corridor session: the #176/#177 render pair (paste into a fresh session)
**Read `claude-memory/project_render_pipeline_digest.md` FIRST** (Option A —
one DrawInside(viewer_cell); binding DO-NOT-RETRY table), then **ISSUES #176
and #177**, then this file. The physics digest
(`claude-memory/project_physics_collision_digest.md`) carries the full
2026-07-06 collision saga if background is needed — do NOT reopen it.
## Where we are (2026-07-06 end of session)
**The #137 Facility Hub corridor collision arc is DONE, user-gated** ("not
collision anymore. Good." / "Looks good"). Branch
`claude/vigorous-joliot-f0c3ad` (worktree), 10 commits ahead of `e73e45da`,
NOT merged to main. All suites green (Core 2562 / App 713 / UI 425 / Net 385).
| Commit | Fix |
|---|---|
| `a11df5b8` | BSPQuery Contact-branch stub slide responses leaked sliding normals (the absorbing wedge). Retail's BSP layer never writes `collision_info.sliding_normal` — only `validate_transition` 0x0050ac21; body persistence success-only (`SetPositionInternal` 0x005154c2). |
| `e8651b38` | `slide_sphere` opposing-normals branch returned OK; retail returns COLLIDED_TS (0x0053762c). The "phantom wall" normal was SYNTHETIC (negated movement). PortalSide-poly theory refuted. |
| `d4869154` | `CheckOtherCells` queried remaining cells at a stale pre-climb center (P2 lesson one loop deeper) — the seam shake. Per-iteration `footCenter = sp.GlobalSphere[0].Origin` refresh. |
| `aa96d7ad` | The collision capsule topped out at 1.2 m (callers passed `sphereHeight: 1.2f`; head sphere center 0.72). Dat Setup 0x02000001: spheres (0,0,0.475)+(0,0,1.350) r=0.48, top 1.83 = Height 1.835. Callers now pass 1.835. Register TS-46. The window climb. |
**#137 stays OPEN for the DOORS half only** (block/pass per open state).
The #175 door-pose fix (2026-07-05) still needs its user gate — ask for it
whenever the user is next at the hub double door (closed blocks AT the
visual panels from both sides, no embed, no phantom wall).
## NEXT ARC: #176 + #177 (render, both filed 2026-07-06 from the gate session)
- **#176 — purple flashing on dungeon floors at cell seams, camera-angle
dependent.** Survives all physics fixes → render-side. Magenta/purple =
the placeholder-texture class ([[feedback_ui_resolve_zero_magenta]]).
- **#177 — stairs between levels pop in/out.** Invisible from the corridor
looking into the stair room, appear on entering, vanish on the last step
running down. The #119 visibility class, dungeon edition. Anchor cells:
the transit `0x8A020182 → 0x8A020183` drops z 6 → 9 on stairs
(launch-137-gate2.log).
**The load-bearing topology fact both issues share (discovered this
session):** Facility Hub corridor FLOORS are portal polygons — PortalSide
floor-portals to under-rooms (e.g. 0x8A02016E visual polys 1/3/5 → 0x011E,
horizontal at z=6, spanning the whole floor; 0x011E is a hall at z=12).
Level connections run through these floor-portals. "Purple at the seams" is
purple exactly where portal surfaces meet, and the stairs' rooms hang off
the same portal graph — suspect the render portal-flood/portal-surface
handling of HORIZONTAL portals.
**⚠️ The id-space trap (cost this saga a wrong mechanism):**
`CellPortal.PolygonId` indexes the VISUAL polygon table (`CellStruct.Polygons`),
NOT `PhysicsPolygons`. Same ids in both tables are UNRELATED polygons.
## Tooling built this session (reuse, don't rebuild)
- `Issue137CorridorSeamInspectionTests` — dat-inspection theories (add
`InlineData` cells as needed): portal spans (`CorridorCell_PortalPolygonWorldSpans`),
full-vertex poly dumps (`WindowShaft_FullPolyDump`), physics-BSP leaf
membership, hit-normal candidate sweep (use |align| — winding flips),
`HumanSetup_CollisionSpheres_DatTruth`.
- `Issue137CorridorSeamReplayTests` — dat-backed `PhysicsEngine` corridor
harness (`BuildCorridorEngine`: hydrate THREE portal rings or ring-3
cells are invisible to the flood — why clean-room replays kept passing).
In-test probe capture pattern: `Console.SetOut(StringWriter)` +
`PhysicsDiagnostics.Probe*Enabled = true` → line-diff offline vs live.
- Live probe logs (worktree root, PowerShell Tee = UTF-16, `tr -d '\000'`
before grep): `launch-137-seam-probes.log` (790 MB, step-level),
`launch-137-gate2.log`, `launch-137-gate3.log`,
`resolve-137-seam-capture.jsonl` (body snapshots, untracked).
## Physics DO-NOT-RETRY highlights from today (full table in the digest)
- No `SetSlidingNormal` in the BSP/sphere layer; opposing branch returns
Collided; failed transitions never write body sliding state.
- The absorbed exactly-anti-parallel frame against a persisted sliding
normal is RETAIL behavior — fix normal PROVENANCE, not the abort.
- No height-budget check in the step-down accept — retail's climb cap is
`adjust_sphere_to_plane`'s walk_interp 0.5 overshoot bound (0x00538210)
+ the placement insert rejecting the HEAD in solids.
- Probe-field misreads: `[neg-poly]`/`[neg-poly-dispatch]` print `stepUp=`
= NegStepUp (dispatch class), NOT sp.StepUp. `[walkable-nearest]` is a
logger, not the decision-maker.
- Remaining registered leaks (rows exist, fix later): TS-45
(`SphereCollision`'s SetSlidingNormal tail), TS-4 (Path-6 steep-tangent),
TS-46 (scalar sphere approximation; remotes use human dims).
## Launch protocol (unchanged)
Build green first; PowerShell launch with the env block from CLAUDE.md
(+ `ACDREAM_PROBE_RESOLVE=1 ACDREAM_PROBE_CELL=1` for gates), background +
Tee to `launch-*.log`. The user manages client lifecycle. Graceful close →
ACE session clears in ~5 s; hard kill → ~3 min. The test character may be
saved in odd places after collision testing (last session it was inside the
window alcove and ACE bounced it to Holtburg — the user portals back).

View file

@ -0,0 +1,87 @@
# Pickup prompt — #137 corridor phantom collision (paste into a fresh session)
> **SUPERSEDED 2026-07-06.** The corridor phantom is FIXED (visual gate
> pending) — see `docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md`.
> Mechanism 2 was real (BSPQuery stub slide responses leaked sliding
> normals; fixed). Mechanism 1's framing was WRONG: the recorded wall
> normal was SYNTHETIC (slide_sphere's opposing branch + a `return OK` vs
> retail's COLLIDED_TS misport — fixed); the PortalSide polys are ±Y
> planes perpendicular to the run, directionally culled, tested by
> retail's own BSP leaves too, and plausibly legitimately solid
> (window/grate class). The step 1 cdb session below is NOT needed for
> this repro. Kept for the audit trail only.
Read `claude-memory/project_physics_collision_digest.md` FIRST (binding
DO-NOT-RETRY table), then **ISSUES #137** (the 2026-07-05 CHARACTERIZED
section — the full evidence chain lives there), then this file. The 2026-07-05
session's ledger for context: #172 (CCylSphere family port), #173 (remote
ceiling bounce), #174 (motion-queue drain — doors work after jumping), #175
(door collision at the motion-table closed pose, two takes).
**The bug (user-verified repro, Facility Hub 0x8A02):** running down a
corridor, an INVISIBLE blocker stops the player mid-corridor; the player can
walk around it. Two stacked mechanisms, both evidence-pinned:
## Mechanism 1 — PortalSide portal polygons are solid for us
- Live: crossing corridor cells `0x8A02016E → 0x8A02017A` at world x≈85.25
recorded ONE wall hit, normal (1,0,0) — straight against the movement
(`launch-175-verify2.log:42858`, worktree root).
- Dat (`Issue137CorridorSeamInspectionTests`, committed): cell 0x8A02016E's
three portals to 0x011E (polys 1/3/5, flags=**PortalSide**, NOT ExactMatch)
are PRESENT in `CellStruct.PhysicsPolygons`; every ExactMatch portal in the
same cell is absent from the physics set. The cell's rotation maps those
portal planes into the world X wall the player hit.
- Oracle greps DONE (do not repeat): `CCellStruct::UnPack` 0x00533d00 loads
physics polys + BSP verbatim (no portal stripping);
`CPolygon::pos_hits_sphere`/`hits_sphere`/`polygon_hits_sphere_slow_but_sure`
(0x005394f0/0x00539540/0x00538a10) are pure geometry;
`CCellPortal` (0x0053bab0) carries portal→CPolygon + portal_side +
exact_match but the BSP test chain never consults it.
- **NEXT (step 1 protocol, needs the user):** cdb-attach a live retail
client at this exact corridor (Facility Hub, the 016E↔011E portals) and
trace which path lets retail through: breakpoint
`BSPTREE::find_collisions` / `BSPLEAF::sphere_intersects_poly` /
`CPolygon::pos_hits_sphere` and see whether the portal polys are ever
TESTED (candidate A: sidedness/stippling — the polys carry stip=NoPos —
or the pos_hits_sphere tail's directional cull) or never REACHED
(candidate B: transit/membership order hands the sphere to the neighbor
cell whose geometry has a real hole). Toolchain crib:
`claude-memory/project_retail_debugger.md` + the CLAUDE.md
"Retail debugger toolchain" section. Verify the binary with
`py tools/pdb-extract/check_exe_pdb.py` first.
- ⚠️ Do NOT ship a "skip portal polys in the physics BSP" filter on
assumption — if retail's answer is sidedness or test order, a blanket
skip opens holes (some PortalSide polys may be legitimately solid from
one side — one-way drops etc.).
## Mechanism 2 — the sliding-normal absorbing wedge (fix independently)
- After the single seam hit, EVERY forward resolve returns `ok=False
hit=no` with zero advance: the body-persisted SlidingNormal (1,0,0)
projects the +X offset to exactly ZERO in `Transition.AdjustOffset`, and
the stepping loop's abort-small-offset fires at step 0 (TransitionTypes
`FindValidPosition` loop, `return i != 0 && …`) — BEFORE any collision
test could refresh the state. An ABSORBING wedge; strafing escapes it
(the user's "push through on the side").
- Retail re-derives slide state per frame — `OBJECTINFO::get_object_info`
pc:279992 "governs only the NEXT frame" (#116 notes in the digest).
AUDIT: who writes the body's persisted SlidingNormal
(PhysicsEngine.ResolveWithTransition seed ~:995-1040 + the writeback),
and where retail CLEARS it when contact does not recur. This is the #116
slide-response family — check ISSUES #116 before changing anything
(oracle-first; the digest's DO-NOT-RETRY table applies).
- Likely the bigger playability win: without the wedge, mechanism 1 alone
would be a momentary stutter, not a dead stop.
**Order:** mechanism 2 first (pure acdream-side audit + fix, testable with a
replay-style unit test: seed a body with a stale sliding normal, resolve
forward with no obstruction in range, assert the step is NOT zeroed), then
the mechanism-1 cdb session when the user can run retail side-by-side.
**Gotchas:** PowerShell Tee logs are UTF-16 (`tr -d '\000'` before grep);
the user manages client lifecycle; probes RESOLVE/CELL/BUILDING are
DebugPanel-toggleable (ACDREAM_DEVTOOLS=1); the [shape-pose] line
(ACDREAM_DUMP_MOTION=1) prints each BSP registration's pose source.
Register rows to touch if fixes ship: none exist yet for either mechanism —
add per the same-commit rule; #116's row interactions per the digest.

View file

@ -0,0 +1,115 @@
# #137 mechanism 2 — the sliding-normal lifecycle audit (2026-07-06)
The pickup prompt (`2026-07-06-137-corridor-phantom-pickup-prompt.md`) asked:
*who writes the body's persisted SlidingNormal, and where does retail CLEAR
it when contact does not recur?* This note is the complete decomp-verified
answer, the divergence map it produced, and the fix that shipped.
## Retail lifecycle (every site, named-retail-verified)
`collision_info.sliding_normal` (per-transition) and
`CPhysicsObj::sliding_normal` + `SLIDING_TS` (transient_state bit 4,
body-persisted) form a two-level cache:
| Step | Function | Address / pc line | What it does |
|---|---|---|---|
| seed | `CPhysicsObj::get_object_info` | 0x00511cc0, seed @0x00511d44 | `if (transient_state & 4) CTransition::init_sliding_normal(&this->sliding_normal)` — last frame's persisted normal seeds the new transition |
| consume | `CTransition::adjust_offset` | 0x0050a370 | `dot(offset, sliding_normal) < 0` → project the per-step offset (crease `cross(contact_plane.N, sliding_normal)` when grounded, `offset = n·dot` otherwise); `dot >= 0` (moving away) → `sliding_normal_valid = 0` |
| step gate | `CTransition::find_transitional_position` | 0x0050bdf0, small-offset @0x0050bf83-0x0050bfb7 | adjusted offset `|off|² < EPSILON²` at step 0 → transition FAILS; at step i>0 → succeed iff last validate state OK. **The absorbed frame is retail-faithful** — the persisted normal is a "still pressed against this wall" cache that suppresses re-testing |
| per-step clear | same | @0x0050c010 | `sliding_normal_valid = 0` (+ contact plane) BEFORE each step's `transitional_insert` — a step that runs and does not re-collide leaves the transition clean |
| in-transition write | `CTransition::validate_transition` | 0x0050aa70, write @0x0050ac21-ac30 | `if (collision_normal_valid) set_sliding_normal(collision_normal)`**the ONLY in-transition writer**. Fires when a step needed collision handling |
| body writeback | `CPhysicsObj::SetPositionInternal` | copy @0x005154c2, bit sync @0x005154e1 | `sliding_normal = transition's; SLIDING_TS ⇔ sliding_normal_valid`. **Success-only** — a failed `find_valid_position` discards the transition whole; the body keeps its prior state |
| NOT writers | `CSphere::slide_sphere` 0x00537440, `CCylSphere::slide_sphere` 0x0053b2a0, `BSPTREE::slide_sphere`/`step_sphere_up`/`find_collisions` | pc:321400+, 323700+ | grep-verified: **zero** `sliding_normal` references in the whole sphere/BSP layer (nothing between pc 283518 and 1155326). The sphere-level slide is IN-FRAME (`add_offset_to_check_pos`) |
So the answer to "where does retail clear it": **the success writeback**
(bit 4 syncs to the transition's final `sliding_normal_valid`, which the
per-step clear leaves false unless the last step's validate re-recorded a
collision) plus `adjust_offset`'s moving-away invalidation. On a FAILED
transition nothing clears it — and nothing needs to, because a persisted
normal can only have come from a validate write against real geometry
(pressed-at-a-wall is a correct absorbed state; any oblique input escapes
via the tangential projection remainder and the escape frame's writeback
clears the bit).
ACE mirrors all of it: seed `PhysicsObj.cs:2611`, writeback
`PhysicsObj.cs:1249-1251`, validate write `Transition.cs:1027`, the only
`SetSlidingNormal` call sites in ACE's whole physics tree.
## The wedge (live evidence, launch-175-verify2.log:42858)
The seam-hit frame **succeeded with full advance** (`ok=True`,
`out == tgt` in XY, +8 mm step-up settle, crossing 0x8A02016E→0x8A02017A)
and still recorded `hit=yes n=(1.00,0.03,0.03)`. Retail ending that frame
would write back `sliding_normal_valid=0` (no blocked step at the end → the
per-step clear wins) and the bit would CLEAR. We persisted a normal anyway —
because our BSP Contact branch carried **stub** slide responses
(`SetCollisionNormal + SetSlidingNormal + return Slid`) at the sites where
retail dispatches the real `slide_sphere`. Every following forward resolve
then seeded the stale normal, `adjust_offset` projected the
exactly-anti-parallel corridor push to zero, and the step-0 abort returned
`ok=False hit=no` with zero advance — before any collision test could
refresh the state. An absorbing wedge; strafing escapes because an oblique
offset keeps a tangential remainder.
## Divergence map → what shipped
| Site | Was | Retail | Action |
|---|---|---|---|
| `BSPQuery` Contact foot full-hit, step-up unavailable (recursion guard / engine-null) | stub | blocked step-up funnels to `step_up_slide``CSphere::slide_sphere` | **FIXED** — routes through `Transition.SlideSphereInternal` (the real port, #116-verified thresholds) |
| `BSPQuery` Contact head full-hit | stub | `BSPTREE::slide_sphere` @0x0053a697 (ACE BSPTree.cs:202, 310-316 — slides GlobalSphere[0]) | **FIXED** — same routing; the dead private stub rewritten as the faithful `BSPTREE::slide_sphere` wrapper |
| `PhysicsEngine.ResolveWithTransition` sliding writeback | unconditional (ran on `ok=False`) | `SetPositionInternal` success-only | **FIXED** — gated on `ok` (behaviorally latent today: a failed transition's ci always still holds the seed, so gate-vs-rewrite is value-identical; the gate removes the class) |
| `BSPQuery` Path-6 steep slide-tangent (2 sites) | in-frame projection + `SetSlidingNormal` | no BSP-layer write | left (documented deviation TS-4 — row amended to name the sliding write); L.5+ retail-strict follow-up |
| `Transition.SphereCollision` (shadow Sphere objects) | hand-rolled slide + `SetSlidingNormal` | `CSphere::intersects_sphere``slide_sphere`, no write | left — **new register row TS-45**; fix = route the tail through `SlideSphere` like `CylSlideSphere` (#172) does |
| seed / step loop / `AdjustOffset` / validate write @TransitionTypes:4317 / real `SlideSphere` port | — | — | verified faithful, unchanged |
Tests: `Issue137SlidingNormalLifecycleTests` — two site pins (Contact
foot-fallback + head full-hit must not write the sliding normal; face-on
grounded → `Collided` per the degenerate crease projection) + the
engine-level wall lifecycle pin (persist-on-block via validate →
absorbed exactly-anti-parallel frame → oblique escape CLEARS the body
state). Full solution suite green (Core 2545 / App 713 / UI 425 / Net 385).
## Mechanism 1 RESOLVED the same session — the "phantom wall" never existed
Follow-up dat + decomp work (same day) dissolved the PortalSide-poly theory
entirely; **no cdb session needed for this repro**:
1. **The recorded hit normal matches NO polygon.** A world-space sweep of
both seam cells + every portal-adjacent neighbor
(`Issue137CorridorSeamInspectionTests.CorridorSeam_FindPolygonMatchingLiveHit`)
found zero physics polygons within 18° of `(1.00,0.03,0.03)` near the
hit point. The normal is the player's **negated movement direction** — a
SYNTHETIC value from `slide_sphere`'s opposing-normals branch
(`reversed = gDelta``set_collision_normal`).
2. **The PortalSide polys were a red herring for this hit.** Cell
0x8A02016E has IDENTITY rotation (the prior session's "rotation maps
them into the X wall" was wrong); polys 1/3/5 are ±Y-normal planes at
world y≈38.33, 1.4 m beside the player's track and PERPENDICULAR to
the +X run — `pos_hits_sphere`'s directional cull (dot ≥ 0 → culled,
0x005394f0 tail) rejects them for that movement outright. They ARE
referenced by a physics-BSP leaf (`CorridorCell_PhysicsBspLeafMembership`),
so retail tests them too when approached INTO their plane — most likely
they are legitimately solid one-way/window-class geometry (which is why
the dat keeps PortalSide-only portal polys in the physics set while
removing every ExactMatch one). The pickup's warning against a blanket
"skip portal polys" filter stands — no filter is needed at all.
3. **A second slide_sphere port bug found and fixed:** the opposing-normals
branch returned OK where retail returns COLLIDED_TS
(0x005375d7-0x0053762c: `*normal = gDelta; normalize;
set_collision_normal; return 2`). Our OK let the step complete as-is
while carrying the synthetic reversed-movement collision normal —
`validate_transition`'s epilogue then converted it into the sliding
normal the wedge absorbed on. Fixed at the same TransitionTypes site;
pinned by `SlideSphere_OpposingNormals_ReturnsCollided_WithReversedDisplacementNormal`.
4. **The dat-backed corridor replay reproduces the live frame and runs
clean** (`Issue137CorridorSeamReplayTests`): same input, same full
advance to (85.253, 39.776, 5.992), same 016E→017A transit — now
`hit=no`, no sliding normal persisted, and six further forward frames
advance freely. (The pre-fix code did NOT reproduce the wedge in the
replay — the live entry chain involved session state beyond the
replay's reach — so the replay is the CLEAN-corridor pin, not a
red/green falsification; the site-level pins in
`Issue137SlidingNormalLifecycleTests` are the red→green proof.)
Remaining for #137: the user's corridor re-run (visual gate) + the issue's
door half (doors block/pass per open state — separate acceptance).

View file

@ -0,0 +1,152 @@
# Pickup prompt — #176/#177 root-caused, deferred to A7 dungeon lighting (paste into a fresh session)
**Read `claude-memory/project_render_pipeline_digest.md` FIRST** (top banner
is the #176/#177 outcome + DO-NOT-RETRY), then **ISSUES #176 and #177**, then
this file. Then read **roadmap Phase A7** (`docs/plans/2026-04-11-roadmap.md`
§"Phase A7 — Indoor lighting fidelity") — this session effectively pre-paid
A7's analysis for the light-cap slice.
## Where we are (2026-07-06 end of session)
**Currently working toward: M1.5 — Indoor world feels right.** Critical path:
#137 dungeon collision (DONE, gated), #138 teleport-OUT, **A7 dungeon lighting
(#79/#93 + now #176/#177)**.
HEAD = `d591e3bb` on `main` AND branch `claude/vigorous-joliot-f0c3ad`
(fast-forwarded, in sync). Working tree clean. All suites green
(Core 2591 + 3 skip / App 719 + 2 skip / UI 425 / Net 385).
Three commits this session:
| Commit | What |
|---|---|
| `b8e9e204` | #176/#177 investigation: 12 mechanisms refuted, apparatus shipped, probe protocol staged |
| `4d25e04d` | fix attempt: `MaxGlobalLights` 128→1024 (stops the pops) — **REVERTED** |
| `d591e3bb` | revert to 128 + full deferral docs (register AP-85 rewritten, ISSUES back to OPEN, digest DO-NOT-RETRY) |
A client may still be running (`launch-176-revert-check.log`) — the user
manages lifecycle. It is on the reverted 128 baseline (rooms normal, seam
flashes present = the still-open issue).
## THE ROOT CAUSE (confirmed, not a hypothesis)
**#176 and #177 are ONE bug: per-cell 8-light SET COMPOSITION churning under a
camera-nearest snapshot cap.** `LightManager.BuildPointLightSnapshot` keeps only
the `MaxGlobalLights=128` point lights nearest THE CAMERA; the Facility Hub
registers **366** fixtures, so 238 are evicted per frame by camera distance.
`SelectForObject` (the faithful per-object 8-cap, retail
`minimize_object_lighting` 0x0054d480) can only choose from the surviving 128 —
so an in-range torch of a VISIBLE cell that ranks past the cap drops out of that
cell's 8-set, and the cell's per-vertex Gouraud lighting flips as the camera
moves.
- **#176** — the flipping unit is a CELL → discontinuities at exactly cell-seam
granularity; camera-angle dependent (the chase boom swings the camera position,
re-ranking the 128); the dominant flipping light is the under-room PORTALS'
purple → purple flashes on the floor at seams.
- **#177** — a stair room whose fixtures ALL rank past the cap renders at bare
0.2 ambient (near-black = "not visible from the corridor"); approach
re-admits them ("pops into existence"); the eviction boundary sweeping during
the descent strips the ramp's lights ("disappears on the last step"). **The
geometry never vanished — its LIGHTS did.**
**How it was confirmed (the discriminator):** the user reproduced the flash
while `[light]` (ambient branch — stable 0.2 grey) AND `[pv-input]` (portal
flood — zero drops in 54k frames) read provably healthy in the probe log. That
eliminated every CPU signal the probes COULD see and left the one they can't:
set composition (`[light]` prints counts, not membership). The log's headline
number — `registeredLights=366` vs cap 128 — closed it.
## WHY THE FIX IS DEFERRED (do not re-raise the cap alone)
Uncapping (128→1024, `4d25e04d`) stopped the pops but the full 366-fixture pool
exposed three UNPORTED retail lighting semantics that then dominated the Hub —
this is why it was reverted (user: "rooms have no textures" → actually a magenta
light wash over intact textures; then "purple stripes… something fighting to
draw the purple lightning over the floor"):
1. **Light-through-solid-floors.** Retail registers lights per-CELL
(`insert_light` 0x0054d1b0); a light belongs to a cell and only lights that
cell's geometry. Our snapshot is a flat world-space sphere-overlap with NO
reach/occlusion notion → the under-room portals' purple light washed the
corridors ABOVE them. **This is the big one.** The 128 cap accidentally
MASKED it by keeping the pool camera-local (far under-room lights fell off
the list before they could reach up).
2. **Fixture falloff curve misassignment.** Stationary weenie fixtures
(ACE serves dungeon lanterns/braziers as CreateObject weenies) register via
the `isDynamic:true` path → D3D 1/d falloff (`LightInfoLoader.cs:89`,
GameWindow weenie-light block ~3688). Retail bakes STATIONARY fixture light
with the static 1/d³ curve (`calc_point_light` 0x0059c8b0, static_light_factor
1.3). 1/d is ~9× stronger at 3 m → every pool over-broad + over-saturated.
The `isDynamic` flag should be reserved for genuinely MOVING lights (portal
swirls, projectiles); a stationary fixture — even server-spawned — is static.
3. **Striped floor z-fight-like artifact.** User's 2nd screenshot: regular
magenta bands across one floor region, "like something is fighting to draw
the purple over the floor." **NOT attributed.** Ruled out: not coincident dat
geometry (the `CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs` sweep
found only the legit z=12 under-hall floor quad-fan, nothing near the 6
corridor floor); not a striped texture (all corridor surfaces are plain
`Base1Image` stone 0x08000375/6/7/8). Leading guess: two draws of the same
floor with DIFFERENT light sets (the per-cell-vs-per-something set assignment
splitting), or an MDI instance-order/light-set-index desync exposed only when
the purple light is stably present. **Hunt this in A7 with the full pool
temporarily on** — it's invisible at cap 128.
## THE A7 FIX SHAPE (the real fix, in order)
1. **Port per-cell light registration** (`insert_light` 0x0054d1b0 + the
per-cell light list retail keeps). A light lights its OWN cell's geometry +
cells reachable through portals — NOT arbitrary world-space overlap. This
kills #1 (through-floor) and makes the global pool cap irrelevant (per-cell
sets are naturally bounded), which is what actually lets #176/#177 close.
2. **Static curve for stationary fixtures.** Decide `isDynamic` by whether the
light MOVES, not by dat-static-vs-weenie origin. A server-spawned wall lantern
is stationary → static 1/d³. (Register AP-67/AP-44 are the weenie-light path;
AP-85 is the pool cap; #143 is the curve-by-path decision to revisit.)
3. **Hunt the stripes** with the full pool on (see #3 above).
4. **THEN uncap** `MaxGlobalLights` — un-skip
`LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`
(it asserts the retail end-state: an in-range light of a cell is never
camera-evicted).
## Tooling built this session (reuse, don't rebuild)
- **`tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs`**
— dat truth for the Hub: portal-poly draw verdicts, reciprocal coincidence,
stair geometry owner (`0x8A020182`'s ramp shell, vertical portals, ZERO
statics), CellBSP containment (partitions exactly at portal planes),
under-hall + corridor drawn-poly surface colors, DXT1 alpha histograms (0
transparent texels), and `CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs`
(the stripe-geometry sweep — came back empty for the 6 floor).
- **`tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs`**
— production-matched portal-flood replays (approach/descent/gaze-sweep/walk +
the ScenarioE incoherent-root sensitivity pin). Flood is HEALTHY — do not
re-investigate it for these issues.
- **`tests/AcDream.Core.Tests/Physics/Issue176177SeamTransitLagTests.cs`**
— resolver cell-flip is plane-exact (membership is NOT the bug).
- **`LightManagerTests.PointSnapshot_HubScaleLightCount_*`** — Skip'd
end-state pin (RED@128, GREEN@1024).
- **Ledger**: `docs/research/2026-07-06-176-177-render-pair-investigation.md`
(13 refuted mechanisms + the probe-run discriminator + the OUTCOME banner).
## Live probe env (all zero-cost off; use for the A7 spike)
```
ACDREAM_PROBE_LIGHT=1 # [light] insideCell/ambient/sun/registeredLights/activeLights — rate-limited
ACDREAM_PROBE_PVINPUT=1 # [pv-input] one line/frame: exact flood inputs + count
ACDREAM_PROBE_CELL=1 # [cell-transit] timeline anchors
ACDREAM_PROBE_TEXFLUSH=1 # [tex-flush] staged-upload drain (proves #105 healthy: after=0)
```
A7.L1's planned `[indoor-light]` probe (per-cell active-light dump: position,
color, attenuation, direction) is the natural next apparatus — it prints exactly
the SET COMPOSITION the current `[light]` counts can't. Build it FIRST.
## Launch protocol (unchanged)
`dotnet build` green first; PowerShell launch with the CLAUDE.md env block
(+ the probes above), background + Tee to `launch-*.log`. User manages client
lifecycle (graceful close → ACE clears in ~5 s; hard kill → ~3 min). Strip
`\000` before grep (PowerShell Tee = UTF-16): `tr -d '\000' < launch.log | grep ...`.
Test char spawns near `0x8A020179` (the ramp corridor); the 015E↔017A corridor
loop is the #176 repro; look-into + descend the 0178→0182→0183 stairs for #177.

View file

@ -0,0 +1,181 @@
# #176/#177 render pair — investigation ledger (2026-07-06, session 2)
## ✅ OUTCOME (same day, after the probe launch): ROOT CAUSE FOUND + FIX SHIPPED
**The probe run discriminated it.** The user reproduced the purple floor
flash while BOTH surviving CPU theories read provably healthy in the log —
`[light]` insideCell/ambient rock-stable (one pre-spawn outdoor line, then
flat 0.2 grey through 36 transits), `[pv-input]` flood stable (54k frames,
zero collapses). That eliminated T-A and T-B and exposed the one channel
the probes were structurally blind to: **per-cell 8-light SET COMPOSITION.**
The log's own headline number told the story: `registeredLights=366`
against `MaxGlobalLights = 128`. `BuildPointLightSnapshot` kept the 128
lights nearest THE CAMERA and evicted 238 every frame; `SelectForObject`
(camera-independent, faithfully retail — and unit-PINNED as such) could
only choose from the surviving 128. An in-range torch of a VISIBLE cell
that ranked past the cap dropped out of that cell's 8-set → the cell's
per-vertex Gouraud lighting flipped as the camera moved (the chase boom
swings the camera position by meters, re-ranking the 128):
- **#176:** the flipping unit is A CELL → discontinuity lines at exactly
cell-seam granularity; a torch-losing floor drops to dim blue-grey
stone (0.2 ambient × stone = the perceived purple); camera-angle
dependent by construction.
- **#177:** a stair room whose torches ALL ranked past the cap rendered
at bare 0.2 ambient — near-black in a dungeon = "not visible from the
corridor"; approaching re-admitted them = "pops into existence"; the
boundary sweeping during the descent dropped the ramp's lights =
"disappears on the last step". The geometry never vanished — its
LIGHTS did.
Retail anchor: `minimize_object_lighting` (0x0054d480) selects from the
cell-registered reaching set (`insert_light` 0x0054d1b0) — **no global
camera-nearest pool cap exists in retail.**
**Fix:** `MaxGlobalLights` 128 → 1024 (a non-biting safety valve; the
GPU packer grows to fit — 64 B/light). Register row **AP-85**. TDD pin:
`LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`
(RED at 128 with a Hub-scale 401-light layout, GREEN at 1024). All four
suites green. **Pending the user visual gate.**
Process note: the pre-existing test
`SelectForObject_CameraIndependent_DependsOnlyOnObjectCentre` was written
to pin "the property that kills the lights-up-as-I-approach popping" — it
proved the SELECTOR camera-independent while the SNAPSHOT it selects from
was camera-capped. The pop re-entered one stage upstream of the pin.
---
**Pre-launch status below (kept as the audit trail): mechanism NOT yet pinned — but the hypothesis space is now razor-thin.**
Twelve candidate mechanisms refuted by direct evidence (dat dumps, headless
replays, production-log analysis, code reads). Every layer that can be checked
offline is verified HEALTHY at the anchor cells. The surviving discriminator
requires ONE live probe launch (protocol at the bottom — piggyback on the
pending #175 door gate).
## The issues
- **#176** — purple flashing on dungeon floors at cell seams, camera-angle
dependent (Facility Hub).
- **#177** — stairs pop in/out across levels: (a) vanish on the last step
running down, (b) invisible looking into the stair room from the corridor,
(c) pop into existence on entering.
## Anchor-cell dat truth (Issue176177DungeonSeamInspectionTests)
- Corridor `0x8A02016E` floor = three abutting TEXTURED drawn portal polys
(polys 1/3/5, surface 0x08000377 → DXT1 tex 0x050026F7, `[PortalSide]`) →
under-hall `0x011E` (z=12). Reciprocal ceiling poly = NoPos (not drawn).
- The "stairs" = a RAMP owned by `0x8A020182` (inclined drawn polys, z 9…6
floor + 6…3 ceiling); `0x0183` = flat lower cell. Connections are
VERTICAL wall portals (NOT floor-portals). PortalSide flag asymmetry:
0x0182→0x0183 carries PortalSide; the back-portal does not.
- **Zero StaticObjects in all five anchor cells** (no #119-class statics, no
torch-bearing stabs → no registered point lights except the viewer fill).
- CellBSP volumes partition EXACTLY at the portal planes (no overlap zone).
- All surfaces resolve; DXT1 textures contain **zero** transparent-mode
texels (all 3-color-mode blocks, index 3 never used).
## REFUTED mechanisms (each by direct evidence — do NOT retry)
| # | Hypothesis | Killed by |
|---|---|---|
| 1 | Placeholder/missing texture (magenta class) | All surfaces resolve; drawn-poly sweep 0 misses |
| 2 | Reciprocal portal-poly z-fight | Reciprocal is NoPos (never drawn) |
| 3 | Seal depth-stamp z-fights the drawn floor-portal | Seals fire ONLY for `OtherCellId==0xFFFF` (GameWindow:11437); a sealed dungeon draws zero seals |
| 4 | Root/eye incoherence (viewer root lags the eye across portal planes) | Production camera sweep publishes coherent pairs; out-cell flips at x=85.001/88.335 — mm-exact at the planes (gate2 log). The flood DOES collapse to 1 cell under artificially incoherent inputs (ScenarioE pin) — but production inputs are coherent |
| 5 | Membership transit lag (0.330.47 m in [cell-transit]) as the render-root lag | The resolver flips within one tick-step of the plane in the harness (Issue176177SeamTransitLagTests); the logged "lag" is speed×tick quantization of the PLAYER probe, and the camera root (probe out-cell) is plane-exact |
| 6 | Flood bistability at the anchors | ScenarioC gaze sweep (2° steps, 4 pitches): 0 one-step drops; ScenarioA stair approach: ramp+lower admitted at all tested eyes/pitches |
| 7 | Staircase = EnvCell static culled by viewcone (#119 class) | Zero statics in the anchor cells; the stairs are shell geometry |
| 8 | Undefined DXT mip levels (compressed arrays skip GenerateMipmap) | Both ObjectMeshManager texture paths DECODE DXT→RGBA8 (BcDecoder) — the compressed-array branch of ManagedGLTextureArray is dead WB-heritage code; RGBA8 arrays get real mips |
| 9 | DXT1 3-color-mode alpha=0 texels + opaque-pass `discard a<0.05` / A2C | Block histogram: 0 transparent texels in all Hub floor/wall textures |
| 10 | Fog mix toward purple FogColor at distance | Fog ramp starts at `_nearRadius×192×0.7 ≈ 538 m` (radii stay 4/12 in dungeon mode); Hub sightlines ≤ ~100 m → fog term ≡ 0 |
| 11 | Lightning-flash additive (`uFogParams.z × (0.6,0.6,0.75)`) leaking indoors | `WeatherState._flashLevel` is 0 in production ("Production never TriggerFlashes") — dormant test hook. (The missing indoor gate is still real debt for when storm strobes ship.) |
| 12 | Viewer-light per-cell/per-vertex pops (hard range edge or 8-set membership flips) | The point ramp is `(1d/range)` — smoothly ZERO at the range boundary; set-membership beyond range is a zero-contribution no-op. No torches exist in the Hub cells to churn the 8-cap |
## VERIFIED-HEALTHY layers (offline pins, keep as regression assets)
- `PortalVisibilityBuilder` at the anchors: approach/descent/gaze-sweep/walk
scenarios (`Issue176177FacilityHubFloodReplayTests`) — admissions correct
and stable with coherent inputs; the ScenarioE incoherent-input collapse is
the sensitivity pin (1-cell flood when root≠eye side).
- Membership: `ResolveWithTransition` flips cells within one tick of the
portal plane, both directions (`Issue176177SeamTransitLagTests`).
- Mesh path: `CellMesh.Build` (production, GameWindow:7013) draws the
textured floor-portal strips; snapshot frustum gate (WbFrustum) is the
standard conservative p-vertex test; per-cell AABBs are vertex-derived,
8-corner transformed.
- Per-instance light sets are truly per-instance (EnvCellRenderer MDI).
## SURVIVING theories (need the live discriminator)
- **T-A (ambient flip):** any frame where `playerRoot` resolves null (or
`playerSeenOutside` defaults true) runs the OUTDOOR lighting branch —
purple sky ambient + full sun. Sun is directional-from-above → floors
(N·L≈1) catch it, walls (N·L≈0) barely → a FLOOR-selective purple-bright
flash, temporally lockable to whatever gaps CurrCell/TryGetCell. Desk
analysis found no per-frame gap trigger during plain corridor runs
(UpdatePlayerCurrCell is stale-beats-null; the registry is stable in
AP-36 dungeon mode) — but the branch inputs are exactly probed by
`[light] insideCell=` lines, so one run settles it.
- **T-B (flood with REAL production inputs):** my harness feeds synthetic
viewProj/eye. If the real per-frame inputs at the artifact moments differ
(collided camera pressed into walls near seams, damped-forward gaze), the
flood could still misbehave in configurations the sweeps missed.
`[pv-input]` prints the exact inputs + flood count per frame.
- **T-C:** unknown-unknown (GPU state, driver, MSAA resolve…). If T-A/T-B
both read clean at a flash moment → RenderDoc frame capture next.
## Also found (real, filed, not these bugs)
- **A8 double-sided shells stopgap still live**: `EnvCellRenderer.cs`
RenderModernMDIInternal maps `CullMode.Landblock → CullMode.None`
("while the architectural cause is isolated") — all cell shells draw
two-sided. Perf + correctness debt; retire under the A7/A8 lighting arc.
- **Lightning indoor gate missing** (dormant): when weather strobes ship,
`SceneLightingUbo.Build` needs the `playerInsideCell` gate or dungeons
will strobe blue-violet.
- **Flood follows floor-portals DOWNWARD from above** (ScenarioC: under-hall
network admitted at down-pitches). Retail's `portal_side` side test
(0x005a59a0: portal_side≠0 → NEGATIVE side only; ==0 → POSITIVE only;
IN_PLANE(±0.0002) → refused) *appears* to refuse this direction, but my
plane-sign reading had unresolved contradictions (CCellPortal::UnPack
normalizes portal_side at load, 0x0053ba1c/0x0053bc6a). OPEN QUESTION —
harmless-looking (extra admitted cells draw below the opaque floor,
z-buffer wins) but worth settling when the flood is next touched.
## The live probe protocol (piggyback on the #175 door gate launch)
Env (add to the standard launch block):
```
ACDREAM_PROBE_LIGHT=1 # [light] insideCell/ambient/sun — rate-limited
ACDREAM_PROBE_PVINPUT=1 # [pv-input] one line/frame: exact flood inputs + count
ACDREAM_PROBE_CELL=1 # [cell-transit] timeline anchors
```
User reproduces in Facility Hub (~2 min): run the corridor across several
seams until the purple flash shows; approach the stair room from the
corridor, walk in, run down. Then close. Discrimination:
- Flash moments + `[light]` shows `insideCell=False` blips or ambient jumps
**T-A confirmed** (then root-cause the gap trigger from the same log).
- `[pv-input]` flood count drops (e.g. 12→1) at flash/pop moments while
`[light]` stays clean → **T-B confirmed** (the line carries the exact
inputs to replay in `Issue176177FacilityHubFloodReplayTests`).
- Stairs invisible while the flood contains 0x0182/0x0183 → draw-side hunt
(RenderDoc next); absent → flood-side with the printed inputs.
- All clean → T-C: RenderDoc frame capture of a flash frame.
## Apparatus shipped this session
- `tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs`
— dat truth: portal polys/surfaces/draw verdicts, reciprocal coincidence,
stair geometry owner, CellBSP containment, under-hall surface colors,
DXT1 alpha histograms.
- `tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs`
— production-matched flood replays: stair approach, descent, gaze sweep,
corridor walk, and the ScenarioE incoherent-root sensitivity pin.
- `tests/AcDream.Core.Tests/Physics/Issue176177SeamTransitLagTests.cs`
— resolver cell-flip position at the seam (plane-exact pin).

View file

@ -0,0 +1,268 @@
# A7 dungeon lighting — retail per-cell light model (source-confirmed pseudocode)
**Date:** 2026-07-06 (continuation of the #176/#177 arc)
**Purpose:** the mandated `grep named → decompile → pseudocode → port` step 3 for
the A7 per-cell lighting fix. Captures the RETAIL light-selection model exactly as
read from `docs/research/named-retail/acclient_2013_pseudo_c.txt`, so the port can
match it line-for-line.
> ⚠️ **This document CORRECTS the #176/#177 handoff's framing.** The handoff
> (`2026-07-06-176-177-handoff-A7-lighting.md`) and the digest banner state that
> "retail registers lights per-CELL via `insert_light` 0x0054d1b0" and that
> "retail's `minimize_object_lighting` has NO global camera-nearest pool cap."
> **Both are imprecise.** Reading the source: `insert_light` maintains a GLOBAL
> player-nearest sorted pool with a SMALL cap (40 static + 7 dynamic), functionally
> analogous to acdream's `BuildPointLightSnapshot`. The real per-cell mechanism is
> the *collection phase*: retail rebuilds that global pool **each frame from only
> the currently-VISIBLE cells** (`CEnvCell::add_*_lights` walks the portal-flood
> `visible_cell_table`). That is why retail's tiny cap never bites — the candidate
> pool is pre-scoped by visibility, not by camera distance over the whole dungeon.
> This is a *better* fit for acdream than the handoff's framing, because acdream
> already computes the visible-cell set every frame (the portal flood).
---
## 1. The retail model, as source-confirmed
### 1.1 Each cell owns a light list (`CObjCell` / `CEnvCell`)
- `CObjCell::add_light(this, LIGHTOBJ*)` (`0x0052b1d0`) — appends a light to the
cell's own `light_list` (a `DArray<LIGHTOBJ const*>`), `num_lights` counter.
Populated at cell load: `CEnvCell::UnPack` (`0x0052d470`) unpacks `num_lights`
(line ~310877) and the light list straight from the dat CellStruct; the outdoor
path feeds it from the landblock's static object lights (caller at line ~285976,
`CObjCell::add_light(cell, lights->lightobj + i)`).
- So a light is DATA owned by the cell it sits in — dungeon torches live in the
EnvCell's `light_list`; a landblock's lamp-posts live in the LandCell's list.
### 1.2 A cell pushes its own lights to the global pool
```
CObjCell::add_static_to_global_lights(cell): # 0x0052b350
for lightobj in cell.light_list[0 .. cell.num_lights):
if (lightobj.flags & 1) != 0: # bit 0 set = STATIC light
Render::add_static_light(lightobj.info, cell.m_DID.id, lightobj.frame)
CObjCell::add_dynamic_to_global_lights(cell): # 0x0052b390
for lightobj in cell.light_list[0 .. cell.num_lights):
if (lightobj.flags & 1) == 0: # bit 0 clear = DYNAMIC light
Render::add_dynamic_light(lightobj.info, cell.m_DID.id, lightobj.frame)
```
The cell id (`cell.m_DID.id`) is passed through as `arg6` so the light carries its
owning cell (stored at `+0x6c` on the RenderLight; used by `insert_light` for the
block-offset distance math).
### 1.3 Per frame, ONLY visible cells contribute (the crux)
```
CEnvCell::add_dynamic_lights(): # 0x0052d410
for cell in CEnvCell::visible_cell_table: # the PORTAL-FLOOD visible set
CObjCell::add_dynamic_to_global_lights(cell)
# static counterpart — same function that ends at 0x0052def0 (line ~311650):
for cell in CEnvCell::visible_cell_table: # SAME visible set
cell.init_static_objects()
CObjCell::init_objects(cell)
CObjCell::add_static_to_global_lights(cell)
```
`visible_cell_table` is the set of cells reached by the portal flood from the
viewer's cell (retail `CEnvCell::find_visible_cells` / the `PView` gather). **A
dungeon with 366 fixtures but only 5 visible cells contributes only those 5 cells'
lights to the global pool.** This is the entire reason retail doesn't churn.
### 1.4 The global pool is small and player-sorted (`insert_light`)
```
Render::insert_light(maxCount, &num, lights[], sorted[], info, cellId, frame, base): # 0x0054d1b0
distsq = 0
if info.type == 0: # point light
# squared distance from THIS light to the PLAYER, across the cell block offset
blockOff = LandDefs::get_block_offset(player_pos.objcell_id, cellId)
distsq = |(frame.origin + blockOff) - player_pos.frame.origin|²
# ... write RenderLight fields (color/255, intensity, falloff, cone, distancesq=distsq)
# insertion-sort into sorted[] ascending by distancesq (nearest player first),
# capped at maxCount; when full, evict the farthest-from-player.
Render::add_static_light(info, cellId, frame): # 0x0054d3e0
insert_light(max_static_lights, &world_lights.num_static_lights,
world_lights.static_lights, world_lights.sorted_static_lights,
info, cellId, frame, max_dynamic_lights + 1)
Render::add_dynamic_light(info, cellId, frame): # 0x0054d420
insert_light(max_dynamic_lights, &world_lights.num_dynamic_lights,
world_lights.dynamic_lights, world_lights.sorted_dynamic_lights,
info, cellId, frame, 1)
```
**Cap values:** `max_static_lights` / `max_dynamic_lights` (`0x0081ec94` / `0x0081ec98`)
init to **0x28 = 40** and **0x7 = 7**. Recomputed in `Render::SetDegradeLevelInternal`
(`0x0054c3c0`) as a function of the graphics degrade level (constants 25/50/8/16) —
always small (tens of static, single-digit dynamic). Retail deliberately keeps the
global pool tiny; it can, because §1.3 pre-scopes the input by visibility.
### 1.5 Per-object selection (`minimize_object_lighting`) — this IS acdream's `SelectForObject`
```
Render::minimize_object_lighting(): # 0x0054d480
reset_active_lights_state()
used = 0
# DYNAMIC lights first (priority), pre-sorted nearest-player:
for i in 0 .. num_dynamic_lights:
if used < 8 and remove_object_light(sorted_dynamic_lights[i].info) == keep:
add_active_light(i, 2); used += 1
else: dynamic_light_used[i] = 0
# STATIC lights fill remaining slots:
for i in 0 .. num_static_lights:
if used >= 8: static_light_used[i] = 0; continue
L = sorted_static_lights[i]
if L.info.type != 0: # non-point (directional): always use
add_active_light(i, 1); used += 1
else: # point: sphere-overlap test
reach = L.range + local_object_radius
if |L.pos - local_object_center|² - reach² < 0.0002: # spheres overlap
add_active_light(i, 1); used += 1
else: static_light_used[i] = 0
enable_active_lights()
```
acdream's `LightManager.SelectForObject` already does the sphere-overlap + 8-cap.
The one fidelity gap: retail fills **dynamic-first (priority), then static**, from two
separate player-sorted arrays; acdream selects from one camera-sorted snapshot.
Minor — parity item, not the #176/#177 cause.
### 1.6 Static falloff curve (`calc_point_light`) — fix #2 reference
`calc_point_light` (`0x0059c8b0`) is retail's CPU per-vertex software lighting for
static geometry (accumulates into `CUSTOM_D3D_VERTEX2` r/g/b). Structure:
```
calc_point_light(vertex, &r, &g, &b, info):
d = |info.offset.origin - vertex.pos|
range = info.falloff * static_light_factor # static_light_factor ≈ 1.3
if d < range:
# N·L diffuse gate: 0.5*d + dot(vertex.normal, info.pos - vertex.pos) > 0
if faces_light:
atten = <1/d-ish curve, x87 SEE WARNING>
f = atten * (1 - d/range) * info.intensity
r += clamp(f * info.color.r, .. info.color.r) # per-channel clamp to the light's own colour
g += clamp(f * info.color.g, ..)
b += clamp(f * info.color.b, ..)
```
> ⚠️ **Do NOT port the exact `atten` curve from this BN pseudo-C.** Lines
> 425331425341 are dense x87 FPU register juggling (`distsq/dist` vs
> `1.5/(distsq·dist)` branch on `distsq ≷ 1`), exactly the "x87 dropout / misread"
> class the project has been burned by twice (see `feedback_bn_decomp_field_names`,
> `feedback_retail_binary_dispatch`). When implementing fix #2, cross-reference a
> SECOND source (ACE / ACViewer static-light port, or the Ghidra decomp) and pin
> the curve with a conformance test before trusting it. The STRUCTURE above
> (range = falloff × static_light_factor, per-vertex N·L, intensity scale, colour
> clamp) is solid; the attenuation exponent is the part to verify.
---
## 2. Why #176/#177 happen in acdream (refined root cause)
acdream `LightManager` registers **every** fixture permanently into `_all` (server
weenie spawns + EnvCell static hydration), then `BuildPointLightSnapshot` caps at
`MaxGlobalLights=128` **nearest-CAMERA** over the WHOLE registered set. In the
Facility Hub (366 fixtures) that evicts 238/frame by camera distance; `SelectForObject`
can only choose from the surviving 128, so an in-range torch of a *visible* cell that
ranks past the cap drops from that cell's 8-set and the per-cell Gouraud lighting pops
as the camera moves (#176 seam flash / #177 stair-room pop-in).
**Retail never has 366 candidates.** It rebuilds `world_lights` each frame from ONLY
the visible cells' `light_list`s (§1.3), so the candidate pool is a handful of cells —
under the 40+7 cap — and nothing gets evicted. The camera-distance cap is a backstop
that essentially never fires because the input is already visibility-scoped.
This also explains the **through-floor purple wash** the cap-raise exposed: acdream's
flat world-space sphere-overlap of all 366 lights let an under-room portal light reach
up through a solid floor. Retail's under-room cell isn't in the corridor's
`visible_cell_table` (the flood doesn't pass through the solid floor), so its light
never enters the pool. Per-cell reach = *the light is only a candidate when its cell
is visibly flooded.*
---
## 3. The fix (materially different from "just uncap MaxGlobalLights")
**Port the visibility-scoped per-frame collection**, not a bigger cap:
1. **Tag each `LightSource` with its owning cell id** (add `CellId` to `LightSource`;
populate at every registration site from the cell/landblock in scope). Retail's
`add_*_light(info, cellId, frame)` carries exactly this.
2. **Build the per-frame point-light pool from ONLY the currently-visible cells**
the portal-flood set the renderer already computes — instead of the whole `_all`
set. This is retail's `add_*_lights over visible_cell_table`. The pool is then
naturally bounded; `MaxGlobalLights` stops biting (can keep 128 or adopt retail's
40+7 as a documented backstop). The Skip'd end-state pin
(`LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`)
asserts exactly this: an in-range light of a visible cell is never camera-evicted.
3. **Fix #2 — static curve for stationary fixtures.** Decide `isDynamic` by whether
the light MOVES, not by dat-static-vs-weenie origin. A server-spawned wall lantern
is stationary → static 1/d³ (range × 1.3), reserving `isDynamic` (range × 1.5, 1/d)
for genuinely moving lights (portal swirls, projectiles). See §1.6 warning.
4. **Fix #3 — hunt the striped floor artifact** with the full (now visibility-scoped)
pool on. Invisible at cap 128; see the handoff for the two leading guesses.
5. **THEN uncap / adopt the retail cap** and un-skip the end-state pin.
### 3.1 acdream integration surface — as SHIPPED (slice 1: visible-cell scoping)
The renderers already select per-cell (`EnvCellRenderer.cs:1088`) and per-object
(`WbDrawDispatcher.cs:2095`) from `LightManager.PointSnapshot`; the ONLY defect was
that `PointSnapshot` was built by capping the whole `_all` set at 128 nearest-CAMERA.
The fix scopes that pool to visible cells. Concretely:
1. **`LightSource.CellId`** (new `uint`, 0 = cell-less/global). Retail's per-light cell
(insert_light arg6 → RenderLight +0x6c).
2. **`LightInfoLoader.Load(..., uint cellId = 0)`** propagates it onto each light.
3. **Both registration sites tag the owning cell** from `entity.ParentCellId`:
- Site A live weenie fixtures — `GameWindow.cs:~3682` (`cellId: entity.ParentCellId ?? 0u`).
- Site B dat EnvCell statics — `GameWindow.cs:~7696` (same).
- Viewer fill light keeps `CellId == 0` (always in the pool — retail's per-frame
`add_dynamic_light(&viewer_light, objcell_id)` is unconditional).
4. **`LightManager.BuildPointLightSnapshot(camPos, IReadOnlySet<uint>? visibleCells)`** —
a light joins the pool iff `CellId == 0` OR `visibleCells == null` (outdoor) OR
`visibleCells.Contains(CellId)`. The 128 cap stays as a now-non-biting backstop.
5. **The seam.** The per-frame order is `UpdateViewerLight → Tick → BuildPointLightSnapshot
(null-scope) → SceneLightingUbo.Build → Upload` (`GameWindow.cs:9058-9095`), and the
portal flood + all cell/entity draws happen LATER, INSIDE
`RetailPViewRenderer.DrawInside`. So the scoped rebuild is threaded via a new context
callback: `RetailPViewDrawContext.RebuildScopedLights`, invoked in `DrawInside` right
after `prepareCells` (every cell drawn this frame) is finalized and BEFORE
`PrepareRenderBatches` / the draws (`RetailPViewRenderer.cs:~131`). GameWindow wires it
to `visible => Lighting.BuildPointLightSnapshot(camPos, visible)` (`GameWindow.cs:~9371`).
The renderers hold a reference to the same `_pointSnapshot` list (rebuilt in place), and
`EnvCellRenderer._cellLightSetCache` is `.Clear()`'d every pass, so no stale indices.
`SceneLightingUbo.Build` reads `lights.Active` (Tick), not the snapshot, so it is
unaffected by the relocation. The outdoor `else` path (clipRoot == null: pre-login /
fly) never invokes the callback and keeps the legacy null-scope full pool.
6. **Validation apparatus**`ACDREAM_PROBE_INDOOR_LIGHT=1` → one rate-limited
`[indoor-light]` line per second with the scoped-pool SET COMPOSITION
(`RenderingDiagnostics.EmitIndoorLight`): `visibleCells / pool / cellLess / registered /
droppedNonVisible / byCell[]`. This is the discriminator the `[light]` COUNTS couldn't
give (#176/#177 lived in set membership).
Fixes #2 (static curve) + #3 (stripe hunt) + the cap decision are follow-on slices.
---
## 4. Source anchors (for the register + future sessions)
| Retail fn | Addr | Role |
|---|---|---|
| `CObjCell::add_light` | 0x0052b1d0 | append light to a cell's own list |
| `CObjCell::add_static_to_global_lights` | 0x0052b350 | push a cell's static lights to the global pool |
| `CObjCell::add_dynamic_to_global_lights` | 0x0052b390 | push a cell's dynamic lights to the global pool |
| `CEnvCell::add_dynamic_lights` | 0x0052d410 | per-frame: walk `visible_cell_table`, collect dynamic |
| (static collector, ends) | 0x0052def0 | per-frame: walk `visible_cell_table`, collect static |
| `CEnvCell::UnPack` | 0x0052d470 | unpack a cell's `num_lights` + `light_list` from dat |
| `Render::insert_light` | 0x0054d1b0 | player-nearest sorted insert into `world_lights`, capped |
| `Render::add_static_light` / `add_dynamic_light` | 0x0054d3e0 / 0x0054d420 | thin wrappers → insert_light |
| `Render::minimize_object_lighting` | 0x0054d480 | per-object ≤8 pick (dynamic-priority, then static sphere-overlap) |
| `Render::SetDegradeLevelInternal` | 0x0054c3c0 | recomputes `max_static/dynamic_lights` from degrade level |
| `calc_point_light` | 0x0059c8b0 | CPU per-vertex static light curve (fix #2 ref) |
| `max_static_lights` / `max_dynamic_lights` | 0x0081ec94 / 0x0081ec98 | init 40 / 7 |