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

4
.gitignore vendored
View file

@ -95,3 +95,7 @@ studio-shots/
# MP1b acdream-bake output — user-machine artifact, never committed # MP1b acdream-bake output — user-machine artifact, never committed
# (docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5). # (docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5).
*.pak *.pak
# session-local physics capture artifacts (worktree root)
/resolve-*.jsonl
/launch-*.log

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) ## #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") **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 openings that should block / blocking at openings that should pass, and door collision not
matching the door's open/closed state.) matching the door's open/closed state.)
**Root cause / status (to investigate):** dungeon collision is EnvCell-based — the cell's **✅ CORRIDOR GATE PASSED 2026-07-06 evening (user: "not collision anymore.
collision BSP + portal openings + per-cell static objects (doors). Candidates: door Good.")** — the corridor phantom arc (mechanisms 13) is user-verified
apparatus collision in EnvCells (open/closed BSP swap) not fully ported; portal-opening FIXED. REMAINING #137 scope from the same gate session:
(wall gap) collision geometry handled differently from buildings; the per-cell - **Window/opening climb FIXED + GATE PASSED 2026-07-06 (user: "Looks
shadow-object registration (A6.P4, see the physics digest) for dungeon EnvCell statics. good", incl. the taller-capsule regression sweep — doorways/seams/stairs
Related families: #32 (edge-slide), #116 (slide-response), the door-collision saga clean): the player's collision capsule TOPPED OUT AT 1.2 m.** The callers passed
(see `feedback_dedup_keys_after_cardinality_change`, `feedback_retail_per_cell_shadow_list`). `sphereHeight: 1.2f` and `InitPath` places the head sphere center at
Needs a targeted repro (which door / which opening, expected vs actual) before fixing — `height radius` = 0.72 — the top 0.63 m of a 1.83 m character had NO
oracle-first per the physics digest. 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), **Files:** `src/AcDream.Core/Physics/` (EnvCell collision, CellTransit, the door apparatus),
`src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (per-cell registration). See `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-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-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-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-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-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` | | 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 | | # | 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-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-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-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-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-10 | Dry-corner water depth: retail's 0.1 m allowed sink-in collapsed to 0 | `src/AcDream.Core/Physics/TerrainSurface.cs:481` | The 0.1 offset destabilizes the feet-exactly-on-plane contact-touch check (dist > EPSILON → SetContactPlane never fires → float/fall); retail's ~10 cm sink-in is visually indistinguishable | Masks a contact-touch epsilon fragility — other water-depth values exercising the same instability could oscillate shoreline walkable validation; retail's wet/dry corner sink-in visual absent | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port) |
| AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 | | AP-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-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-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-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) ## 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-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-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-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-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-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) |
| TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 | | TS-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-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-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-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 | | 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) #### 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):** **Hypothesis layers (less mapped than physics):**
- Per-cell environment-light tag association — indoor cells should - Per-cell environment-light tag association — indoor cells should
inherit only their own env lights, not outdoor day-cycle. inherit only their own env lights, not outdoor day-cycle.
- Light visibility culling — what lights actually contribute to each - 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 - Per-entity light direction transform — held-item-spotlight bug
(#L-spotlight) is per-entity attribution gone wrong. (#L-spotlight) is per-entity attribution gone wrong.
- Static-stab atmospheric inheritance (#81). - 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 **Investigation methodology:** less existing infrastructure than
physics. Requires: 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 |

View file

@ -882,8 +882,17 @@ public sealed class PlayerMovementController
// Falls back to simple Z-snap if transition fails. // Falls back to simple Z-snap if transition fails.
var resolveResult = _physics.ResolveWithTransition( var resolveResult = _physics.ResolveWithTransition(
preIntegratePos, postIntegratePos, CellId, preIntegratePos, postIntegratePos, CellId,
sphereRadius: 0.48f, // human player radius from Setup sphereRadius: 0.48f, // human Setup 0x02000001 sphere radius (dat: 0.480)
sphereHeight: 1.2f, // human player height from Setup // #137 window climb (2026-07-06): sphereHeight is the CAPSULE TOP
// (InitPath places the head sphere center at height radius). The
// dat human Setup 0x02000001 has Spheres[1].Origin.Z = 1.350
// (top 1.830) and Height = 1.835 — retail collides with that
// sphere list verbatim (CPhysicsObj::transition 0x00512dc0 →
// init_sphere(GetNumSphere, GetSphere, scale)). The old 1.2f put
// the head sphere center at 0.72 — the top 0.63 m of the
// character had NO collision, letting the player climb into a
// 1.3 m window alcove head-through-lintel. Register TS-46.
sphereHeight: 1.835f,
stepUpHeight: StepUpHeight, stepUpHeight: StepUpHeight,
stepDownHeight: StepDownHeight, // L.2.3a: from Setup.StepDownHeight stepDownHeight: StepDownHeight, // L.2.3a: from Setup.StepDownHeight
isOnGround: _body.OnWalkable, isOnGround: _body.OnWalkable,

View file

@ -3714,7 +3714,8 @@ public sealed class GameWindow : IDisposable
ownerId: entity.Id, ownerId: entity.Id,
entityPosition: entity.Position, entityPosition: entity.Position,
entityRotation: entity.Rotation, entityRotation: entity.Rotation,
isDynamic: true); // #143: server-object lights take the D3D dynamic path (1/d att, range×1.5) isDynamic: true, // #143: server-object lights take the D3D dynamic path (1/d att, range×1.5)
cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility
foreach (var ls in loaded) foreach (var ls in loaded)
_lightingSink.RegisterOwnedLight(ls); _lightingSink.RegisterOwnedLight(ls);
} }
@ -4193,9 +4194,22 @@ public sealed class GameWindow : IDisposable
// span the doorway gap, so the player could walk through. With // span the doorway gap, so the player could walk through. With
// this change the door also registers the part-0 BSP slab // this change the door also registers the part-0 BSP slab
// (1.9 × 0.26 × 2.5 m) that retail uses for the real block. // (1.9 × 0.26 × 2.5 m) that retail uses for the real block.
// #175 (2026-07-05): BSP part shapes must pose at the motion table's
// DEFAULT-STATE frame (the closed pose — what the sequencer renders
// for an idle entity and what retail's live CPhysicsPart pose is),
// not the Setup's placement frame. The Facility Hub double door
// (Setup 0x02000C9D) places its panels AJAR in the placement frame
// (yaw 150°/30°, 0.44 m behind the doorway) while rendering
// closed — the user embedded into the visual door on one side and
// hit a phantom slab on the other. Null (no motion table / no
// cycle / part-count mismatch) falls back to placement frames.
var closedPose = MotionTableDefaultPose(
spawn.MotionTableId ?? 0u, setup.Parts.Count);
var raw = AcDream.Core.Physics.ShadowShapeBuilder.FromSetup( var raw = AcDream.Core.Physics.ShadowShapeBuilder.FromSetup(
setup, entScale, setup, entScale,
id => _physicsDataCache.GetGfxObj(id)?.BSP?.Root is not null); id => _physicsDataCache.GetGfxObj(id)?.BSP?.Root is not null,
partPoseOverride: closedPose);
// Substitute the real bounding-sphere radius for BSP shapes — // Substitute the real bounding-sphere radius for BSP shapes —
// the pure builder's 2.0 placeholder works for typical doors // the pure builder's 2.0 placeholder works for typical doors
@ -4284,6 +4298,37 @@ public sealed class GameWindow : IDisposable
} }
} }
/// <summary>
/// #175: the motion table's default-state pose (the closed pose for
/// doors) — the derivation lives in
/// <see cref="AcDream.Core.Physics.Motion.MotionTablePose"/> (Core,
/// dat-conformance-tested; the first cut here used a bare-style cycle
/// key, always missed, and silently no-oped — the "175 is not fixed"
/// report). Returns null → placement-frame fallback.
/// </summary>
private IReadOnlyList<DatReaderWriter.Types.Frame>? MotionTableDefaultPose(
uint motionTableId, int partCount)
{
if (motionTableId == 0u || partCount == 0 || _dats is null) return null;
var mt = _dats.Get<DatReaderWriter.DBObjs.MotionTable>(motionTableId);
if (mt is null) return null;
var pose = AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames(
mt, id => _dats.Get<DatReaderWriter.DBObjs.Animation>(id));
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
{
string desc = pose is null ? "null->placement-fallback"
: System.FormattableString.Invariant(
$"part0=({pose[0].Origin.X:F2},{pose[0].Origin.Y:F2},{pose[0].Origin.Z:F2})");
Console.WriteLine(System.FormattableString.Invariant(
$"[shape-pose] mt=0x{motionTableId:X8} parts={partCount} {desc}"));
}
return pose;
}
/// <summary> /// <summary>
/// R3-W4: one-time per-remote wiring of the animation-dispatch stack — /// R3-W4: one-time per-remote wiring of the animation-dispatch stack —
/// the persistent <see cref="RemoteMotion.Sink"/> (ObservedOmega turn /// the persistent <see cref="RemoteMotion.Sink"/> (ObservedOmega turn
@ -4318,7 +4363,20 @@ public sealed class GameWindow : IDisposable
rmForSink.ObservedOmega = System.Numerics.Vector3.Zero, rmForSink.ObservedOmega = System.Numerics.Vector3.Zero,
}; };
rm.Motion.DefaultSink = rm.Sink; rm.Motion.DefaultSink = rm.Sink;
rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations; // #174 (2026-07-05): the 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 fires MotionDone → CMotionInterp pops its
// pending_motions node in lockstep). The previous binding was the
// bare sequence strip — every LeaveGround (jump) stripped the
// animations that queued manager nodes were counting down on,
// orphaning them (NumAnims>0, anims gone) and permanently damming
// BOTH queues: MotionsPending() never drained again, so every
// armed moveto (ACE's walk-to-door mt-6, the close-range use turn)
// starved — the "door only works on a fresh session" bug.
rm.Motion.RemoveLinkAnimations = () => ae.Sequencer.Manager.HandleEnterWorld();
rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState(); rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState();
// R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions // R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions
// 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions). // 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions).
@ -7670,7 +7728,8 @@ public sealed class GameWindow : IDisposable
datSetup, datSetup,
ownerId: entity.Id, ownerId: entity.Id,
entityPosition: entity.Position, entityPosition: entity.Position,
entityRotation: entity.Rotation); entityRotation: entity.Rotation,
cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility
foreach (var ls in loaded) foreach (var ls in loaded)
_lightingSink.RegisterOwnedLight(ls); _lightingSink.RegisterOwnedLight(ls);
} }
@ -9350,6 +9409,11 @@ public sealed class GameWindow : IDisposable
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null, CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
Camera = camera, Camera = camera,
CameraWorldPosition = camPos, CameraWorldPosition = camPos,
// A7 #176/#177: once DrawInside has resolved the visible-cell set,
// rebuild the point-light pool from ONLY those cells' lights (retail's
// per-frame add_*_lights over visible_cell_table). The renderers hold a
// reference to the same PointSnapshot list, rebuilt in place here.
RebuildScopedLights = visible => Lighting.BuildPointLightSnapshot(camPos, visible),
Frustum = frustum, Frustum = frustum,
PlayerLandblockId = playerLb, PlayerLandblockId = playerLb,
AnimatedEntityIds = animatedIds, AnimatedEntityIds = animatedIds,
@ -10461,14 +10525,16 @@ public sealed class GameWindow : IDisposable
if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0) if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0)
{ {
// Sphere dims match local-player defaults (human Setup // Sphere dims match local-player defaults (human Setup
// bounds — ~0.48m radius, ~1.2m height). Good enough for // 0x02000001: sphere radius 0.480, capsule top 1.835 =
// Setup.Height; see the #137 TS-46 note at the
// PlayerMovementController call). Good enough for
// grounded humanoid remotes; can be setup-derived later // grounded humanoid remotes; can be setup-derived later
// if creatures of wildly different sizes need different // if creatures of wildly different sizes need different
// collision profiles. // collision profiles.
var resolveResult = _physicsEngine.ResolveWithTransition( var resolveResult = _physicsEngine.ResolveWithTransition(
preIntegratePos, postIntegratePos, rm.CellId, preIntegratePos, postIntegratePos, rm.CellId,
sphereRadius: 0.48f, sphereRadius: 0.48f,
sphereHeight: 1.2f, sphereHeight: 1.835f,
stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f
stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f
// K-fix9 (2026-04-26): mirror the K-fix7 gate — // K-fix9 (2026-04-26): mirror the K-fix7 gate —
@ -10496,6 +10562,55 @@ public sealed class GameWindow : IDisposable
if (resolveResult.CellId != 0) if (resolveResult.CellId != 0)
rm.CellId = resolveResult.CellId; rm.CellId = resolveResult.CellId;
// #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions
// (pc:282699-282715) runs after EVERY SetPositionInternal —
// remote objects included; a VectorUpdate-launched jump arc
// is ordinary object physics in retail. acdream ported the
// velocity reflection for the LOCAL player only (L.3a,
// PlayerMovementController ~:940), so a remote jumping into
// a dungeon ceiling had its POSITION pinned by the sweep
// while its +Z velocity kept integrating — the char hovered
// at the roof until gravity burned the arc off, landing
// late (user report, 0x0007 dungeon). Mirror the local
// site exactly:
// v_new = v (1 + elasticity)·dot(v, n)·n
// with the AD-25 suppression (bounce only when airborne
// before AND after — corridor slides and landings don't
// reflect; the landing snap below keeps its
// `Velocity.Z <= 0` gate intact). Inelastic movers
// (missiles, later) zero out instead.
if (resolveResult.CollisionNormalValid)
{
bool prevOnWalkable = rm.Body.OnWalkable;
bool nowOnWalkable = resolveResult.IsOnGround;
bool applyBounce = rm.Body.State.HasFlag(
AcDream.Core.Physics.PhysicsStateFlags.Sledding)
? !(prevOnWalkable && nowOnWalkable)
: (!prevOnWalkable && !nowOnWalkable);
if (applyBounce)
{
if (rm.Body.State.HasFlag(
AcDream.Core.Physics.PhysicsStateFlags.Inelastic))
{
rm.Body.Velocity = System.Numerics.Vector3.Zero;
}
else
{
var vRem = rm.Body.Velocity;
var nRem = resolveResult.CollisionNormal;
float dotVN = System.Numerics.Vector3.Dot(vRem, nRem);
if (dotVN < 0f)
{
rm.Body.Velocity =
vRem + nRem * (-(dotVN * (rm.Body.Elasticity + 1f)));
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
Console.WriteLine(
$"VU.bounce guid=0x{serverGuid:X8} n=({nRem.X:F2},{nRem.Y:F2},{nRem.Z:F2}) vZ {vRem.Z:F2}->{rm.Body.Velocity.Z:F2}");
}
}
}
}
// K-fix15 (2026-04-26): post-resolve landing // K-fix15 (2026-04-26): post-resolve landing
// detection for airborne remotes. Mirrors // detection for airborne remotes. Mirrors
// PlayerMovementController's local-player landing // PlayerMovementController's local-player landing
@ -13699,7 +13814,14 @@ public sealed class GameWindow : IDisposable
// R3-W4: bind the player interp's retail seams to the player // R3-W4: bind the player interp's retail seams to the player
// sequencer — LeaveGround/HitGround strip transition links here // sequencer — LeaveGround/HitGround strip transition links here
// (the K-fix18 replacement). // (the K-fix18 replacement).
_playerController.Motion.RemoveLinkAnimations = playerSeq.RemoveAllLinkAnimations; // #174 (2026-07-05): the seam is HandleEnterWorld (strip + FULL
// queue drain), not the bare sequence strip — see the remote
// bind site's comment (retail 0x0050fe20 → 0x00517d70 →
// 0x0051bdd0). The bare strip orphaned every pending manager
// node on each jump's LeaveGround; the local player's
// pending_motions then never drained and every armed moveto
// starved (#174: doors unusable after the first jump/run).
_playerController.Motion.RemoveLinkAnimations = () => playerSeq.Manager.HandleEnterWorld();
_playerController.Motion.InitializeMotionTables = _playerController.Motion.InitializeMotionTables =
() => playerSeq.Manager.InitializeState(); () => playerSeq.Manager.InitializeState();
_playerController.Motion.CheckForCompletedMotions = _playerController.Motion.CheckForCompletedMotions =

View file

@ -146,6 +146,12 @@ public sealed class RetailPViewRenderer
prepareCells = _lookInPrepareScratch; prepareCells = _lookInPrepareScratch;
} }
// A7 #176/#177: scope this frame's point-light pool to the cells actually being
// drawn, NOW that the flood has resolved the visible set (retail collects lights
// per-frame over visible_cell_table). Must run before the cell/entity draws below
// that select from LightManager.PointSnapshot.
ctx.RebuildScopedLights?.Invoke(prepareCells);
_envCells.PrepareRenderBatches( _envCells.PrepareRenderBatches(
ctx.ViewProjection, ctx.ViewProjection,
ctx.CameraWorldPosition, ctx.CameraWorldPosition,
@ -1102,6 +1108,16 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
public Action? DrawUnattachedSceneParticles { get; init; } public Action? DrawUnattachedSceneParticles { get; init; }
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; } public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; } public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
/// <summary>A7 #176/#177: rebuild the point-light snapshot scoped to the cells
/// this frame actually draws — invoked AFTER the portal flood resolves the visible
/// set and BEFORE any cell/entity draw (the faithful port of retail's per-frame
/// light collection: <c>CObjCell::add_*_to_global_lights</c> walked over
/// <c>CEnvCell::visible_cell_table</c>). The argument is every cell drawn this frame
/// (main flood + interior-root look-ins). A cell-less light (viewer fill) is kept
/// regardless. Null-safe: outdoor/no-flood callers leave it unset and keep the
/// legacy full-pool snapshot.</summary>
public Action<IReadOnlySet<uint>>? RebuildScopedLights { get; init; }
} }
public sealed class RetailPViewFrameResult public sealed class RetailPViewFrameResult

View file

@ -37,7 +37,8 @@ public static class LightInfoLoader
uint ownerId, uint ownerId,
Vector3 entityPosition, Vector3 entityPosition,
Quaternion entityRotation, Quaternion entityRotation,
bool isDynamic = false) bool isDynamic = false,
uint cellId = 0)
{ {
var results = new List<LightSource>(); var results = new List<LightSource>();
if (setup?.Lights is null || setup.Lights.Count == 0) return results; if (setup?.Lights is null || setup.Lights.Count == 0) return results;
@ -89,6 +90,7 @@ public static class LightInfoLoader
Range = info.Falloff * (isDynamic ? 1.5f : 1.3f), Range = info.Falloff * (isDynamic ? 1.5f : 1.3f),
ConeAngle = info.ConeAngle, ConeAngle = info.ConeAngle,
OwnerId = ownerId, OwnerId = ownerId,
CellId = cellId, // owning cell — scopes the per-frame visible-cell pool (A7 #176/#177)
IsLit = true, IsLit = true,
IsDynamic = isDynamic, IsDynamic = isDynamic,
}; };

View file

@ -176,8 +176,27 @@ public sealed class LightManager
public const int MaxLightsPerObject = 8; public const int MaxLightsPerObject = 8;
/// <summary>Hard cap on the per-frame global point-light snapshot the shader /// <summary>Hard cap on the per-frame global point-light snapshot the shader
/// indexes. AC scenes rarely exceed a few dozen lit point lights in view; 128 /// indexes. ⚠️ LOAD-BEARING STOPGAP — read before touching (#176/#177,
/// is generous. If exceeded, the nearest-to-camera are kept (cold path).</summary> /// 2026-07-06): this cap BITES in the Facility Hub (366 registered fixtures →
/// 238 camera-distance evictions/frame), and the eviction is the CONFIRMED
/// mechanism of the #176 purple seam flash + the #177 stair-room light
/// pop-in — an in-range torch of a visible cell that ranks past the cap
/// drops out of that cell's 8-set, so per-cell Gouraud lighting pops as the
/// camera moves. Retail's <c>minimize_object_lighting</c> (0x0054d480) has
/// NO global camera-nearest cap. HOWEVER: raising the cap to 1024 was
/// live-tested 2026-07-06 and REVERTED — with the full pool active, three
/// unported retail lighting semantics dominate the frame: (a) lights reach
/// THROUGH solid floors/walls (retail registers lights per-CELL via
/// <c>insert_light</c> 0x0054d1b0 — a portal's purple light below never
/// touches the corridor above; our flat sphere-overlap selection has no
/// reach/occlusion notion), (b) stationary weenie fixtures ride the DYNAMIC
/// 1/d falloff (~9× stronger at 3 m than retail's static 1/d³ bake curve),
/// (c) an unexplained striped z-fight-like artifact on lit floor regions
/// (user screenshot, launch-176-texflush session). The proper fix is the
/// A7 dungeon-lighting arc: per-cell light registration + the static curve
/// for fixtures + the stripe hunt, THEN uncap. Register row AP-85; desired
/// end-state pin (currently Skip):
/// LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant.</summary>
public const int MaxGlobalLights = 128; public const int MaxGlobalLights = 128;
private readonly List<LightSource> _pointSnapshot = new(); private readonly List<LightSource> _pointSnapshot = new();
@ -199,11 +218,33 @@ public sealed class LightManager
/// per-object selection. /// per-object selection.
/// </summary> /// </summary>
public void BuildPointLightSnapshot(Vector3 cameraWorldPos) public void BuildPointLightSnapshot(Vector3 cameraWorldPos)
=> BuildPointLightSnapshot(cameraWorldPos, visibleCells: null);
/// <summary>
/// Visible-cell-scoped snapshot build — the faithful port of retail's per-frame
/// light collection (<c>CObjCell::add_*_to_global_lights</c> 0x0052b350/0x0052b390
/// walked over <c>CEnvCell::visible_cell_table</c> 0x0052d410). When
/// <paramref name="visibleCells"/> is non-null (an indoor root with a portal
/// flood), a cell-tagged light is a candidate ONLY when its <see cref="LightSource.CellId"/>
/// is in the visible set — a cell-less light (<c>CellId == 0</c>: the viewer fill,
/// global lights) is always included. This is what (a) stops an under-room light
/// washing THROUGH a solid floor (its cell isn't visibly flooded → excluded) and
/// (b) bounds the pool to the handful of visible cells so <see cref="MaxGlobalLights"/>
/// never evicts a visible cell's in-range light (the #176/#177 mechanism). When
/// <paramref name="visibleCells"/> is null (outdoor root / no flood) the behaviour
/// is unchanged from the legacy full-pool path.
/// </summary>
public void BuildPointLightSnapshot(Vector3 cameraWorldPos, IReadOnlySet<uint>? visibleCells)
{ {
_pointSnapshot.Clear(); _pointSnapshot.Clear();
foreach (var light in _all) foreach (var light in _all)
{ {
if (!light.IsLit || light.Kind == LightKind.Directional) continue; if (!light.IsLit || light.Kind == LightKind.Directional) continue;
// Visible-cell scoping (retail add_*_lights over visible_cell_table). A
// cell-less light (CellId == 0: viewer fill / global) is always a candidate;
// a cell-tagged light joins the pool ONLY when its cell is visibly flooded.
if (visibleCells is not null && light.CellId != 0 && !visibleCells.Contains(light.CellId))
continue;
light.DistSq = (light.WorldPosition - cameraWorldPos).LengthSquared(); light.DistSq = (light.WorldPosition - cameraWorldPos).LengthSquared();
_pointSnapshot.Add(light); _pointSnapshot.Add(light);
} }
@ -212,6 +253,11 @@ public sealed class LightManager
_pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq)); _pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq));
_pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights); _pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights);
} }
// A7.L1 SET-COMPOSITION probe — only meaningful on the scoped (indoor) path.
// Inert unless ACDREAM_PROBE_INDOOR_LIGHT=1; the flag check keeps it zero-cost off.
if (visibleCells is not null && AcDream.Core.Rendering.RenderingDiagnostics.ProbeIndoorLightEnabled)
AcDream.Core.Rendering.RenderingDiagnostics.EmitIndoorLight(visibleCells.Count, _all, _pointSnapshot);
} }
// ── Viewer light — retail SmartBox::set_viewer (0x00452c40) ────────────── // ── Viewer light — retail SmartBox::set_viewer (0x00452c40) ──────────────

View file

@ -46,6 +46,12 @@ public sealed class LightSource
public float Range = 10f; // metres, hard cutoff public float Range = 10f; // metres, hard cutoff
public float ConeAngle = 0f; // radians, Spot only public float ConeAngle = 0f; // radians, Spot only
public uint OwnerId; // attached entity id; 0 = world-global public uint OwnerId; // attached entity id; 0 = world-global
public uint CellId; // owning cell id (0xLLLLNNNN); 0 = cell-less/global (viewer fill, sun).
// Retail carries this on the RenderLight (insert_light arg6, +0x6c) so the
// per-frame pool can be built from only the VISIBLE cells' lights
// (CObjCell::add_*_to_global_lights over CEnvCell::visible_cell_table).
// acdream uses it to scope BuildPointLightSnapshot — a cell-tagged light is
// only a candidate when its cell is visibly flooded (#176/#177 A7 fix).
public bool IsLit = true; // SetLightHook latch public bool IsLit = true; // SetLightHook latch
public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5); public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5);
// false = static dat-baked bake (1/d³, range×1.3) // false = static dat-baked bake (1/d³, range×1.3)

View file

@ -1399,26 +1399,35 @@ public static class BSPQuery
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// slide_sphere — BSPTree level // slide_sphere — BSPTree level
// ACE: BSPTree.cs slide_sphere // Retail: BSPTREE::slide_sphere (find_collisions Contact head-hit dispatch
// at 0x0053a697). ACE: BSPTree.cs:310-316.
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/// <summary> /// <summary>
/// BSPTree.slide_sphere — apply sliding collision response. /// BSPTree.slide_sphere — dispatch the real sphere-level slide
/// (<c>CSphere::slide_sphere</c> 0x00537440, ported as
/// <see cref="Transition.SlideSphereInternal"/>): slide IN-FRAME along the
/// crease between the collision normal and the contact plane, applied to
/// sphere 0's check position (ACE BSPTree.cs:315 —
/// <c>GlobalSphere[0].SlideSphere(..., GlobalCurrCenter[0].Center)</c>).
/// ///
/// <para> /// <para>
/// Sets the sliding normal on CollisionInfo so the outer transition loop /// #137 mechanism 2 (2026-07-06): this was a stub that set
/// applies a wall-slide projection. /// <c>CollisionInfo.SlidingNormal</c> and returned Slid. Retail's BSP layer
/// never writes the sliding normal — its only in-transition writer is
/// <c>CTransition::validate_transition</c> (0x0050ac21) — so the stub's
/// leaked normal survived to the body writeback and absorbed the next
/// frame's exactly-anti-parallel offset: the Facility Hub corridor
/// phantom's dead-stop half (ISSUES #137).
/// </para> /// </para>
///
/// <para>ACE: BSPTree.cs slide_sphere — calls GlobalSphere[0].SlideSphere.</para>
/// </summary> /// </summary>
/// <param name="worldNormal">Collision normal already in world space (the
/// call sites apply L2W; ACE globalizes inside slide_sphere instead).</param>
private static TransitionState SlideSphere( private static TransitionState SlideSphere(
Transition transition, Transition transition,
Vector3 collisionNormal) Vector3 worldNormal)
{ => transition.SlideSphereInternal(
transition.CollisionInfo.SetSlidingNormal(collisionNormal); worldNormal, transition.SpherePath.GlobalCurrCenter[0].Origin);
return TransitionState.Slid;
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// collide_with_pt — BSPTree level // collide_with_pt — BSPTree level
@ -1894,11 +1903,14 @@ public static class BSPQuery
if (engine is not null && !path.StepUp && !path.StepDown) if (engine is not null && !path.StepUp && !path.StepDown)
return StepSphereUp(transition, worldNormal, engine); return StepSphereUp(transition, worldNormal, engine);
// No engine OR step-up/step-down already in progress — fall // No engine OR step-up/step-down already in progress — the
// back to wall-slide. // real slide response. Retail: a blocked step-up funnels to
collisions.SetCollisionNormal(worldNormal); // SPHEREPATH::step_up_slide → CSphere::slide_sphere (ACE
collisions.SetSlidingNormal(worldNormal); // SpherePath.cs:316); the slide records the collision normal
return TransitionState.Slid; // itself and never writes the sliding normal (#137 mechanism
// 2 — the old SetSlidingNormal stub here leaked a normal that
// wedged the next frame's forward offset).
return SlideSphere(transition, worldNormal);
} }
// Sphere 0 didn't fully hit. Per retail, the head-sphere test AND // Sphere 0 didn't fully hit. Per retail, the head-sphere test AND
@ -1937,9 +1949,10 @@ public static class BSPQuery
PhysicsDiagnostics.LastBspHitPoly = hitPoly1; PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
var worldNormal = L2W(hitPoly1!.Plane.Normal); var worldNormal = L2W(hitPoly1!.Plane.Normal);
collisions.SetCollisionNormal(worldNormal); // Retail head-sphere full hit → BSPTREE::slide_sphere
collisions.SetSlidingNormal(worldNormal); // (0x0053a697; ACE BSPTree.cs:202) — the real in-frame
return TransitionState.Slid; // slide, no sliding-normal write (#137 mechanism 2).
return SlideSphere(transition, worldNormal);
} }
// Sphere 1 (head) near-miss → neg_poly_hit, neg_step_up = false → outer slide. // Sphere 1 (head) near-miss → neg_poly_hit, neg_step_up = false → outer slide.

View file

@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// #175: the motion table's DEFAULT-STATE part pose — the pose an idle
/// entity's parts hold (retail: <c>CMotionTable::SetDefaultState</c>
/// 0x005230a0 installs <c>StyleDefaults[DefaultStyle]</c>'s cycle; the parts
/// then sit at that animation's frames — the live <c>CPhysicsPart</c> pose
/// collision tests against). Used as the BSP shadow-shape part-pose override
/// at server-entity registration (doors: the CLOSED pose).
///
/// <para>
/// Cycle key arithmetic mirrors <see cref="CMotionTable"/>'s
/// <c>LookupCycle</c> (CMotionTable.cs:683): <c>(style &lt;&lt; 16) |
/// (substate &amp; 0xFFFFFF)</c> — the raw dat <c>Cycles</c> dictionary is
/// keyed by the COMBINED word, not the bare style (the first cut of this
/// helper looked up the bare style, always missed, and silently fell back
/// to placement frames — the #175 "not fixed" report).
/// </para>
/// </summary>
public static class MotionTablePose
{
/// <summary>
/// Resolve the default-state pose frames. Returns null (callers fall
/// back to placement frames) when the table has no default-style
/// substate, no matching cycle, or no animation. A pose covering FEWER
/// parts than the Setup is returned as-is —
/// <see cref="ShadowShapeBuilder"/> falls back to the placement frame
/// PER PART beyond the override's length (e.g. a door anim that poses
/// only the panel parts, not the BSP-less frame header).
/// </summary>
/// <param name="mt">The raw dat motion table (wire MotionTableId).</param>
/// <param name="loadAnimation">Animation loader (production:
/// <c>id => dats.Get&lt;Animation&gt;(id)</c>).</param>
public static IReadOnlyList<Frame>? DefaultStatePartFrames(
MotionTable mt,
Func<uint, Animation?> loadAnimation)
{
if (mt is null) return null;
// SetDefaultState: StyleDefaults[DefaultStyle] → the default substate.
if (!mt.StyleDefaults.TryGetValue(mt.DefaultStyle, out var defaultSubstateCmd))
return null;
// LookupCycle key (CMotionTable.cs:683 — same wrap semantics).
uint style = (uint)mt.DefaultStyle;
uint substate = (uint)defaultSubstateCmd;
int key = (int)((style << 16) | (substate & 0xFFFFFFu));
if (!mt.Cycles.TryGetValue(key, out var cycle) || cycle.Anims.Count == 0)
return null;
var animRef = cycle.Anims[0];
var anim = loadAnimation(animRef.AnimId);
if (anim is null || anim.PartFrames.Count == 0) return null;
int idx = Math.Clamp((int)animRef.LowFrame, 0, anim.PartFrames.Count - 1);
var frames = anim.PartFrames[idx].Frames;
return frames.Count > 0 ? frames : null;
}
}

View file

@ -1085,16 +1085,27 @@ public sealed class PhysicsEngine
body.WalkableVertices = null; body.WalkableVertices = null;
} }
if (ci.SlidingNormalValid // Retail persists sliding state to the body ONLY on transition
&& ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) // SUCCESS: CPhysicsObj::SetPositionInternal copies the normal at
// 0x005154c2 and syncs SLIDING_TS (bit 4) from the transition's
// final sliding_normal_valid at 0x005154e1 — and SetPositionInternal
// is unreachable when find_valid_position fails (the transition is
// discarded whole; the body keeps its prior state). #137 mechanism
// 2: an unconditional writeback here could persist a normal retail
// would discard.
if (ok)
{ {
body.SlidingNormal = ci.SlidingNormal; if (ci.SlidingNormalValid
body.TransientState |= TransientStateFlags.Sliding; && ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
} {
else body.SlidingNormal = ci.SlidingNormal;
{ body.TransientState |= TransientStateFlags.Sliding;
body.SlidingNormal = Vector3.Zero; }
body.TransientState &= ~TransientStateFlags.Sliding; else
{
body.SlidingNormal = Vector3.Zero;
body.TransientState &= ~TransientStateFlags.Sliding;
}
} }
// L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity. // L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity.

View file

@ -38,10 +38,22 @@ public static class ShadowShapeBuilder
/// every radius, height, and local offset.</param> /// every radius, height, and local offset.</param>
/// <param name="hasPhysicsBsp">Predicate: does the GfxObj with this id /// <param name="hasPhysicsBsp">Predicate: does the GfxObj with this id
/// have a non-null PhysicsBSP? Production: <c>id => cache.GetGfxObj(id)?.BSP?.Root is not null</c>.</param> /// have a non-null PhysicsBSP? Production: <c>id => cache.GetGfxObj(id)?.BSP?.Root is not null</c>.</param>
/// <param name="partPoseOverride">#175: per-part pose override for the
/// BSP part shapes — the entity's motion-table DEFAULT-STATE pose (the
/// closed pose for doors). Retail collision tests each part's LIVE
/// <c>CPhysicsPart</c> pose, which for an idle entity is the motion
/// table's default state, NOT the Setup's placement frame — the two
/// differ on e.g. the Facility Hub double door (Setup 0x02000C9D:
/// placement poses the panels AJAR at yaw 150°/30°, y 0.44 m; the
/// closed pose is straight). Null / short lists fall back to the
/// placement frame per part (entities with no motion table, and the
/// CylSphere/Sphere shapes, are unaffected — retail poses those from
/// the setup too).</param>
public static IReadOnlyList<ShadowShape> FromSetup( public static IReadOnlyList<ShadowShape> FromSetup(
Setup setup, Setup setup,
float entScale, float entScale,
Func<uint, bool> hasPhysicsBsp) Func<uint, bool> hasPhysicsBsp,
IReadOnlyList<Frame>? partPoseOverride = null)
{ {
if (setup is null) throw new ArgumentNullException(nameof(setup)); if (setup is null) throw new ArgumentNullException(nameof(setup));
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp)); if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
@ -84,15 +96,21 @@ public static class ShadowShapeBuilder
} }
// 3. Parts — one BSP shape per part with a non-null PhysicsBSP. // 3. Parts — one BSP shape per part with a non-null PhysicsBSP.
// Pose priority per part: partPoseOverride (the motion-table
// default-state pose, #175) → placement frame → identity.
AnimationFrame? placementFrame = ResolvePlacementFrame(setup); AnimationFrame? placementFrame = ResolvePlacementFrame(setup);
for (int i = 0; i < setup.Parts.Count; i++) for (int i = 0; i < setup.Parts.Count; i++)
{ {
uint gfxId = (uint)setup.Parts[i]; uint gfxId = (uint)setup.Parts[i];
if (!hasPhysicsBsp(gfxId)) continue; if (!hasPhysicsBsp(gfxId)) continue;
Frame partFrame = placementFrame is not null && i < placementFrame.Frames.Count Frame partFrame;
? placementFrame.Frames[i] if (partPoseOverride is not null && i < partPoseOverride.Count)
: new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; partFrame = partPoseOverride[i];
else if (placementFrame is not null && i < placementFrame.Frames.Count)
partFrame = placementFrame.Frames[i];
else
partFrame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
// BSP radius default; caller substitutes the real BoundingSphere.Radius // BSP radius default; caller substitutes the real BoundingSphere.Radius
// at registration time when available. Loose-but-safe broadphase value. // at registration time when available. Loose-but-safe broadphase value.

View file

@ -1804,6 +1804,21 @@ public sealed class Transition
{ {
if (cellId == sp.CheckCellId) continue; if (cellId == sp.CheckCellId) continue;
// #137 seam shake (2026-07-06): a mid-loop query can MOVE the
// sphere — a Path-5 full hit dispatches step_sphere_up, and a
// successful climb lifts the foot (the 0.6 mm ramp-slab lift at
// the Facility Hub boundaries) yet returns OK, so the loop
// continues. Retail's check_other_cells reads the LIVE
// sphere_path.global_sphere for every cell (each cell's
// find_collisions receives the transition itself, pc:272717+);
// querying the remaining cells with the pre-climb center re-tests
// the boundary ~0.4 mm inside the floor slab and grazes the
// under-room's CEILING (the slab underside) — the neg-poly
// step-up-with-a-downward-normal chain that shook the player at
// every corridor seam. Same lesson as the P2 cellar-lip fix
// (RunCheckOtherCellsAndAdvance's entry refresh), one loop deeper.
footCenter = sp.GlobalSphere[0].Origin;
if ((cellId & 0xFFFFu) < 0x0100u) if ((cellId & 0xFFFFu) < 0x0100u)
{ {
var terrainWalkable = engine.SampleTerrainWalkable(footCenter.X, footCenter.Y); var terrainWalkable = engine.SampleTerrainWalkable(footCenter.X, footCenter.Y);
@ -2811,9 +2826,12 @@ public sealed class Transition
// Effect (pc:276973-276977): // Effect (pc:276973-276977):
// state_3 = OK_TS ← force passable // state_3 = OK_TS ← force passable
// collision_normal_valid = 0 ← clear stale slide normal // collision_normal_valid = 0 ← clear stale slide normal
// Note: Cylinder and Sphere shapes already return OK from their own // Note: the BSP branch AND (since the 2026-07-05 CCylSphere family
// obstruction_ethereal early-out, so this clause only fires in practice // port) the Cylinder branch rely on this clause for ethereal
// for the BSP branch, but is written unconditionally as retail does. // passability — CylinderCollision's branch 1 returns Collided on
// overlap like retail, and THIS override clears it for non-static
// targets. Only the Sphere branch still early-outs on
// obstruction_ethereal (consume site 1).
if (result != TransitionState.OK if (result != TransitionState.OK
&& sp.ObstructionEthereal && sp.ObstructionEthereal
&& !sp.StepDown && !sp.StepDown
@ -3171,168 +3189,453 @@ public sealed class Transition
} }
/// <summary> /// <summary>
/// Cylinder collision test for CylSphere objects (tree trunks, rock pillars, NPCs, /// Retail <c>CCylSphere::intersects_sphere</c> dispatcher — verbatim port
/// door foot-colliders). For Contact-grounded movers, attempts to step over short /// of <c>0x0053b440</c> (acclient_2013_pseudo_c.txt:324558). Full family:
/// cylinders (retail-faithful CCylSphere::step_sphere_up). For airborne movers, /// <c>collides_with_sphere</c> 0x0053a880, <c>normal_of_collision</c>
/// movers already stepping, or cylinders too tall to step over, applies a /// 0x0053ab50, <c>collide_with_point</c> 0x0053acb0, <c>slide_sphere</c>
/// horizontal wall-slide response. /// 0x0053b2a0, <c>step_sphere_up</c> 0x0053b310, <c>land_on_cylinder</c>
/// 0x0053b3d0, <c>step_sphere_down</c> 0x0053a9b0. Pseudocode + settled
/// BN ambiguities + ACE cross-reference notes:
/// docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md.
/// ///
/// <para> /// <para>
/// A6.P6 (2026-05-25): the step-over path matches retail's /// Replaces the pre-2026-07-05 hand-rolled approximation (A6.P6 step-up
/// <c>CCylSphere::step_sphere_up</c> at /// gate + radial wall-slide) which had NO cylinder-TOP support: a
/// <c>acclient_2013_pseudo_c.txt:324516-324538</c>. The door's foot /// grounded mover stepping up onto a WIDE cylinder (the Holtburg
/// cylinder (h=0.20m, r=0.10m) is too tall for the static slide to /// town-network portal platform, Setup 0x020019E3, r=2.597 m h=0.256 m)
/// produce smooth sliding along the slab — the radial push-out /// could never validate a landing — the step-up's internal step-down
/// fires as a "phantom collision" at the door's center when the /// probe needs branch 2 (step_sphere_down → contact plane ON the flat
/// sphere is touching the slab face and the cyl is just within reach. /// top) — so DoStepUp failed into StepUpSlide and the player orbited the
/// Retail steps the sphere over the cyl (succeeds when /// rim forever. Airborne landings on tops (land_on_cylinder + the
/// <c>step_up_height &gt;= sphere.radius + cyl.height - offset.z</c>), /// collide-flag exact-TOI branch) were likewise missing.
/// which lets the sphere walk past the cyl without the radial push. /// </para>
/// On step-up failure (cyl too tall, no walkable surface beyond), ///
/// falls back to <c>step_up_slide</c> — the same crease-projection /// <para>
/// slide the BSP path uses, which produces smoother behavior than /// The <see cref="ShadowEntry"/> already carries the wrapper overload's
/// the radial push. /// (0x0053b8f0) work: Position = globalized low_pt (entity frame applied
/// at registration), Radius/CylHeight pre-scaled; the cylinder axis stays
/// world-Z. Ethereal targets: branch 1 returns Collided on overlap and
/// the caller's Layer-2 override (pc:276961-276989) clears it for
/// non-static targets — the retail passability mechanism (#150); the
/// step-down pass never reaches here for ethereal targets (pc:276799
/// skip).
/// </para> /// </para>
/// </summary> /// </summary>
private TransitionState CylinderCollision(ShadowEntry obj, SpherePath sp, PhysicsEngine engine) private TransitionState CylinderCollision(ShadowEntry obj, SpherePath sp, PhysicsEngine engine)
{ {
// Consume site 2 — CCylSphere::intersects_sphere @ 0x0053b4a0 (pc:324573). // Degenerate dat heights: registration sites apply the same fallback;
// When obstruction_ethereal is set (target is ETHEREAL-alone, state & 0x4), // kept for entries registered before it (pre-dates this port).
// the retail function is void and all paths in the ethereal branch return float cylHeight = obj.CylHeight > 0f ? obj.CylHeight : obj.Radius * 4f;
// without producing a COLLIDED/Slid result — the player is fully passable.
// We mirror this by returning OK immediately, skipping all blocking paths. var s0 = sp.GlobalSphere[0];
// Retail ref: acclient_2013_pseudo_c.txt:324573. Vector3 disp0 = s0.Origin - obj.Position;
if (sp.ObstructionEthereal) // radsum: ε shaved ONCE in the dispatcher preamble (0x0053b48e) —
return TransitionState.OK; // this is what lets a sphere REST exactly on the top without
// re-colliding every frame.
float radsum = obj.Radius - PhysicsGlobals.EPSILON + s0.Radius;
bool hasHead = sp.NumSphere > 1;
Vector3 disp1 = default;
float headRadius = 0f;
if (hasHead)
{
disp1 = sp.GlobalSphere[1].Origin - obj.Position;
headRadius = sp.GlobalSphere[1].Radius;
}
// ── Branch 1 (0x0053b4a0): placement / ethereal — detection only. ──
if (sp.InsertType == InsertType.Placement || sp.ObstructionEthereal)
{
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
return TransitionState.Collided;
if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))
return TransitionState.Collided;
return TransitionState.OK;
}
// ── Branch 2 (0x0053b4c0): step-down probe — land on the top. ──
if (sp.StepDown)
return CylStepSphereDown(obj, sp, cylHeight, disp0, radsum);
// ── Branch 3 (0x0053b4d7): walkable probe — occupancy blocks. ──
if (sp.CheckWalkable)
{
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
return TransitionState.Collided;
if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))
return TransitionState.Collided;
return TransitionState.OK;
}
var ci = CollisionInfo;
var oi = ObjectInfo; var oi = ObjectInfo;
Vector3 sphereCurrPos = sp.GlobalCurrCenter[0].Origin;
Vector3 sphereCheckPos = sp.GlobalSphere[0].Origin;
float sphRadius = sp.GlobalSphere[0].Radius;
Vector3 sphMovement = sphereCheckPos - sphereCurrPos;
// Vertical check: does sphere reach the cylinder's height range at all? if (!sp.Collide)
float cylTop = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 4f; {
float checkZ = sphereCheckPos.Z; // ── Branch 4 (0x0053b701): normal transition sweep. ──
if (checkZ - sphRadius > obj.Position.Z + cylTop || if ((oi.State & (ObjectInfoState.Contact | ObjectInfoState.OnWalkable)) != 0)
checkZ + sphRadius < obj.Position.Z) {
// Grounded mover: foot hit → step over / onto; head hit → slide.
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
return CylStepSphereUp(obj, sp, engine, cylHeight, disp0, radsum);
if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))
// Retail 0x0053b843 passes the HEAD disp (its x_2); ACE's
// port passes the foot disp here — retail wins (pseudocode
// doc §8.2).
return CylSlideSphere(obj, sp, cylHeight, disp1, radsum, 1);
}
else if ((oi.State & ObjectInfoState.PathClipped) != 0)
{
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
return CylCollideWithPoint(obj, sp, cylHeight, s0, disp0, radsum, 0);
}
else
{
// Airborne: foot hit → land on the top; head hit → point hit.
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
return CylLandOnCylinder(obj, sp, cylHeight, disp0, radsum);
if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))
return CylCollideWithPoint(obj, sp, cylHeight, sp.GlobalSphere[1], disp1, radsum, 1);
}
return TransitionState.OK;
}
// ── Branch 5 (0x0053b525): collide-flag re-test — exact-TOI cap landing. ──
// Runs on the attempt after land_on_cylinder set sp.Collide: rewinds
// the sphere along the REVERSE movement to rest exactly on the top,
// sets the contact plane, and consumes walk_interp.
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius)
|| (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius)))
{
// movement = curr check. Retail subtracts the cur→check
// landblock offset (get_curr_pos_check_pos_block_offset); our
// physics frame is continuous world-space → zero (same standing
// adaptation as SlideSphere's gDelta).
Vector3 movement = sp.GlobalCurrCenter[0].Origin - s0.Origin;
if (MathF.Abs(movement.Z) < PhysicsGlobals.EPSILON)
return TransitionState.Collided;
float timecheck = (cylHeight + s0.Radius - disp0.Z) / movement.Z;
Vector3 offset = movement * timecheck;
Vector3 sum = offset + disp0;
if (radsum * radsum < sum.X * sum.X + sum.Y * sum.Y)
return TransitionState.OK; // rewound point is off the cap — not a top landing
float t = (1f - timecheck) * sp.WalkInterp;
if (t >= sp.WalkInterp || t < -0.1f)
return TransitionState.Collided;
Vector3 pt = s0.Origin + offset;
pt.Z -= s0.Radius;
// is_water=1 is verbatim retail (0x0053b6b9 → set_contact_plane
// arg3 = contact_plane_is_water, 0x00509db1). Do not "fix".
var contactPlane = new Plane(Vector3.UnitZ, -pt.Z);
CollisionInfo.SetContactPlane(contactPlane, sp.CheckCellId, isWater: true);
sp.WalkInterp = t;
sp.AddOffsetToCheckPos(offset);
return TransitionState.Adjusted;
}
return TransitionState.OK;
}
/// <summary>
/// Retail <c>CCylSphere::collides_with_sphere</c> (0x0053a880,
/// pc:323943): XY overlap against radsum, then Z-band overlap against the
/// cylinder's [0, height] extent. <paramref name="disp"/> = sphere center
/// cylinder base point.
/// </summary>
private static bool CylCollidesWithSphere(Vector3 disp, float radsum, float cylHeight, float sphereRadius)
{
if (disp.X * disp.X + disp.Y * disp.Y <= radsum * radsum)
{
float halfH = cylHeight * 0.5f;
if (sphereRadius - PhysicsGlobals.EPSILON + halfH >= MathF.Abs(halfH - disp.Z))
return true;
}
return false;
}
/// <summary>
/// Retail <c>CCylSphere::normal_of_collision</c> (0x0053ab50, pc:324102).
/// Discriminates on where the sphere was at the START of the step
/// (GlobalCurrCenter): XY-outside the combined radius → radial side
/// normal; XY-inside the footprint → cap normal (descending → top +Z,
/// ascending → underside Z; polarity settled by ACE + geometry — BN's
/// x87 branch rendering is untrustworthy here). Returns "definite":
/// false when a radial contact could actually be a diagonal cap hit
/// (curr was outside the Z band AND there is vertical movement) —
/// consumed only by <see cref="CylCollideWithPoint"/>.
/// </summary>
private bool CylNormalOfCollision(ShadowEntry obj, SpherePath sp, float cylHeight,
Vector3 dispCheck, float radsum, float sphereRadius, int sphereNum, out Vector3 normal)
{
Vector3 dispCurr = sp.GlobalCurrCenter[sphereNum].Origin - obj.Position;
if (radsum * radsum < dispCurr.X * dispCurr.X + dispCurr.Y * dispCurr.Y)
{
normal = new Vector3(dispCurr.X, dispCurr.Y, 0f);
float halfH = cylHeight * 0.5f;
bool zBandOverlapAtCurr =
sphereRadius - PhysicsGlobals.EPSILON + halfH >= MathF.Abs(halfH - dispCurr.Z);
bool noZMovement = MathF.Abs(dispCurr.Z - dispCheck.Z) <= PhysicsGlobals.EPSILON;
return zBandOverlapAtCurr || noZMovement;
}
normal = new Vector3(0f, 0f, dispCheck.Z - dispCurr.Z <= 0f ? 1f : -1f);
return true;
}
/// <summary>
/// Retail <c>AC1Legacy::Vector3::normalize_check_small</c>: returns true
/// (degenerate — caller yields Collided) when |v| &lt; F_EPSILON, else
/// normalizes in place.
/// </summary>
private static bool NormalizeCheckSmall(ref Vector3 v)
{
float mag = v.Length();
if (mag < PhysicsGlobals.EPSILON)
return true;
v /= mag;
return false;
}
/// <summary>
/// Retail <c>CCylSphere::step_sphere_up</c> (0x0053b310, pc:324516).
/// Too-tall cylinders slide; otherwise the generic step-up
/// (<see cref="DoStepUp"/> = CTransition::step_up) runs — its internal
/// step-down probe lands on the cylinder top via
/// <see cref="CylStepSphereDown"/> (branch 2), which is what makes
/// stepping ONTO a wide cylinder possible.
/// </summary>
private TransitionState CylStepSphereUp(ShadowEntry obj, SpherePath sp, PhysicsEngine engine,
float cylHeight, Vector3 disp0, float radsum)
{
var s0 = sp.GlobalSphere[0];
// step_up_height must clear (sphere.radius + height disp.z) — the
// lift needed so the sphere bottom rests on the top (0x0053b323).
if (ObjectInfo.StepUpHeight < s0.Radius + cylHeight - disp0.Z)
return CylSlideSphere(obj, sp, cylHeight, disp0, radsum, 0);
// Retail computes the normal and ignores the definite flag here.
CylNormalOfCollision(obj, sp, cylHeight, disp0, radsum, s0.Radius, 0, out var n);
if (NormalizeCheckSmall(ref n))
return TransitionState.Collided;
// Retail rotates the normal by the target OBJECT's frame
// (localtoglobalvec via the wrapper's cached localspace_pos,
// 0x0053b38d) before CTransition::step_up. Yaw-only AC frames leave
// vertical normals unchanged; radial normals pick up the yaw.
var nWorld = Vector3.Transform(n, obj.Rotation);
// engine==null only in bare unit-test transitions — no step-up
// machinery available; the retail-faithful fallback is the slide.
if (engine is not null && DoStepUp(nWorld, engine))
return TransitionState.OK; return TransitionState.OK;
// XY distance from sphere check position to cylinder axis. return sp.StepUpSlide(this);
float dxCheck = sphereCheckPos.X - obj.Position.X; }
float dyCheck = sphereCheckPos.Y - obj.Position.Y;
float distSqCheck = dxCheck * dxCheck + dyCheck * dyCheck;
float combinedR = sphRadius + obj.Radius;
float combinedRSq = combinedR * combinedR;
if (distSqCheck >= combinedRSq) /// <summary>
return TransitionState.OK; // not overlapping at check position /// Retail <c>CCylSphere::step_sphere_down</c> (0x0053a9b0, pc:324032):
/// during a step-down probe, land the foot sphere ON the cylinder's flat
/// top — contact plane (0,0,1) through the top, walk_interp consumed,
/// CheckPos lifted so the sphere bottom rests exactly on it. THE piece
/// whose absence made every step-up onto a wide cylinder fail (the
/// portal-platform rim orbit).
/// </summary>
private TransitionState CylStepSphereDown(ShadowEntry obj, SpherePath sp,
float cylHeight, Vector3 disp0, float radsum)
{
var s0 = sp.GlobalSphere[0];
// ─── Overlap detected ───────────────────────────────────── bool hit = CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius);
// Horizontal outward normal from the cylinder axis to the sphere if (!hit && sp.NumSphere > 1)
// check position. For the degenerate case where the sphere center
// is exactly on the axis, use the movement direction as a fallback
// (pushes the sphere back out along the way it came in).
float distCheck = MathF.Sqrt(distSqCheck);
Vector3 collisionNormal;
if (distCheck < PhysicsGlobals.EPSILON)
{ {
// Sphere center on cylinder axis — push along reverse movement. Vector3 disp1 = sp.GlobalSphere[1].Origin - obj.Position;
float mxy = MathF.Sqrt(sphMovement.X * sphMovement.X + sphMovement.Y * sphMovement.Y); hit = CylCollidesWithSphere(disp1, radsum, cylHeight, sp.GlobalSphere[1].Radius);
if (mxy > PhysicsGlobals.EPSILON)
collisionNormal = new Vector3(-sphMovement.X / mxy, -sphMovement.Y / mxy, 0f);
else
collisionNormal = Vector3.UnitX;
} }
else if (!hit)
return TransitionState.OK;
float stepScale = sp.StepDownAmt * sp.WalkInterp;
if (MathF.Abs(stepScale) < PhysicsGlobals.EPSILON)
return TransitionState.Collided;
// Lift so the foot sphere's bottom rests on the top disc. The
// (1 deltaZ/stepScale) divisor is stepScale — BN garbled it;
// settled via ACE CylSphere.StepSphereDown (pseudocode doc §4).
float deltaZ = cylHeight + s0.Radius - disp0.Z;
float interp = (1f - deltaZ / stepScale) * sp.WalkInterp;
if (interp >= sp.WalkInterp || interp < -0.1f)
return TransitionState.Collided;
float topZ = s0.Origin.Z + deltaZ - s0.Radius;
// is_water=1 verbatim retail (0x0053aae2). Do not "fix".
var contactPlane = new Plane(Vector3.UnitZ, -topZ);
CollisionInfo.SetContactPlane(contactPlane, sp.CheckCellId, isWater: true);
sp.WalkInterp = interp;
sp.AddOffsetToCheckPos(new Vector3(0f, 0f, deltaZ));
return TransitionState.Adjusted;
}
/// <summary>
/// Retail <c>CCylSphere::slide_sphere</c> (0x0053b2a0, pc:324502):
/// normal_of_collision → CSphere::slide_sphere (our
/// <see cref="SlideSphere"/>) with the sliding sphere's own curr center.
/// </summary>
private TransitionState CylSlideSphere(ShadowEntry obj, SpherePath sp,
float cylHeight, Vector3 disp, float radsum, int sphereNum)
{
CylNormalOfCollision(obj, sp, cylHeight, disp, radsum,
sp.GlobalSphere[sphereNum].Radius, sphereNum, out var n);
if (NormalizeCheckSmall(ref n))
return TransitionState.Collided;
return SlideSphere(n, sp.GlobalCurrCenter[sphereNum].Origin, sphereNum);
}
/// <summary>
/// Retail <c>CCylSphere::land_on_cylinder</c> (0x0053b3d0, pc:324542):
/// airborne foot hit — arm the Collide re-test (backup + flag) and relax
/// the walkable allowance to LandingZ. The NEXT attempt's branch 5 then
/// rests the sphere on the top with the exact time-of-impact.
/// </summary>
private TransitionState CylLandOnCylinder(ShadowEntry obj, SpherePath sp,
float cylHeight, Vector3 disp0, float radsum)
{
CylNormalOfCollision(obj, sp, cylHeight, disp0, radsum,
sp.GlobalSphere[0].Radius, 0, out var n);
if (NormalizeCheckSmall(ref n))
return TransitionState.Collided;
sp.SetCollide(n);
sp.WalkableAllowance = PhysicsGlobals.LandingZ; // 0.0871557 (0x0053b41f)
return TransitionState.Adjusted;
}
/// <summary>
/// Retail <c>CCylSphere::collide_with_point</c> (0x0053acb0, pc:324173):
/// PathClipped movers + airborne head-sphere hits. Non-PerfectClip movers
/// record the collision normal and hard-stop; PerfectClip movers get the
/// exact time-of-impact reposition. TOI sub-branches ported per ACE
/// CylSphere.CollideWithPoint (BN mush too heavy in 0x0053adb6+); no
/// PerfectClip mover exists in M1.5 (players never set it), so only the
/// Collided path is load-bearing today — revisit against Ghidra if
/// missiles ever arm PerfectClip (pseudocode doc §7).
/// </summary>
private TransitionState CylCollideWithPoint(ShadowEntry obj, SpherePath sp,
float cylHeight, Sphere checkSphere, Vector3 disp, float radsum, int sphereNum)
{
bool definite = CylNormalOfCollision(obj, sp, cylHeight, disp, radsum,
checkSphere.Radius, sphereNum, out var n);
if (NormalizeCheckSmall(ref n))
return TransitionState.Collided;
if (!ObjectInfo.State.HasFlag(ObjectInfoState.PerfectClip))
{ {
collisionNormal = new Vector3(dxCheck / distCheck, dyCheck / distCheck, 0f); CollisionInfo.SetCollisionNormal(n);
return TransitionState.Collided;
} }
// A6.P6 (2026-05-25): retail-faithful CCylSphere::step_sphere_up for // Retail reads global_curr_center[0] even for the head hit
// Contact-grounded movers. acclient_2013_pseudo_c.txt:324516-324538. // (0x0053ad26; ACE agrees) — verbatim.
// Vector3 globCenter = sp.GlobalCurrCenter[0].Origin;
// Retail check: step_up_height must clear (sphere.radius + cyl.height // Block offset = 0 (continuous world frame; see branch 5 note).
// - offset.z) where offset.z is sphere center Z minus cyl low_pt Z. Vector3 movement = checkSphere.Origin - globCenter;
// Geometrically: the height we need to lift the sphere to clear the Vector3 oldDisp = globCenter - obj.Position;
// cyl's top, less the sphere center's current height above the cyl float radsumEps = radsum + PhysicsGlobals.EPSILON;
// base, equals cyl top minus sphere bottom (positive when sphere
// currently below cyl top).
//
// For the door's foot cyl (h=0.20m, sphere radius 0.48m, step_up 0.60m)
// at standing height (offset.z ~0.38m): cyl_clearance =
// 0.48 + 0.20 - 0.38 = 0.30m, step_up_height = 0.60m → step over OK.
if (oi.Contact && !sp.StepUp && !sp.StepDown && engine is not null)
{
float offsetZ = sphereCheckPos.Z - obj.Position.Z;
float cylClearance = sphRadius + cylTop - offsetZ;
if (oi.StepUpHeight >= cylClearance) float xyMoveLenSq = movement.X * movement.X + movement.Y * movement.Y;
float dot2d = movement.X * oldDisp.X + movement.Y * oldDisp.Y;
float xyDiff = -dot2d;
float oldDispXYSq = oldDisp.X * oldDisp.X + oldDisp.Y * oldDisp.Y;
float diffSq = xyDiff * xyDiff - (oldDispXYSq - radsumEps * radsumEps) * xyMoveLenSq;
float time;
Vector3 scaledMovement;
if (!definite)
{
if (MathF.Abs(movement.Z) < PhysicsGlobals.EPSILON)
return TransitionState.Collided;
if (movement.Z > 0f)
{ {
// Try step-up over the cyl (DoStepUp probes upward by n = new Vector3(0f, 0f, -1f);
// step_up_height, then step-down for walkable surface). time = (movement.Z + checkSphere.Radius) / movement.Z * -1f;
// On success: sphere is repositioned past/over the cyl,
// ContactPlane updated, returns OK.
if (DoStepUp(collisionNormal, engine))
return TransitionState.OK;
// Step-up failed — sphere couldn't find a walkable surface
// beyond the cyl (e.g., a wall behind it). Fall back to
// step_up_slide which uses the SlideSphereInternal crease
// projection — smoother than the radial push-out below
// because it follows the contact-plane / cyl-normal crease
// direction.
return sp.StepUpSlide(this);
}
// else: cyl too tall to step over — fall through to radial slide
}
// ─── Fallback: airborne / non-Contact / cyl-too-tall — wall-slide ───
// Wall-slide position (in world space):
// curr = sphereCurrPos (pre-step)
// movement = sphMovement
// projected = movement - (movement · normal) * normal
// slidPos = curr + projected
// Then push outward if still inside the cylinder radius.
Vector3 horizMovement = new Vector3(sphMovement.X, sphMovement.Y, 0f);
float movementIntoWall = Vector3.Dot(horizMovement, collisionNormal);
Vector3 projectedMovement = horizMovement - collisionNormal * movementIntoWall;
// Preserve vertical movement component (jumping/falling).
projectedMovement.Z = sphMovement.Z;
Vector3 slidPos = sphereCurrPos + projectedMovement;
// Ensure slid position is outside the cylinder radius horizontally.
float sdx = slidPos.X - obj.Position.X;
float sdy = slidPos.Y - obj.Position.Y;
float sDistSq = sdx * sdx + sdy * sdy;
float minDist = combinedR + 0.01f;
if (sDistSq < minDist * minDist)
{
float sDist = MathF.Sqrt(sDistSq);
if (sDist < PhysicsGlobals.EPSILON)
{
// Degenerate: push out along collisionNormal
slidPos.X = obj.Position.X + collisionNormal.X * minDist;
slidPos.Y = obj.Position.Y + collisionNormal.Y * minDist;
} }
else else
{ {
float pushDist = (minDist - sDist); n = new Vector3(0f, 0f, 1f);
slidPos.X += (sdx / sDist) * pushDist; time = (checkSphere.Radius + cylHeight - movement.Z) / movement.Z;
slidPos.Y += (sdy / sDist) * pushDist;
} }
scaledMovement = movement * time;
Vector3 landed = scaledMovement + oldDisp;
if (landed.X * landed.X + landed.Y * landed.Y >= radsumEps * radsumEps)
{
if (MathF.Abs(xyMoveLenSq) < PhysicsGlobals.EPSILON)
return TransitionState.Collided;
if (diffSq >= 0f && xyMoveLenSq > PhysicsGlobals.EPSILON)
{
float diff = MathF.Sqrt(diffSq);
time = xyDiff - diff < 0f
? (diff - dot2d) / xyMoveLenSq
: (xyDiff - diff) / xyMoveLenSq;
scaledMovement = movement * time;
}
n = (scaledMovement + globCenter - obj.Position) / radsumEps;
n.Z = 0f;
}
if (time < 0f || time > 1f)
return TransitionState.Collided;
Vector3 offsetOut = globCenter - scaledMovement - checkSphere.Origin;
sp.AddOffsetToCheckPos(offsetOut);
CollisionInfo.SetCollisionNormal(n);
return TransitionState.Adjusted;
} }
// Apply the offset (difference between slid and current CheckPos) if (n.Z != 0f)
Vector3 delta = slidPos - sphereCheckPos; {
sp.AddOffsetToCheckPos(delta); if (MathF.Abs(movement.Z) < PhysicsGlobals.EPSILON)
return TransitionState.Collided;
ci.SetCollisionNormal(collisionNormal); time = movement.Z > 0f
ci.SetSlidingNormal(collisionNormal); ? -((oldDisp.Z + checkSphere.Radius) / movement.Z)
return TransitionState.Slid; : (checkSphere.Radius + cylHeight - oldDisp.Z) / movement.Z;
scaledMovement = movement * time;
if (time < 0f || time > 1f)
return TransitionState.Collided;
Vector3 offsetOut = globCenter + scaledMovement - checkSphere.Origin;
sp.AddOffsetToCheckPos(offsetOut);
CollisionInfo.SetCollisionNormal(n);
return TransitionState.Adjusted;
}
if (diffSq < 0f || xyMoveLenSq < PhysicsGlobals.EPSILON)
return TransitionState.Collided;
{
float diff = MathF.Sqrt(diffSq);
time = xyDiff - diff < 0f
? (diff - dot2d) / xyMoveLenSq
: (xyDiff - diff) / xyMoveLenSq;
scaledMovement = movement * time;
if (time < 0f || time > 1f)
return TransitionState.Collided;
n = (scaledMovement + globCenter - obj.Position) / radsumEps;
n.Z = 0f;
Vector3 offsetOut = globCenter + scaledMovement - checkSphere.Origin;
sp.AddOffsetToCheckPos(offsetOut);
CollisionInfo.SetCollisionNormal(n);
return TransitionState.Adjusted;
}
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@ -3356,7 +3659,11 @@ public sealed class Transition
internal TransitionState SlideSphereInternal(Vector3 collisionNormal, Vector3 currPos) internal TransitionState SlideSphereInternal(Vector3 collisionNormal, Vector3 currPos)
=> SlideSphere(collisionNormal, currPos); => SlideSphere(collisionNormal, currPos);
private TransitionState SlideSphere(Vector3 collisionNormal, Vector3 currPos) /// <param name="sphereNum">Which path sphere is sliding (retail
/// CSphere::slide_sphere's <c>this</c> is the sphere instance — the head
/// sphere slides by its OWN displacement, 0x0053b843 passes
/// global_sphere[1]). Default 0 preserves every existing call site.</param>
private TransitionState SlideSphere(Vector3 collisionNormal, Vector3 currPos, int sphereNum = 0)
{ {
var sp = SpherePath; var sp = SpherePath;
var ci = CollisionInfo; var ci = CollisionInfo;
@ -3364,7 +3671,7 @@ public sealed class Transition
// Degenerate case: zero collision normal — nudge halfway. // Degenerate case: zero collision normal — nudge halfway.
if (collisionNormal.LengthSquared() < PhysicsGlobals.EpsilonSq) if (collisionNormal.LengthSquared() < PhysicsGlobals.EpsilonSq)
{ {
Vector3 halfOffset = (currPos - sp.GlobalSphere[0].Origin) * 0.5f; Vector3 halfOffset = (currPos - sp.GlobalSphere[sphereNum].Origin) * 0.5f;
sp.AddOffsetToCheckPos(halfOffset); sp.AddOffsetToCheckPos(halfOffset);
return TransitionState.Adjusted; return TransitionState.Adjusted;
} }
@ -3374,7 +3681,7 @@ public sealed class Transition
// gDelta: displacement from currPos to the current check sphere center. // gDelta: displacement from currPos to the current check sphere center.
// In the retail code this includes a block offset for cross-landblock // In the retail code this includes a block offset for cross-landblock
// transitions; for outdoor single-landblock movement this is zero. // transitions; for outdoor single-landblock movement this is zero.
Vector3 gDelta = sp.GlobalSphere[0].Origin - currPos; Vector3 gDelta = sp.GlobalSphere[sphereNum].Origin - currPos;
// Get the contact plane (prefer current, fall back to last known). // Get the contact plane (prefer current, fall back to last known).
System.Numerics.Plane contactPlane; System.Numerics.Plane contactPlane;
@ -3438,15 +3745,25 @@ public sealed class Transition
return TransitionState.Slid; return TransitionState.Slid;
} }
// Opposing normals: give up, reverse direction. // Opposing normals (collision normal anti-parallel to the contact
// Retail returns OK here to allow retry with the reversed normal. // plane, e.g. a ceiling-facing normal while grounded): record the
// REVERSED displacement as the collision normal and return COLLIDED.
// Retail CSphere::slide_sphere 0x00537440 @0x005375d7-0x0053762c:
// `*normal = -gDelta; normalize_check_small; set_collision_normal;
// return 2 (COLLIDED_TS)`. #137 (2026-07-06): this previously
// returned OK ("to allow retry with the reversed normal" — a decomp
// misread), which let the step complete as-is carrying a SYNTHETIC
// reversed-movement collision normal — the live corridor hit's
// `n=(-1.00,0.03,-0.03)` (= the negated run direction) matched no
// dat polygon; validate's epilogue then turned it into a persisted
// sliding normal and wedged all forward motion.
Vector3 reversed = -gDelta; Vector3 reversed = -gDelta;
if (reversed.LengthSquared() > PhysicsGlobals.EpsilonSq) if (reversed.LengthSquared() > PhysicsGlobals.EpsilonSq)
{ {
reversed = Vector3.Normalize(reversed); reversed = Vector3.Normalize(reversed);
ci.SetCollisionNormal(reversed); ci.SetCollisionNormal(reversed);
} }
return TransitionState.OK; return TransitionState.Collided;
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------

View file

@ -271,6 +271,33 @@ public static class RenderingDiagnostics
public static bool ProbeLightEnabled { get; set; } = public static bool ProbeLightEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1"; Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1";
/// <summary>
/// A7.L1 (2026-07-06) per-cell light SET-COMPOSITION probe — the apparatus the
/// <c>[light]</c> counts could not provide (the #176/#177 discriminator: the bug
/// lived in set MEMBERSHIP, not counts). When true, the scoped
/// <c>LightManager.BuildPointLightSnapshot</c> emits ONE rate-limited
/// <c>[indoor-light]</c> line describing the visible-cell-scoped point-light pool
/// (see <see cref="EmitIndoorLight"/>):
/// <code>
/// [indoor-light] visibleCells=&lt;N&gt; pool=&lt;M&gt; cellLess=&lt;K&gt; registered=&lt;R&gt;
/// droppedNonVisible=&lt;R-M&gt; byCell=[0x&lt;id&gt;:&lt;count&gt;,...]
/// </code>
/// This validates the A7 fix's load-bearing assumption end-to-end:
/// <list type="bullet">
/// <item><description><c>cellLess==pool</c> (every pool light is CellId 0) ⇒
/// cell tagging FAILED (ParentCellId not flowing) — scoping is a silent no-op.</description></item>
/// <item><description><c>pool==cellLess</c> while <c>registered</c> is large in a
/// LIT room ⇒ tagged CellIds never match the visible set (wrong id form) — the
/// room would go dark.</description></item>
/// <item><description><c>droppedNonVisible&gt;0</c> with <c>byCell</c> tracking the
/// visible rooms ⇒ scoping WORKING (the under-room/through-floor lights are the
/// dropped ones).</description></item>
/// </list>
/// Output-only, inert when off. Initial state from <c>ACDREAM_PROBE_INDOOR_LIGHT=1</c>.
/// </summary>
public static bool ProbeIndoorLightEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LIGHT") == "1";
// Cell-change gate for EmitVis. The probe fires once per distinct root cell // Cell-change gate for EmitVis. The probe fires once per distinct root cell
// so launch.log stays readable under motion (the per-frame call is a no-op // so launch.log stays readable under motion (the per-frame call is a no-op
// when the root is unchanged). Sentinel 0 = "no root yet" — the first real // when the root is unchanged). Sentinel 0 = "no root yet" — the first real
@ -451,6 +478,66 @@ public static class RenderingDiagnostics
} }
} }
// Wall-clock rate-limit gate for EmitIndoorLight (shares the 1 s interval).
private static long _lastIndoorLightEmitTicks;
/// <summary>
/// A7.L1 — emit ONE rate-limited <c>[indoor-light]</c> line describing the
/// visible-cell-scoped point-light pool: the SET COMPOSITION the <c>[light]</c>
/// counts can't show. Cheap no-op when <see cref="ProbeIndoorLightEnabled"/> is
/// false; otherwise fires at most once per second. Called from the scoped
/// <c>LightManager.BuildPointLightSnapshot</c> (visibleCells != null path).
/// </summary>
/// <param name="visibleCellCount">Size of the portal-flood visible-cell set this frame.</param>
/// <param name="allRegistered">Every registered light (<c>LightManager._all</c>).</param>
/// <param name="scopedSnapshot">The visible-cell-scoped point-light pool just built.</param>
public static void EmitIndoorLight(int visibleCellCount,
IReadOnlyList<AcDream.Core.Lighting.LightSource> allRegistered,
IReadOnlyList<AcDream.Core.Lighting.LightSource> scopedSnapshot)
{
if (!ProbeIndoorLightEnabled) return;
long now = DateTime.UtcNow.Ticks;
if (_lastIndoorLightEmitTicks != 0 && (now - _lastIndoorLightEmitTicks) < LightEmitIntervalTicks)
return;
_lastIndoorLightEmitTicks = now;
int registeredLitPoints = 0;
foreach (var l in allRegistered)
if (l.IsLit && l.Kind != AcDream.Core.Lighting.LightKind.Directional) registeredLitPoints++;
int pool = scopedSnapshot.Count;
int cellLess = 0;
var hist = new Dictionary<uint, int>();
foreach (var l in scopedSnapshot)
{
if (l.CellId == 0) cellLess++;
hist.TryGetValue(l.CellId, out var c);
hist[l.CellId] = c + 1;
}
var sb = new StringBuilder(220);
sb.Append("[indoor-light] visibleCells=").Append(visibleCellCount);
sb.Append(" pool=").Append(pool);
sb.Append(" cellLess=").Append(cellLess);
sb.Append(" registered=").Append(registeredLitPoints);
// Lights excluded by visibility scoping (retail: cells not in visible_cell_table
// contribute nothing) — the through-floor/under-room lights kept out of the pool.
sb.Append(" droppedNonVisible=").Append(registeredLitPoints - pool);
sb.Append(" byCell=[");
const int MaxCells = 12;
int shown = 0;
foreach (var kv in hist)
{
if (shown >= MaxCells) { sb.Append(",..."); break; }
if (shown > 0) sb.Append(',');
sb.Append("0x").Append(kv.Key.ToString("X8")).Append(':').Append(kv.Value);
shown++;
}
sb.Append(']');
Console.WriteLine(sb.ToString());
}
private static bool _probeEnvCellEnabled = private static bool _probeEnvCellEnabled =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1"; Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1";

View file

@ -0,0 +1,280 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.App.Rendering;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #176 (purple flashing at corridor seams, camera-angle dependent) + #177
/// (stairs pop in/out) — headless portal-flood replay in the Facility Hub
/// landblock 0x8A02. The unified hypothesis after the dat + draw-path reads:
/// both artifacts are FLOOD ADMISSION instability (a cell dropping out of
/// PortalVisibilityBuilder's admitted set paints the fog-purple clear color
/// where its geometry was; stair cells failing admission = the pop).
///
/// Production-matched inputs: Build(root, eye, lookup, viewProj,
/// buildingMembership: null, drawLiftZ: ShellDrawLiftZ) — the drawLiftZ
/// mirrors RetailPViewRenderer.DrawInside.
///
/// Scenarios:
/// A. #177 approach — stand in corridor 0x0178, look +X at the stair ramp
/// (0x0182) and the lower cell (0x0183): are they admitted?
/// B. #177 descent — eye path down the ramp crossing into 0x0183: does
/// 0x0182 (the ramp geometry's owner) drop near the transit?
/// C. #176 gaze sweep — eye parked in 0x016E near the 0x017A seam, yaw
/// sweep at several pitches: any cell admitted at angle k, gone at k+1,
/// back at k+2 (the bistability signature)?
/// D. #176 walk — eye tracks down the corridor across two seams, gaze
/// locked +X: per-step admitted-set diffs (drop-for-one-step churn).
/// </summary>
public class Issue176177FacilityHubFloodReplayTests
{
private const uint FacilityHub = 0x8A020000u;
private readonly ITestOutputHelper _out;
public Issue176177FacilityHubFloodReplayTests(ITestOutputHelper output) => _out = output;
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 gazeDir)
{
var view = Matrix4x4.CreateLookAt(eye, eye + gazeDir, Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f);
return view * proj;
}
private static List<uint> Flood(
Dictionary<uint, LoadedCell> cells, uint rootId, Vector3 eye, Vector3 gazeDir)
{
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
var frame = PortalVisibilityBuilder.Build(
cells[rootId], eye, lookup, ViewProjFor(eye, gazeDir),
buildingMembership: null,
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
var result = new List<uint>(frame.OrderedVisibleCells);
result.Sort();
return result;
}
private static string CellSetString(IEnumerable<uint> ids)
=> string.Join(" ", ids.Select(id => $"{id & 0xFFFFu:X4}"));
[Fact]
public void ScenarioA_StairApproach_AdmissionsFromCorridor()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
Assert.True(cells.ContainsKey(FacilityHub | 0x0178u), "0x0178 not loaded");
// 0x0178 spans x<=95 at z 6..3 (floor 6); the ramp cell 0x0182 runs
// x 95→98.33 descending; 0x0183 continues at z 9 beyond x=98.33.
// Eye at standing height (~1.7 m above the 6 floor), approaching the
// stair portal at x=95, gazing +X with a slight downward pitch (the
// natural look at a descending stair).
foreach (float eyeX in new[] { 88f, 90f, 92f, 94f, 94.9f })
{
var eye = new Vector3(eyeX, -40f, -4.3f);
foreach (var (gaze, label) in new (Vector3, string)[]
{
(new Vector3(1f, 0f, 0f), "level"),
(Vector3.Normalize(new Vector3(1f, 0f, -0.35f)), "pitch-19"),
(Vector3.Normalize(new Vector3(1f, 0f, -0.7f)), "pitch-35"),
})
{
var visible = Flood(cells, FacilityHub | 0x0178u, eye, gaze);
bool ramp = visible.Contains(FacilityHub | 0x0182u);
bool lower = visible.Contains(FacilityHub | 0x0183u);
_out.WriteLine($"eyeX={eyeX,5:F1} gaze={label,-8} flood={visible.Count,2} " +
$"ramp0182={(ramp ? "Y" : "MISSING")} lower0183={(lower ? "Y" : "MISSING")} " +
$"[{CellSetString(visible)}]");
}
}
}
[Fact]
public void ScenarioB_StairDescent_RampCellRetention()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
// Descend the ramp: floor z goes 6 (x=95) → 9 (x=98.33), then flat.
// Eye rides ~1.7 m above the local floor. Root = the cell containing
// the eye by x-range (0x0178 x<95, ramp 0x0182 95..98.33, 0x0183 after).
// Gaze: forward and slightly down (running down stairs).
var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.4f));
List<uint>? prev = null;
for (float x = 94.0f; x <= 100.5f; x += 0.1f)
{
float floorZ = x < 95f ? -6f
: x < 98.333f ? -6f - 3f * (x - 95f) / 3.333f
: -9f;
var eye = new Vector3(x, -40f, floorZ + 1.7f);
uint rootId = x < 95f ? FacilityHub | 0x0178u
: x < 98.333f ? FacilityHub | 0x0182u
: FacilityHub | 0x0183u;
var visible = Flood(cells, rootId, eye, gazeDir);
bool ramp = visible.Contains(FacilityHub | 0x0182u);
bool upper = visible.Contains(FacilityHub | 0x0178u);
string diff = "";
if (prev is not null)
{
var removed = prev.Except(visible).ToList();
var added = visible.Except(prev).ToList();
if (removed.Count > 0) diff += $" REMOVED=[{CellSetString(removed)}]";
if (added.Count > 0) diff += $" added=[{CellSetString(added)}]";
}
_out.WriteLine($"x={x,6:F1} root={rootId & 0xFFFFu:X4} eyeZ={eye.Z,6:F2} flood={visible.Count,2} " +
$"ramp0182={(ramp ? "Y" : "MISSING")} up0178={(upper ? "Y" : "-")}{diff}");
prev = visible;
}
}
[Fact]
public void ScenarioC_CorridorSeamGazeSweep_Bistability()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
// Parked eye in 0x016E near the 0x017A seam (x=85), standing height.
var eye = new Vector3(83.5f, -40f, -4.3f);
uint rootId = FacilityHub | 0x016Eu;
int churnEvents = 0;
foreach (float pitchZ in new[] { 0f, -0.35f, -0.75f, -1.2f })
{
List<uint>? prevSet = null;
float prevYaw = 0f;
var perYawSets = new List<(float yaw, List<uint> set)>();
for (float yawDeg = -180f; yawDeg <= 180f; yawDeg += 2f)
{
float rad = yawDeg * MathF.PI / 180f;
var gaze = Vector3.Normalize(new Vector3(MathF.Cos(rad), MathF.Sin(rad), pitchZ));
var visible = Flood(cells, rootId, eye, gaze);
perYawSets.Add((yawDeg, visible));
if (prevSet is not null)
{
var removed = prevSet.Except(visible).ToList();
var added = visible.Except(prevSet).ToList();
if (removed.Count > 0 || added.Count > 0)
{
_out.WriteLine($"pitch={pitchZ,5:F2} yaw {prevYaw,6:F0}->{yawDeg,6:F0}: " +
$"flood {prevSet.Count}->{visible.Count}" +
(removed.Count > 0 ? $" REMOVED=[{CellSetString(removed)}]" : "") +
(added.Count > 0 ? $" added=[{CellSetString(added)}]" : ""));
}
}
prevSet = visible;
prevYaw = yawDeg;
}
// Bistability: a cell present at yaw k, absent at k+1, present at k+2.
for (int i = 2; i < perYawSets.Count; i++)
{
var flicker = perYawSets[i - 2].set
.Intersect(perYawSets[i].set)
.Except(perYawSets[i - 1].set)
.ToList();
if (flicker.Count > 0)
{
churnEvents++;
_out.WriteLine($">>> BISTABLE pitch={pitchZ:F2} yaw={perYawSets[i - 1].yaw:F0}: " +
$"cells [{CellSetString(flicker)}] dropped for ONE 2-degree step");
}
}
}
_out.WriteLine($"bistable one-step drop events: {churnEvents}");
}
/// <summary>
/// THE production lag-window scenario (from launch-137-gate2.log
/// [cell-transit] lines): membership transits fire 0.10.6 m PAST the
/// portal plane in the travel direction (016E→017A at x=85.3385.47 vs
/// the plane at x=85.00; 0182→0183 at 98.5698.64 vs 98.33). The render
/// root (viewer cell, same membership machinery) therefore holds the OLD
/// cell while the camera eye is already beyond the boundary portal's
/// plane. This scenario reproduces exactly that window: root=old cell,
/// eye stepped across and past the plane, gaze forward. If the forward
/// chain (the next corridor cells) drops inside the window, that is #176
/// (purple = fog clear color where the forward cells' geometry was) and
/// #177(a)/(c) at the stair transit.
/// </summary>
[Theory]
[InlineData(0x016Eu, 0x017Au, 85.00f, -4.3f)] // corridor seam, plane x=85
[InlineData(0x0182u, 0x0183u, 98.333f, -7.3f)] // stair-bottom transit, plane x=98.33
public void ScenarioE_RootLagWindow_ForwardChainRetention(
uint rootLow, uint forwardLow, float planeX, float eyeZ)
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
uint rootId = FacilityHub | rootLow;
uint forwardId = FacilityHub | forwardLow;
var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.3f));
_out.WriteLine($"root=0x{rootLow:X4} forward=0x{forwardLow:X4} plane x={planeX:F2} " +
"(eye sweeps across; root HELD at the old cell = the production lag window)");
foreach (float dx in new[] { -0.30f, -0.10f, -0.02f, 0.00f, 0.02f, 0.05f, 0.10f, 0.20f, 0.30f, 0.45f, 0.60f })
{
var eye = new Vector3(planeX + dx, -40f, eyeZ);
var visible = Flood(cells, rootId, eye, gazeDir);
bool fwd = visible.Contains(forwardId);
_out.WriteLine($" eyeX=plane{(dx >= 0 ? "+" : "")}{dx:F2} flood={visible.Count,2} " +
$"forward={(fwd ? "Y" : ">>> DROPPED <<<")} [{CellSetString(visible)}]");
}
}
[Fact]
public void ScenarioD_CorridorWalk_PerStepChurn()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
// Walk 0x0165 → 0x016E → 0x017A along y=40 (seams at x=75 and x=85),
// gaze locked +X, slight downward pitch (the running view). Root flips
// by x-range at the seams (the camera transits the same portals).
var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.3f));
List<uint>? prev = null;
int churn = 0;
for (float x = 71f; x <= 89f; x += 0.05f)
{
uint rootId = x < 75f ? FacilityHub | 0x0165u
: x < 85f ? FacilityHub | 0x016Eu
: FacilityHub | 0x017Au;
if (!cells.ContainsKey(rootId)) continue;
var eye = new Vector3(x, -40f, -4.3f);
var visible = Flood(cells, rootId, eye, gazeDir);
if (prev is not null)
{
var removed = prev.Except(visible).ToList();
var added = visible.Except(prev).ToList();
if (removed.Count > 0 || added.Count > 0)
{
churn++;
_out.WriteLine($"x={x,6:F2} root={rootId & 0xFFFFu:X4} flood={visible.Count,2}" +
(removed.Count > 0 ? $" REMOVED=[{CellSetString(removed)}]" : "") +
(added.Count > 0 ? $" added=[{CellSetString(added)}]" : ""));
}
}
prev = visible;
}
_out.WriteLine($"admitted-set change events over the walk: {churn}");
}
}

View file

@ -122,7 +122,9 @@ public class PlayerMoveToCutoverTests
// the A9 pending_motions node (a null sink orphans it and the // the A9 pending_motions node (a null sink orphans it and the
// MoveToManager wait-for-anims gate never opens — the live stall). // MoveToManager wait-for-anims gate never opens — the live stall).
controller.Motion.DefaultSink = new MotionTableDispatchSink(seq); controller.Motion.DefaultSink = new MotionTableDispatchSink(seq);
controller.Motion.RemoveLinkAnimations = seq.RemoveAllLinkAnimations; // #174: production wiring — HandleEnterWorld (strip + drain), not
// the bare sequence strip (which orphaned pending manager nodes).
controller.Motion.RemoveLinkAnimations = () => seq.Manager.HandleEnterWorld();
controller.Motion.InitializeMotionTables = () => seq.Manager.InitializeState(); controller.Motion.InitializeMotionTables = () => seq.Manager.InitializeState();
controller.Motion.CheckForCompletedMotions = seq.Manager.CheckForCompletedMotions; controller.Motion.CheckForCompletedMotions = seq.Manager.CheckForCompletedMotions;
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);

View file

@ -103,7 +103,9 @@ public class W6EdgeDrivenMovementTests
// dispatch, else its A9 pending_motions node is orphaned). // dispatch, else its A9 pending_motions node is orphaned).
controller.Motion.DefaultSink = new MotionTableDispatchSink(seq); controller.Motion.DefaultSink = new MotionTableDispatchSink(seq);
var s = seq; var s = seq;
controller.Motion.RemoveLinkAnimations = s.RemoveAllLinkAnimations; // #174: production wiring — HandleEnterWorld (strip + drain), not
// the bare sequence strip (which orphaned pending manager nodes).
controller.Motion.RemoveLinkAnimations = () => s.Manager.HandleEnterWorld();
controller.Motion.InitializeMotionTables = () => s.Manager.InitializeState(); controller.Motion.InitializeMotionTables = () => s.Manager.InitializeState();
controller.Motion.CheckForCompletedMotions = s.Manager.CheckForCompletedMotions; controller.Motion.CheckForCompletedMotions = s.Manager.CheckForCompletedMotions;
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);

View file

@ -6,7 +6,7 @@ namespace AcDream.Core.Tests.Lighting;
public sealed class LightManagerTests public sealed class LightManagerTests
{ {
private static LightSource MakePoint(Vector3 pos, float range, uint ownerId = 0, bool lit = true) private static LightSource MakePoint(Vector3 pos, float range, uint ownerId = 0, bool lit = true, uint cellId = 0)
=> new LightSource => new LightSource
{ {
Kind = LightKind.Point, Kind = LightKind.Point,
@ -14,6 +14,7 @@ public sealed class LightManagerTests
Range = range, Range = range,
IsLit = lit, IsLit = lit,
OwnerId = ownerId, OwnerId = ownerId,
CellId = cellId,
}; };
[Fact] [Fact]
@ -176,6 +177,55 @@ public sealed class LightManagerTests
Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3); Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3);
} }
// ── Visible-cell scoping (retail: add_*_lights over visible_cell_table) ────
// A7 #176/#177: the per-frame pool is built from ONLY the lights of currently-
// visible cells (plus cell-less globals), not a flat world-space set.
[Fact]
public void BuildPointLightSnapshot_VisibleScope_ExcludesLightsOfNonVisibleCells()
{
var mgr = new LightManager();
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAA0101u)); // visible cell
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); // under-room, NOT visible
var visible = new System.Collections.Generic.HashSet<uint> { 0xAAAA0101u };
mgr.BuildPointLightSnapshot(Vector3.Zero, visible);
// Only the visible cell's light survives — the under-room light can't wash
// through the floor (retail: its cell isn't in visible_cell_table).
Assert.Single(mgr.PointSnapshot);
Assert.Equal(0xAAAA0101u, mgr.PointSnapshot[0].CellId);
}
[Fact]
public void BuildPointLightSnapshot_VisibleScope_AlwaysIncludesCellLessGlobals()
{
var mgr = new LightManager();
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0u)); // viewer/global — CellId 0
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); // non-visible cell
var visible = new System.Collections.Generic.HashSet<uint> { 0xAAAA0101u }; // does NOT contain 0102
mgr.BuildPointLightSnapshot(Vector3.Zero, visible);
// The cell-less light (viewer fill) is always a candidate; the non-visible
// cell's light is dropped.
Assert.Single(mgr.PointSnapshot);
Assert.Equal(0u, mgr.PointSnapshot[0].CellId);
}
[Fact]
public void BuildPointLightSnapshot_NullScope_KeepsFullPool()
{
var mgr = new LightManager();
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAA0101u));
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u));
// Null visible set = outdoor root / no flood → legacy full-pool behaviour.
mgr.BuildPointLightSnapshot(Vector3.Zero, visibleCells: null);
Assert.Equal(2, mgr.PointSnapshot.Count);
}
[Fact] [Fact]
public void SelectForObject_EmptySnapshot_ReturnsZero() public void SelectForObject_EmptySnapshot_ReturnsZero()
{ {
@ -256,4 +306,69 @@ public sealed class LightManagerTests
Assert.Equal(na, nb); Assert.Equal(na, nb);
Assert.Equal(a[0], b[0]); Assert.Equal(a[0], b[0]);
} }
/// <summary>
/// #176/#177 (2026-07-06) — the end-state pin, via the SHIPPED fix (visible-cell
/// scoping, not "uncap"). Before: <c>BuildPointLightSnapshot</c> kept only the
/// <c>MaxGlobalLights</c> nearest THE CAMERA over the WHOLE registered set, so in
/// the Facility Hub (366 fixtures) an in-range torch of a VISIBLE cell could rank
/// past the cap and be evicted → the cell's 8-set (and its Gouraud vertex lighting)
/// flipped as the camera moved (#176 seam flash / #177 stair-room pop-in). The fix
/// is retail's per-frame collection: the pool is built from ONLY the lights of the
/// currently-VISIBLE cells (<c>CObjCell::add_*_to_global_lights</c> over
/// <c>CEnvCell::visible_cell_table</c>), so the visible pool is a handful of cells,
/// the cap never bites, and a visible cell's in-range light is never camera-evicted.
/// The same scoping keeps a NON-visible cell's light out of the pool entirely
/// (through-floor prevention). See <c>docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md</c>.
/// </summary>
[Fact]
public void PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant()
{
var mgr = new LightManager();
// 400 fixtures clustered near the origin, all in the UNDER-ROOM cell (not
// visible from the target room). These would have filled every low
// camera-distance rank under the old camera-nearest cap.
const uint underRoom = 0xAAAA0102u;
for (int i = 0; i < 400; i++)
mgr.Register(MakePoint(new Vector3(i * 0.05f, 0, 0), range: 5f, ownerId: (uint)(i + 1), cellId: underRoom));
// The target torch: far from the origin-side camera, in the VISIBLE room
// cell, squarely in range of the target object around (200, 0, 0).
const uint targetRoom = 0xAAAA0101u;
var torch = MakePoint(new Vector3(198f, 0, 0), range: 15f, ownerId: 0xF00DF00Du, cellId: targetRoom);
mgr.Register(torch);
// The portal flood says only the target room is visible.
var visible = new System.Collections.Generic.HashSet<uint> { targetRoom };
Span<int> sel = stackalloc int[LightManager.MaxLightsPerObject];
// Camera parked at the origin end — the torch must still light the visible cell.
mgr.BuildPointLightSnapshot(cameraWorldPos: Vector3.Zero, visible);
int n1 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
bool torchSelectedFar = SelectedContains(mgr.PointSnapshot, sel, n1, torch);
// The 400 under-room lights are NOT in the pool (their cell isn't visible).
int underRoomInPool = 0;
foreach (var l in mgr.PointSnapshot) if (l.CellId == underRoom) underRoomInPool++;
// Camera next to the cell — the reference behaviour.
mgr.BuildPointLightSnapshot(cameraWorldPos: new Vector3(200f, 0, 0), visible);
int n2 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
bool torchSelectedNear = SelectedContains(mgr.PointSnapshot, sel, n2, torch);
Assert.True(torchSelectedNear, "sanity: the torch reaches the cell when the camera is beside it");
Assert.True(torchSelectedFar,
"an in-range light of a VISIBLE cell was evicted by the snapshot cap — " +
"per-cell lighting would pop with camera movement (the #176/#177 mechanism)");
Assert.Equal(0, underRoomInPool); // through-floor prevention: non-visible cell's lights excluded
static bool SelectedContains(
System.Collections.Generic.IReadOnlyList<LightSource> snapshot,
Span<int> indices, int count, LightSource target)
{
for (int i = 0; i < count; i++)
if (ReferenceEquals(snapshot[indices[i]], target)) return true;
return false;
}
}
} }

View file

@ -699,8 +699,13 @@ public class BSPQueryTests
// Regression guard for the FULL-HIT case in the same Path 5 branch. // Regression guard for the FULL-HIT case in the same Path 5 branch.
// Sphere overlaps wall AND moves INTO it: moveDot < 0, cull does NOT // Sphere overlaps wall AND moves INTO it: moveDot < 0, cull does NOT
// reject, pos_hits_sphere returns 1, Path 5 takes the `if (hit0)` // reject, pos_hits_sphere returns 1, Path 5 takes the `if (hit0)`
// branch. With engine=null we fall through to the slide fallback // branch. With engine=null we fall through to the real slide
// (SetCollisionNormal + SetSlidingNormal + return Slid). // (CSphere::slide_sphere via Transition.SlideSphereInternal). No
// contact plane is seeded on this bare Transition, so the slide takes
// the wall-only branch (project out the into-wall displacement,
// return Slid) — and per retail it must NOT write the sliding normal
// (#137 mechanism 2; validate_transition 0x0050ac21 is the only
// in-transition writer).
var (root, resolved) = BuildSingleWallBsp(); var (root, resolved) = BuildSingleWallBsp();
var transition = new Transition(); var transition = new Transition();
@ -731,6 +736,9 @@ public class BSPQueryTests
Assert.Equal(TransitionState.Slid, state); Assert.Equal(TransitionState.Slid, state);
Assert.True(transition.CollisionInfo.CollisionNormalValid, Assert.True(transition.CollisionInfo.CollisionNormalValid,
"Full hit should set the collision normal (slide fallback)."); "Full hit should set the collision normal (slide fallback).");
Assert.False(transition.CollisionInfo.SlidingNormalValid,
"find_collisions must not write the sliding normal — retail's " +
"only in-transition writer is validate_transition (#137).");
Assert.False(transition.SpherePath.NegPolyHit, Assert.False(transition.SpherePath.NegPolyHit,
"Full hit should NOT also fire NegPolyHit — that's the near-miss " + "Full hit should NOT also fire NegPolyHit — that's the near-miss " +
"path only. Retail at acclient_2013_pseudo_c.txt:0053a647 returns " + "path only. Retail at acclient_2013_pseudo_c.txt:0053a647 returns " +

View file

@ -0,0 +1,287 @@
using System;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
using Xunit.Abstractions;
using Plane = System.Numerics.Plane;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Conformance tests for the retail <c>CCylSphere</c> collision family port
/// (2026-07-05) — dispatcher <c>0x0053b440</c> + <c>step_sphere_down</c>
/// <c>0x0053a9b0</c> + <c>step_sphere_up</c> <c>0x0053b310</c> +
/// <c>land_on_cylinder</c> <c>0x0053b3d0</c>. Pseudocode:
/// docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md.
///
/// <para>
/// The driving repro: the Holtburg town-network portal platform (stab
/// 0xC0A9B465, Setup 0x020019E3) registers a WIDE LOW cylinder
/// (r=2.597 m, h=0.256 m). Retail steps a grounded player UP ONTO its flat
/// top; the pre-port approximation could only radial-slide, so the player
/// orbited the rim forever (launch-137-repro.log, 2026-07-05). These tests
/// pin the three retail behaviors the family provides: grounded
/// step-up-onto-top, too-tall side slide, and the airborne top landing.
/// Synthetic cylinders only — no dat dependency.
/// </para>
/// </summary>
public class CylSphereFamilyTests
{
private readonly ITestOutputHelper _out;
public CylSphereFamilyTests(ITestOutputHelper output) => _out = output;
private const uint TestLandblockId = 0xA9B40000u;
private const uint TestCellId = TestLandblockId | 0x0001u; // landcell (0,0)
private const float SphereRadius = 0.48f; // retail player capsule radius
private const float SphereHeight = 1.20f;
private const float StepUpHeight = 0.60f;
private const float StepDownHeight = 0.04f;
// The live platform's registered shape ([cyl-test] launch-137-repro.log).
private const float PlatformRadius = 2.597f;
private const float PlatformHeight = 0.256f;
/// <summary>
/// The portal-platform repro: a grounded player walking into the wide low
/// cylinder must STEP UP onto its flat top (retail
/// grounded branch → step_sphere_up → CTransition::step_up, whose
/// step-down probe lands via step_sphere_down's top-disc contact plane) —
/// not slide around the rim.
/// </summary>
[Fact]
public void Grounded_WalkIntoWideLowCylinder_StepsUpOntoTop()
{
var engine = BuildEngine(out _);
RegisterCylinder(engine, entityId: 0xCAFEu,
worldPos: new Vector3(12f, 14f, 0f),
radius: PlatformRadius, height: PlatformHeight);
var body = MakeGroundedBody(new Vector3(12f, 10.4f, 0f));
Vector3 pos = body.Position;
uint cellId = TestCellId;
bool grounded = true;
var perTick = new Vector3(0f, 0.10f, 0f);
for (int tick = 0; tick < 40; tick++)
{
var result = engine.ResolveWithTransition(
pos, pos + perTick, cellId,
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
grounded,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0);
body.Position = result.Position;
pos = result.Position;
cellId = result.CellId;
grounded = result.IsOnGround;
}
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) grounded={grounded}");
// Rim contact is at Y ≈ 14 2.597 0.48 = 10.92. Pre-port the player
// pinned there (Z stayed 0, Y never passed the rim). Post-port the
// player must be standing ON the platform top.
Assert.True(pos.Y > 11.5f,
$"Player must advance past the rim contact (pre-port it pinned at Y≈10.9); got Y={pos.Y:F3}");
Assert.True(MathF.Abs(pos.Z - PlatformHeight) < 0.05f,
$"Player must stand ON the platform top (Z≈{PlatformHeight:F3}); got Z={pos.Z:F3}");
Assert.True(grounded, "Player must remain grounded after stepping onto the platform");
}
/// <summary>
/// A tall thin cylinder (the Holtburg torch shape, r=0.2 h=2.2 — #149)
/// exceeds step_up_height: the grounded dead-center approach must NOT
/// step up and must NOT pass through — retail slides (dead-center the
/// crease projection degenerates to a hard stop).
/// </summary>
[Fact]
public void Grounded_WalkIntoTallCylinder_BlocksBeforeAxis()
{
var engine = BuildEngine(out _);
RegisterCylinder(engine, entityId: 0xF00Du,
worldPos: new Vector3(12f, 14f, 0f),
radius: 0.2f, height: 2.2f);
var body = MakeGroundedBody(new Vector3(12f, 12.6f, 0f));
Vector3 pos = body.Position;
uint cellId = TestCellId;
bool grounded = true;
var perTick = new Vector3(0f, 0.10f, 0f);
for (int tick = 0; tick < 30; tick++)
{
var result = engine.ResolveWithTransition(
pos, pos + perTick, cellId,
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
grounded,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0);
body.Position = result.Position;
pos = result.Position;
cellId = result.CellId;
grounded = result.IsOnGround;
}
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) grounded={grounded}");
// Surface contact at Y = 14 0.2 0.48 = 13.32.
Assert.True(pos.Y < 13.4f,
$"Tall cylinder must block the dead-center approach; got Y={pos.Y:F3}");
Assert.True(pos.Z < 0.5f,
$"Player must NOT end up on top of a 2.2 m cylinder; got Z={pos.Z:F3}");
}
/// <summary>
/// Airborne landing: a falling sphere over the platform center must land
/// ON the flat top (land_on_cylinder → Collide re-test → branch-5
/// exact-TOI rest + top-disc contact plane), not fall through to the
/// terrain inside the footprint.
/// </summary>
[Fact]
public void Airborne_FallOntoWideCylinder_LandsOnTop()
{
var engine = BuildEngine(out _);
RegisterCylinder(engine, entityId: 0xCAFEu,
worldPos: new Vector3(12f, 14f, 0f),
radius: PlatformRadius, height: PlatformHeight);
Vector3 pos = new(12f, 14f, 1.0f); // 1 m above the base, over the center
uint cellId = TestCellId;
bool grounded = false;
var perTick = new Vector3(0f, 0f, -0.25f);
int landedTick = -1;
for (int tick = 0; tick < 20; tick++)
{
var result = engine.ResolveWithTransition(
pos, pos + perTick, cellId,
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
grounded,
body: null,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0);
pos = result.Position;
cellId = result.CellId;
grounded = result.IsOnGround;
if (grounded) { landedTick = tick; break; }
}
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) grounded={grounded} landedTick={landedTick}");
Assert.True(grounded, "Falling sphere must land (ground) on the platform top");
Assert.True(MathF.Abs(pos.Z - PlatformHeight) < 0.05f,
$"Landing must rest on the top disc (Z≈{PlatformHeight:F3}), not the terrain " +
$"(Z=0) inside the footprint; got Z={pos.Z:F3}");
}
/// <summary>
/// Ethereal cylinders stay fully passable through the caller's Layer-2
/// override (pc:276961-276989) — branch 1 detects, the override clears.
/// Guards the #150 door behavior against the branch-1 change from the
/// old early-OK consume.
/// </summary>
[Fact]
public void Grounded_EtherealCylinder_IsFullyPassable()
{
var engine = BuildEngine(out _);
RegisterCylinder(engine, entityId: 0xE7E7u,
worldPos: new Vector3(12f, 14f, 0f),
radius: 0.2f, height: 2.2f,
state: 0x4u); // ETHEREAL_PS, non-static
var body = MakeGroundedBody(new Vector3(12f, 12.6f, 0f));
Vector3 pos = body.Position;
uint cellId = TestCellId;
bool grounded = true;
var perTick = new Vector3(0f, 0.10f, 0f);
for (int tick = 0; tick < 30; tick++)
{
var result = engine.ResolveWithTransition(
pos, pos + perTick, cellId,
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
grounded,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0);
body.Position = result.Position;
pos = result.Position;
cellId = result.CellId;
grounded = result.IsOnGround;
}
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3})");
Assert.True(pos.Y > 14.5f,
$"Ethereal cylinder must not block (walked from 12.6 to past the axis); got Y={pos.Y:F3}");
}
// ───────────────────────────────────────────────────────────────
// Harness
// ───────────────────────────────────────────────────────────────
private static PhysicsEngine BuildEngine(out PhysicsDataCache cache)
{
cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
// Flat terrain at Z=0 across the whole landblock.
var heights = new byte[81];
var heightTable = new float[256]; // all zero → terrain Z = 0
engine.AddLandblock(
landblockId: TestLandblockId,
terrain: new TerrainSurface(heights, heightTable),
cells: Array.Empty<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
return engine;
}
private static void RegisterCylinder(PhysicsEngine engine, uint entityId,
Vector3 worldPos, float radius, float height, uint state = 0u)
{
engine.ShadowObjects.Register(
entityId, gfxObjId: 0u,
worldPos, Quaternion.Identity, radius,
worldOffsetX: 0f, worldOffsetY: 0f, landblockId: TestLandblockId,
collisionType: ShadowCollisionType.Cylinder,
cylHeight: height,
state: state);
}
private static PhysicsBody MakeGroundedBody(Vector3 position)
{
var floorPlane = new Plane(Vector3.UnitZ, 0f);
var floorVerts = new[]
{
new Vector3(-100f, -100f, 0f),
new Vector3( 100f, -100f, 0f),
new Vector3( 100f, 100f, 0f),
new Vector3(-100f, 100f, 0f),
};
return new PhysicsBody
{
Position = position,
Orientation = Quaternion.Identity,
ContactPlaneValid = true,
ContactPlane = floorPlane,
ContactPlaneCellId = TestCellId,
WalkablePolygonValid = true,
WalkablePlane = floorPlane,
WalkableVertices = floorVerts,
WalkableUp = Vector3.UnitZ,
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
};
}
}

View file

@ -0,0 +1,518 @@
using System;
using System.IO;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
using Env = System.Environment;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// #137 corridor-seam inspection (2026-07-05, Facility Hub). Live probe
/// evidence (launch-175-verify2.log:42858): crossing corridor cells
/// 0x8A02016E → 0x8A02017A at world x≈85.25 records a wall hit with normal
/// (1,0,0) — pointing straight back against the movement — after which the
/// stale sliding normal wedges all forward motion (ok=False hit=no, offset
/// projected to zero). Question this dump answers: does cell 0x8A02017A's
/// PHYSICS polygon set contain a portal-spanning polygon at its entry plane
/// (normal ≈ ±X at the portal's local X) — i.e., are portal-sealing polys in
/// our collision set where retail filters them?
/// </summary>
public class Issue137CorridorSeamInspectionTests
{
private readonly ITestOutputHelper _out;
public Issue137CorridorSeamInspectionTests(ITestOutputHelper output) => _out = output;
[Theory]
[InlineData(0x8A02016Eu)]
[InlineData(0x8A02017Au)]
[InlineData(0x8A02011Eu)] // the under-floor room the corridor's floor-portals lead to
[InlineData(0x8A020179u)] // the ramp corridor cell with the window (the #137 window-climb repro)
[InlineData(0x8A02017Eu)] // the cell beyond the window the player climbed into
public void CorridorCell_PhysicsPolysAndPortals_DatInspection(uint envCellId)
{
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
if (!Directory.Exists(datDir))
{
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
var envCell = dats.Get<EnvCell>(envCellId);
Assert.NotNull(envCell);
_out.WriteLine($"=== EnvCell 0x{envCellId:X8} ===");
_out.WriteLine($" pos=({envCell!.Position.Origin.X:F2},{envCell.Position.Origin.Y:F2},{envCell.Position.Origin.Z:F2}) " +
$"rot=({envCell.Position.Orientation.X:F3},{envCell.Position.Orientation.Y:F3},{envCell.Position.Orientation.Z:F3},{envCell.Position.Orientation.W:F3})");
_out.WriteLine($" EnvironmentId=0x{envCell.EnvironmentId:X4} CellStructure={envCell.CellStructure}");
_out.WriteLine($" CellPortals={envCell.CellPortals.Count}");
foreach (var p in envCell.CellPortals)
_out.WriteLine($" portal poly={p.PolygonId} other=0x{p.OtherCellId:X4} flags={p.Flags}");
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
Assert.NotNull(environment);
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs));
_out.WriteLine($" PhysicsPolygons={cs!.PhysicsPolygons.Count} (portal-relevant normals below)");
foreach (var (id, poly) in cs.PhysicsPolygons)
{
// Compute the face normal from the vertex fan (same math as
// PhysicsDataCache.ResolvePolygons).
var verts = poly.VertexIds;
if (verts.Count < 3) continue;
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[0], out var v0)) continue;
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[1], out var v1)) continue;
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[2], out var v2)) continue;
var n = System.Numerics.Vector3.Normalize(System.Numerics.Vector3.Cross(
v1.Origin - v0.Origin, v2.Origin - v0.Origin));
// Only print near-horizontal-normal polys (walls) — the seam wall
// candidates; floors/ceilings are noise here.
if (MathF.Abs(n.Z) > 0.3f) continue;
_out.WriteLine($" poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) v0=({v0.Origin.X:F2},{v0.Origin.Y:F2},{v0.Origin.Z:F2}) verts={verts.Count} sides={poly.SidesType} stip={poly.Stippling}");
}
// The portal polygons live in the VISUAL polygon set — print their
// ids so overlap with the physics set (same id space?) is visible.
_out.WriteLine($" VisualPolygons={cs.Polygons.Count}");
foreach (var p in envCell.CellPortals)
{
if (cs.Polygons.TryGetValue((ushort)p.PolygonId, out var vp))
{
_out.WriteLine($" portal-poly {p.PolygonId} IS in the visual set (verts={vp.VertexIds.Count})");
bool inPhysics = cs.PhysicsPolygons.ContainsKey((ushort)p.PolygonId);
_out.WriteLine($" portal-poly {p.PolygonId} in PHYSICS set: {inPhysics}");
}
}
}
/// <summary>
/// Mechanism-1 follow-up (2026-07-06): being in the CellStruct's
/// <c>PhysicsPolygons</c> TABLE does not mean the physics BSP ever tests a
/// polygon — retail's <c>BSPLEAF::sphere_intersects_poly</c> (0x0053d580)
/// iterates the LEAF's <c>in_polys</c> index list (leaf construction
/// 0x0053d4a0: <c>in_polys[i] = &amp;pack_poly[index]</c>), and our
/// BSPQuery walks the dat's PhysicsBSP leaves the same way. This dump
/// answers: do the physics-BSP LEAVES of the corridor cells reference the
/// portal polygons? If yes, retail's own BSP query would test them too
/// (→ the passable mechanism must be transit/approach-side — the cdb
/// question). If no, our collision is testing polys retail never reaches
/// (→ a desk-fixable acdream divergence).
/// </summary>
[Theory]
[InlineData(0x8A02016Eu)]
[InlineData(0x8A02017Au)]
public void CorridorCell_PhysicsBspLeafMembership_OfPortalPolys(uint envCellId)
{
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
if (!Directory.Exists(datDir))
{
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
var envCell = dats.Get<EnvCell>(envCellId);
Assert.NotNull(envCell);
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell!.EnvironmentId);
Assert.NotNull(environment);
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs));
var portalPolyIds = new System.Collections.Generic.HashSet<ushort>();
foreach (var p in envCell.CellPortals)
portalPolyIds.Add((ushort)p.PolygonId);
_out.WriteLine($"=== EnvCell 0x{envCellId:X8} — physics BSP leaf membership ===");
_out.WriteLine($" Env=0x{envCell.EnvironmentId:X4} struct={envCell.CellStructure} " +
$"portalPolyIds=[{string.Join(",", portalPolyIds)}] " +
$"physicsTable=[{string.Join(",", cs!.PhysicsPolygons.Keys)}]");
var root = cs.PhysicsBSP?.Root;
Assert.NotNull(root);
int leafCount = 0;
var leafPolyIds = new System.Collections.Generic.HashSet<ushort>();
var portalPolyLeafHits = new System.Collections.Generic.List<string>();
var stack = new System.Collections.Generic.Stack<(DatReaderWriter.Types.PhysicsBSPNode Node, string Path)>();
stack.Push((root!, "R"));
while (stack.Count > 0)
{
var (n, path) = stack.Pop();
if (n.Polygons is { Count: > 0 })
{
leafCount++;
foreach (var pid in n.Polygons)
{
leafPolyIds.Add(pid);
if (portalPolyIds.Contains(pid))
portalPolyLeafHits.Add($"poly {pid} in leaf@{path} (type={n.Type}, polys=[{string.Join(",", n.Polygons)}])");
}
}
if (n.PosNode is not null) stack.Push((n.PosNode, path + "+"));
if (n.NegNode is not null) stack.Push((n.NegNode, path + "-"));
}
_out.WriteLine($" BSP leaves-with-polys={leafCount} distinctLeafPolyIds=[{string.Join(",", leafPolyIds)}]");
var tableNotInLeaves = new System.Collections.Generic.List<ushort>();
foreach (var pid in cs.PhysicsPolygons.Keys)
if (!leafPolyIds.Contains(pid))
tableNotInLeaves.Add(pid);
_out.WriteLine($" physics-table polys NOT referenced by any BSP leaf: [{string.Join(",", tableNotInLeaves)}]");
if (portalPolyLeafHits.Count == 0)
{
_out.WriteLine(" >>> NO portal polygon is referenced by any physics-BSP leaf — " +
"retail's sphere_intersects_poly never tests them from this cell's BSP.");
}
else
{
foreach (var hit in portalPolyLeafHits)
_out.WriteLine($" >>> PORTAL POLY IN PHYSICS LEAF: {hit}");
}
}
/// <summary>
/// #137 window climb: the dat truth for the player's collision spheres.
/// Our InitPath places the head sphere at (sphereHeight radius) = 0.72
/// (capsule top 1.2 m); retail collides with the Setup's SPHERE LIST
/// verbatim (CPhysicsObj::transition → init_sphere(GetNumSphere,
/// GetSphere, scale)). Print human Setup 0x02000001's spheres.
/// </summary>
[Fact]
public void HumanSetup_CollisionSpheres_DatTruth()
{
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
if (!Directory.Exists(datDir))
{
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
var setup = dats.Get<DatReaderWriter.DBObjs.Setup>(0x02000001u);
Assert.NotNull(setup);
_out.WriteLine($"Setup 0x02000001: Height={setup!.Height:F3} Radius={setup.Radius:F3} " +
$"StepUp={setup.StepUpHeight:F3} StepDown={setup.StepDownHeight:F3}");
_out.WriteLine($"Spheres ({setup.Spheres.Count}):");
foreach (var s in setup.Spheres)
_out.WriteLine($" origin=({s.Origin.X:F3},{s.Origin.Y:F3},{s.Origin.Z:F3}) r={s.Radius:F3}");
_out.WriteLine($"CylSpheres ({setup.CylSpheres.Count}):");
foreach (var c in setup.CylSpheres)
_out.WriteLine($" origin=({c.Origin.X:F3},{c.Origin.Y:F3},{c.Origin.Z:F3}) r={c.Radius:F3} h={c.Height:F3}");
}
/// <summary>
/// #137 window-climb geometry (2026-07-06): full world-space vertex dump
/// of the shaft cell 0x8A02017E (all physics polys) and 0x8A020179's
/// south-wall family — the opening's lintel/ceiling spans decide where
/// retail blocks the head.
/// </summary>
[Fact]
public void WindowShaft_FullPolyDump()
{
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
if (!Directory.Exists(datDir))
{
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
foreach (var cellId in new[] { 0x8A02017Eu, 0x8A020179u })
{
var envCell = dats.Get<EnvCell>(cellId);
Assert.NotNull(envCell);
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell!.EnvironmentId);
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs));
var rot = new System.Numerics.Quaternion(
envCell.Position.Orientation.X, envCell.Position.Orientation.Y,
envCell.Position.Orientation.Z, envCell.Position.Orientation.W);
var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot)
* System.Numerics.Matrix4x4.CreateTranslation(
envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z);
_out.WriteLine($"=== 0x{cellId:X8} full physics polys (world verts) ===");
foreach (var (id, poly) in cs!.PhysicsPolygons)
{
var verts = poly.VertexIds;
if (verts.Count < 3) continue;
var w = new System.Collections.Generic.List<System.Numerics.Vector3>();
foreach (var vid in verts)
if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v))
w.Add(System.Numerics.Vector3.Transform(v.Origin, world));
var n = System.Numerics.Vector3.Normalize(
System.Numerics.Vector3.Cross(w[1] - w[0], w[2] - w[0]));
// 017E: everything. 0179: south-wall family + ceilings only.
if (cellId == 0x8A020179u && MathF.Abs(n.Y) < 0.3f && n.Z > -0.3f) continue;
var vs = string.Join(" ", w.ConvertAll(p => $"({p.X:F2},{p.Y:F2},{p.Z:F2})"));
_out.WriteLine($" poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) verts={vs}");
}
}
}
/// <summary>
/// Mechanism-1 re-characterization (2026-07-06): the live hit normal
/// (1.00, 0.03, 0.03) at world (85.253, 39.776, 5.992) matches NO
/// physics polygon of either corridor cell — 0x8A02016E (identity
/// rotation) and 0x8A02017A (180° Z) both have only ±Y-normal wall polys,
/// and the PortalSide portals to 0x011E (polys 1/3/5) are ±Y planes
/// 1.4 m north of the player's track, perpendicular to the +X run — the
/// pos_hits_sphere directional cull rejects them for this movement. This
/// sweep hunts the ACTUAL culprit: every physics poly of the seam cell +
/// all portal-adjacent neighbors, world-transformed, scored against the
/// hit point + normal.
/// </summary>
[Fact]
public void CorridorSeam_FindPolygonMatchingLiveHit()
{
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
if (!Directory.Exists(datDir))
{
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
// Live evidence (launch-175-verify2.log:42858).
var hitPoint = new System.Numerics.Vector3(85.253f, -39.776f, -5.992f);
var hitNormal = new System.Numerics.Vector3(-1.00f, 0.03f, -0.03f);
hitNormal = System.Numerics.Vector3.Normalize(hitNormal);
const float sphereRadius = 0.48f;
// Seam cells + every portal-adjacent neighbor of both.
var cellIds = new System.Collections.Generic.HashSet<uint>
{
0x8A02016Eu, 0x8A02017Au,
};
foreach (var seed in new[] { 0x8A02016Eu, 0x8A02017Au })
{
var seedCell = dats.Get<EnvCell>(seed);
if (seedCell is null) continue;
foreach (var p in seedCell.CellPortals)
cellIds.Add(0x8A020000u | p.OtherCellId);
}
foreach (var cellId in cellIds)
{
var envCell = dats.Get<EnvCell>(cellId);
if (envCell is null) { _out.WriteLine($"cell 0x{cellId:X8}: NOT FOUND"); continue; }
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
if (environment is null || !environment.Cells.TryGetValue(envCell.CellStructure, out var cs))
continue;
var rot = new System.Numerics.Quaternion(
envCell.Position.Orientation.X, envCell.Position.Orientation.Y,
envCell.Position.Orientation.Z, envCell.Position.Orientation.W);
var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot)
* System.Numerics.Matrix4x4.CreateTranslation(
envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z);
var portalPolyIds = new System.Collections.Generic.HashSet<ushort>();
foreach (var p in envCell.CellPortals) portalPolyIds.Add((ushort)p.PolygonId);
foreach (var (id, poly) in cs!.PhysicsPolygons)
{
var verts = poly.VertexIds;
if (verts.Count < 3) continue;
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[0], out var v0)) continue;
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[1], out var v1)) continue;
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[2], out var v2)) continue;
var w0 = System.Numerics.Vector3.Transform(v0.Origin, world);
var w1 = System.Numerics.Vector3.Transform(v1.Origin, world);
var w2 = System.Numerics.Vector3.Transform(v2.Origin, world);
var n = System.Numerics.Vector3.Normalize(
System.Numerics.Vector3.Cross(w1 - w0, w2 - w0));
float align = System.Numerics.Vector3.Dot(n, hitNormal);
// |align|: the vertex-fan winding convention can flip the
// computed normal vs the physics plane's true facing — accept
// both signs (2026-07-06 sweep flaw fix).
if (MathF.Abs(align) < 0.95f) continue; // within ~18° of the recorded normal
// Plane distance from the hit point.
float d = -System.Numerics.Vector3.Dot(n, w0);
float dist = System.Numerics.Vector3.Dot(n, hitPoint) + d;
if (MathF.Abs(dist) > sphereRadius + 0.1f) continue;
// Rough proximity: hit point near the polygon's vertex span.
float minX = MathF.Min(w0.X, MathF.Min(w1.X, w2.X)) - 1f;
float maxX = MathF.Max(w0.X, MathF.Max(w1.X, w2.X)) + 1f;
float minY = MathF.Min(w0.Y, MathF.Min(w1.Y, w2.Y)) - 1f;
float maxY = MathF.Max(w0.Y, MathF.Max(w1.Y, w2.Y)) + 1f;
if (hitPoint.X < minX || hitPoint.X > maxX ||
hitPoint.Y < minY || hitPoint.Y > maxY) continue;
_out.WriteLine(
$">>> CANDIDATE cell=0x{cellId:X8} poly={id} " +
$"worldN=({n.X:F3},{n.Y:F3},{n.Z:F3}) align={align:F3} planeDist={dist:F3} " +
$"isPortalPoly={portalPolyIds.Contains(id)} " +
$"w0=({w0.X:F2},{w0.Y:F2},{w0.Z:F2}) w1=({w1.X:F2},{w1.Y:F2},{w1.Z:F2}) w2=({w2.X:F2},{w2.Y:F2},{w2.Z:F2}) " +
$"verts={verts.Count} sides={poly.SidesType} stip={poly.Stippling}");
}
}
_out.WriteLine("(sweep complete)");
}
/// <summary>
/// Entry-poly hunt: the synthetic reversed-movement collision normal is
/// produced by slide_sphere's opposing-normals branch, which needs an
/// INPUT collision normal anti-parallel to the grounded contact plane —
/// i.e., a DOWNWARD-facing polygon (lintel / arch underside). Those were
/// filtered out of the wall dump (|n.Z| &gt; 0.3). Sweep both corridor
/// cells for downward polys near the seam column and print where their
/// planes sit relative to the player's head sphere.
/// </summary>
[Fact]
public void CorridorSeam_DownwardPolysNearSeam()
{
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
if (!Directory.Exists(datDir))
{
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
foreach (var cellId in new[] { 0x8A02016Eu, 0x8A02017Au })
{
var envCell = dats.Get<EnvCell>(cellId);
Assert.NotNull(envCell);
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell!.EnvironmentId);
Assert.NotNull(environment);
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs));
var rot = new System.Numerics.Quaternion(
envCell.Position.Orientation.X, envCell.Position.Orientation.Y,
envCell.Position.Orientation.Z, envCell.Position.Orientation.W);
var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot)
* System.Numerics.Matrix4x4.CreateTranslation(
envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z);
_out.WriteLine($"=== 0x{cellId:X8} downward physics polys (n.Z < -0.3) ===");
foreach (var (id, poly) in cs!.PhysicsPolygons)
{
var verts = poly.VertexIds;
if (verts.Count < 3) continue;
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[0], out var v0)) continue;
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[1], out var v1)) continue;
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[2], out var v2)) continue;
var w0 = System.Numerics.Vector3.Transform(v0.Origin, world);
var w1 = System.Numerics.Vector3.Transform(v1.Origin, world);
var w2 = System.Numerics.Vector3.Transform(v2.Origin, world);
var n = System.Numerics.Vector3.Normalize(
System.Numerics.Vector3.Cross(w1 - w0, w2 - w0));
if (n.Z > -0.3f) continue;
// Only near the seam column the player crossed.
float minX = MathF.Min(w0.X, MathF.Min(w1.X, w2.X));
float maxX = MathF.Max(w0.X, MathF.Max(w1.X, w2.X));
if (maxX < 83.5f || minX > 87.0f) continue;
var allW = new System.Collections.Generic.List<System.Numerics.Vector3>();
foreach (var vid in verts)
if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var vv))
allW.Add(System.Numerics.Vector3.Transform(vv.Origin, world));
float minZ = float.MaxValue, maxZ = float.MinValue, minY = float.MaxValue, maxY = float.MinValue;
foreach (var w in allW)
{
minZ = MathF.Min(minZ, w.Z); maxZ = MathF.Max(maxZ, w.Z);
minY = MathF.Min(minY, w.Y); maxY = MathF.Max(maxY, w.Y);
}
_out.WriteLine(
$" poly {id}: worldN=({n.X:F2},{n.Y:F2},{n.Z:F2}) x=[{minX:F2},{maxX:F2}] " +
$"y=[{minY:F2},{maxY:F2}] z=[{minZ:F2},{maxZ:F2}] verts={verts.Count} " +
$"sides={poly.SidesType} stip={poly.Stippling}");
}
}
_out.WriteLine("(downward sweep complete)");
}
/// <summary>
/// 2026-07-06 gate-session follow-up: seam crossings SUCCEED at
/// y≈40.8..41.2 and BLOCK at y≈39.5..39.8 (cell-transit log,
/// launch-137-corridor-gate.log). A y-dependent boundary with no physics
/// polygon culprit points at the PORTAL POLYGONS — if the doorway
/// openings don't span the full corridor width, the transit/membership
/// machinery only hands the sphere to the neighbor inside the portal
/// poly's span. Dump every portal polygon's world-space vertex extent.
/// </summary>
[Theory]
[InlineData(0x8A02016Eu)]
[InlineData(0x8A02017Au)]
public void CorridorCell_PortalPolygonWorldSpans(uint envCellId)
{
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
if (!Directory.Exists(datDir))
{
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
var envCell = dats.Get<EnvCell>(envCellId);
Assert.NotNull(envCell);
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell!.EnvironmentId);
Assert.NotNull(environment);
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs));
var rot = new System.Numerics.Quaternion(
envCell.Position.Orientation.X, envCell.Position.Orientation.Y,
envCell.Position.Orientation.Z, envCell.Position.Orientation.W);
var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot)
* System.Numerics.Matrix4x4.CreateTranslation(
envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z);
_out.WriteLine($"=== 0x{envCellId:X8} portal polygons (world spans) ===");
foreach (var p in envCell.CellPortals)
{
if (!cs!.Polygons.TryGetValue((ushort)p.PolygonId, out var poly))
{
_out.WriteLine($" portal poly {p.PolygonId} -> 0x{p.OtherCellId:X4} {p.Flags}: NOT in visual set");
continue;
}
var min = new System.Numerics.Vector3(float.MaxValue);
var max = new System.Numerics.Vector3(float.MinValue);
foreach (var vid in poly.VertexIds)
{
if (!cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v)) continue;
var w = System.Numerics.Vector3.Transform(v.Origin, world);
min = System.Numerics.Vector3.Min(min, w);
max = System.Numerics.Vector3.Max(max, w);
}
_out.WriteLine(
$" portal poly {p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] " +
$"x=[{min.X:F2},{max.X:F2}] y=[{min.Y:F2},{max.Y:F2}] z=[{min.Z:F2},{max.Z:F2}] " +
$"verts={poly.VertexIds.Count}");
}
}
}

View file

@ -0,0 +1,500 @@
using System;
using System.IO;
using System.Numerics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using AcDream.Core.Physics;
using Xunit;
using Xunit.Abstractions;
using Env = System.Environment;
using Plane = System.Numerics.Plane;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// #137 corridor-seam replay (2026-07-06) — dat-backed reproduction of the
/// Facility Hub phantom hit (launch-175-verify2.log:42858): running +X down
/// the corridor, crossing 0x8A02016E → 0x8A02017A at x≈85.25, the live
/// client recorded `ok=True hit=yes n=(1.00,0.03,0.03)` with full advance,
/// persisted the sliding normal, and every later forward resolve absorbed to
/// zero (`ok=False hit=no`).
///
/// <para>
/// Dat facts pinned by <see cref="Issue137CorridorSeamInspectionTests"/>:
/// neither corridor cell (nor any portal-adjacent neighbor) has a physics
/// polygon whose plane matches that normal near the hit point — the recorded
/// normal is SYNTHETIC (the negated movement direction), which is exactly
/// what slide_sphere's opposing-normals branch records. Retail
/// (<c>CSphere::slide_sphere</c> 0x00537440 @0x0053762c) returns
/// COLLIDED_TS from that branch; our port returned OK — letting the step
/// complete with full advance and the synthetic normal persisted.
/// </para>
///
/// <para>
/// This replay drives the real engine over the real dat cells with the
/// live-log positions and player dimensions, and pins: the seam crossing
/// must complete WITHOUT persisting a sliding normal, and continued forward
/// running must keep advancing (no absorbing wedge).
/// </para>
/// </summary>
public class Issue137CorridorSeamReplayTests
{
private readonly ITestOutputHelper _out;
public Issue137CorridorSeamReplayTests(ITestOutputHelper output) => _out = output;
private const uint SeamCellWest = 0x8A02016Eu;
private const uint SeamCellEast = 0x8A02017Au;
private static string? FindDatDir()
{
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(datDir) ? datDir : null;
}
/// <summary>
/// Hydrate the two seam cells + every portal-adjacent neighbor into a
/// PhysicsEngine, exactly as the streaming path does (CacheCellStruct
/// with the dat world transform).
/// </summary>
private static PhysicsEngine BuildCorridorEngine(DatCollection dats)
{
var engine = new PhysicsEngine();
engine.DataCache = new PhysicsDataCache();
var toLoad = new System.Collections.Generic.HashSet<uint> { SeamCellWest, SeamCellEast };
foreach (var seed in new[] { SeamCellWest, SeamCellEast })
{
var seedCell = dats.Get<EnvCell>(seed);
Assert.NotNull(seedCell);
foreach (var p in seedCell!.CellPortals)
toLoad.Add(0x8A020000u | p.OtherCellId);
}
// Expand three portal rings — the live collision cell array reaches
// cells three hops out (0x8A020166, the under-ramp room whose ceiling
// is the ramp slab's underside, is ring-3 in the 2026-07-06
// seam-shake trace; with only two rings the offline flood can never
// add it and the shake does not reproduce).
for (int ring = 0; ring < 3; ring++)
{
foreach (var known in new System.Collections.Generic.List<uint>(toLoad))
{
var cell = dats.Get<EnvCell>(known);
if (cell is null) continue;
foreach (var p in cell.CellPortals)
toLoad.Add(0x8A020000u | p.OtherCellId);
}
}
foreach (var cellId in toLoad)
{
var envCell = dats.Get<EnvCell>(cellId);
if (envCell is null) continue;
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
if (environment is null) continue;
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) continue;
var rot = new Quaternion(
envCell.Position.Orientation.X, envCell.Position.Orientation.Y,
envCell.Position.Orientation.Z, envCell.Position.Orientation.W);
var world = Matrix4x4.CreateFromQuaternion(rot)
* Matrix4x4.CreateTranslation(
envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z);
engine.DataCache.CacheCellStruct(cellId, envCell, cs!, world);
}
return engine;
}
private static PhysicsBody GroundedBody()
{
var body = new PhysicsBody();
body.ContactPlaneValid = true;
// Corridor floor at world z = 6 → n·p + d = 0 with n = +Z, d = 6.
body.ContactPlane = new Plane(Vector3.UnitZ, 6f);
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
// The live session carried a walkable polygon (walkable=True on every
// [resolve] line) — seed the corridor floor slab so the transition's
// SetWalkable path runs like live.
body.WalkablePolygonValid = true;
body.WalkablePlane = new Plane(Vector3.UnitZ, 6f);
body.WalkableUp = Vector3.UnitZ;
body.WalkableVertices = new[]
{
new Vector3(75f, -41.67f, -6f),
new Vector3(85f, -41.67f, -6f),
new Vector3(85f, -38.33f, -6f),
new Vector3(75f, -38.33f, -6f),
};
return body;
}
private ResolveResult Resolve(PhysicsEngine engine, PhysicsBody body,
Vector3 from, Vector3 to, uint cellId)
=> engine.ResolveWithTransition(
currentPos: from,
targetPos: to,
cellId: cellId,
sphereRadius: 0.48f, // human player, PlayerMovementController:885
sphereHeight: 1.2f, // human player, PlayerMovementController:886
stepUpHeight: 0.4f, // PlayerMovementController defaults
stepDownHeight: 0.4f,
isOnGround: true,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
/// <summary>
/// 2026-07-06 seam-shake repro, snapshot-exact (probe session
/// launch-137-seam-probes.log + resolve-137-seam-capture.jsonl tick 4101,
/// repeated ×46): running WEST across the x=75 boundary
/// (0x8A02016E → 0x8A020165, the ramp cell) from (75.287, 40.035, 6)
/// toward (74.685, 39.988, 6), the resolve blocks with the SYNTHETIC
/// reversed-movement normal (0.997, 0.078, 0.002) and out==in — every
/// frame — the "shaking at the seam" report.
///
/// <para>
/// Probe-traced chain: the foot sphere (tangent to the floor) crosses
/// onto 0165's ramp floor; the ramp slab is double-faced and the
/// UNDERSIDE face (poly 0, n=(0.03,0,1)) grazes the sphere within the
/// hit threshold → recorded as a foot near-miss → neg-poly step-up
/// dispatch with a downward normal → the nested step-up's walkable probe
/// rejects the exactly-tangent ramp floor ([walkable-nearest]
/// gap=0.0000 overlapsSphere=False) → StepUpSlide →
/// slide_sphere(downward normal vs up-facing contact plane) → the
/// opposing-normals branch → Collided → revert. Repeat.
/// </para>
/// </summary>
[Fact]
public void SeamShake_WestBoundary_SnapshotExact_Advances()
{
var datDir = FindDatDir();
if (datDir is null)
{
_out.WriteLine("SKIP: dat directory not found");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
var engine = BuildCorridorEngine(dats);
// Body seeded EXACTLY from the capture's bodyBefore (tick 4101).
var body = new PhysicsBody();
body.ContactPlaneValid = true;
body.ContactPlane = new Plane(Vector3.UnitZ, 6f);
body.ContactPlaneCellId = SeamCellWest;
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
body.WalkablePolygonValid = true;
body.WalkablePlane = new Plane(Vector3.UnitZ, 6f);
body.WalkableUp = Vector3.UnitZ;
body.WalkableVertices = new[]
{
new Vector3(75f, -38.33333f, -6f),
new Vector3(75f, -41.66667f, -6f),
new Vector3(78.33333f, -41.66667f, -6f),
new Vector3(78.33333f, -38.33333f, -6f),
};
var from = new Vector3(75.28674f, -40.03537f, -6f);
var to = new Vector3(74.6854f, -39.988018f, -6f);
// Emit the same step-level probes the live session logged so the
// offline trace can be line-diffed against launch-137-seam-probes.log
// — the first divergent line names the state the replay is missing.
var probeBuffer = new System.IO.StringWriter();
var prevOut = Console.Out;
ResolveResult r1;
try
{
Console.SetOut(probeBuffer);
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
PhysicsDiagnostics.ProbePushBackEnabled = true;
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
r1 = engine.ResolveWithTransition(
currentPos: from,
targetPos: to,
cellId: SeamCellWest,
sphereRadius: 0.48f,
sphereHeight: 1.2f,
stepUpHeight: 0.6f, // live Setup values from the capture
stepDownHeight: 1.5f,
isOnGround: true,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
}
finally
{
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
PhysicsDiagnostics.ProbePushBackEnabled = false;
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
Console.SetOut(prevOut);
}
_out.WriteLine(probeBuffer.ToString());
_out.WriteLine($"r1: ok={r1.Ok} out=({r1.Position.X:F3},{r1.Position.Y:F3},{r1.Position.Z:F3}) " +
$"cell=0x{r1.CellId:X8} hit={r1.CollisionNormalValid} " +
$"n=({r1.CollisionNormal.X:F2},{r1.CollisionNormal.Y:F2},{r1.CollisionNormal.Z:F2}) " +
$"bodySliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)}");
Assert.True(r1.Position.X < from.X - 0.3f,
$"The westward boundary crossing onto the ramp must advance " +
$"({from.X:F3} → {r1.Position.X:F3}, target {to.X:F3}); zero " +
$"advance with the reversed-movement normal = the seam shake.");
}
/// <summary>
/// #137 window-climb repro (2026-07-06 gate 2, launch-137-gate2.log):
/// running from the ramp top in 0x8A020179 into the corridor-end opening
/// (the portal to the 0x8A02017E shaft, wall plane world y=41.67), the
/// player stepped INTO the niche — `in=(89.531,41.506,5.112) →
/// out=(90.209,41.774,5.209) cell=0x8A02017E` — ending with the head
/// (and camera) through the opening's roof. The opening is ~1.3 m tall
/// (z 5.2..3.9); a 1.68 m character cannot fit — retail blocks entry
/// (the raised probe's HEAD sphere hits the lintel/ceiling). User axiom:
/// "should not be able to run into it".
/// </summary>
[Fact]
public void WindowOpening_HeadCannotFit_EntryBlocked()
{
var datDir = FindDatDir();
if (datDir is null)
{
_out.WriteLine("SKIP: dat directory not found");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
var engine = BuildCorridorEngine(dats);
var body = new PhysicsBody();
body.ContactPlaneValid = true;
body.ContactPlane = new Plane(Vector3.UnitZ, 5.112f); // ramp-top level
body.ContactPlaneCellId = 0x8A020179u;
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
// Walk the live approach (ramp-top toward the corridor-end opening)
// so the engine self-accumulates its contact-plane/walkable state,
// then push into the opening for several held-key frames (the live
// climb happened under a held key, not a single resolve).
var pos = new Vector3(88.60f, -41.10f, -5.05f);
uint cell = 0x8A020179u;
ResolveResult r = default;
bool probeFrames = Env.GetEnvironmentVariable("ACDREAM_TEST_WINDOW_PROBE") == "1";
for (int i = 0; i < 22; i++)
{
var dir = Vector3.Normalize(new Vector3(90.209f, -41.809f, 0f) - new Vector3(pos.X, pos.Y, 0f));
var step = new Vector3(dir.X, dir.Y, 0f) * 0.13f;
var probeBuffer = new System.IO.StringWriter();
var prevOut = Console.Out;
try
{
if (probeFrames && i >= 9)
{
Console.SetOut(probeBuffer);
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
}
r = engine.ResolveWithTransition(
currentPos: pos,
targetPos: pos + step,
cellId: cell,
sphereRadius: 0.48f,
// #137: the corrected capsule top (dat Setup 0x02000001,
// head sphere center 1.350 → top 1.830; Height 1.835).
// The live climb happened under the old 1.2f (head top
// 1.2 m — no head collision at the lintel).
sphereHeight: 1.835f,
stepUpHeight: 0.6f,
stepDownHeight: 1.5f,
isOnGround: true,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
}
finally
{
if (probeFrames && i >= 9)
{
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
Console.SetOut(prevOut);
}
}
if (probeFrames && i >= 9 && i <= 10)
_out.WriteLine(probeBuffer.ToString());
_out.WriteLine($"r{i}: ok={r.Ok} out=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) " +
$"cell=0x{r.CellId:X8} hit={r.CollisionNormalValid} " +
$"n=({r.CollisionNormal.X:F2},{r.CollisionNormal.Y:F2},{r.CollisionNormal.Z:F2})");
pos = r.Position;
cell = r.CellId;
Assert.NotEqual(0x8A02017Eu, r.CellId);
Assert.True(r.Position.Y > -41.6f,
$"A 1.68 m character must not enter the 1.3 m-tall opening " +
$"(wall plane y=41.67); frame {i} got Y={r.Position.Y:F3} " +
$"cell=0x{r.CellId:X8} (live bug: ended at 41.774 inside " +
$"0x8A02017E, head through the roof).");
}
}
/// <summary>
/// The window-climb's placement half, pinned at the exact site: at the
/// step-up's raised position on the alcove sill (foot 5.019), the HEAD
/// sphere (center 3.339, span 3.82..2.86) pokes ~6 cm past the south
/// wall plane into the SOLID rock above the alcove ceiling (0x8A020179's
/// lintel band, polys 14/15 at y=41.67 z∈[3.90,3.00]). Retail's
/// step-down placement insert (CTransition::step_down 0x0050b3b3 →
/// placement transitional_insert → BSPTREE::sphere_intersects_solid
/// 0x0053d5f0) REJECTS — that's what makes the 0.7 m sill unclimbable.
/// Our placement passed (the live + offline climb), so our Path-1 solid
/// test misses the head-vs-solid overlap.
/// </summary>
[Fact]
public void WindowAlcove_RaisedPlacement_HeadInLintelSolid_Collides()
{
var datDir = FindDatDir();
if (datDir is null)
{
_out.WriteLine("SKIP: dat directory not found");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
var engine = BuildCorridorEngine(dats);
var cell = engine.DataCache!.GetCellStruct(0x8A020179u);
Assert.NotNull(cell);
Assert.NotNull(cell!.BSP?.Root);
// The raised (post-sill-climb) pose from the offline repro's r9.
var footWorld = new Vector3(89.683f, -41.247f, -4.539f); // foot sphere CENTER
var headWorld = new Vector3(89.683f, -41.247f, -3.339f); // head sphere CENTER
var footLocal = Vector3.Transform(footWorld, cell.InverseWorldTransform);
var headLocal = Vector3.Transform(headWorld, cell.InverseWorldTransform);
var t = new Transition();
t.SpherePath.InitPath(
new Vector3(89.683f, -41.247f, -5.019f),
new Vector3(89.683f, -41.247f, -5.019f),
0x8A020179u, 0.48f, 1.2f);
t.SpherePath.InsertType = InsertType.Placement;
Matrix4x4.Decompose(cell.WorldTransform, out _, out var cellRot, out var cellOrigin);
var result = BSPQuery.FindCollisions(
cell.BSP!.Root,
cell.Resolved,
t,
new DatReaderWriter.Types.Sphere { Origin = footLocal, Radius = 0.48f },
new DatReaderWriter.Types.Sphere { Origin = headLocal, Radius = 0.48f },
footLocal,
Vector3.UnitZ,
1.0f,
cellRot,
engine,
worldOrigin: cellOrigin);
_out.WriteLine($"placement result={result} footLocal=({footLocal.X:F3},{footLocal.Y:F3},{footLocal.Z:F3}) " +
$"headLocal=({headLocal.X:F3},{headLocal.Y:F3},{headLocal.Z:F3})");
Assert.Equal(TransitionState.Collided, result);
}
/// <summary>
/// 2026-07-06 gate session repro (launch-137-corridor-gate.log): standing
/// at (84.851, 39.764, 6.000) — the foot sphere already straddling the
/// x=85 cell boundary by 0.33 m — the first move attempt toward
/// (85.453, 39.782) blocked with the synthetic reversed-movement normal
/// (1.00, 0.03, 0.02), out==in, cp lost (cp=none), and repeated every
/// frame (the "shaking at the seam" report). The deeper straddle start is
/// what the original replay frame (84.638 → 85.253) didn't cover.
/// </summary>
[Fact]
public void SeamCrossing_FromDeepStraddleStart_Advances()
{
var datDir = FindDatDir();
if (datDir is null)
{
_out.WriteLine("SKIP: dat directory not found");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
var engine = BuildCorridorEngine(dats);
var body = GroundedBody();
var from = new Vector3(84.851f, -39.764f, -6.000f);
var to = new Vector3(85.453f, -39.782f, -6.000f);
var r1 = Resolve(engine, body, from, to, SeamCellWest);
_out.WriteLine($"r1: ok={r1.Ok} out=({r1.Position.X:F3},{r1.Position.Y:F3},{r1.Position.Z:F3}) " +
$"cell=0x{r1.CellId:X8} hit={r1.CollisionNormalValid} " +
$"n=({r1.CollisionNormal.X:F2},{r1.CollisionNormal.Y:F2},{r1.CollisionNormal.Z:F2}) " +
$"bodySliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)} " +
$"bodyCpValid={body.ContactPlaneValid}");
Assert.True(r1.Position.X > from.X + 0.2f,
$"The straddling-start seam crossing must advance " +
$"({from.X:F3} → {r1.Position.X:F3}); zero advance with a " +
$"reversed-movement normal = the 2026-07-06 seam shake.");
}
[Fact]
public void SeamCrossing_DoesNotPersistSyntheticSlidingNormal_AndRunContinues()
{
var datDir = FindDatDir();
if (datDir is null)
{
_out.WriteLine("SKIP: dat directory not found");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
var engine = BuildCorridorEngine(dats);
var body = GroundedBody();
// ── The live hit frame verbatim (launch-175-verify2.log:42858) ──
var from = new Vector3(84.638f, -39.758f, -6.000f);
var to = new Vector3(85.253f, -39.776f, -6.000f);
var r1 = Resolve(engine, body, from, to, SeamCellWest);
_out.WriteLine($"r1: ok={r1.Ok} out=({r1.Position.X:F3},{r1.Position.Y:F3},{r1.Position.Z:F3}) " +
$"cell=0x{r1.CellId:X8} hit={r1.CollisionNormalValid} " +
$"n=({r1.CollisionNormal.X:F2},{r1.CollisionNormal.Y:F2},{r1.CollisionNormal.Z:F2}) " +
$"bodySliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)} " +
$"slidingN=({body.SlidingNormal.X:F2},{body.SlidingNormal.Y:F2},{body.SlidingNormal.Z:F2})");
// The corridor is straight and open: the crossing must not leave the
// body carrying a sliding normal (there is no wall to slide on —
// Issue137CorridorSeamInspectionTests proved no polygon matches the
// live-recorded normal; retail's slide_sphere opposing branch returns
// COLLIDED and its validate handling never lets a synthetic
// reversed-movement normal survive a clean corridor run).
Assert.False(body.TransientState.HasFlag(TransientStateFlags.Sliding),
"Crossing the open corridor seam must not persist a sliding " +
"normal — the live wedge's entry state (#137 mechanism 2).");
// ── Keep running +X (the live session's held-W frames) ──────────
var pos = r1.Position;
var cell = r1.CellId;
for (int i = 0; i < 6; i++)
{
var step = new Vector3(0.13f, -0.004f, 0f); // ~run speed per tick, same heading
var r = Resolve(engine, body, pos, pos + step, cell);
_out.WriteLine($"r{i + 2}: ok={r.Ok} out=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) " +
$"cell=0x{r.CellId:X8} hit={r.CollisionNormalValid} " +
$"bodySliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)}");
Assert.True(r.Position.X > pos.X + 0.05f,
$"Forward run must keep advancing through the open corridor " +
$"(frame {i + 2}: {pos.X:F3} → {r.Position.X:F3}) — zero advance " +
$"= the #137 absorbing wedge.");
pos = r.Position;
cell = r.CellId;
}
}
}

View file

@ -0,0 +1,343 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using AcDream.Core.Physics;
using Xunit;
using Plane = System.Numerics.Plane;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// #137 mechanism 2 — the sliding-normal absorbing wedge (2026-07-06).
///
/// <para>
/// Retail's in-transition <c>collision_info.sliding_normal</c> has exactly ONE
/// writer besides the per-frame seed: <c>CTransition::validate_transition</c>
/// (0x0050ac21-ac30, "if collision_normal_valid → set_sliding_normal"). The
/// BSP collision layer NEVER writes it — <c>BSPTREE::find_collisions</c>'
/// Contact branch dispatches full hits to <c>step_sphere_up</c> (foot,
/// 0x0053a719) / <c>BSPTREE::slide_sphere</c> (head, 0x0053a697), and
/// <c>CSphere::slide_sphere</c> (0x00537440) slides IN-FRAME via
/// <c>add_offset_to_check_pos</c> without touching sliding_normal
/// (grep-verified: zero sliding_normal references between 0x005155 and
/// 0x00841f in acclient_2013_pseudo_c.txt). ACE mirrors this: the only
/// SetSlidingNormal call sites are CollisionInfo.cs:58 (the setter) and
/// Transition.cs:1027 (validate). The body-side persistence
/// (<c>CPhysicsObj::SetPositionInternal</c> 0x005154c2, SLIDING_TS bit-4 sync
/// at 0x005154e1) runs only on transition SUCCESS.
/// </para>
///
/// <para>
/// acdream's BSPQuery Contact branch carried stub fallbacks
/// (SetCollisionNormal + SetSlidingNormal + return Slid) instead of the real
/// slide. The leaked sliding normal survived to the transition end, the
/// unconditional body writeback persisted it, and the next frame's seed
/// projected an exactly-anti-parallel push to zero — aborting at step 0
/// BEFORE any collision test could refresh the state. Live shape: the
/// Facility Hub corridor phantom (launch-175-verify2.log:42858 — one wall
/// hit at the 0x8A02016E→0x8A02017A seam, then endless ok=False hit=no
/// zero-advance resolves; strafe escapes).
/// </para>
/// </summary>
public class Issue137SlidingNormalLifecycleTests
{
// =========================================================================
// Site-level pins — BSPQuery.FindCollisions Contact branch must not write
// the transition's sliding normal (retail: only validate_transition does).
// =========================================================================
/// <summary>
/// Contact foot-sphere FULL HIT with the step-up recursion unavailable
/// (engine=null / step-up already in progress) must dispatch the real
/// sphere slide — never the SetSlidingNormal stub.
///
/// <para>
/// Retail: a blocked step-up funnels to <c>SPHEREPATH::step_up_slide</c> →
/// <c>CSphere::slide_sphere</c> (ACE SpherePath.cs:316 → Sphere.cs:558) —
/// in-frame slide, no sliding_normal write. Face-on into a vertical wall
/// while grounded: the crease projection (cross(wallN, floorN)) has no
/// component along the movement, the slide offset is degenerate
/// (&lt; F_EPSILON), and slide_sphere returns COLLIDED_TS (0x00537735).
/// </para>
/// </summary>
[Fact]
public void ContactFootFullHit_StepUpUnavailable_RealSlide_NoSlidingNormalWrite()
{
var (root, resolved) = BSPStepUpFixtures.TallWall();
// Grounded mover pushing face-on (+X) into the 5 m wall at x=0.5
// (normal X). Sphere center reach 0.35+0.2=0.55 penetrates the wall.
var from = new Vector3(0.10f, 0f, BSPStepUpFixtures.SphereRadius);
var to = new Vector3(0.35f, 0f, BSPStepUpFixtures.SphereRadius);
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to);
var localSphere = new DatReaderWriter.Types.Sphere
{
Origin = to,
Radius = BSPStepUpFixtures.SphereRadius,
};
var result = BSPQuery.FindCollisions(
root, resolved, t, localSphere, null,
from, Vector3.UnitZ, 1.0f);
Assert.False(t.CollisionInfo.SlidingNormalValid,
"find_collisions must not write collision_info.sliding_normal — " +
"retail's only in-transition writer is validate_transition " +
"(0x0050ac21). A sliding normal leaked here survives to the body " +
"writeback and absorbs the next frame's forward offset (#137).");
Assert.True(t.CollisionInfo.CollisionNormalValid,
"The real slide records the collision normal (CSphere::slide_sphere " +
"→ set_collision_normal).");
Assert.Equal(TransitionState.Collided, result);
}
/// <summary>
/// Contact HEAD-sphere FULL HIT must dispatch <c>BSPTREE::slide_sphere</c>
/// (retail 0x0053a697; ACE BSPTree.cs:202 → 310-316: the real
/// <c>Sphere.SlideSphere</c> on GlobalSphere[0]) — never the stub.
/// The corridor phantom's portal-side polys span head height; this is the
/// path that recorded the (1,0,0) normal the wedge absorbed on.
/// </summary>
[Fact]
public void ContactHeadFullHit_RealSlide_NoSlidingNormalWrite()
{
// Raised wall: z ∈ [0.6, 5] at x=0.5, normal X. The foot sphere
// (center z=0.2, r=0.2 → z-span [0, 0.4]) passes under it; the head
// sphere (center z=0.8 → z-span [0.6, 1.0]) fully hits it.
var resolved = new Dictionary<ushort, ResolvedPolygon>();
var floorVerts = new[]
{
new Vector3(-2f, -1f, 0f), new Vector3(2f, -1f, 0f),
new Vector3(2f, 1f, 0f), new Vector3(-2f, 1f, 0f),
};
resolved[1] = new ResolvedPolygon
{
Vertices = floorVerts,
Plane = new Plane(Vector3.UnitZ, 0f),
NumPoints = 4,
SidesType = CullMode.None,
};
var wallNormal = new Vector3(-1f, 0f, 0f);
var wallVerts = new[]
{
new Vector3(0.5f, -1f, 0.6f),
new Vector3(0.5f, -1f, 5f),
new Vector3(0.5f, 1f, 5f),
new Vector3(0.5f, 1f, 0.6f),
};
resolved[2] = new ResolvedPolygon
{
Vertices = wallVerts,
Plane = new Plane(wallNormal, 0.5f), // n·p + d = 0 at x=0.5
NumPoints = 4,
SidesType = CullMode.None,
};
var leaf = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new DatReaderWriter.Types.Sphere { Origin = new Vector3(0f, 0f, 2.5f), Radius = 10f },
};
leaf.Polygons.Add(1);
leaf.Polygons.Add(2);
var from = new Vector3(0.10f, 0f, BSPStepUpFixtures.SphereRadius);
var to = new Vector3(0.35f, 0f, BSPStepUpFixtures.SphereRadius);
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to);
var footSphere = new DatReaderWriter.Types.Sphere
{
Origin = to,
Radius = BSPStepUpFixtures.SphereRadius,
};
var headSphere = new DatReaderWriter.Types.Sphere
{
Origin = new Vector3(to.X, to.Y, 0.8f),
Radius = BSPStepUpFixtures.SphereRadius,
};
var result = BSPQuery.FindCollisions(
leaf, resolved, t, footSphere, headSphere,
from, Vector3.UnitZ, 1.0f);
Assert.False(t.CollisionInfo.SlidingNormalValid,
"Head full hit must go through the real BSPTREE::slide_sphere — " +
"no sliding_normal write at the BSP layer (retail 0x0053a697).");
Assert.True(t.CollisionInfo.CollisionNormalValid);
Assert.Equal(TransitionState.Collided, result);
}
/// <summary>
/// <c>CSphere::slide_sphere</c>'s opposing-normals branch (collision
/// normal anti-parallel to the contact plane — e.g. a ceiling-facing
/// normal while grounded) records the REVERSED displacement as the
/// collision normal and returns <b>COLLIDED_TS</b> — retail 0x00537440
/// @0x005375d7-0x0053762c: <c>*normal = gDelta; normalize;
/// set_collision_normal; return 2</c>. Our port returned OK (its comment
/// even claimed "retail returns OK here"), letting the step complete
/// as-is with a synthetic reversed-movement collision normal — the exact
/// signature of the live corridor hit (`hit=yes n=(1.00,0.03,0.03)` =
/// the negated run direction, matching NO dat polygon).
/// </summary>
[Fact]
public void SlideSphere_OpposingNormals_ReturnsCollided_WithReversedDisplacementNormal()
{
var t = new Transition();
t.SpherePath.InitPath(
new Vector3(0f, 0f, 0.2f), new Vector3(0.3f, 0f, 0.2f),
0xA9B40001u, BSPStepUpFixtures.SphereRadius);
t.CollisionInfo.SetContactPlane(new Plane(Vector3.UnitZ, 0f), 0xA9B40001u, false);
// Make gDelta exactly (0.4, 0, 0): currPos = check sphere (0.4,0,0).
var currPos = t.SpherePath.GlobalSphere[0].Origin - new Vector3(0.4f, 0f, 0f);
// Downward collision normal vs the +Z contact plane → cross ≈ 0
// (parallel), dot = 1 < 0 (opposing) → the reverse branch.
var result = t.SlideSphereInternal(new Vector3(0f, 0f, -1f), currPos);
Assert.Equal(TransitionState.Collided, result);
Assert.True(t.CollisionInfo.CollisionNormalValid);
Assert.True(t.CollisionInfo.CollisionNormal.X < -0.99f,
$"Collision normal must be the normalized reversed displacement " +
$"(1,0,0); got ({t.CollisionInfo.CollisionNormal.X:F3}," +
$"{t.CollisionInfo.CollisionNormal.Y:F3},{t.CollisionInfo.CollisionNormal.Z:F3}).");
}
// =========================================================================
// Engine-level lifecycle pin — the retail persist/absorb/clear cycle at a
// REAL wall. Guards the fix against regressing wall behavior, and
// documents where retail CLEARS the body's sliding state (the successful
// transition's writeback, when no step re-records a collision).
// =========================================================================
private const uint CellId = 0xA9B40157u;
private static PhysicsEngine BuildWallEngine()
{
var (wallRoot, wallResolved) = BSPStepUpFixtures.TallWall();
var cell = new CellPhysics
{
BSP = new PhysicsBSPTree { Root = wallRoot },
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = wallResolved,
CellBSP = new CellBSPTree
{
Root = new CellBSPNode { Type = BSPNodeType.Leaf },
},
};
var engine = new PhysicsEngine();
engine.DataCache = new PhysicsDataCache();
// Flat terrain strip so the outdoor fall-through has something to
// sample if it ever fires (same shape as FindEnvCollisionsMultiCellTests).
var heights = new byte[81];
Array.Fill(heights, (byte)0);
engine.AddLandblock(0xA9B4FFFFu, new TerrainSurface(heights, BuildHeightTable()),
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
worldOffsetX: 0f, worldOffsetY: 0f);
engine.DataCache.RegisterCellStructForTest(CellId, cell);
return engine;
}
private static float[] BuildHeightTable()
{
var ht = new float[256];
for (int i = 0; i < 256; i++) ht[i] = i * 1.0f;
return ht;
}
private static PhysicsBody GroundedBody()
{
var body = new PhysicsBody();
body.ContactPlaneValid = true;
body.ContactPlane = new Plane(Vector3.UnitZ, 0f);
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
return body;
}
private ResolveResult ResolveForward(PhysicsEngine engine, PhysicsBody body,
Vector3 from, Vector3 to)
=> engine.ResolveWithTransition(
currentPos: from,
targetPos: to,
cellId: CellId,
sphereRadius: BSPStepUpFixtures.SphereRadius,
sphereHeight: 0f, // single sphere — keeps the scenario deterministic
stepUpHeight: 0.04f, // cannot scale the 5 m wall
stepDownHeight: 0.04f,
isOnGround: true,
body: body);
/// <summary>
/// The full retail lifecycle at a real wall:
/// (1) a blocked face-on push persists the validate-recorded sliding
/// normal via the SUCCESS writeback (SetPositionInternal bit-4 sync,
/// 0x005154e1);
/// (2) the next exactly-anti-parallel push is absorbed by the seed
/// (get_object_info 0x00511d44 → adjust_offset projects to zero →
/// find_transitional_position's step-0 small-offset abort) — the
/// retail cache semantics: "still pressed against this wall";
/// (3) an oblique push escapes along the wall tangent, the step runs
/// without re-recording a collision, and the successful writeback
/// CLEARS the body's sliding state (sliding_normal_valid==0 → bit
/// 4 cleared).
/// </summary>
[Fact]
public void WallLifecycle_PersistOnBlock_AbsorbExactAntiParallel_ClearOnEscape()
{
var engine = BuildWallEngine();
var body = GroundedBody();
// ── 1. Face-on +X into the wall at x=0.5 (normal X) ─────────────
var r1 = ResolveForward(engine, body,
from: new Vector3(0.10f, 0f, 0f),
to: new Vector3(0.35f, 0f, 0f));
Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding),
"A blocked push must persist the validate-recorded sliding normal " +
"(retail SetPositionInternal 0x005154c2 on transition success).");
Assert.True(body.SlidingNormal.X < -0.9f,
$"Persisted normal should face the mover (X); got {body.SlidingNormal}.");
Assert.True(r1.Position.X + BSPStepUpFixtures.SphereRadius
<= 0.5f + PhysicsGlobals.EPSILON * 20f,
$"The 5 m wall must block the sphere; reach={r1.Position.X + BSPStepUpFixtures.SphereRadius:F4}.");
// ── 2. Exactly-anti-parallel push again: absorbed frame ──────────
var r2 = ResolveForward(engine, body,
from: r1.Position,
to: r1.Position + new Vector3(0.15f, 0f, 0f));
Assert.False(r2.Ok,
"The seeded sliding normal projects the exactly-anti-parallel " +
"offset to zero → step-0 abort (retail find_transitional_position " +
"0050bfb7/0050c0ef). Faithful absorbed frame at a REAL wall.");
Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding),
"A failed transition leaves the body's sliding state untouched " +
"(retail: SetPositionInternal never runs on failure).");
// ── 3. Oblique push escapes and CLEARS the persisted state ───────
var r3 = ResolveForward(engine, body,
from: r2.Position,
to: r2.Position + new Vector3(0.10f, 0.15f, 0f));
Assert.True(r3.Ok, "Oblique push must escape along the wall tangent.");
Assert.True(r3.Position.Y > r2.Position.Y + 0.05f,
$"Expected tangential advance along +Y; got Y={r3.Position.Y:F4} " +
$"(from {r2.Position.Y:F4}).");
Assert.False(body.TransientState.HasFlag(TransientStateFlags.Sliding),
"A successful transition whose steps re-record no collision must " +
"CLEAR the body's sliding state (retail SetPositionInternal " +
"0x005154e1: bit 4 synced from the transition's final " +
"sliding_normal_valid, which each step clears before its insert).");
Assert.Equal(Vector3.Zero, body.SlidingNormal);
}
}

View file

@ -0,0 +1,265 @@
using System;
using System.IO;
using System.Linq;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
using Xunit;
using Xunit.Abstractions;
using Env = System.Environment;
using Placement = DatReaderWriter.Enums.Placement;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// #175 (2026-07-05) — read-only dat inspection for the Facility Hub door
/// (Setup 0x02000C9D, guid 0x78A020C7 in the live session). User report:
/// the door's COLLISION sits displaced to the far side of the VISUAL panel
/// (embed from one side deep enough to camera-clip; a phantom wall on the
/// other side that can push the player out of use radius).
///
/// Hypothesis under test: collision registers from the Setup's
/// PlacementFrames (ShadowShapeBuilder.FromSetup — Resting|Default|first)
/// while the rendered panel poses from the motion table's default/closed
/// state through the sequencer; retail tests the part's LIVE pose
/// (CPhysicsPart), so a door whose placement frame differs from its
/// motion-table closed pose shows exactly this offset. This test dumps both
/// poses so the divergence (or its absence) is a dat fact, not a theory.
///
/// SKIP when the dat directory is absent (CI); local runs have it.
/// </summary>
public class Issue175HubDoorPoseInspectionTests
{
private readonly ITestOutputHelper _out;
public Issue175HubDoorPoseInspectionTests(ITestOutputHelper output) => _out = output;
private const uint HubDoorSetupId = 0x02000C9Du;
[Fact]
public void HubDoorSetup_PlacementVsMotionPose_DatInspection()
{
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
if (!Directory.Exists(datDir))
{
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
var setup = dats.Get<Setup>(HubDoorSetupId);
Assert.NotNull(setup);
_out.WriteLine($"=== Setup 0x{HubDoorSetupId:X8} ===");
_out.WriteLine($" Flags = {setup!.Flags} (0x{(uint)setup.Flags:X8})");
_out.WriteLine($" Parts = {setup.Parts.Count}");
for (int i = 0; i < setup.Parts.Count; i++)
_out.WriteLine($" [{i}] gfxObj=0x{setup.Parts[i]:X8}");
_out.WriteLine($" DefaultAnimation = 0x{setup.DefaultAnimation:X8}");
_out.WriteLine($" DefaultScript = 0x{setup.DefaultScript:X8}");
_out.WriteLine($" DefaultMotionTable = 0x{setup.DefaultMotionTable:X8}");
_out.WriteLine($" CylSpheres={setup.CylSpheres.Count} Spheres={setup.Spheres.Count} Radius={setup.Radius:F3}");
foreach (var c in setup.CylSpheres)
_out.WriteLine($" cyl r={c.Radius:F3} h={c.Height:F3} origin=({c.Origin.X:F3},{c.Origin.Y:F3},{c.Origin.Z:F3})");
_out.WriteLine($" PlacementFrames = {setup.PlacementFrames.Count}");
foreach (var kv in setup.PlacementFrames)
{
_out.WriteLine($" [{kv.Key}] frames={kv.Value.Frames.Count}");
for (int i = 0; i < kv.Value.Frames.Count; i++)
{
var f = kv.Value.Frames[i];
_out.WriteLine(
$" part[{i}] pos=({f.Origin.X:F3},{f.Origin.Y:F3},{f.Origin.Z:F3}) " +
$"rot=({f.Orientation.X:F3},{f.Orientation.Y:F3},{f.Orientation.Z:F3},{f.Orientation.W:F3})");
}
}
// Part 0's physics BSP bounds — where the slab actually is in
// PART-LOCAL space (composed with the poses above for world).
foreach (uint gfxId in setup.Parts.Distinct())
{
var gfx = dats.Get<GfxObj>(gfxId);
_out.WriteLine($"=== GfxObj 0x{gfxId:X8} ===");
if (gfx is null) { _out.WriteLine(" NULL"); continue; }
var root = gfx.PhysicsBSP?.Root;
_out.WriteLine($" PhysicsBSP.Root = {(root is null ? "NULL" : "non-null")}");
if (root?.BoundingSphere is { } bs)
_out.WriteLine($" BSP bounds = ({bs.Origin.X:F3},{bs.Origin.Y:F3},{bs.Origin.Z:F3}) r={bs.Radius:F3}");
if (gfx.PhysicsPolygons is { } pp && gfx.VertexArray?.Vertices is { } verts)
{
float minX = float.MaxValue, maxX = float.MinValue;
float minY = float.MaxValue, maxY = float.MinValue;
float minZ = float.MaxValue, maxZ = float.MinValue;
foreach (var poly in pp.Values)
foreach (var vid in poly.VertexIds)
{
if (!verts.TryGetValue((ushort)vid, out var sv)) continue;
minX = Math.Min(minX, sv.Origin.X); maxX = Math.Max(maxX, sv.Origin.X);
minY = Math.Min(minY, sv.Origin.Y); maxY = Math.Max(maxY, sv.Origin.Y);
minZ = Math.Min(minZ, sv.Origin.Z); maxZ = Math.Max(maxZ, sv.Origin.Z);
}
_out.WriteLine($" Physics AABB (part-local) = X[{minX:F3},{maxX:F3}] Y[{minY:F3},{maxY:F3}] Z[{minZ:F3},{maxZ:F3}]");
}
}
// The motion-table default (closed) pose, if the setup names one:
// frame 0 of the default style's default cycle — what the sequencer
// renders for an idle closed door.
if (setup.DefaultMotionTable != 0)
{
var mt = dats.Get<MotionTable>(setup.DefaultMotionTable);
_out.WriteLine($"=== MotionTable 0x{setup.DefaultMotionTable:X8} ===");
if (mt is null) { _out.WriteLine(" NULL"); return; }
_out.WriteLine($" DefaultStyle = 0x{(uint)mt.DefaultStyle:X8}");
if (mt.Cycles.TryGetValue((int)mt.DefaultStyle, out var defCycle)
&& defCycle.Anims.Count > 0)
{
var animRef = defCycle.Anims[0];
_out.WriteLine($" default cycle anim[0] id=0x{animRef.AnimId:X8} lo={animRef.LowFrame} hi={animRef.HighFrame}");
var anim = dats.Get<Animation>(animRef.AnimId);
if (anim is not null && anim.PartFrames.Count > 0)
{
var f0 = anim.PartFrames[Math.Clamp((int)animRef.LowFrame, 0, anim.PartFrames.Count - 1)];
for (int i = 0; i < f0.Frames.Count; i++)
{
var f = f0.Frames[i];
_out.WriteLine(
$" anim frame0 part[{i}] pos=({f.Origin.X:F3},{f.Origin.Y:F3},{f.Origin.Z:F3}) " +
$"rot=({f.Orientation.X:F3},{f.Orientation.Y:F3},{f.Orientation.Z:F3},{f.Orientation.W:F3})");
}
}
else
{
_out.WriteLine(" anim NULL or no PartFrames");
}
}
else
{
_out.WriteLine(" no default-style cycle");
}
}
else
{
_out.WriteLine("=== no DefaultMotionTable on the setup ===");
}
}
/// <summary>
/// #175 derivation conformance — REAL-DAT pin for
/// <see cref="AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames"/>.
/// The first cut of the derivation looked up <c>Cycles[DefaultStyle]</c>
/// with the bare style word; the dictionary is keyed by the COMBINED
/// <c>(style &lt;&lt; 16) | substate</c> word (CMotionTable.cs:683), so it
/// always missed and the #175 fix silently no-oped. This pin loads the
/// human motion table (0x09000001 — guaranteed present, default state
/// NonCombat/Ready) and asserts the derivation actually resolves a pose.
/// </summary>
[Fact]
public void MotionTablePose_DefaultState_ResolvesOnRealTable()
{
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
if (!Directory.Exists(datDir))
{
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
var mt = dats.Get<MotionTable>(0x09000001u);
Assert.NotNull(mt);
var pose = AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames(
mt!, id => dats.Get<Animation>(id));
Assert.NotNull(pose);
_out.WriteLine($"human MT default pose parts={pose!.Count} " +
$"part0=({pose[0].Origin.X:F3},{pose[0].Origin.Y:F3},{pose[0].Origin.Z:F3})");
Assert.True(pose.Count >= 1);
}
// ── #175 fix pins: ShadowShapeBuilder partPoseOverride ──────────────
private static Setup MakeTwoPartSetup()
{
var setup = new Setup();
setup.Parts.Add(0x01000001u);
setup.Parts.Add(0x01000002u);
var placement = new AnimationFrame(2);
placement.Frames.Clear();
placement.Frames.Add(new Frame { Origin = new Vector3(0.88f, -0.44f, 1.37f),
Orientation = new Quaternion(0f, 0f, -0.966f, 0.259f) });
placement.Frames.Add(new Frame { Origin = new Vector3(-0.88f, -0.44f, 1.37f),
Orientation = new Quaternion(0f, 0f, -0.259f, 0.966f) });
setup.PlacementFrames[Placement.Default] = placement;
return setup;
}
/// <summary>
/// With a motion-table pose override, the BSP part shapes must use it —
/// the closed pose, not the ajar placement pose (the #175 offset).
/// </summary>
[Fact]
public void FromSetup_PartPoseOverride_ReplacesPlacementFrames()
{
var setup = MakeTwoPartSetup();
var closed = new[]
{
new Frame { Origin = new Vector3(0.85f, 0f, 1.37f), Orientation = Quaternion.Identity },
new Frame { Origin = new Vector3(-0.85f, 0f, 1.37f), Orientation = Quaternion.Identity },
};
var shapes = ShadowShapeBuilder.FromSetup(
setup, entScale: 1f, hasPhysicsBsp: _ => true, partPoseOverride: closed);
Assert.Equal(2, shapes.Count);
Assert.Equal(new Vector3(0.85f, 0f, 1.37f), shapes[0].LocalPosition);
Assert.Equal(Quaternion.Identity, shapes[0].LocalRotation);
Assert.Equal(new Vector3(-0.85f, 0f, 1.37f), shapes[1].LocalPosition);
}
/// <summary>
/// Null override (no motion table) keeps the pre-#175 placement-frame
/// behavior — landblock statics and table-less entities unchanged.
/// </summary>
[Fact]
public void FromSetup_NoOverride_KeepsPlacementFrames()
{
var setup = MakeTwoPartSetup();
var shapes = ShadowShapeBuilder.FromSetup(
setup, entScale: 1f, hasPhysicsBsp: _ => true);
Assert.Equal(2, shapes.Count);
Assert.Equal(new Vector3(0.88f, -0.44f, 1.37f), shapes[0].LocalPosition);
Assert.Equal(new Quaternion(0f, 0f, -0.966f, 0.259f), shapes[0].LocalRotation);
}
/// <summary>
/// A short override (fewer frames than parts) falls back to placement
/// frames — a mismatched motion table must not misplace collision.
/// </summary>
[Fact]
public void FromSetup_ShortOverride_FallsBackPerPart()
{
var setup = MakeTwoPartSetup();
var shortOverride = new[]
{
new Frame { Origin = new Vector3(0.85f, 0f, 1.37f), Orientation = Quaternion.Identity },
};
var shapes = ShadowShapeBuilder.FromSetup(
setup, entScale: 1f, hasPhysicsBsp: _ => true, partPoseOverride: shortOverride);
Assert.Equal(2, shapes.Count);
Assert.Equal(new Vector3(0.85f, 0f, 1.37f), shapes[0].LocalPosition); // override
Assert.Equal(new Vector3(-0.88f, -0.44f, 1.37f), shapes[1].LocalPosition); // placement fallback
}
}

View file

@ -0,0 +1,157 @@
using System;
using System.IO;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
using Env = System.Environment;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// #176/#177 membership half: production [cell-transit] lines
/// (launch-137-gate2.log) fire 0.10.6 m PAST the portal plane in the travel
/// direction (016E→017A at x=85.3385.47 vs the plane at x=85.00), while the
/// dat CellBSP volumes partition EXACTLY at the plane
/// (Issue176177DungeonSeamInspectionTests.SeamCells_CellBspContainment) —
/// retail's center-only point_in_cell flips at the plane. The render root
/// (viewer cell) resolves through the same machinery; while it lags, the
/// portal flood correctly refuses the boundary portal the eye has already
/// crossed and the whole forward chain drops (the purple seam flash /
/// stair pop). This replay measures OUR resolver's flip point across the
/// x=85 seam in a controlled run.
/// </summary>
public class Issue176177SeamTransitLagTests
{
private const uint SeamCellWest = 0x8A02016Eu; // x 75..85
private const uint SeamCellEast = 0x8A02017Au; // x 85..88.33
private readonly ITestOutputHelper _out;
public Issue176177SeamTransitLagTests(ITestOutputHelper output) => _out = output;
private static string? ResolveDatDir()
{
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(datDir) ? datDir : null;
}
private static PhysicsEngine BuildEngine(DatCollection dats)
{
var engine = new PhysicsEngine();
engine.DataCache = new PhysicsDataCache();
var toLoad = new System.Collections.Generic.HashSet<uint> { SeamCellWest, SeamCellEast };
for (int ring = 0; ring < 3; ring++)
{
foreach (var known in new System.Collections.Generic.List<uint>(toLoad))
{
var cell = dats.Get<EnvCell>(known);
if (cell is null) continue;
foreach (var p in cell.CellPortals)
toLoad.Add(0x8A020000u | p.OtherCellId);
}
}
foreach (var cellId in toLoad)
{
var envCell = dats.Get<EnvCell>(cellId);
if (envCell is null) continue;
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
if (environment is null) continue;
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) continue;
var rot = new Quaternion(
envCell.Position.Orientation.X, envCell.Position.Orientation.Y,
envCell.Position.Orientation.Z, envCell.Position.Orientation.W);
var world = Matrix4x4.CreateFromQuaternion(rot)
* Matrix4x4.CreateTranslation(
envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z);
engine.DataCache.CacheCellStruct(cellId, envCell, cs!, world);
}
return engine;
}
private static PhysicsBody GroundedBody()
{
var body = new PhysicsBody();
body.ContactPlaneValid = true;
body.ContactPlane = new Plane(Vector3.UnitZ, 6f);
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
body.WalkablePolygonValid = true;
body.WalkablePlane = new Plane(Vector3.UnitZ, 6f);
body.WalkableUp = Vector3.UnitZ;
body.WalkableVertices = new[]
{
new Vector3(75f, -41.67f, -6f),
new Vector3(85f, -41.67f, -6f),
new Vector3(85f, -38.33f, -6f),
new Vector3(75f, -38.33f, -6f),
};
return body;
}
/// <summary>
/// Run +X across the x=85 seam at run-speed tick steps (13.5 cm/tick ≈
/// 4 m/s at 30 Hz) and record where ResolveWithTransition's CellId flips.
/// Retail (center-only point_in_cell, exact-partition CellBSP) flips on
/// the first tick whose END position is past x=85.00 — any flip later
/// than one step past the plane is OUR lag.
/// </summary>
[Theory]
[InlineData(+1)] // west → east across x=85
[InlineData(-1)] // east → west back across
public void RunAcrossSeam_CellFlipPosition(int direction)
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var engine = BuildEngine(dats);
var body = GroundedBody();
const float step = 0.135f;
float startX = direction > 0 ? 83.8f : 86.2f;
uint cell = direction > 0 ? SeamCellWest : SeamCellEast;
var pos = new Vector3(startX, -40f, -6f);
float? flipX = null;
for (int tick = 0; tick < 26; tick++)
{
var target = pos + new Vector3(direction * step, 0f, 0f);
var r = engine.ResolveWithTransition(
currentPos: pos,
targetPos: target,
cellId: cell,
sphereRadius: 0.48f,
sphereHeight: 1.835f,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
isOnGround: true,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
bool flipped = r.CellId != cell;
_out.WriteLine($"tick={tick,2} pos=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) " +
$"cell=0x{r.CellId:X8} ok={r.Ok}{(flipped ? " <<< FLIP" : "")}");
if (flipped && flipX is null)
flipX = r.Position.X;
cell = r.CellId;
pos = r.Position;
if (direction > 0 && pos.X > 86.4f) break;
if (direction < 0 && pos.X < 83.6f) break;
}
Assert.NotNull(flipX);
float lag = direction > 0 ? flipX!.Value - 85.00f : 85.00f - flipX!.Value;
_out.WriteLine($"flip at x={flipX:F3} → lag past the plane = {lag:F3} m " +
$"(one-tick quantization bound = {step:F3} m)");
// Diagnostic, not a pin: the finding is the printed lag. A lag beyond
// one tick step is the divergence under investigation.
}
}

View file

@ -0,0 +1,97 @@
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Physics.Motion;
// ─────────────────────────────────────────────────────────────────────────────
// #174 pin (2026-07-05): the RemoveLinkAnimations seam must be retail
// CPhysicsObj::RemoveLinkAnimations 0x0050fe20 — a TAILCALL to
// CPartArray::HandleEnterWorld 0x00517d70 → MotionTableManager::
// HandleEnterWorld 0x0051bdd0: CSequence::remove_all_link_animations PLUS a
// full pending_animations drain (`while (head) AnimationDone(0)`), each pop
// relaying MotionDone → CMotionInterp pops its pending_motions node in
// lockstep.
//
// The pre-fix binding was the bare sequence strip: every LeaveGround (jump)
// removed the link animations that queued MotionTableManager nodes were
// counting down on, orphaning them (NumAnims > 0, animations gone). Both
// queues then dammed permanently — MotionsPending() never drained at rest —
// and BeginTurnToHeading/BeginMoveForward (retail 0x00529b90 motions_pending
// gate) starved every armed moveto: ACE's walk-to-door mt-6 armed but the
// body never walked; the close-range Use turn never completed so the
// deferred action was silently eaten. Live evidence: launch-174-autowalk.log
// (last player pending=False at the first MovementJump press; old jump
// motions still completing at rest minutes later).
// ─────────────────────────────────────────────────────────────────────────────
public class Issue174LinkStripDrainTests
{
private readonly ITestOutputHelper _out;
public Issue174LinkStripDrainTests(ITestOutputHelper output) => _out = output;
/// <summary>
/// The jam repro: queue motions (link + cycle nodes land in BOTH the
/// interp's pending_motions and the manager's pending_animations), then
/// fire the LeaveGround-side seam. With the retail HandleEnterWorld
/// binding both queues drain to empty; the pre-fix bare-strip binding
/// left both non-empty forever.
/// </summary>
[Fact]
public void RemoveLinkAnimationsSeam_DrainsBothQueues()
{
var h = new RemoteChaseHarness(_out);
// Drive a motion burst — walk, run, stop — the shape a player's
// pre-jump input produces. Each successful dispatch pairs an interp
// node with a manager node.
var p = new MovementParameters();
h.Interp.DoMotion(MotionCommand.WalkForward, p);
h.Interp.set_hold_run(true, interrupt: false);
h.Interp.StopMotion(MotionCommand.WalkForward, p);
Assert.True(h.Interp.MotionsPending(),
"precondition: the burst must leave pending interp nodes");
Assert.NotEmpty(h.Seq.Manager.PendingAnimations);
// The LeaveGround seam (retail CMotionInterp::LeaveGround 0x00528b00
// fires CPhysicsObj::RemoveLinkAnimations).
h.Interp.RemoveLinkAnimations!.Invoke();
Assert.False(h.Interp.MotionsPending(),
"HandleEnterWorld's drain must pop every pending interp node " +
"(retail: each AnimationDone(0) relays MotionDone)");
Assert.Empty(h.Seq.Manager.PendingAnimations);
}
/// <summary>
/// The post-jump livability pin: after the seam fires mid-activity, a
/// NEW moveto-style dispatch must be able to queue and complete — the
/// #174 symptom was that BeginTurnToHeading's motions_pending gate never
/// re-opened after a jump, permanently starving armed movetos.
/// </summary>
[Fact]
public void AfterSeamDrain_NewMotionsQueueAndComplete()
{
var h = new RemoteChaseHarness(_out);
var p = new MovementParameters();
// Pre-jump activity, then the jump's LeaveGround strip+drain.
h.Interp.DoMotion(MotionCommand.WalkForward, p);
h.Interp.RemoveLinkAnimations!.Invoke();
Assert.False(h.Interp.MotionsPending());
// A fresh dispatch (the armed moveto's turn) queues...
h.Interp.DoMotion(MotionCommand.TurnRight, p);
Assert.True(h.Interp.MotionsPending());
// ...and the normal completion path (the manager's queue feeding
// MotionDone) drains it — the gate re-opens.
while (h.Seq.Manager.PendingAnimations.GetEnumerator() is var e && e.MoveNext())
h.Seq.Manager.AnimationDone(success: true);
h.Seq.Manager.CheckForCompletedMotions();
Assert.False(h.Interp.MotionsPending(),
"the normal AnimationDone → MotionDone chain must drain the new node");
}
}

View file

@ -174,7 +174,10 @@ internal sealed class RemoteChaseHarness
TurnStopped = () => ObservedOmega = Vector3.Zero, TurnStopped = () => ObservedOmega = Vector3.Zero,
}; };
Interp.DefaultSink = Sink; Interp.DefaultSink = Sink;
Interp.RemoveLinkAnimations = Seq.RemoveAllLinkAnimations; // #174: production binds the seam to Manager.HandleEnterWorld
// (strip + full queue drain, retail 0x0050fe20 → 0x0051bdd0) —
// the bare sequence strip orphaned pending manager nodes.
Interp.RemoveLinkAnimations = () => Seq.Manager.HandleEnterWorld();
Interp.InitializeMotionTables = () => Seq.Manager.InitializeState(); Interp.InitializeMotionTables = () => Seq.Manager.InitializeState();
Interp.CheckForCompletedMotions = Seq.Manager.CheckForCompletedMotions; Interp.CheckForCompletedMotions = Seq.Manager.CheckForCompletedMotions;

View file

@ -487,9 +487,20 @@ public class PhysicsEngineTests
collisionType: ShadowCollisionType.Cylinder, collisionType: ShadowCollisionType.Cylinder,
cylHeight: 1.835f); cylHeight: 1.835f);
// Without the gate (movingEntityId == 0): the sweep must self-push. // Without the gate (movingEntityId == 0): the sweep must be
// This proves the registry actually causes a collision, so the // INTERFERED WITH by the self-entry. This proves the registry
// following filtered case is not a vacuous pass. // actually causes a collision, so the following filtered case is not
// a vacuous pass.
//
// Observable updated for the 2026-07-05 CCylSphere family port: the
// old hand-rolled response radial-pushed the sphere ~1 m sideways
// (the original #42 symptom this test asserted). Retail's dispatcher
// (0x0053b440) resolves this geometry — airborne, dead-center on the
// cylinder axis, moving up — through land_on_cylinder → the Collide
// re-test, whose interp gate hard-stops (COLLIDED); ValidateTransition
// then reverts to a stay-put (no sideways teleport, Ok=true). The
// response-model-independent interference signal is the DENIED +Z
// movement: the sweep must NOT reach the +0.022 target.
var unfiltered = engine.ResolveWithTransition( var unfiltered = engine.ResolveWithTransition(
currentPos: bodyPos, targetPos: targetPos, currentPos: bodyPos, targetPos: targetPos,
cellId: 0xA9B40039u, cellId: 0xA9B40039u,
@ -498,11 +509,11 @@ public class PhysicsEngineTests
isOnGround: false, isOnGround: false,
movingEntityId: 0u); movingEntityId: 0u);
float unfilteredXY = MathF.Sqrt( Assert.True(unfiltered.Position.Z < targetPos.Z - 0.01f,
(unfiltered.Position.X - targetPos.X) * (unfiltered.Position.X - targetPos.X) + $"Without movingEntityId, the sweep must collide with the mover's own " +
(unfiltered.Position.Y - targetPos.Y) * (unfiltered.Position.Y - targetPos.Y)); $"ShadowEntry and deny the +Z movement (retail: land_on_cylinder → " +
Assert.True(unfilteredXY > 0.5f, $"Collide re-test → COLLIDED → stay-put). Got Z={unfiltered.Position.Z:F4}, " +
$"Without movingEntityId, sweep should self-push (got XY drift {unfilteredXY:F3}m)"); $"target Z={targetPos.Z:F4}");
// With the gate: the sweep must leave XY unchanged. // With the gate: the sweep must leave XY unchanged.
var filtered = engine.ResolveWithTransition( var filtered = engine.ResolveWithTransition(

View file

@ -0,0 +1,624 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
using Env = System.Environment;
namespace AcDream.Core.Tests.Rendering;
/// <summary>
/// #176 (purple flashing on dungeon floors at cell seams) + #177 (stairs pop
/// in/out across levels) — dat-truth inspection for the Facility Hub anchor
/// cells. The load-bearing topology fact from the #137 arc: corridor FLOORS
/// are portal polygons (PortalSide floor-portals to under-rooms, e.g.
/// 0x8A02016E visual polys 1/3/5 → 0x011E). These dumps answer:
///
/// (a) are the floor-portal VISUAL polys textured (drawn by CellMesh.Build)
/// or NoPos-stippled (skipped)? Same question for the RECIPROCAL portal
/// polys in the other cell — two textured coincident planes would
/// z-fight (#176's angle-dependent flash candidate);
/// (b) which cell owns the actual stair-step geometry at the
/// 0x8A020182 → 0x8A020183 level transit (#177's pop-in subject);
/// (c) do any drawn polys reference surfaces that fail to resolve
/// (the magenta-placeholder class)?
///
/// ⚠️ id-space trap (cost the #137 saga a wrong mechanism):
/// CellPortal.PolygonId indexes CellStruct.Polygons (VISUAL), never
/// PhysicsPolygons — same ids in both tables are unrelated polygons.
/// </summary>
public class Issue176177DungeonSeamInspectionTests
{
private readonly ITestOutputHelper _out;
public Issue176177DungeonSeamInspectionTests(ITestOutputHelper output) => _out = output;
private static string? ResolveDatDir()
{
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(datDir) ? datDir : null;
}
private static Matrix4x4 WorldTransform(EnvCell cell)
{
var rot = new Quaternion(
cell.Position.Orientation.X, cell.Position.Orientation.Y,
cell.Position.Orientation.Z, cell.Position.Orientation.W);
return Matrix4x4.CreateFromQuaternion(rot)
* Matrix4x4.CreateTranslation(
cell.Position.Origin.X, cell.Position.Origin.Y, cell.Position.Origin.Z);
}
private static (EnvCell cell, DatReaderWriter.Types.CellStruct cs)? LoadCell(DatCollection dats, uint cellId)
{
var envCell = dats.Get<EnvCell>(cellId);
if (envCell is null) return null;
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
if (environment is null) return null;
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) return null;
return (envCell, cs!);
}
private static List<Vector3> WorldVerts(
DatReaderWriter.Types.CellStruct cs, DatReaderWriter.Types.Polygon poly, Matrix4x4 world)
{
var result = new List<Vector3>(poly.VertexIds.Count);
foreach (var vid in poly.VertexIds)
if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v))
result.Add(Vector3.Transform(v.Origin, world));
return result;
}
/// <summary>
/// Mirror of CellMesh.Build's inclusion rules (verts ≥ 3, no NoPos
/// stippling, PosSurface index in range) — the DRAWN verdict per poly.
/// </summary>
private static bool WouldDraw(DatReaderWriter.Types.Polygon poly, EnvCell cell) =>
poly.VertexIds.Count >= 3
&& !poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.NoPos)
&& poly.PosSurface >= 0
&& poly.PosSurface < cell.Surfaces.Count;
/// <summary>
/// (a)+(c): every CellPortal of the cell — the visual portal poly's
/// stippling/sides/surface, world plane, span, DRAWN verdict, and whether
/// the referenced Surface resolves in the dat.
/// </summary>
[Theory]
[InlineData(0x8A02016Eu)] // corridor with floor-portals 1/3/5 → 0x011E (#176 anchor)
[InlineData(0x8A02011Eu)] // the under-hall at z=12 those portals lead to
[InlineData(0x8A02017Au)] // adjacent corridor cell (the #137 seam partner)
[InlineData(0x8A020182u)] // stair transit upper cell, z 6 (#177 anchor)
[InlineData(0x8A020183u)] // stair transit lower cell, z 9 (#177 anchor)
public void PortalPolys_SurfaceAndDrawVerdict_Dump(uint cellId)
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var loaded = LoadCell(dats, cellId);
Assert.NotNull(loaded);
var (cell, cs) = loaded!.Value;
var world = WorldTransform(cell);
_out.WriteLine($"=== 0x{cellId:X8} Env=0x{cell.EnvironmentId:X4} struct={cell.CellStructure} " +
$"pos=({cell.Position.Origin.X:F2},{cell.Position.Origin.Y:F2},{cell.Position.Origin.Z:F2}) ===");
_out.WriteLine($" Surfaces ({cell.Surfaces.Count}): " +
string.Join(" ", cell.Surfaces.ConvertAll(s => $"0x{0x08000000u | (uint)s:X8}")));
_out.WriteLine($" visualPolys={cs.Polygons.Count} physicsPolys={cs.PhysicsPolygons.Count} portals={cell.CellPortals.Count}");
// #177 pivot check: dungeon staircases are often EnvCell STATICS (the
// #119 tower class) — if one lives here, the vanish subject is the
// static's cull, not the shell flood.
_out.WriteLine($" StaticObjects={cell.StaticObjects.Count}");
foreach (var so in cell.StaticObjects)
_out.WriteLine($" static id=0x{so.Id:X8} at ({so.Frame.Origin.X:F2},{so.Frame.Origin.Y:F2},{so.Frame.Origin.Z:F2})");
foreach (var p in cell.CellPortals)
{
if (!cs.Polygons.TryGetValue((ushort)p.PolygonId, out var poly))
{
_out.WriteLine($" portal poly={p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] NOT IN VISUAL TABLE");
continue;
}
var w = WorldVerts(cs, poly, world);
var n = w.Count >= 3
? Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0]))
: Vector3.Zero;
var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue);
foreach (var v in w) { min = Vector3.Min(min, v); max = Vector3.Max(max, v); }
bool drawn = WouldDraw(poly, cell);
string surfInfo = "posSurf=OUT-OF-RANGE";
if (poly.PosSurface >= 0 && poly.PosSurface < cell.Surfaces.Count)
{
uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface];
var surface = dats.Get<Surface>(surfaceId);
surfInfo = surface is null
? $"posSurf[{poly.PosSurface}]=0x{surfaceId:X8} SURFACE-MISS"
: $"posSurf[{poly.PosSurface}]=0x{surfaceId:X8} type={surface.Type} origTex=0x{(uint)surface.OrigTextureId:X8}";
}
_out.WriteLine(
$" portal poly={p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] " +
$"stip={poly.Stippling} sides={poly.SidesType} verts={poly.VertexIds.Count} " +
$"n=({n.X:F2},{n.Y:F2},{n.Z:F2}) " +
$"x=[{min.X:F2},{max.X:F2}] y=[{min.Y:F2},{max.Y:F2}] z=[{min.Z:F2},{max.Z:F2}] " +
$"{surfInfo} DRAWN={drawn}");
}
// (c) sweep: any DRAWN poly in the whole cell whose surface misses.
int drawnCount = 0, missCount = 0;
foreach (var (id, poly) in cs.Polygons)
{
if (!WouldDraw(poly, cell)) continue;
drawnCount++;
uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface];
if (dats.Get<Surface>(surfaceId) is null)
{
missCount++;
_out.WriteLine($" >>> DRAWN poly {id} has MISSING surface 0x{surfaceId:X8}");
}
}
_out.WriteLine($" drawn-poly sweep: {drawnCount} drawn, {missCount} with missing surfaces");
}
/// <summary>
/// (a) reciprocal check: for each anchor pair, world-transform BOTH
/// sides' portal polys and test plane coincidence + both-drawn — the
/// #176 z-fight candidate is a coincident pair with DRAWN=true twice.
/// </summary>
[Theory]
[InlineData(0x8A02016Eu, 0x8A02011Eu)]
[InlineData(0x8A02016Eu, 0x8A02017Au)]
[InlineData(0x8A020182u, 0x8A020183u)]
public void ReciprocalPortalPolys_CoincidenceAndDrawVerdict(uint cellA, uint cellB)
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var la = LoadCell(dats, cellA);
var lb = LoadCell(dats, cellB);
Assert.NotNull(la);
Assert.NotNull(lb);
var (ca, csa) = la!.Value;
var (cb, csb) = lb!.Value;
var wa = WorldTransform(ca);
var wb = WorldTransform(cb);
ushort lowA = (ushort)(cellA & 0xFFFFu);
ushort lowB = (ushort)(cellB & 0xFFFFu);
_out.WriteLine($"=== reciprocal pair 0x{cellA:X8} <-> 0x{cellB:X8} ===");
foreach (var pa in ca.CellPortals)
{
if (pa.OtherCellId != lowB) continue;
if (!csa.Polygons.TryGetValue((ushort)pa.PolygonId, out var polyA)) continue;
var va = WorldVerts(csa, polyA, wa);
if (va.Count < 3) continue;
var na = Vector3.Normalize(Vector3.Cross(va[1] - va[0], va[2] - va[0]));
float da = Vector3.Dot(na, va[0]);
bool drawnA = WouldDraw(polyA, ca);
_out.WriteLine($" A poly={pa.PolygonId} [{pa.Flags}] n=({na.X:F2},{na.Y:F2},{na.Z:F2}) planeD={da:F2} " +
$"stip={polyA.Stippling} sides={polyA.SidesType} DRAWN={drawnA}");
foreach (var pb in cb.CellPortals)
{
if (pb.OtherCellId != lowA) continue;
if (!csb.Polygons.TryGetValue((ushort)pb.PolygonId, out var polyB)) continue;
var vb = WorldVerts(csb, polyB, wb);
if (vb.Count < 3) continue;
var nb = Vector3.Normalize(Vector3.Cross(vb[1] - vb[0], vb[2] - vb[0]));
float db = Vector3.Dot(nb, vb[0]);
bool drawnB = WouldDraw(polyB, cb);
float align = Vector3.Dot(na, nb);
// Coincident planes: |align|≈1 and same plane offset (sign per normal direction).
bool coincident = MathF.Abs(align) > 0.99f
&& MathF.Abs(MathF.Abs(da) - MathF.Abs(db)) < 0.05f;
_out.WriteLine($" B poly={pb.PolygonId} [{pb.Flags}] n=({nb.X:F2},{nb.Y:F2},{nb.Z:F2}) planeD={db:F2} " +
$"stip={polyB.Stippling} sides={polyB.SidesType} DRAWN={drawnB} " +
$"align={align:F3} coincident={coincident}" +
(coincident && drawnA && drawnB ? " >>> Z-FIGHT CANDIDATE (both drawn, same plane)" : ""));
}
}
}
/// <summary>
/// #176 THE STRIPES (user screenshot, 2026-07-06 evening): a floor region
/// z-fights in regular bands between a purple-lit copy and an unlit copy —
/// two COINCIDENT DRAWN surfaces with different per-cell light sets. This
/// sweep hunts the pair in the dat: every pair of DRAWN polys across the
/// corridor neighborhood that is coplanar AND overlapping in area. Before
/// the light-cap fix both copies were usually equally unlit (the purple
/// portal light was cap-evicted) so the fight was invisible; the stable
/// light exposed it.
/// </summary>
[Fact]
public void CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs()
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
// Seed cells around the screenshot location (the 016E/017A seam) +
// one portal ring.
var cellIds = new HashSet<uint> { 0x8A020165u, 0x8A02016Eu, 0x8A02017Au };
foreach (var seed in new List<uint>(cellIds))
{
var seedCell = dats.Get<EnvCell>(seed);
if (seedCell is null) continue;
foreach (var p in seedCell.CellPortals)
cellIds.Add(0x8A020000u | p.OtherCellId);
}
// Collect all DRAWN polys world-space per cell.
var drawn = new List<(uint CellId, ushort PolyId, Vector3 N, float D,
Vector3 Min, Vector3 Max, uint SurfaceId)>();
foreach (var cellId in cellIds)
{
var loaded = LoadCell(dats, cellId);
if (loaded is null) continue;
var (cell, cs) = loaded.Value;
var world = WorldTransform(cell);
foreach (var (id, poly) in cs.Polygons)
{
if (!WouldDraw(poly, cell)) continue;
var w = WorldVerts(cs, poly, world);
if (w.Count < 3) continue;
var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0]));
float d = Vector3.Dot(n, w[0]);
var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue);
foreach (var v in w) { min = Vector3.Min(min, v); max = Vector3.Max(max, v); }
uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface];
drawn.Add((cellId, id, n, d, min, max, surfaceId));
}
}
_out.WriteLine($"cells={cellIds.Count} drawnPolys={drawn.Count}");
int pairs = 0;
for (int i = 0; i < drawn.Count; i++)
{
for (int j = i + 1; j < drawn.Count; j++)
{
var a = drawn[i]; var b = drawn[j];
if (a.CellId == b.CellId && a.PolyId == b.PolyId) continue;
float align = Vector3.Dot(a.N, b.N);
if (MathF.Abs(align) < 0.999f) continue;
float dB = align > 0 ? b.D : -b.D;
if (MathF.Abs(a.D - dB) > 0.02f) continue; // same plane within 2 cm
// Overlap in world AABB, with meaningful area in the plane.
float ox = MathF.Min(a.Max.X, b.Max.X) - MathF.Max(a.Min.X, b.Min.X);
float oy = MathF.Min(a.Max.Y, b.Max.Y) - MathF.Max(a.Min.Y, b.Min.Y);
float oz = MathF.Min(a.Max.Z, b.Max.Z) - MathF.Max(a.Min.Z, b.Min.Z);
if (ox < 0.05f || oy < 0.05f) continue;
// For horizontal planes require XY overlap area; for walls allow thin Z.
bool horizontal = MathF.Abs(a.N.Z) > 0.85f;
if (horizontal && ox * oy < 0.05f) continue;
if (!horizontal && oz < 0.05f) continue;
pairs++;
_out.WriteLine(
$">>> COPLANAR-OVERLAP {(a.CellId == b.CellId ? "SAME-CELL" : "CROSS-CELL")}: " +
$"0x{a.CellId:X8} poly {a.PolyId} (surf 0x{a.SurfaceId:X8}) <-> " +
$"0x{b.CellId:X8} poly {b.PolyId} (surf 0x{b.SurfaceId:X8}) " +
$"n=({a.N.X:F2},{a.N.Y:F2},{a.N.Z:F2}) align={align:F3} " +
$"overlap x={ox:F2} y={oy:F2} z=[{MathF.Max(a.Min.Z, b.Min.Z):F2}..{MathF.Min(a.Max.Z, b.Max.Z):F2}]");
}
}
_out.WriteLine($"coplanar overlapping drawn pairs: {pairs}");
}
/// <summary>
/// #176 candidate (A2C): the opaque pass derives GL_SAMPLE_ALPHA_TO_COVERAGE
/// from the sampled texture alpha (mesh_modern.frag uRenderPass==0 keeps
/// alpha as-sampled). If the corridor floor texture decodes with alpha
/// below 1.0, MSAA coverage punches see-through holes in the floor —
/// fog-purple clear color — worst at grazing angles (mip-level dependent
/// → camera-angle dependent, far floor = at seams). Decode the floor
/// surface chain and histogram the alpha channel.
/// </summary>
[Theory]
[InlineData(0x08000377u)] // corridor floor (portal strips + plain floors)
[InlineData(0x08000376u)]
[InlineData(0x08000375u)]
[InlineData(0x08000378u)]
[InlineData(0x08000379u)] // under-level walls
[InlineData(0x080000DFu)] // stair-transit cells' 5th surface
public void FloorSurface_DecodedAlphaHistogram(uint surfaceId)
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var surface = dats.Get<Surface>(surfaceId);
Assert.NotNull(surface);
_out.WriteLine($"Surface 0x{surfaceId:X8}: type={surface!.Type} origTex=0x{(uint)surface.OrigTextureId:X8} " +
$"transl={surface.Translucency:F2}");
if (surface.OrigTextureId == 0) { _out.WriteLine(" (no texture — solid color surface)"); return; }
var surfTex = dats.Get<SurfaceTexture>((uint)surface.OrigTextureId);
Assert.NotNull(surfTex);
_out.WriteLine($" SurfaceTexture 0x{(uint)surface.OrigTextureId:X8}: {surfTex!.Textures.Count} textures " +
$"[{string.Join(" ", surfTex.Textures.ConvertAll(t => $"0x{t:X8}"))}]");
foreach (var texId in surfTex.Textures)
{
var rs = dats.Get<RenderSurface>((uint)texId);
if (rs is null) { _out.WriteLine($" RenderSurface 0x{texId:X8}: MISS"); continue; }
// Decode with the production Core helpers (same paths the WB atlas uses).
var data = new byte[rs.Width * rs.Height * 4];
bool decodedOk = true;
switch (rs.Format)
{
case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16:
{
var pal = dats.Get<Palette>(rs.DefaultPaletteId);
if (pal is null) { _out.WriteLine($" RenderSurface 0x{texId:X8}: INDEX16 with no palette 0x{rs.DefaultPaletteId:X8}"); decodedOk = false; break; }
AcDream.Core.Rendering.Wb.TextureHelpers.FillIndex16(rs.SourceData, pal, data, rs.Width, rs.Height);
break;
}
case DatReaderWriter.Enums.PixelFormat.PFID_P8:
{
var pal = dats.Get<Palette>(rs.DefaultPaletteId);
if (pal is null) { _out.WriteLine($" RenderSurface 0x{texId:X8}: P8 with no palette"); decodedOk = false; break; }
AcDream.Core.Rendering.Wb.TextureHelpers.FillP8(rs.SourceData, pal, data, rs.Width, rs.Height);
break;
}
case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5:
AcDream.Core.Rendering.Wb.TextureHelpers.FillR5G6B5(rs.SourceData, data, rs.Width, rs.Height);
break;
case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4:
AcDream.Core.Rendering.Wb.TextureHelpers.FillA4R4G4B4(rs.SourceData, data, rs.Width, rs.Height);
break;
case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8:
AcDream.Core.Rendering.Wb.TextureHelpers.FillA8R8G8B8(rs.SourceData, data, rs.Width, rs.Height);
break;
case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8:
AcDream.Core.Rendering.Wb.TextureHelpers.FillR8G8B8(rs.SourceData, data, rs.Width, rs.Height);
break;
case DatReaderWriter.Enums.PixelFormat.PFID_DXT1:
{
// DXT1/BC1: 8-byte blocks — c0 (u16 LE), c1 (u16 LE), 16×2-bit
// indices. c0 <= c1 selects 3-COLOR mode where index 3 decodes
// to TRANSPARENT BLACK (alpha=0). Our atlas uploads DXT1 as the
// RGBA variant (TextureFormatExtensions.ToCompressedGL), so any
// such texel reaches the shader with alpha=0 — and the opaque
// pass discards alpha<0.05 fragments. Count them.
int blocks = rs.SourceData.Length / 8;
int threeColorBlocks = 0;
long transparentTexels = 0;
for (int b = 0; b < blocks; b++)
{
int o = b * 8;
ushort c0 = (ushort)(rs.SourceData[o] | (rs.SourceData[o + 1] << 8));
ushort c1 = (ushort)(rs.SourceData[o + 2] | (rs.SourceData[o + 3] << 8));
if (c0 > c1) continue; // 4-color opaque mode
threeColorBlocks++;
for (int bi = 0; bi < 4; bi++)
{
byte row = rs.SourceData[o + 4 + bi];
for (int t = 0; t < 4; t++)
if (((row >> (t * 2)) & 0x3) == 3)
transparentTexels++;
}
}
_out.WriteLine($" RenderSurface 0x{texId:X8} DXT1 {rs.Width}x{rs.Height}: blocks={blocks} " +
$"threeColorBlocks={threeColorBlocks} alpha0Texels={transparentTexels}" +
(transparentTexels > 0
? " >>> ALPHA=0 TEXELS PRESENT (opaque-pass discard punches holes)"
: " (no transparent-mode texels)"));
decodedOk = false; // histogram printed above; skip the RGBA path
break;
}
default:
_out.WriteLine($" RenderSurface 0x{texId:X8}: fmt={rs.Format} (not decoded by this test)");
decodedOk = false;
break;
}
if (!decodedOk) continue;
// Alpha histogram over the decoded RGBA bytes (stride 4, alpha at +3).
int n = data.Length / 4;
int a255 = 0, aHigh = 0, aMid = 0, aLow = 0, a0 = 0;
byte minA = 255;
for (int i = 0; i < n; i++)
{
byte a = data[i * 4 + 3];
if (a < minA) minA = a;
if (a == 255) a255++;
else if (a >= 243) aHigh++; // ≥0.95 — safe for A2C
else if (a >= 13) aMid++; // 0.05..0.95 — partial coverage
else if (a > 0) aLow++;
else a0++;
}
_out.WriteLine($" RenderSurface 0x{texId:X8} fmt={rs.Format} {rs.Width}x{rs.Height}: " +
$"alpha histogram n={n} a=255:{a255} 243-254:{aHigh} 13-242:{aMid} 1-12:{aLow} 0:{a0} minA={minA}" +
(aMid + aLow + a0 > 0 ? " >>> SUB-OPAQUE ALPHA PRESENT (A2C hole candidate)" : " (fully opaque)"));
}
}
/// <summary>
/// #176 candidate: the under-hall 0x011E floods in at down-pitches and
/// its surface list carries 0x08000034 (Base1Solid|Translucent — a
/// COLORED translucent solid). If its drawn polys sit at z=6 (coplanar
/// with the corridor floor), the transparent pass blends that color over
/// the floor whenever the under-hall is admitted — angle-dependent
/// purple at seams. Dump every DRAWN poly (plane, z-span, surface, and
/// the surface's ColorValue) of the under-hall + its under-level
/// neighbors.
/// </summary>
[Theory]
[InlineData(0x8A02011Eu)]
[InlineData(0x8A020119u)]
[InlineData(0x8A02011Du)]
[InlineData(0x8A020122u)]
[InlineData(0x8A02011Fu)]
[InlineData(0x8A02016Eu)] // corridor cells — the striped-floor screenshot area
[InlineData(0x8A02017Au)]
[InlineData(0x8A020165u)]
public void UnderHall_DrawnPolys_SurfaceColors(uint cellId)
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var loaded = LoadCell(dats, cellId);
if (loaded is null) { _out.WriteLine($"0x{cellId:X8} NOT FOUND"); return; }
var (cell, cs) = loaded.Value;
var world = WorldTransform(cell);
_out.WriteLine($"=== 0x{cellId:X8} drawn polys ===");
foreach (var (id, poly) in cs.Polygons)
{
if (!WouldDraw(poly, cell)) continue;
var w = WorldVerts(cs, poly, world);
if (w.Count < 3) continue;
var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0]));
float minZ = float.MaxValue, maxZ = float.MinValue;
foreach (var v in w) { minZ = MathF.Min(minZ, v.Z); maxZ = MathF.Max(maxZ, v.Z); }
uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface];
var surface = dats.Get<Surface>(surfaceId);
string surfInfo = surface is null
? $"0x{surfaceId:X8} MISS"
: $"0x{surfaceId:X8} type={surface.Type} color=0x{surface.ColorValue:X8} origTex=0x{(uint)surface.OrigTextureId:X8} lum={surface.Luminosity:F2} transl={surface.Translucency:F2}";
_out.WriteLine($" poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) z=[{minZ:F2},{maxZ:F2}] " +
$"verts={poly.VertexIds.Count} surf={surfInfo}");
}
}
/// <summary>
/// The transit-lag question (#176/#177 root-cause fork): production
/// [cell-transit] lines fire 0.10.6 m PAST the portal plane. Is that
/// (a) dat-real — the cells' CellBSP volumes OVERLAP past the plane, so
/// retail's point_in_cell (same dat, same walk) keeps the old cell too —
/// or (b) our membership's bug? Probe raw CellBSP containment for both
/// cells of each seam across the portal plane.
/// </summary>
[Theory]
[InlineData(0x8A02016Eu, 0x8A02017Au, 85.00f, -40f, -5.0f)]
[InlineData(0x8A020182u, 0x8A020183u, 98.333f, -40f, -7.5f)]
public void SeamCells_CellBspContainment_AcrossPortalPlane(
uint cellAId, uint cellBId, float planeX, float y, float z)
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var la = LoadCell(dats, cellAId);
var lb = LoadCell(dats, cellBId);
Assert.NotNull(la);
Assert.NotNull(lb);
var (ca, csa) = la!.Value;
var (cb, csb) = lb!.Value;
Matrix4x4.Invert(WorldTransform(ca), out var invA);
Matrix4x4.Invert(WorldTransform(cb), out var invB);
_out.WriteLine($"=== CellBSP containment across plane x={planeX:F2} " +
$"(A=0x{cellAId:X8}, B=0x{cellBId:X8}) ===");
for (float dx = -0.6f; dx <= 0.65f; dx += 0.05f)
{
var world = new Vector3(planeX + dx, y, z);
bool inA = csa.CellBSP?.Root is not null
&& Physics.PointInCellBspViaBspQuery(csa.CellBSP.Root, Vector3.Transform(world, invA));
bool inB = csb.CellBSP?.Root is not null
&& Physics.PointInCellBspViaBspQuery(csb.CellBSP.Root, Vector3.Transform(world, invB));
_out.WriteLine($" x=plane{(dx >= 0 ? "+" : "")}{dx:F2} inA={(inA ? "Y" : "-")} inB={(inB ? "Y" : "-")}" +
(inA && inB ? " <<< OVERLAP" : !inA && !inB ? " <<< NEITHER" : ""));
}
}
private static class Physics
{
// Thin forwarder so this Rendering-side test reads clearly; the walk
// is the production BSPQuery.PointInsideCellBsp.
public static bool PointInCellBspViaBspQuery(
DatReaderWriter.Types.CellBSPNode node, Vector3 localPoint)
=> AcDream.Core.Physics.BSPQuery.PointInsideCellBsp(node, localPoint);
}
/// <summary>
/// (b) #177: which cell owns the stair-step geometry? Histogram of DRAWN
/// visual polys by normal class + the z-ladder of horizontal polys
/// (stair steps show as a ladder of small floor polys at stepped
/// z-levels). Also lists every portal with its plane orientation —
/// is the 0x0182↔0x0183 connection a floor-portal or a wall-portal?
/// </summary>
[Theory]
[InlineData(0x8A020182u)]
[InlineData(0x8A020183u)]
public void StairTransit_GeometryOwnerAndPortalOrientation(uint cellId)
{
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var loaded = LoadCell(dats, cellId);
Assert.NotNull(loaded);
var (cell, cs) = loaded!.Value;
var world = WorldTransform(cell);
int floors = 0, ceilings = 0, walls = 0, inclined = 0;
var floorZLevels = new SortedDictionary<int, int>(); // z rounded to 0.1 m → count
foreach (var (id, poly) in cs.Polygons)
{
var w = WorldVerts(cs, poly, world);
if (w.Count < 3) continue;
var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0]));
float az = MathF.Abs(n.Z);
if (az > 0.85f)
{
// Horizontal poly — bucket by mean z.
float meanZ = 0; foreach (var v in w) meanZ += v.Z; meanZ /= w.Count;
int zKey = (int)MathF.Round(meanZ * 10f);
floorZLevels.TryGetValue(zKey, out var c);
floorZLevels[zKey] = c + 1;
if (n.Z > 0) floors++; else ceilings++;
}
else if (az < 0.25f) walls++;
else
{
inclined++;
float minZ = float.MaxValue, maxZ = float.MinValue;
foreach (var v in w) { minZ = MathF.Min(minZ, v.Z); maxZ = MathF.Max(maxZ, v.Z); }
_out.WriteLine($" INCLINED poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) z=[{minZ:F2},{maxZ:F2}] " +
$"verts={poly.VertexIds.Count} stip={poly.Stippling} DRAWN={WouldDraw(poly, cell)}");
}
}
_out.WriteLine($"=== 0x{cellId:X8} poly histogram: floors={floors} ceilings={ceilings} walls={walls} inclined={inclined} ===");
_out.WriteLine(" horizontal-poly z-ladder (z → count): " +
string.Join(" ", System.Linq.Enumerable.Select(floorZLevels, kv => $"{kv.Key / 10f:F1}:{kv.Value}")));
foreach (var p in cell.CellPortals)
{
if (!cs.Polygons.TryGetValue((ushort)p.PolygonId, out var poly)) continue;
var w = WorldVerts(cs, poly, world);
if (w.Count < 3) continue;
var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0]));
string orient = MathF.Abs(n.Z) > 0.85f ? "HORIZONTAL (floor/ceiling portal)"
: MathF.Abs(n.Z) < 0.25f ? "vertical (wall portal)"
: "INCLINED portal";
var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue);
foreach (var v in w) { min = Vector3.Min(min, v); max = Vector3.Max(max, v); }
_out.WriteLine($" portal poly={p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] {orient} " +
$"n=({n.X:F2},{n.Y:F2},{n.Z:F2}) z=[{min.Z:F2},{max.Z:F2}]");
}
}
}