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

@ -46,6 +46,411 @@ Copy this block when adding a new issue:
---
## #179 — Lightning flash has no indoor gate (dormant until weather strobes ship)
**Status:** OPEN (dormant — zero production impact today)
**Severity:** LOW (latent)
**Filed:** 2026-07-06
**Component:** render — scene lighting UBO / weather
**Description:** `mesh_modern.frag` adds `uFogParams.z × vec3(0.6, 0.6, 0.75)`
(the lightning-flash bump) to EVERY fragment, and `SceneLightingUbo.Build`
copies `atmo.LightningFlash` with no indoor gating (the GameWindow fog
override explicitly preserves `.z`). Today `WeatherState._flashLevel` is 0
in production ("Production never TriggerFlashes" — test hook only), so
nothing is visible. The moment storm strobes ship, sealed dungeons will
flash blue-violet with every strike. Retail's indoor lighting path (flat
ambient, sun killed via the seen_outside gate) carries no storm terms.
**Acceptance:** the flash term is zeroed for `playerInsideCell` frames (or
gated at the UBO build), verified by a storm-in-dungeon probe when weather
strobes land. Found during the #176/#177 investigation
(`docs/research/2026-07-06-176-177-render-pair-investigation.md`).
---
## #178 — Retire the A8 double-sided cell-shell stopgap (CullMode.Landblock → None)
**Status:** OPEN
**Severity:** LOW-MEDIUM (correctness/perf debt; 2× shell fragment load)
**Filed:** 2026-07-06
**Component:** render — EnvCellRenderer MDI draw
**Description:** `EnvCellRenderer.RenderModernMDIInternal` still carries the
Phase A8 visual-gate stopgap: `if (cullMode == CullMode.Landblock) cullMode
= CullMode.None;` — "render cell polys double-sided while the architectural
cause is isolated." Every cell shell draws two-sided to this day. Retail
draws cell polygons single-sided (the drawing BSP + winding decide facing).
The "architectural cause" (winding convention vs the frame-global CW
front-face) was never isolated; the stopgap outlived its gate. Retiring it
needs the winding audit (which side do CellStruct polys wind under our
extraction?) + a visual gate — walls/floors must not vanish.
**Acceptance:** cell shells draw with proper backface culling, no missing
surfaces at the Holtburg + Facility Hub gates. Found during the #176/#177
investigation (`docs/research/2026-07-06-176-177-render-pair-investigation.md`).
---
## #177 — Dungeon stairs pop in/out across levels (invisible until entering the room; last step vanishes running down)
**⚠️ UPDATE 2026-07-06 (visual gate) — this is NOT lighting.** The A7 visible-cell
light-scoping fix shipped + was probe-validated, but the user's gate showed the stairs
STILL not visible looking back from the corridor (zoom-out changes the last-step case).
Eye-position/flood behavior ⇒ a portal-VISIBILITY miss at the stair cells
(0178/0182/0183), NOT the "its LIGHTS went dark" attribution recorded below. Re-diagnose
as visibility. See the render digest banner.
**Status:** OPEN
**Severity:** MEDIUM (visible geometry churn in the M1.5 dungeon)
**Filed:** 2026-07-06
**Component:** render — indoor portal-flood visibility (dungeon multi-level)
**Description (user, Facility Hub, 2026-07-06 gate session):** a staircase
connecting two levels (a) disappears on roughly the last step when running
DOWN it, (b) is not visible when looking into the stair room from the
corridor, and (c) pops into existence on entering the room. Classic
portal-visibility miss: the stair geometry's cell is not reached by the
portal flood from the viewer's cell until the viewer crosses into it.
**Status:** OPEN — root cause CONFIRMED; fix DEFERRED to the A7
dungeon-lighting arc (the cap-raise fix was live-tested and REVERTED,
see below).
**Root cause (confirmed via the probe launches):** the geometry never
vanishes — its LIGHTS do. `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.
A room whose torches all rank past the cap renders at bare 0.2 ambient
(near-black in a dungeon = "not visible"); approaching re-admits them
("pops into existence"); the eviction boundary sweeping with the camera
drops the ramp's lights mid-descent ("disappears on the last step").
Retail's `minimize_object_lighting` (0x0054d480) has no global
camera-nearest cap.
**Why the fix is deferred:** raising the cap to 1024 (commit `4d25e04d`)
made the pops stop but exposed three unported retail lighting semantics
that DOMINATE the frame with the full pool active: (a) lights reach
through solid floors/walls — retail registers lights per-CELL
(`insert_light` 0x0054d1b0) so the under-room portal light never touches
the corridor above; our flat sphere-overlap has no reach notion; (b)
stationary weenie fixtures ride the DYNAMIC 1/d falloff (~9× retail's
static 1/d³ at 3 m — the #143 isDynamic misassignment for ACE-served
fixtures); (c) an unexplained striped z-fight-like artifact on lit floor
regions (user screenshot). Reverted to 128 (`AP-85` documents the
stopgap; the desired-end-state pin is Skip'd in LightManagerTests).
**A7 fix shape:** per-cell light registration (insert_light port) +
static curve for stationary fixtures + the stripe hunt, THEN uncap.
Full investigation ledger:
`docs/research/2026-07-06-176-177-render-pair-investigation.md`.
**Acceptance:** the staircase renders whenever its room is visible through
the connecting opening, and stays rendered through the full descent.
---
## #176 — Purple flashing on dungeon floors at cell seams, camera-angle dependent
**⚠️ UPDATE 2026-07-06 (visual gate) — the light-set/camera-cap theory is REFUTED.** The
A7 scoping fix shipped + was probe-validated (~285 through-floor lights dropped/frame),
yet the purple flash was UNCHANGED. `[light-detail]` names the real cause: a single
over-bright purple POINT light — `kind=Point range=9 intensity=100 color=(0.784,0,0.784)`
(200/255,0,200/255) — washing the floor. NEXT: identify its owning entity/Setup; decide
whether `intensity=100` is a dat mis-parse or a portal/effect light we render wrong. Not
set-composition, not through-floor. See the render digest banner.
**Status:** OPEN
**Severity:** MEDIUM (visible artifact along every corridor seam in the M1.5 dungeon)
**Filed:** 2026-07-06
**Component:** render — floor-portal polygons / portal surface state
**Description (user, Facility Hub, 2026-07-06 gate sessions):** the floor
flashes with a purple overlay at cell seams, at certain camera angles only.
Initially suspected to be the #137 physics oscillation exposed by the
render; the physics fix landed (seam shake gone, user-gated) and the flash
REMAINS — so it is a render-side issue in its own right, correlated with
camera angle.
**Status:** OPEN — root cause CONFIRMED; fix DEFERRED to the A7
dungeon-lighting arc (see #177 for the revert story — same mechanism,
same deferral).
**Root cause (confirmed via the probe launches):** per-cell LIGHTING pops,
not a draw failure. The probe run reproduced the flash while the ambient
branch ([light] — stable 0.2 grey) and the portal flood ([pv-input] —
zero drops in 54k frames) were provably healthy, which eliminated the
last CPU-side theories and left the one channel the probes cannot see:
per-cell 8-light SET COMPOSITION. The camera-capped snapshot (128 of the
Hub's 366 fixtures, nearest-to-camera) evicts in-range lights of visible
cells; the flipping unit is a CELL, so the discontinuities sit at exactly
cell-seam granularity, swing with the camera position (the chase boom),
and the dominant flipping light is the under-room PORTALS' purple —
hence purple flashes on the floor. Twelve other mechanisms were refuted
first — ledger in
`docs/research/2026-07-06-176-177-render-pair-investigation.md`.
**Deferral:** the uncapped pool (live-tested `4d25e04d`, reverted)
stabilizes the pops but floods rooms with through-floor portal light
(no per-cell reach semantics), over-strong dynamic-curve fixture light,
and a striped floor artifact — the A7 arc owns the real fix (per-cell
`insert_light` registration + static fixture curve + stripe hunt, then
uncap). Register row AP-85.
**Acceptance:** no purple/placeholder flashes on dungeon floors from any
camera angle at the corridor seams.
---
## #175 — Door collision registers the Setup PLACEMENT pose, not the motion-table CLOSED pose (phantom slab behind the visual door)
**Status:** 🟡 FIX SHIPPED 2026-07-05 (same session) — pending user gate (Facility Hub double door: closed blocks AT the visual panels from both sides, no embed, no phantom wall; Holtburg cottage door unregressed).
**FIX:** `ShadowShapeBuilder.FromSetup` gains a `partPoseOverride` (BSP part
shapes only; CylSphere/Sphere unchanged); `RegisterServerEntityCollision`
derives it via `GameWindow.MotionTableDefaultPose` — the wire MotionTableId's
default style, first cycle, LowFrame part frames (the closed/idle pose retail's
live CPhysicsPart holds). Null / short poses fall back per-part to placement
frames (table-less entities + landblock statics unchanged). Register row
AP-84 (one-shot registration snapshot vs retail's per-frame live pose —
equivalent for the door lifecycle since open = ETHEREAL). Pins: the three
`FromSetup_*` tests in `Issue175HubDoorPoseInspectionTests`.
**Severity:** MEDIUM-HIGH (embed into doors from one side; phantom wall on the other — can push the player out of use radius)
**Filed:** 2026-07-05
**Component:** physics — server-entity collision registration (door part poses)
**Description (user, Facility Hub door guid 0x78A020C7 / Setup 0x02000C9D):**
running at the door embeds the player INTO the visual panel (deep enough to
camera-clip to the other side); the actual blocking plane sits displaced to
the FAR side, and approaching from that side there's a phantom wall in front
of the visual door — far enough that the door can be out of use range.
**Mechanism (dat-confirmed, 2026-07-05):** the hub door is a DOUBLE door —
Setup 0x02000C9D has 3 parts; panels part[0]/part[1] (GfxObj 0x01002936,
physics slab 1.66×0.29×2.95 m) pose in the Setup's `Default` PLACEMENT
frames at yaw **150° / 30°** with origin **(±0.88, 0.44, 1.37)** — an
AJAR pose displaced 0.44 m behind the doorway plane. The RENDERED door poses
its panels from the motion table's default (closed) state via the sequencer
(the setup itself has no DefaultMotionTable; the wire spawn supplies it).
Collision registers via `ShadowShapeBuilder.FromSetup`, which reads the
PLACEMENT frames (`Resting|Default|first`) — so the physical slabs sit at
the ajar placement pose while the visuals show closed panels: the exact
offset the user walked into. Retail tests each part's LIVE pose
(`CPhysicsPart` — see the #150 notes: for a CLOSED door the live pose IS
the motion-table closed pose; the open swing never matters because ETHEREAL
bypasses collision entirely).
**Fix shape (retail-faithful, next session):** the BSP shadow shapes for
server entities with a sequencer must use the SEQUENCER's part transforms
(the motion-table default/closed pose) instead of the raw placement frames —
either sample at registration (the sequencer exists by then — verify spawn
wiring order) or re-register via `ShadowObjectRegistry.UpdatePosition`-style
refresh after the sequencer's first advance. Parts without animation data
keep the placement-frame fallback. Watch: entScale composition, multi-part
dedup ([[feedback_dedup_keys_after_cardinality_change]]), and the Holtburg
single-door apparatus must stay green (its placement pose ≈ closed pose,
which is why #99/#150 never surfaced this).
**Files:** `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (placement-frame
read), `src/AcDream.App/Rendering/GameWindow.cs`
(`RegisterServerEntityCollision` ~4130), inspection
`tests/AcDream.Core.Tests/Physics/Issue175HubDoorPoseInspectionTests.cs`.
**Acceptance:** at the Facility Hub double door: closed door blocks AT the
visual panels (no embed, no phantom wall on either side); open door fully
passable; use radius reachable from both sides. Holtburg cottage door
unregressed (door apparatus green).
---
## #174 — Door Use dies after the first jump: the RemoveLinkAnimations seam stripped animations without retail's queue drain
**Status:** 🟡 FIX SHIPPED 2026-07-05 (same session) — pending user gate (jump around, then use the Facility Hub door from close AND from ~3 m).
**FIX:** the `MotionInterpreter.RemoveLinkAnimations` seam is retail
`CPhysicsObj::RemoveLinkAnimations` 0x0050fe20 — a tailcall to
`CPartArray::HandleEnterWorld` 0x00517d70 → `MotionTableManager::
HandleEnterWorld` 0x0051bdd0: strip the sequence's link animations AND drain
`pending_animations` completely (each pop relays MotionDone → the interp pops
its `pending_motions` node in lockstep). acdream bound the seam to the BARE
sequence strip (`RemoveAllLinkAnimations`), so every jump's LeaveGround
removed the animations that queued manager nodes were counting down on —
orphaning them and permanently damming BOTH queues; `MotionsPending()` then
starved every armed moveto (the far-range walk-to-door and the close-range
use turn — both door faces below). Rebound at both production sites
(GameWindow remote bindings + the player's EnterPlayerModeNow block) to
`Manager.HandleEnterWorld()`; harness mirrors updated; pins
`Issue174LinkStripDrainTests` (seam drains both queues; new motions queue +
complete after). The `UseDone` (0x01C7) display gap stays open below.
**Severity:** HIGH (can't open doors reliably → blocks the #137 door acceptance + normal play)
**Filed:** 2026-07-05
**Component:** interaction — B.4b Use pipeline / AP-23 speculative moveto deferral (R5-V5 facade)
**Description (user, Facility Hub 0x8A02, door guid 0x78A020C7 Setup 0x02000C9D
useRadius=0.50):** double-clicking / R-using the door does nothing. The same
door opens fine from the retail client on the same ACE, and acdream renders
the observed swing correctly (inbound path healthy).
**Evidence (3 wire captures + app log, 2026-07-05):**
1. First attempt (log): `use-deferred seq=624` fired (the close-range
deferral COMPLETED once — arrival callback ran), then 625 + 642647 sent
far-range. Door never opened. ACE's replies to those weren't captured.
2. Retail control (capture `door-use3.pcapng`): retail sent
`[0xF7B1][seq][0x36][guid]` ×5 — ACE responded EVERY time with door
`0xF74C` UpdateMotion + `0xF74B` SetState (ETHEREAL toggling 0x1001C ↔
0x10018), broadcast to BOTH clients. Message format identical to ours.
3. acdream re-try (capture `door-use4.pcapng` + log): 2× DoubleClick + 4× R
— picks land, but ZERO `[B.4b] use` lines and ZERO door-guid packets on
the wire. The Use is swallowed CLIENT-SIDE before the send.
**Mechanism (code trace):** `SendUse` close-range branch (≤2 m by the AP-23
bucket — this door's flags → 2.0 m, overriding its real 0.5 m) parks the
action in `_pendingPostArrivalAction` and fires it ONLY from
`MoveToComplete(WeenieError.None)` (natural completion of the speculative
TurnToObject installed through the R5-V5 facade). A cancel (user input) or a
never-starting turn silently eats the use — no toast, no log (the deferral
prints are probe-gated). Candidates for "never completes": (a) the
`BeginTurnToHeading` `MotionsPending()` early-return starving the turn (the
#170 class, local-player edition — the log shows a steady
`MOTIONDONE pending=True` stream); (b) every attempt instantly cancelled by
concurrent user movement input (retail-faithful per-attempt, but the user
also clicked while standing still). Also noted: `GameEventType.UseDone`
(0x01C7) is parsed nowhere (not registered in `GameEventWiring`) — ACE
rejection reasons are invisible; and the first attempt's 7 sent uses never
opened the door either (suspect: concurrent outbound movement cancelling
ACE's MoveToChain server-side — unproven, replies not captured).
**Files:** `src/AcDream.App/Rendering/GameWindow.cs` (`SendUse` ~12607,
`OnAutoWalkArrivedSendDeferredAction` ~12761, `InstallSpeculativeTurnToTarget`
~12842, `MoveToFactory` callback wiring ~13530);
`src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`BeginNextNode` /
`BeginTurnToHeading` gates). Captures in the session scratchpad
(`door-use3.pcapng`, `door-use4.pcapng`).
**ROOT MECHANISM FOUND (2026-07-05 evening, probe round `launch-174-autowalk.log`):
the local player's pending-motion queue drains at ~1 node/sec and backs up
minutes deep during active play — MotionsPending() then starves every
manager-driven movement.** Chain, all evidence in the log:
1. Fresh session, standing at the door: every Use completes same-tick
(`[autowalk-end] err=None` ×6, seqs 1116 sent, door opens — "now it
works??"). Queue shallow ⇒ pipeline healthy.
2. After the jump/run sequence: the LAST player `pending=False` completion
is at the first `MovementJump Press` (log line 371); from there to the
end (line 939) every player MOTIONDONE reports `pending=True` — INCLUDING
at rest, with old jump-family motions (0x6500000D/0F) still completing
minutes later. That is a slow-draining BACKLOG, not one immortal node.
3. With MotionsPending() true, `BeginTurnToHeading`/`BeginMoveForward`
(retail 0x00529b90 `if (motions_pending) return`) never start:
- far range (wire-proven, seqs 98101): Use SENT, ACE replies mt-6
MoveToObject (objDist=0.50 — ACE is healthy), `[autowalk-begin]
mt=0x06` arms, body NEVER walks ([autowalk-up] position frozen) → ACE
waits forever → door never opens. Same as the original session's
642647.
- close range (round-3 silence): TurnToObject armed, never completes →
`_pendingPostArrivalAction` never fires → use eaten with zero feedback.
4. Retail contrast: the #170 live cdb drain trace showed retail's queue
stays SHALLOW (add_to_queue == MotionDone, drained same-tick); our
`CheckForCompletedMotions` completes ~one node per animation cycle, so
adds outpace drains during any active play. This is the #170
pending_motions-flood family — LOCAL-player drain-rate edition (the
remote fix `427332ac` removed the flood's feeder; the local queue's
DRAIN semantics are the divergence here).
**Next (fix session):** oracle-first on the drain: decomp
`MotionTableManager::CheckForCompletedMotions` (0x0051bfd0) +
`AnimationDone/MotionDone` pop semantics — which queued nodes retail
completes per tick (superseded/non-playing nodes must flush immediately,
not serialize behind animations). Add a queue-dump probe (node ids + ages)
before changing anything. Then re-verify the door BOTH branches + re-check
`UseDone` (0x01C7) wiring so ACE rejections become visible.
DO NOT band-aid: no MotionsPending bypass in BeginTurnToHeading (the gate
is verbatim retail), no deferral-skipping (turn-to-face is retail).
---
## #173 — Observed character jumping into a ceiling hovers at the roof until the arc decays (no collision-velocity response on remotes)
**Status:** 🟡 FIX SHIPPED 2026-07-05 (this commit) — pending user visual gate (watch a second client jump into the 0x0007 dungeon roof; it should bounce down immediately like the local player).
**Severity:** MEDIUM (remote-motion fidelity indoors; lands visibly late)
**Filed:** 2026-07-05
**Component:** physics — remote dead-reckoning collision response
**Description (user, 0x0007 dungeon):** watching another character jump into
the dungeon roof, the observed char sticks to the ceiling until the jump arc
would naturally have come down — "like we are calculating the entire jump
instead of actually checking the collision" — and lands later than retail,
with the animation pinned at the roof. The LOCAL player's own jump bounces
off the roof immediately.
**Root cause (code-confirmed):** the remote DR tick integrates the
VectorUpdate launch ballistically and DOES sweep collision
(`ResolveWithTransition`, GameWindow remote block) — the sweep pins the
POSITION at the ceiling — but the retail post-transition velocity response
(`CPhysicsObj::handle_all_collisions`, pc:282699-282715: reflect
`v = (1+elasticity)·dot(v,n)·n`) was only ever ported for the LOCAL player
(L.3a, `PlayerMovementController` ~:940). The remote body kept its +Z launch
velocity, re-integrated it into the roof every tick, and only descended once
gravity burned the arc off. Retail runs handle_all_collisions after every
SetPositionInternal for every physics object — remotes included.
**Fix (this commit):** mirror the local L.3a reflection block in the remote
sweep's post-resolve path (same formula, same AD-25 airborne-before-AND-after
suppression so corridor slides and landings don't reflect, same Inelastic
zero-out). Register AD-25 extended to cover both sites.
**Files:** `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep, #173
block after `rm.Body.Position = resolveResult.Position`).
**Acceptance:** from acdream, watch a second client jump into a dungeon
ceiling: the observed char deflects off the roof immediately and lands at
retail timing; grounded remote movement (corridor wall slides, NPC chases)
unchanged.
---
## #172 — Town-network portal platform blocks instead of stepping up (CCylSphere family was never ported)
**Status:** 🟡 FIX SHIPPED 2026-07-05 (this commit) — pending user visual gate (walk up onto the Holtburg portal platform, then the 0x0007 dungeon run).
**Severity:** HIGH (blocks dungeon access — gates the whole #137 repro)
**Filed:** 2026-07-05
**Component:** physics — CylSphere object collision response
**Description (user):** the Holtburg town-network portal sits on a stone
platform the player collides with instead of stepping up onto it (retail just
walks up). Entity `0xC0A9B465` = landblock stab #0x65, Setup `0x020019E3`,
one CylSphere **r=2.597 m, h=0.256 m** — a 26 cm disc, trivially steppable in
retail. Surfaced the moment #149 (`4cf6eeb`) started registering BSP-less
stab CylSpheres (before that fix the platform had NO collision at all, so the
player clipped through it — the collision *shape* was the #149 fix; the
collision *response* was never retail).
**Root cause (probe-confirmed, `launch-137-repro.log`):** the pre-port
`CylinderCollision` was a hand-rolled approximation (AP-6): step-up gate +
radial wall-slide only. Every contact returned `Slid` with a horizontal rim
normal (`[cyl-test] … result=Slid`, `[resolve] … n=(0.99,-0.11,0.00)`) and
the player orbited the rim forever. The step-up *gate* passed (clearance
0.256 ≤ 0.6) but `DoStepUp`'s internal step-down probe could never validate a
landing ON the cylinder top — a cylinder has no polygons, and the port had no
`step_sphere_down` cap-landing (top-disc contact plane). Airborne landings on
tops (`land_on_cylinder` + the Collide-flag exact-TOI branch) were missing
too.
**Fix (this commit):** verbatim port of the full retail `CCylSphere` family —
dispatcher `intersects_sphere` 0x0053b440, `collides_with_sphere` 0x0053a880,
`normal_of_collision` 0x0053ab50, `collide_with_point` 0x0053acb0,
`slide_sphere` 0x0053b2a0, `step_sphere_up` 0x0053b310, `land_on_cylinder`
0x0053b3d0, `step_sphere_down` 0x0053a9b0. Pseudocode + settled BN
ambiguities + two ACE-bug findings:
`docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md`.
Register: AP-6 retired, AP-83 added (PerfectClip TOI tail per ACE, dead code
in M1.5). Conformance: `CylSphereFamilyTests` (grounded step-up-onto-top on
the exact platform shape, tall-cylinder block, airborne top landing, ethereal
Layer-2 guard); the #42 self-shadow control assertion updated to the retail
observable (denied movement, not the old artifact radial push).
**Files:** `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylinderCollision`
+ `Cyl*` family), `tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs`.
**Acceptance:** walk straight onto the Holtburg town-network portal platform
(no rim slide); jumping onto it also lands. Doors/torches/NPC cylinders
unregressed (suites green; #150 open-door behavior unchanged). Likely also
advances #137's door-foot half — re-check in the dungeon repro.
---
## #171 — Group melee: monsters interpenetrate + facing drifts (sticky melee unbound, arrival radii = 0)
**Status:** DONE (2026-07-04) — **user visual gate PASSED** ("Looks good, ship it")
@ -929,15 +1334,165 @@ walls** in particular. (Symptoms not fully characterized yet: likely walking thr
openings that should block / blocking at openings that should pass, and door collision not
matching the door's open/closed state.)
**Root cause / status (to investigate):** dungeon collision is EnvCell-based — the cell's
collision BSP + portal openings + per-cell static objects (doors). Candidates: door
apparatus collision in EnvCells (open/closed BSP swap) not fully ported; portal-opening
(wall gap) collision geometry handled differently from buildings; the per-cell
shadow-object registration (A6.P4, see the physics digest) for dungeon EnvCell statics.
Related families: #32 (edge-slide), #116 (slide-response), the door-collision saga
(see `feedback_dedup_keys_after_cardinality_change`, `feedback_retail_per_cell_shadow_list`).
Needs a targeted repro (which door / which opening, expected vs actual) before fixing —
oracle-first per the physics digest.
**✅ CORRIDOR GATE PASSED 2026-07-06 evening (user: "not collision anymore.
Good.")** — the corridor phantom arc (mechanisms 13) is user-verified
FIXED. REMAINING #137 scope from the same gate session:
- **Window/opening climb FIXED + GATE PASSED 2026-07-06 (user: "Looks
good", incl. the taller-capsule regression sweep — doorways/seams/stairs
clean): the player's collision capsule TOPPED OUT AT 1.2 m.** The callers passed
`sphereHeight: 1.2f` and `InitPath` places the head sphere center at
`height radius` = 0.72 — the top 0.63 m of a 1.83 m character had NO
collision. The dat human Setup 0x02000001 (dumped in
`HumanSetup_CollisionSpheres_DatTruth`): spheres `(0,0,0.475) r=0.48` +
`(0,0,1.350) r=0.48` (top 1.83 = Setup.Height 1.835); retail collides
with that list verbatim (`CPhysicsObj::transition` 0x00512dc0 →
`init_sphere(GetNumSphere, GetSphere, scale)`). At the corridor-end
window alcove (0x8A020179 → 0x8A02017E: sill face 0.70 m, opening 1.3 m
tall, sloped funnel behind — full-vertex dump in
`WindowShaft_FullPolyDump`), the missing head let the step-up's
placement pass and the player climbed in head-through-lintel. Fix: both
live callers now pass 1.835 (capsule top; head center 1.355 ≈ dat
1.350); register TS-46 documents the residual 5 mm scalar
approximation. Pins: `WindowOpening_HeadCannotFit_EntryBlocked` (walked
approach wall-slides and never enters 0x8A02017E) +
`WindowAlcove_RaisedPlacement_HeadInLintelSolid_Collides` (the raised
placement rejects on the head-vs-lintel). Captured-input replay
fixtures keep their recorded 1.2 inputs — InitPath unchanged.
- Doors half (block/pass per open state) — unchanged.
Two RENDER issues also observed at the gate (filed separately below as
#176/#177): the purple floor flashing at seams is angle/camera-dependent
(the floor IS a portal polygon to the under-room — likely the portal
surface drawn under some culling state), and a stairs pop-in/out between
levels (the #119 visibility class, dungeon edition).
**SEAM SHAKE FIXED (same day): the stale `footCenter` in `CheckOtherCells`'
per-cell loop** — the P2 cellar-lip lesson one loop deeper. A mid-loop
other-cell query can MOVE the sphere (the boundary full-hit dispatches
step_sphere_up; the successful climb lifts the foot +0.6 mm and returns
OK), and the remaining cells were then queried with the by-value
pre-climb center — 0.4 mm inside the floor slab, grazing the under-room's
ceiling and firing the chain below. Retail's `check_other_cells` reads the
LIVE `sphere_path.global_sphere` per cell (pc:272717+). Fix: re-read
`footCenter = sp.GlobalSphere[0].Origin` per iteration. All three
`Issue137CorridorSeamReplayTests` repros un-skipped and GREEN; full suites
green. Visual gate pending. (The step 3 "restore clobbers CheckPos" wording
below was the right CLASS but the wrong site — CheckPos was fine; the
stale copy was the loop's captured parameter.)
**GATE 2026-07-06 FAILED — THIRD MECHANISM CHARACTERIZED (the seam shake),
deterministic offline repro secured:** with mechanisms 1+2 fixed the dead
stop became a SHAKE at cell seams (+ purple floor flashing there — almost
certainly the render exposing the same per-frame position/OnWalkable
oscillation; re-check after the physics fix). Full chain, every link
probe-traced (`launch-137-seam-probes.log`, capture
`resolve-137-seam-capture.jsonl` tick 4101 ×46):
1. Corridor cells sit above under-rooms; the shared floor slab is
double-faced (up-face + underside as separate physics polys) and IS a
portal plane (e.g. 0x8A020165's ramp over 0x8A020166). The resting foot
sphere is permanently within ±0.5 mm of THREE thresholds there (poly-hit
rε, walkable rε, portal-straddle r+ε).
2. Walking across the boundary at the flat-floor height penetrates the
ramp slab by ~0.4 mm → foot full-hit on the up-face → StepSphereUp →
step-down accepts the ramp (+0.6 mm lift, CheckPos 5.999,
`[stepsphereup] stepped=True`).
3. **THE BUG: the lifted position is then LOST** — the next pass runs at
the UNLIFTED height (GlobalSphere center 5.520 vs the lifted 5.519;
the P2 stale-snapshot class, single-slot Save/RestoreCheckPos clobber
suspected — retail `CTransition::step_up` 0x0050b6cc restores ONLY on
failure) → the re-test at 0.4 mm inside the slab grazes the NEIGHBOR
under-room's CEILING (the slab underside, n≈(0.03,0,1)) within the
near-miss window → recorded (retail records it too — pos_hits_sphere
registers geometric hits pre-cull) → neg-poly step-up dispatch with the
DOWNWARD normal → the nested step-down finds no walkable at exact
tangency → StepUpSlide → slide_sphere(down normal vs up contact plane)
→ the opposing branch → reversed-movement collision normal → Collided →
validate revert (Contact/OnWalkable stripped) → next step's AdjustOffset
zeroes → out==in every frame = the shake. Retail never enters at step 3:
its kept step-up lift leaves the sphere ON the surface, no graze.
4. Offline repro: `Issue137CorridorSeamReplayTests` (3 tests, currently
`Skip="#137 seam shake"`) reproduce the block deterministically — the
key was hydrating THREE portal rings (the under-room 0x8A020166 is
ring-3; with fewer rings the flood can't add it and everything passes).
NEXT: read our `TransitionalInsert` attempt loop against retail
0x0050b6f0 to find the restore that clobbers the successful step-up's
position; fix; un-skip the three tests.
**CORRIDOR PHANTOM mechanisms 1+2 FIXED 2026-07-06 (see
`docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md`
for the full audit):** mechanism 2 = BSPQuery Contact-branch stub slide
responses leaked sliding normals retail's BSP layer never writes (fixed:
real `slide_sphere` routing + success-gated body writeback). Mechanism 1 as
theorized is REFUTED: the recorded wall normal `(1.00,0.03,0.03)` matches
NO dat polygon (world-space sweep of both seam cells + all portal-adjacent
neighbors) — it is the SYNTHETIC negated movement direction from
`slide_sphere`'s opposing-normals branch, which our port let survive by
returning OK where retail returns COLLIDED_TS (0x0053762c; second fix). The
PortalSide polys to 0x011E were a red herring: cell 0x8A02016E has IDENTITY
rotation, the polys are ±Y planes perpendicular to the run (directionally
culled), retail's physics-BSP leaves reference them too, and the dat's
keep-PortalSide/strip-ExactMatch asymmetry reads as intentional (solid
window/grate-class portals) — NO portal-poly filter needed, no cdb session
needed for this repro. Dat-backed replay
(`Issue137CorridorSeamReplayTests`) reproduces the live frame exactly and
runs the corridor clean. The issue's DOOR half remains open.
**CHARACTERIZED 2026-07-05 (Facility Hub corridor repro, probe + dat evidence)
— two stacked mechanisms (historical; see the 2026-07-06 resolution above):**
1. **PortalSide portal polygons are IN the physics polygon set and we treat
them as solid.** Live: running the corridor, the seam crossing
`0x8A02016E → 0x8A02017A` (x≈85.25) records a wall hit with normal
(1,0,0) — straight against the movement (`launch-175-verify2.log:42858`).
Dat (`Issue137CorridorSeamInspectionTests`): cell 0x8A02016E's portals to
0x011E (polys 1/3/5, flags=**PortalSide**, no 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 local ±Y portal
planes to world ±X — the phantom mid-corridor wall. Retail must honor the
portal's SIDE (pass from one side / solid from the other, or pass when the
neighbor is loaded); we collide with the raw polygon unconditionally.
**Oracle findings so far (2026-07-05 evening — greps done, question
OPEN):** `CCellStruct::UnPack` (0x00533d00) loads physics_polygons +
physics_bsp verbatim — NO portal-poly stripping at load;
`CPolygon::pos_hits_sphere`/`hits_sphere`/`polygon_hits_sphere_slow_but_sure`
(0x005394f0/0x00539540/0x00538a10) are pure geometry — no portal check;
`CCellPortal` (0x0053bab0) carries portal→CPolygon ptr + portal_side +
exact_match but nothing in the BSP test chain consults it. So retail's
passability for a PortalSide physics poly is NOT a load filter and NOT a
poly-level flag — remaining candidates: the transit/membership order
makes the sphere test the NEIGHBOR cell first (never hitting the portal
poly from the passable side), or a sidedness interaction
(stippling=NoPos + approach direction). NEXT: cdb-attach retail at this
exact corridor (0x8A02016E→011E portals) per the CLAUDE.md step 1
protocol — the decomp alone hasn't settled it.
2. **The stale sliding normal then wedges all forward motion** (the #116
slide-response family): after the single seam hit, EVERY subsequent
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 AdjustOffset, aborting at step 0 BEFORE any collision test could
update the state — an ABSORBING wedge escaped only by strafing ("push
through on the side"). Retail re-derives slide state per frame
(get_object_info pc:279992 governs only the NEXT frame — #116 notes);
audit who clears the body's sliding normal when no contact recurs.
**MECHANISM 2 FIXED 2026-07-06 (audit complete — full lifecycle in
`docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md`):**
retail's ONLY in-transition sliding-normal writer is
`validate_transition` (0x0050ac21); the BSP/sphere layer never writes it,
and the body persistence (`SetPositionInternal` 0x005154c2/0x005154e1)
is success-only. Our BSPQuery Contact-branch full-hit responses were
STUBS (`SetSlidingNormal + return Slid`) where retail dispatches the
real `slide_sphere` — the seam hit (a SUCCESSFUL full-advance resolve,
`ok=True` in the log, not a failed one) leaked the phantom wall's normal
into the body, and the seed absorbed every later forward push. Fix:
both stub sites now route through the real
`Transition.SlideSphereInternal` (`CSphere::slide_sphere` 0x00537440,
in-frame, no sliding write) and the body writeback is gated on
transition success. Pins: `Issue137SlidingNormalLifecycleTests` (2 site
pins + the persist/absorb/clear wall lifecycle). Register: TS-4 amended
(steep-tangent sites still write the normal — documented), TS-45 added
(`SphereCollision`'s write — same class, out of blast radius). The
absorbed exactly-anti-parallel frame at a REAL wall is retail-faithful
(the persisted normal is a "still pressed" cache); only the phantom
PROVENANCE was the bug. Corridor re-test rides the mechanism-1 session.
**Files:** `src/AcDream.Core/Physics/` (EnvCell collision, CellTransit, the door apparatus),
`src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (per-cell registration). See

View file

@ -85,7 +85,7 @@ accepted-divergence entries (#96, #49, #50).
| 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:874` | 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-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding. **2026-07-05 (#173): the same reflection + suppression now also runs in the remote DR sweep** (remote jumps hitting ceilings reflect like the local player; both sites share the rule and this row) | `src/AcDream.App/Input/PlayerMovementController.cs:874`; `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep post-resolve, #173 block) | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral — both the local and remote landing snaps use that gate); 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-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Rendering/GameWindow.cs:12939` (MoveToComplete subscription) + `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete` seam doc) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract (never on cancel) | 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; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) |
| AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 |
| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` |
@ -99,7 +99,7 @@ accepted-divergence entries (#96, #49, #50).
---
## 3. Documented approximation (AP) — 73 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin)
## 3. Documented approximation (AP) — 73 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin; AP-6 retired 2026-07-05 — full CCylSphere family ported verbatim, residual AP-83 added same commit)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -108,7 +108,6 @@ accepted-divergence entries (#96, #49, #50).
| 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-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 |
@ -180,6 +179,9 @@ accepted-divergence entries (#96, #49, #50).
| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
| AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) |
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = (speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
| AP-85 | **⚠️ CORRECTED 2026-07-06: visible-cell scoping SHIPPED (`LightSource.CellId` from `entity.ParentCellId` + `BuildPointLightSnapshot(camPos, visibleCells)` over the portal-flood set; probe-proven to drop ~285 through-floor lights/frame in the Hub; end-state pin un-skipped). The visual gate REFUTED the light-cap #176/#177 attribution stated below — BOTH symptoms were UNCHANGED, so #176 = an intensity-100 purple point light `(0.784,0,0.784)` and #177 = a portal-visibility miss (see digest banner). Residual deviation now: 128 backstop vs retail 40+7 (0x0081ec94/8), no dynamic-priority split — benign (visible-scoped pool is 19). HISTORICAL claim below.** **Per-frame flat point-light snapshot capped at the 128 lights nearest THE CAMERA** (`BuildPointLightSnapshot`); retail registers lights per-CELL (`insert_light` 0x0054d1b0) and `minimize_object_lighting` (0x0054d480) consults the reaching set with NO global pool cap. The cap BITES in the Facility Hub (366 registered fixtures → 238 evictions/frame) and the eviction is the CONFIRMED mechanism of #176 (purple seam flash — an in-range torch of a visible cell ranks past the cap and drops from that cell's 8-set; per-cell Gouraud pops as the camera moves) + #177 (a stair room's fixtures all past the cap render it 0.2-ambient-dark until approach). ⚠️ Raising to 1024 was live-tested 2026-07-06 and REVERTED: the uncapped pool exposes (a) light-through-solid-floors (no per-cell reach/occlusion — the under-room portal light washes the corridor above), (b) stationary weenie fixtures on the DYNAMIC 1/d falloff (~9× retail's static 1/d³ at 3 m; #143 misassignment for ACE-served fixtures), (c) an unexplained striped floor artifact. Fix = the A7 arc: per-cell light registration + static curve for fixtures + the stripe hunt, THEN uncap | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights` — the load-bearing-stopgap comment); desired-end-state pin (Skip) `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant` | The 128 cap keeps the light pool local to the camera, which accidentally APPROXIMATES per-cell reach (far lights can't leak through floors into view) — the least-wrong state until A7 ports real per-cell registration | The #176/#177 pop class stays live until A7 (purple flashes at seams; unlit rooms popping lit on approach); any dungeon with >128 fixtures has camera-dependent per-cell lighting | `minimize_object_lighting` 0x0054d480 (no global pool cap); `insert_light` 0x0054d1b0 (per-cell registration); `calc_point_light` 0x0059c8b0 (static 1/d³ bake curve) |
| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified). Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping missiles | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |
## 4. Temporary stopgap (TS) — 39 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded)
@ -188,7 +190,7 @@ accepted-divergence entries (#96, #49, #50).
| 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-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. **Includes a `SetSlidingNormal` write at both sites** — retail's BSP layer never writes `collision_info.sliding_normal` (only `validate_transition` 0x0050ac21 does; the #137 mechanism-2 class), so on transition success the steep-face normal persists to the body and seeds the next frame | `src/AcDream.Core/Physics/BSPQuery.cs` (Path-6 steep branches, `worldNormal.Z < FloorZ`) | 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; a persisted steep-face normal can absorb an exactly-anti-parallel next-frame push (#137 wedge class) until an oblique input clears it | `BSPTREE::find_collisions` SetCollide pc:323783-323821 |
| TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2). R3-W3 extends this row: `IWeenieObject.JumpStaminaCost`/`PlayerWeenie.JumpStaminaCost` are new (feeding `jump_is_allowed`'s verbatim stamina-refusal branch) and are ALSO always-affordable/cost-0 stubs for the same reason | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` (`CanJump`), `:52` (`JumpStaminaCost`, R3-W3) | 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; `jump_is_allowed` 0x005282b0 `JumpStaminaCost` vtable +0x44 |
| 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 |
@ -224,6 +226,8 @@ accepted-divergence entries (#96, #49, #50).
| TS-41 | Grounded remote NPCs WITHOUT an armed moveto are body-driven by UP-synthesized server velocity (`HasServerVelocity` → the SERVERVEL per-tick leg: `Body.Velocity = ServerVelocity`, `MovementManager.UseTime` (the R5-V5 facade relay, ex-loose `MoveToManager.UseTime`) SKIPPED, stale-decay stop via `ApplyServerControlledVelocityCycle(Zero)`); retail has no wire-velocity leg-driver anywhere — `MovementManager::UseTime` runs UNCONDITIONALLY per tick and between-UP translation comes from the motion state (`get_state_velocity`), UPs only hard-snap. **#170 residual fix narrowed this branch: an ARMED moveto (`MovementTypeState != Invalid`) now always takes the MOVETO leg** — the old arbitration starved the verbatim MoveToManager for exactly the duration of a server-side chase (UPs flowing → UseTime never ran → legs stayed Ready while the body glided = the #170 slide; live funnel 16 arms → 1 run install). **R5-V3 (#171) narrowed it again: a STUCK entity (`PositionManager.GetStickyObjectId() != 0`) also takes the MOVETO leg** — after the sticky arrival the moveto is cleaned (Invalid) but `StickyManager::adjust_offset` owns the between-snap translation; SERVERVEL would glide the body against the sticky steer (same starvation class) | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch, `moveToArmed`+`stickyArmed` gate) | ACE moves some entities by position updates alone (scripted paths, missiles) with no UM/moveto stream — without a velocity fallback they freeze between UPs; entities WITH a moveto now get the retail drive | An entity class that carries BOTH wire velocity and an armed moveto with conflicting truths follows the moveto; UP hard-snaps bound the drift. Non-moveto entities keep the non-retail stale-stop heuristics (AP-80 thresholds) | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` call @0x00515998, unconditional); retire in R6 (full per-tick order) |
| TS-42 | Per-tick DRAIN ORDER inverted vs retail: acdream's `TickAnimations` runs `HandleTargetting``Movement.UseTime` (the R5-V5 MovementManager relay, ex-loose `MoveTo.UseTime`) FIRST and the animation-completion drain (Sequencer.Advance → AnimDone hooks → `MotionTableManager.AnimationDone`/`UseTime``CMotionInterp.MotionDone` pops) LAST, so every motion-completion-gated decision (`BeginTurnToHeading`'s `motions_pending` wait) sees a queue that is one frame STALE — the unblock after a stop/swing lands one frame later than retail. Retail order (pinned from the named decomp this session): `UpdatePositionInternal` (CPartArray::Update + `process_hooks` @0x00512d3d — the drain) runs BEFORE `TargetManager::HandleTargetting` @0x00515989`MovementManager::UseTime` @0x00515998`CPartArray::HandleMovement` @0x005159a4 (zero-tick sweep) in `UpdateObjectInternal` | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` per-entity phase order; the R2-Q4 comment already marks the placement "provisional until R6") | Bounded to exactly ONE frame (~16 ms) of extra latency per completion-gated event; every queue eventually drains identically (RemoteChaseEndToEndHarnessTests conformance) | Motion-completion-gated transitions (chase turn start, post-swing re-arm) systematically lag retail by one frame; under compound churn the lag can cost an extra retry cycle | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 + `UpdatePositionInternal` 0x00512c30 (`process_hooks` @0x00512d3d); retire in R6 (retail UpdateObjectInternal order) |
| TS-44 | NPC UpdatePosition hard-snaps (position @`OnLivePositionUpdated` + orientation + velocity/cycle adoption) are SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`) — an adaptation of retail's chain semantics to the legacy snap path: retail routes UP corrections through the InterpolationManager into the SAME per-tick `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES them while armed (0x00555190 order, 0x00555430 assigns m_fOrigin), so a server correction can never fight an armed stick; the legacy NPC path snaps OUTSIDE the chain, producing snap-out/steer-back position flapping + stale-facing stomps (the 2026-07-04 #171 gate residuals). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick, bounded by the 1 s sticky lease | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's mechanism (sticky-overwrites-interp) is unreachable until the NPC path gets the interp-queue architecture (the player-remote branch already has it — the R5-V3 combiner→sticky chain); the gate reproduces the retail-observable behavior on the snap architecture | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target by construction) would drift until unstick+next UP; worst case bounded by the 1 s lease + the next UM re-arm | `PositionManager::adjust_offset` 0x00555190; `InterpolationManager` UP routing (`CPhysicsObj::MoveOrTeleport`); retire when the NPC path unifies onto the interp queue (S6/R6) |
| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001 spheres `(0,0,0.475) r=0.48`, `(0,0,1.350) r=0.48`). Remotes also use the hardcoded HUMAN dims regardless of creature Setup/scale. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — 0.63 m of headless character; the #137 window climb. Fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`), `src/AcDream.App/Input/PlayerMovementController.cs` + `src/AcDream.App/Rendering/GameWindow.cs` (the two `sphereHeight:` call sites) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold for the human capsule and keeps every captured-input replay fixture byte-identical | 5 mm offsets can flip marginal grazes near the rε/r+ε knife edges (today's seam class); creature-scale remotes collide with human-sized capsules until setup-derived dims are plumbed | `CPhysicsObj::transition` 0x00512dc0; dat Setup 0x02000001; retire by plumbing the Setup sphere list into `InitPath` |
| TS-45 | `SphereCollision` (the shadow-object Sphere response) is a hand-rolled 3-D wall-slide that ALSO calls `SetSlidingNormal` — retail's `CSphere::intersects_sphere` (0x00537A80) dispatches `CSphere::slide_sphere` (0x00537440), which slides in-frame and never writes `collision_info.sliding_normal` (the only in-transition writer is `validate_transition` 0x0050ac21). Same leak class as the #137 mechanism-2 stubs fixed 2026-07-06 (BSPQuery Contact branch); left in place because the response's blocking semantics for sphere-shaped server objects are untested against the real slide and #171 sticky-melee behavior is freshly gated | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollision`, the `ci.SetSlidingNormal` tail) | The in-frame push-out already moves the check position; the extra sliding normal only persists on transition success, and pure-Sphere shadow shapes are rare (most creatures/statics are CylSphere, which routes through the real `SlideSphere` since #172) | A sphere-shaped object touch persists a normal retail would discard — an exactly-anti-parallel follow-up push absorbs to a zero offset (#137 wedge class) at that object until an oblique input clears it | `CSphere::intersects_sphere` 0x00537A80 → `slide_sphere` 0x00537440 (pc:321678+); fix = route the tail through `Transition.SlideSphere` like `CylSlideSphere` does |
| TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition``PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port |
---

View file

@ -293,14 +293,32 @@ successfully 2026-04-30 for the steep-roof case. Matching binaries
#### Phase A7 — Indoor lighting fidelity (RenderDoc + retail-decomp driven)
**Now also owns #176/#177 (2026-07-06):** the Facility Hub purple seam
flash + stair-room light pop-in are ROOT-CAUSED to this phase's "light
visibility culling" layer — a camera-nearest `MaxGlobalLights=128`
snapshot cap evicts in-range lights of visible cells (Hub has 366
fixtures), so per-cell 8-light sets churn as the camera moves.
Uncapping was live-tested and reverted because the full pool exposes the
per-cell-reach + fixture-curve defects below (through-floor light,
1/d-vs-1/d³). Analysis PRE-PAID — see
`docs/research/2026-07-06-176-177-handoff-A7-lighting.md` (the fix order:
per-cell `insert_light` registration → static fixture curve → stripe
hunt → uncap) + register AP-85.
**Hypothesis layers (less mapped than physics):**
- Per-cell environment-light tag association — indoor cells should
inherit only their own env lights, not outdoor day-cycle.
- Light visibility culling — what lights actually contribute to each
cell's render.
cell's render. **CONFIRMED bug here (#176/#177): no per-cell light
registration — lights are a flat world-space sphere-overlap pool that
reaches through solid floors; retail's `insert_light` 0x0054d1b0
scopes each light to its cell.**
- Per-entity light direction transform — held-item-spotlight bug
(#L-spotlight) is per-entity attribution gone wrong.
- Static-stab atmospheric inheritance (#81).
- **Fixture falloff curve (#176/#177): stationary server-spawned
fixtures ride the DYNAMIC 1/d path (#143 `isDynamic`); should be the
static 1/d³ bake (`calc_point_light` 0x0059c8b0).**
**Investigation methodology:** less existing infrastructure than
physics. Requires:

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 |