diff --git a/.gitignore b/.gitignore index 604bb0a7..5ac2beda 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,7 @@ studio-shots/ # MP1b acdream-bake output — user-machine artifact, never committed # (docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5). *.pak + +# session-local physics capture artifacts (worktree root) +/resolve-*.jsonl +/launch-*.log diff --git a/docs/ISSUES.md b/docs/ISSUES.md index d6917dbd..9f19098e 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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 + 642–647 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 11–16 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 98–101): 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 + 642–647. + - close range (round-3 silence): TurnToObject armed, never completes → + `_pendingPostArrivalAction` never fires → use eaten with zero feedback. +4. Retail contrast: the #170 live cdb drain trace showed retail's queue + stays SHALLOW (add_to_queue == MotionDone, drained same-tick); our + `CheckForCompletedMotions` completes ~one node per animation cycle, so + adds outpace drains during any active play. This is the #170 + pending_motions-flood family — LOCAL-player drain-rate edition (the + remote fix `427332ac` removed the flood's feeder; the local queue's + DRAIN semantics are the divergence here). + +**Next (fix session):** oracle-first on the drain: decomp +`MotionTableManager::CheckForCompletedMotions` (0x0051bfd0) + +`AnimationDone/MotionDone` pop semantics — which queued nodes retail +completes per tick (superseded/non-playing nodes must flush immediately, +not serialize behind animations). Add a queue-dump probe (node ids + ages) +before changing anything. Then re-verify the door BOTH branches + re-check +`UseDone` (0x01C7) wiring so ACE rejections become visible. +DO NOT band-aid: no MotionsPending bypass in BeginTurnToHeading (the gate +is verbatim retail), no deferral-skipping (turn-to-face is retail). + +--- + +## #173 — Observed character jumping into a ceiling hovers at the roof until the arc decays (no collision-velocity response on remotes) + +**Status:** 🟡 FIX SHIPPED 2026-07-05 (this commit) — pending user visual gate (watch a second client jump into the 0x0007 dungeon roof; it should bounce down immediately like the local player). +**Severity:** MEDIUM (remote-motion fidelity indoors; lands visibly late) +**Filed:** 2026-07-05 +**Component:** physics — remote dead-reckoning collision response + +**Description (user, 0x0007 dungeon):** watching another character jump into +the dungeon roof, the observed char sticks to the ceiling until the jump arc +would naturally have come down — "like we are calculating the entire jump +instead of actually checking the collision" — and lands later than retail, +with the animation pinned at the roof. The LOCAL player's own jump bounces +off the roof immediately. + +**Root cause (code-confirmed):** the remote DR tick integrates the +VectorUpdate launch ballistically and DOES sweep collision +(`ResolveWithTransition`, GameWindow remote block) — the sweep pins the +POSITION at the ceiling — but the retail post-transition velocity response +(`CPhysicsObj::handle_all_collisions`, pc:282699-282715: reflect +`v −= (1+elasticity)·dot(v,n)·n`) was only ever ported for the LOCAL player +(L.3a, `PlayerMovementController` ~:940). The remote body kept its +Z launch +velocity, re-integrated it into the roof every tick, and only descended once +gravity burned the arc off. Retail runs handle_all_collisions after every +SetPositionInternal for every physics object — remotes included. + +**Fix (this commit):** mirror the local L.3a reflection block in the remote +sweep's post-resolve path (same formula, same AD-25 airborne-before-AND-after +suppression so corridor slides and landings don't reflect, same Inelastic +zero-out). Register AD-25 extended to cover both sites. + +**Files:** `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep, #173 +block after `rm.Body.Position = resolveResult.Position`). + +**Acceptance:** from acdream, watch a second client jump into a dungeon +ceiling: the observed char deflects off the roof immediately and lands at +retail timing; grounded remote movement (corridor wall slides, NPC chases) +unchanged. + +--- + +## #172 — Town-network portal platform blocks instead of stepping up (CCylSphere family was never ported) + +**Status:** 🟡 FIX SHIPPED 2026-07-05 (this commit) — pending user visual gate (walk up onto the Holtburg portal platform, then the 0x0007 dungeon run). +**Severity:** HIGH (blocks dungeon access — gates the whole #137 repro) +**Filed:** 2026-07-05 +**Component:** physics — CylSphere object collision response + +**Description (user):** the Holtburg town-network portal sits on a stone +platform the player collides with instead of stepping up onto it (retail just +walks up). Entity `0xC0A9B465` = landblock stab #0x65, Setup `0x020019E3`, +one CylSphere **r=2.597 m, h=0.256 m** — a 26 cm disc, trivially steppable in +retail. Surfaced the moment #149 (`4cf6eeb`) started registering BSP-less +stab CylSpheres (before that fix the platform had NO collision at all, so the +player clipped through it — the collision *shape* was the #149 fix; the +collision *response* was never retail). + +**Root cause (probe-confirmed, `launch-137-repro.log`):** the pre-port +`CylinderCollision` was a hand-rolled approximation (AP-6): step-up gate + +radial wall-slide only. Every contact returned `Slid` with a horizontal rim +normal (`[cyl-test] … result=Slid`, `[resolve] … n=(0.99,-0.11,0.00)`) and +the player orbited the rim forever. The step-up *gate* passed (clearance +0.256 ≤ 0.6) but `DoStepUp`'s internal step-down probe could never validate a +landing ON the cylinder top — a cylinder has no polygons, and the port had no +`step_sphere_down` cap-landing (top-disc contact plane). Airborne landings on +tops (`land_on_cylinder` + the Collide-flag exact-TOI branch) were missing +too. + +**Fix (this commit):** verbatim port of the full retail `CCylSphere` family — +dispatcher `intersects_sphere` 0x0053b440, `collides_with_sphere` 0x0053a880, +`normal_of_collision` 0x0053ab50, `collide_with_point` 0x0053acb0, +`slide_sphere` 0x0053b2a0, `step_sphere_up` 0x0053b310, `land_on_cylinder` +0x0053b3d0, `step_sphere_down` 0x0053a9b0. Pseudocode + settled BN +ambiguities + two ACE-bug findings: +`docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md`. +Register: AP-6 retired, AP-83 added (PerfectClip TOI tail per ACE, dead code +in M1.5). Conformance: `CylSphereFamilyTests` (grounded step-up-onto-top on +the exact platform shape, tall-cylinder block, airborne top landing, ethereal +Layer-2 guard); the #42 self-shadow control assertion updated to the retail +observable (denied movement, not the old artifact radial push). + +**Files:** `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylinderCollision` ++ `Cyl*` family), `tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs`. + +**Acceptance:** walk straight onto the Holtburg town-network portal platform +(no rim slide); jumping onto it also lands. Doors/torches/NPC cylinders +unregressed (suites green; #150 open-door behavior unchanged). Likely also +advances #137's door-foot half — re-check in the dungeon repro. + +--- + ## #171 — Group melee: monsters interpenetrate + facing drifts (sticky melee unbound, arrival radii = 0) **Status:** DONE (2026-07-04) — **user visual gate PASSED** ("Looks good, ship it") @@ -929,15 +1334,165 @@ walls** in particular. (Symptoms not fully characterized yet: likely walking thr openings that should block / blocking at openings that should pass, and door collision not matching the door's open/closed state.) -**Root cause / status (to investigate):** dungeon collision is EnvCell-based — the cell's -collision BSP + portal openings + per-cell static objects (doors). Candidates: door -apparatus collision in EnvCells (open/closed BSP swap) not fully ported; portal-opening -(wall gap) collision geometry handled differently from buildings; the per-cell -shadow-object registration (A6.P4, see the physics digest) for dungeon EnvCell statics. -Related families: #32 (edge-slide), #116 (slide-response), the door-collision saga -(see `feedback_dedup_keys_after_cardinality_change`, `feedback_retail_per_cell_shadow_list`). -Needs a targeted repro (which door / which opening, expected vs actual) before fixing — -oracle-first per the physics digest. +**✅ CORRIDOR GATE PASSED 2026-07-06 evening (user: "not collision anymore. +Good.")** — the corridor phantom arc (mechanisms 1–3) is user-verified +FIXED. REMAINING #137 scope from the same gate session: +- **Window/opening climb FIXED + GATE PASSED 2026-07-06 (user: "Looks + good", incl. the taller-capsule regression sweep — doorways/seams/stairs + clean): the player's collision capsule TOPPED OUT AT 1.2 m.** The callers passed + `sphereHeight: 1.2f` and `InitPath` places the head sphere center at + `height − radius` = 0.72 — the top 0.63 m of a 1.83 m character had NO + collision. The dat human Setup 0x02000001 (dumped in + `HumanSetup_CollisionSpheres_DatTruth`): spheres `(0,0,0.475) r=0.48` + + `(0,0,1.350) r=0.48` (top 1.83 = Setup.Height 1.835); retail collides + with that list verbatim (`CPhysicsObj::transition` 0x00512dc0 → + `init_sphere(GetNumSphere, GetSphere, scale)`). At the corridor-end + window alcove (0x8A020179 → 0x8A02017E: sill face 0.70 m, opening 1.3 m + tall, sloped funnel behind — full-vertex dump in + `WindowShaft_FullPolyDump`), the missing head let the step-up's + placement pass and the player climbed in head-through-lintel. Fix: both + live callers now pass 1.835 (capsule top; head center 1.355 ≈ dat + 1.350); register TS-46 documents the residual 5 mm scalar + approximation. Pins: `WindowOpening_HeadCannotFit_EntryBlocked` (walked + approach wall-slides and never enters 0x8A02017E) + + `WindowAlcove_RaisedPlacement_HeadInLintelSolid_Collides` (the raised + placement rejects on the head-vs-lintel). Captured-input replay + fixtures keep their recorded 1.2 inputs — InitPath unchanged. +- Doors half (block/pass per open state) — unchanged. +Two RENDER issues also observed at the gate (filed separately below as +#176/#177): the purple floor flashing at seams is angle/camera-dependent +(the floor IS a portal polygon to the under-room — likely the portal +surface drawn under some culling state), and a stairs pop-in/out between +levels (the #119 visibility class, dungeon edition). + +**SEAM SHAKE FIXED (same day): the stale `footCenter` in `CheckOtherCells`' +per-cell loop** — the P2 cellar-lip lesson one loop deeper. A mid-loop +other-cell query can MOVE the sphere (the boundary full-hit dispatches +step_sphere_up; the successful climb lifts the foot +0.6 mm and returns +OK), and the remaining cells were then queried with the by-value +pre-climb center — 0.4 mm inside the floor slab, grazing the under-room's +ceiling and firing the chain below. Retail's `check_other_cells` reads the +LIVE `sphere_path.global_sphere` per cell (pc:272717+). Fix: re-read +`footCenter = sp.GlobalSphere[0].Origin` per iteration. All three +`Issue137CorridorSeamReplayTests` repros un-skipped and GREEN; full suites +green. Visual gate pending. (The step 3 "restore clobbers CheckPos" wording +below was the right CLASS but the wrong site — CheckPos was fine; the +stale copy was the loop's captured parameter.) + +**GATE 2026-07-06 FAILED — THIRD MECHANISM CHARACTERIZED (the seam shake), +deterministic offline repro secured:** with mechanisms 1+2 fixed the dead +stop became a SHAKE at cell seams (+ purple floor flashing there — almost +certainly the render exposing the same per-frame position/OnWalkable +oscillation; re-check after the physics fix). Full chain, every link +probe-traced (`launch-137-seam-probes.log`, capture +`resolve-137-seam-capture.jsonl` tick 4101 ×46): +1. Corridor cells sit above under-rooms; the shared floor slab is + double-faced (up-face + underside as separate physics polys) and IS a + portal plane (e.g. 0x8A020165's ramp over 0x8A020166). The resting foot + sphere is permanently within ±0.5 mm of THREE thresholds there (poly-hit + r−ε, walkable r−ε, portal-straddle r+ε). +2. Walking across the boundary at the flat-floor height penetrates the + ramp slab by ~0.4 mm → foot full-hit on the up-face → StepSphereUp → + step-down accepts the ramp (+0.6 mm lift, CheckPos −5.999, + `[stepsphereup] stepped=True`). +3. **THE BUG: the lifted position is then LOST** — the next pass runs at + the UNLIFTED height (GlobalSphere center −5.520 vs the lifted −5.519; + the P2 stale-snapshot class, single-slot Save/RestoreCheckPos clobber + suspected — retail `CTransition::step_up` 0x0050b6cc restores ONLY on + failure) → the re-test at 0.4 mm inside the slab grazes the NEIGHBOR + under-room's CEILING (the slab underside, n≈(−0.03,0,−1)) within the + near-miss window → recorded (retail records it too — pos_hits_sphere + registers geometric hits pre-cull) → neg-poly step-up dispatch with the + DOWNWARD normal → the nested step-down finds no walkable at exact + tangency → StepUpSlide → slide_sphere(down normal vs up contact plane) + → the opposing branch → reversed-movement collision normal → Collided → + validate revert (Contact/OnWalkable stripped) → next step's AdjustOffset + zeroes → out==in every frame = the shake. Retail never enters at step 3: + its kept step-up lift leaves the sphere ON the surface, no graze. +4. Offline repro: `Issue137CorridorSeamReplayTests` (3 tests, currently + `Skip="#137 seam shake"`) reproduce the block deterministically — the + key was hydrating THREE portal rings (the under-room 0x8A020166 is + ring-3; with fewer rings the flood can't add it and everything passes). + NEXT: read our `TransitionalInsert` attempt loop against retail + 0x0050b6f0 to find the restore that clobbers the successful step-up's + position; fix; un-skip the three tests. + +**CORRIDOR PHANTOM mechanisms 1+2 FIXED 2026-07-06 (see +`docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md` +for the full audit):** mechanism 2 = BSPQuery Contact-branch stub slide +responses leaked sliding normals retail's BSP layer never writes (fixed: +real `slide_sphere` routing + success-gated body writeback). Mechanism 1 as +theorized is REFUTED: the recorded wall normal `(−1.00,0.03,−0.03)` matches +NO dat polygon (world-space sweep of both seam cells + all portal-adjacent +neighbors) — it is the SYNTHETIC negated movement direction from +`slide_sphere`'s opposing-normals branch, which our port let survive by +returning OK where retail returns COLLIDED_TS (0x0053762c; second fix). The +PortalSide polys to 0x011E were a red herring: cell 0x8A02016E has IDENTITY +rotation, the polys are ±Y planes perpendicular to the run (directionally +culled), retail's physics-BSP leaves reference them too, and the dat's +keep-PortalSide/strip-ExactMatch asymmetry reads as intentional (solid +window/grate-class portals) — NO portal-poly filter needed, no cdb session +needed for this repro. Dat-backed replay +(`Issue137CorridorSeamReplayTests`) reproduces the live frame exactly and +runs the corridor clean. The issue's DOOR half remains open. + +**CHARACTERIZED 2026-07-05 (Facility Hub corridor repro, probe + dat evidence) +— two stacked mechanisms (historical; see the 2026-07-06 resolution above):** +1. **PortalSide portal polygons are IN the physics polygon set and we treat + them as solid.** Live: running the corridor, the seam crossing + `0x8A02016E → 0x8A02017A` (x≈85.25) records a wall hit with normal + (−1,0,0) — straight against the movement (`launch-175-verify2.log:42858`). + Dat (`Issue137CorridorSeamInspectionTests`): cell 0x8A02016E's portals to + 0x011E (polys 1/3/5, flags=**PortalSide**, no ExactMatch) are PRESENT in + `CellStruct.PhysicsPolygons` — every ExactMatch portal in the same cell is + absent from the physics set. The cell's rotation maps those local ±Y portal + planes to world ±X — the phantom mid-corridor wall. Retail must honor the + portal's SIDE (pass from one side / solid from the other, or pass when the + neighbor is loaded); we collide with the raw polygon unconditionally. + **Oracle findings so far (2026-07-05 evening — greps done, question + OPEN):** `CCellStruct::UnPack` (0x00533d00) loads physics_polygons + + physics_bsp verbatim — NO portal-poly stripping at load; + `CPolygon::pos_hits_sphere`/`hits_sphere`/`polygon_hits_sphere_slow_but_sure` + (0x005394f0/0x00539540/0x00538a10) are pure geometry — no portal check; + `CCellPortal` (0x0053bab0) carries portal→CPolygon ptr + portal_side + + exact_match but nothing in the BSP test chain consults it. So retail's + passability for a PortalSide physics poly is NOT a load filter and NOT a + poly-level flag — remaining candidates: the transit/membership order + makes the sphere test the NEIGHBOR cell first (never hitting the portal + poly from the passable side), or a sidedness interaction + (stippling=NoPos + approach direction). NEXT: cdb-attach retail at this + exact corridor (0x8A02016E→011E portals) per the CLAUDE.md step −1 + protocol — the decomp alone hasn't settled it. +2. **The stale sliding normal then wedges all forward motion** (the #116 + slide-response family): after the single seam hit, EVERY subsequent + forward resolve returns `ok=False hit=no` with zero advance — the + body-persisted SlidingNormal (−1,0,0) projects the +X offset to exactly + zero in AdjustOffset, aborting at step 0 BEFORE any collision test could + update the state — an ABSORBING wedge escaped only by strafing ("push + through on the side"). Retail re-derives slide state per frame + (get_object_info pc:279992 governs only the NEXT frame — #116 notes); + audit who clears the body's sliding normal when no contact recurs. + + **MECHANISM 2 FIXED 2026-07-06 (audit complete — full lifecycle in + `docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md`):** + retail's ONLY in-transition sliding-normal writer is + `validate_transition` (0x0050ac21); the BSP/sphere layer never writes it, + and the body persistence (`SetPositionInternal` 0x005154c2/0x005154e1) + is success-only. Our BSPQuery Contact-branch full-hit responses were + STUBS (`SetSlidingNormal + return Slid`) where retail dispatches the + real `slide_sphere` — the seam hit (a SUCCESSFUL full-advance resolve, + `ok=True` in the log, not a failed one) leaked the phantom wall's normal + into the body, and the seed absorbed every later forward push. Fix: + both stub sites now route through the real + `Transition.SlideSphereInternal` (`CSphere::slide_sphere` 0x00537440, + in-frame, no sliding write) and the body writeback is gated on + transition success. Pins: `Issue137SlidingNormalLifecycleTests` (2 site + pins + the persist/absorb/clear wall lifecycle). Register: TS-4 amended + (steep-tangent sites still write the normal — documented), TS-45 added + (`SphereCollision`'s write — same class, out of blast radius). The + absorbed exactly-anti-parallel frame at a REAL wall is retail-faithful + (the persisted normal is a "still pressed" cache); only the phantom + PROVENANCE was the bug. Corridor re-test rides the mechanism-1 session. **Files:** `src/AcDream.Core/Physics/` (EnvCell collision, CellTransit, the door apparatus), `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (per-cell registration). See diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 24ff2e1a..abb563ae 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -85,7 +85,7 @@ accepted-divergence entries (#96, #49, #50). | AD-22 | Async streamed mesh loading with point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's per-frame meshMissing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:211` | Documented convergence argument: the self-heal makes absence transient, converging the async pipeline to retail's never-absent guarantee | A missing mesh referenced OUTSIDE the dispatcher's walk (a future consumer not touching meshMissing) stays permanently invisible — the #119/#128 broken-stairs class; best case, late pop-in | retail synchronous content load (note at WbMeshAdapter.cs:211) | | AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal | | AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) | -| AD-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding | `src/AcDream.App/Input/PlayerMovementController.cs:874` | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral); at elasticity 0.05 retail's landing bounce is imperceptible; sledding reverts to retail rule | Landing-reflection-dependent behavior (slope-landing momentum, high-elasticity surfaces) won't reproduce; the suppression masks the landing-snap gate fragility and could outlive its reason | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 | +| AD-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding. **2026-07-05 (#173): the same reflection + suppression now also runs in the remote DR sweep** (remote jumps hitting ceilings reflect like the local player; both sites share the rule and this row) | `src/AcDream.App/Input/PlayerMovementController.cs:874`; `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep post-resolve, #173 block) | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral — both the local and remote landing snaps use that gate); at elasticity 0.05 retail's landing bounce is imperceptible; sledding reverts to retail rule | Landing-reflection-dependent behavior (slope-landing momentum, high-elasticity surfaces) won't reproduce; the suppression masks the landing-snap gate fragility and could outlive its reason | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 | | AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Rendering/GameWindow.cs:12939` (MoveToComplete subscription) + `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete` seam doc) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract (never on cancel) | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) | | AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 | | AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` | @@ -99,7 +99,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 73 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin) +## 3. Documented approximation (AP) — 73 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin; AP-6 retired 2026-07-05 — full CCylSphere family ported verbatim, residual AP-83 added same commit) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -108,7 +108,6 @@ accepted-divergence entries (#96, #49, #50). | AP-3 | Step-down chain triggered only when contact is invalid OR steeper than walkable; retail's `transitional_insert` OK-path ALWAYS runs it | `src/AcDream.Core/Physics/TransitionTypes.cs:1197` | Conditional preserves the observed-to-matter cases (edge departure, steep cliff-slide) without running the chain every step (per pc:273191 agent reports) | Steps where retail runs step-down despite a valid walkable contact (bump maintenance, edge-slide arming) are skipped — float-off or missed edge slides in untested geometry | `transitional_insert` OK-path pc:273191 | | AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) | | AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 | -| AP-6 | Analytic swept-sphere cylinder collision (XY overlap + step-over + wall-slide) instead of retail CylSphere functions via the 6-path dispatcher; A6.P6 step-over branch ports `step_sphere_up`'s clearance check | `src/AcDream.Core/Physics/TransitionTypes.cs:2601` | Claimed to match retail for the exercised cases (trunks, NPC bodies, door foot-colliders); step-over and step_up_slide fallback retro-fitted from retail when the door phantom surfaced | Unported branches (push direction, interpenetration resolution) differ from retail against cylinder entities — the phantom-collision / sticky-NPC family | `CCylSphere::step_sphere_up` pc:324516-324538 | | AP-7 | `calc_friction` threshold 0.0 with retail's state gate missing; retail uses 0.25 gated by an undecoded state check | `src/AcDream.Core/Physics/PhysicsBody.cs:307` | Bumping the threshold without the gate hammered normal walking (3 → 0.16 m/s); as-read 0.0 kept; locomotion probably state-exempted in retail. Filed L.3c-followup | Friction engages under different conditions — post-landing slides, knockback decay, sledding speeds mismatch retail's deceleration | pc:276702-276705 (state gate + 0.25) | | AP-10 | Dry-corner water depth: retail's 0.1 m allowed sink-in collapsed to 0 | `src/AcDream.Core/Physics/TerrainSurface.cs:481` | The 0.1 offset destabilizes the feet-exactly-on-plane contact-touch check (dist > EPSILON → SetContactPlane never fires → float/fall); retail's ~10 cm sink-in is visually indistinguishable | Masks a contact-touch epsilon fragility — other water-depth values exercising the same instability could oscillate shoreline walkable validation; retail's wet/dry corner sink-in visual absent | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port) | | AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80–350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 | @@ -180,6 +179,9 @@ accepted-divergence entries (#96, #49, #50). | AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) | | AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) | | AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) | +| AP-85 | **⚠️ CORRECTED 2026-07-06: visible-cell scoping SHIPPED (`LightSource.CellId` from `entity.ParentCellId` + `BuildPointLightSnapshot(camPos, visibleCells)` over the portal-flood set; probe-proven to drop ~285 through-floor lights/frame in the Hub; end-state pin un-skipped). The visual gate REFUTED the light-cap #176/#177 attribution stated below — BOTH symptoms were UNCHANGED, so #176 = an intensity-100 purple point light `(0.784,0,0.784)` and #177 = a portal-visibility miss (see digest banner). Residual deviation now: 128 backstop vs retail 40+7 (0x0081ec94/8), no dynamic-priority split — benign (visible-scoped pool is 1–9). HISTORICAL claim below.** **Per-frame flat point-light snapshot capped at the 128 lights nearest THE CAMERA** (`BuildPointLightSnapshot`); retail registers lights per-CELL (`insert_light` 0x0054d1b0) and `minimize_object_lighting` (0x0054d480) consults the reaching set with NO global pool cap. The cap BITES in the Facility Hub (366 registered fixtures → 238 evictions/frame) and the eviction is the CONFIRMED mechanism of #176 (purple seam flash — an in-range torch of a visible cell ranks past the cap and drops from that cell's 8-set; per-cell Gouraud pops as the camera moves) + #177 (a stair room's fixtures all past the cap render it 0.2-ambient-dark until approach). ⚠️ Raising to 1024 was live-tested 2026-07-06 and REVERTED: the uncapped pool exposes (a) light-through-solid-floors (no per-cell reach/occlusion — the under-room portal light washes the corridor above), (b) stationary weenie fixtures on the DYNAMIC 1/d falloff (~9× retail's static 1/d³ at 3 m; #143 misassignment for ACE-served fixtures), (c) an unexplained striped floor artifact. Fix = the A7 arc: per-cell light registration + static curve for fixtures + the stripe hunt, THEN uncap | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights` — the load-bearing-stopgap comment); desired-end-state pin (Skip) `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant` | The 128 cap keeps the light pool local to the camera, which accidentally APPROXIMATES per-cell reach (far lights can't leak through floors into view) — the least-wrong state until A7 ports real per-cell registration | The #176/#177 pop class stays live until A7 (purple flashes at seams; unlit rooms popping lit on approach); any dungeon with >128 fixtures has camera-dependent per-cell lighting | `minimize_object_lighting` 0x0054d480 (no global pool cap); `insert_light` 0x0054d1b0 (per-cell registration); `calc_point_light` 0x0059c8b0 (static 1/d³ bake curve) | +| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities | +| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified). Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping missiles | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` | ## 4. Temporary stopgap (TS) — 39 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded) @@ -188,7 +190,7 @@ accepted-divergence entries (#96, #49, #50). | TS-1 | PrecipiceSlide context missing — conservative stop-at-edge instead of retail's EdgeSlide → PrecipiceSlide / CliffSlide | `src/AcDream.Core/Physics/TransitionTypes.cs:1254` | Awaiting the next L.2c slice; a diagnostic records which ingredient (precipice context / steep plane / EdgeSlide flag) is missing | Player stops dead at precipice edges where retail slides along/over — visible mismatch at cliff and roof edges | retail EdgeSlide → PrecipiceSlide chain | | TS-2 | `BspOnlyDispatch` reduces retail's `(HAS_PHYSICS_BSP_PS && !pvpTargetPlayer && !missileIgnore)` to the flag test alone (M1.5 scope: no PK, no missiles) | `src/AcDream.Core/Physics/TransitionTypes.cs:660` | Both omitted terms are genuinely false pre-M2; comment directs wiring them with PK (M2+) and missiles (F.3) | If PK or missiles land without the terms, flagged entities get BSP-only where retail tests cyl+sphere — pass-through / wrong blocking in PvP/missile interactions | `FindObjCollisions` pc:276861; HAS_PHYSICS_BSP_PS acclient.h:2833 | | TS-3 | `FramesStationaryFall` accounting absent (`moved = true` unconditionally in the accepted-move branch) | `src/AcDream.Core/Physics/TransitionTypes.cs:3691` | Explicitly deferred to the full physics port | A body wedged falling-in-place never triggers retail's stuck-fall escalation — indefinite falling-animation wedges | CPhysicsObj frames_stationary_fall | -| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place | `src/AcDream.Core/Physics/BSPQuery.cs:2001` | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge | `BSPTREE::find_collisions` SetCollide pc:323783-323821 | +| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place. **Includes a `SetSlidingNormal` write at both sites** — retail's BSP layer never writes `collision_info.sliding_normal` (only `validate_transition` 0x0050ac21 does; the #137 mechanism-2 class), so on transition success the steep-face normal persists to the body and seeds the next frame | `src/AcDream.Core/Physics/BSPQuery.cs` (Path-6 steep branches, `worldNormal.Z < FloorZ`) | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge; a persisted steep-face normal can absorb an exactly-anti-parallel next-frame push (#137 wedge class) until an oblique input clears it | `BSPTREE::find_collisions` SetCollide pc:323783-323821 | | TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2). R3-W3 extends this row: `IWeenieObject.JumpStaminaCost`/`PlayerWeenie.JumpStaminaCost` are new (feeding `jump_is_allowed`'s verbatim stamina-refusal branch) and are ALSO always-affordable/cost-0 stubs for the same reason | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` (`CanJump`), `:52` (`JumpStaminaCost`, R3-W3) | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry; `jump_is_allowed` 0x005282b0 `JumpStaminaCost` vtable +0x44 | | TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) | | TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 | @@ -224,6 +226,8 @@ accepted-divergence entries (#96, #49, #50). | TS-41 | Grounded remote NPCs WITHOUT an armed moveto are body-driven by UP-synthesized server velocity (`HasServerVelocity` → the SERVERVEL per-tick leg: `Body.Velocity = ServerVelocity`, `MovementManager.UseTime` (the R5-V5 facade relay, ex-loose `MoveToManager.UseTime`) SKIPPED, stale-decay stop via `ApplyServerControlledVelocityCycle(Zero)`); retail has no wire-velocity leg-driver anywhere — `MovementManager::UseTime` runs UNCONDITIONALLY per tick and between-UP translation comes from the motion state (`get_state_velocity`), UPs only hard-snap. **#170 residual fix narrowed this branch: an ARMED moveto (`MovementTypeState != Invalid`) now always takes the MOVETO leg** — the old arbitration starved the verbatim MoveToManager for exactly the duration of a server-side chase (UPs flowing → UseTime never ran → legs stayed Ready while the body glided = the #170 slide; live funnel 16 arms → 1 run install). **R5-V3 (#171) narrowed it again: a STUCK entity (`PositionManager.GetStickyObjectId() != 0`) also takes the MOVETO leg** — after the sticky arrival the moveto is cleaned (Invalid) but `StickyManager::adjust_offset` owns the between-snap translation; SERVERVEL would glide the body against the sticky steer (same starvation class) | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch, `moveToArmed`+`stickyArmed` gate) | ACE moves some entities by position updates alone (scripted paths, missiles) with no UM/moveto stream — without a velocity fallback they freeze between UPs; entities WITH a moveto now get the retail drive | An entity class that carries BOTH wire velocity and an armed moveto with conflicting truths follows the moveto; UP hard-snaps bound the drift. Non-moveto entities keep the non-retail stale-stop heuristics (AP-80 thresholds) | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` call @0x00515998, unconditional); retire in R6 (full per-tick order) | | TS-42 | Per-tick DRAIN ORDER inverted vs retail: acdream's `TickAnimations` runs `HandleTargetting` → `Movement.UseTime` (the R5-V5 MovementManager relay, ex-loose `MoveTo.UseTime`) FIRST and the animation-completion drain (Sequencer.Advance → AnimDone hooks → `MotionTableManager.AnimationDone`/`UseTime` → `CMotionInterp.MotionDone` pops) LAST, so every motion-completion-gated decision (`BeginTurnToHeading`'s `motions_pending` wait) sees a queue that is one frame STALE — the unblock after a stop/swing lands one frame later than retail. Retail order (pinned from the named decomp this session): `UpdatePositionInternal` (CPartArray::Update + `process_hooks` @0x00512d3d — the drain) runs BEFORE `TargetManager::HandleTargetting` @0x00515989 → `MovementManager::UseTime` @0x00515998 → `CPartArray::HandleMovement` @0x005159a4 (zero-tick sweep) in `UpdateObjectInternal` | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` per-entity phase order; the R2-Q4 comment already marks the placement "provisional until R6") | Bounded to exactly ONE frame (~16 ms) of extra latency per completion-gated event; every queue eventually drains identically (RemoteChaseEndToEndHarnessTests conformance) | Motion-completion-gated transitions (chase turn start, post-swing re-arm) systematically lag retail by one frame; under compound churn the lag can cost an extra retry cycle | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 + `UpdatePositionInternal` 0x00512c30 (`process_hooks` @0x00512d3d); retire in R6 (retail UpdateObjectInternal order) | | TS-44 | NPC UpdatePosition hard-snaps (position @`OnLivePositionUpdated` + orientation + velocity/cycle adoption) are SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`) — an adaptation of retail's chain semantics to the legacy snap path: retail routes UP corrections through the InterpolationManager into the SAME per-tick `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES them while armed (0x00555190 order, 0x00555430 assigns m_fOrigin), so a server correction can never fight an armed stick; the legacy NPC path snaps OUTSIDE the chain, producing snap-out/steer-back position flapping + stale-facing stomps (the 2026-07-04 #171 gate residuals). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick, bounded by the 1 s sticky lease | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's mechanism (sticky-overwrites-interp) is unreachable until the NPC path gets the interp-queue architecture (the player-remote branch already has it — the R5-V3 combiner→sticky chain); the gate reproduces the retail-observable behavior on the snap architecture | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target by construction) would drift until unstick+next UP; worst case bounded by the 1 s lease + the next UM re-arm | `PositionManager::adjust_offset` 0x00555190; `InterpolationManager` UP routing (`CPhysicsObj::MoveOrTeleport`); retire when the NPC path unifies onto the interp queue (S6/R6) | +| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001 spheres `(0,0,0.475) r=0.48`, `(0,0,1.350) r=0.48`). Remotes also use the hardcoded HUMAN dims regardless of creature Setup/scale. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — 0.63 m of headless character; the #137 window climb. Fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`), `src/AcDream.App/Input/PlayerMovementController.cs` + `src/AcDream.App/Rendering/GameWindow.cs` (the two `sphereHeight:` call sites) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold for the human capsule and keeps every captured-input replay fixture byte-identical | 5 mm offsets can flip marginal grazes near the r−ε/r+ε knife edges (today's seam class); creature-scale remotes collide with human-sized capsules until setup-derived dims are plumbed | `CPhysicsObj::transition` 0x00512dc0; dat Setup 0x02000001; retire by plumbing the Setup sphere list into `InitPath` | +| TS-45 | `SphereCollision` (the shadow-object Sphere response) is a hand-rolled 3-D wall-slide that ALSO calls `SetSlidingNormal` — retail's `CSphere::intersects_sphere` (0x00537A80) dispatches `CSphere::slide_sphere` (0x00537440), which slides in-frame and never writes `collision_info.sliding_normal` (the only in-transition writer is `validate_transition` 0x0050ac21). Same leak class as the #137 mechanism-2 stubs fixed 2026-07-06 (BSPQuery Contact branch); left in place because the response's blocking semantics for sphere-shaped server objects are untested against the real slide and #171 sticky-melee behavior is freshly gated | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollision`, the `ci.SetSlidingNormal` tail) | The in-frame push-out already moves the check position; the extra sliding normal only persists on transition success, and pure-Sphere shadow shapes are rare (most creatures/statics are CylSphere, which routes through the real `SlideSphere` since #172) | A sphere-shaped object touch persists a normal retail would discard — an exactly-anti-parallel follow-up push absorbs to a zero offset (#137 wedge class) at that object until an oblique input clears it | `CSphere::intersects_sphere` 0x00537A80 → `slide_sphere` 0x00537440 (pc:321678+); fix = route the tail through `Transition.SlideSphere` like `CylSlideSphere` does | | TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition` → `PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port | --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index cc2e19a5..2ce9c2c5 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -293,14 +293,32 @@ successfully 2026-04-30 for the steep-roof case. Matching binaries #### Phase A7 — Indoor lighting fidelity (RenderDoc + retail-decomp driven) +**Now also owns #176/#177 (2026-07-06):** the Facility Hub purple seam +flash + stair-room light pop-in are ROOT-CAUSED to this phase's "light +visibility culling" layer — a camera-nearest `MaxGlobalLights=128` +snapshot cap evicts in-range lights of visible cells (Hub has 366 +fixtures), so per-cell 8-light sets churn as the camera moves. +Uncapping was live-tested and reverted because the full pool exposes the +per-cell-reach + fixture-curve defects below (through-floor light, +1/d-vs-1/d³). Analysis PRE-PAID — see +`docs/research/2026-07-06-176-177-handoff-A7-lighting.md` (the fix order: +per-cell `insert_light` registration → static fixture curve → stripe +hunt → uncap) + register AP-85. + **Hypothesis layers (less mapped than physics):** - Per-cell environment-light tag association — indoor cells should inherit only their own env lights, not outdoor day-cycle. - Light visibility culling — what lights actually contribute to each - cell's render. + cell's render. **CONFIRMED bug here (#176/#177): no per-cell light + registration — lights are a flat world-space sphere-overlap pool that + reaches through solid floors; retail's `insert_light` 0x0054d1b0 + scopes each light to its cell.** - Per-entity light direction transform — held-item-spotlight bug (#L-spotlight) is per-entity attribution gone wrong. - Static-stab atmospheric inheritance (#81). +- **Fixture falloff curve (#176/#177): stationary server-spawned + fixtures ride the DYNAMIC 1/d path (#143 `isDynamic`); should be the + static 1/d³ bake (`calc_point_light` 0x0059c8b0).** **Investigation methodology:** less existing infrastructure than physics. Requires: diff --git a/docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md b/docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md new file mode 100644 index 00000000..a68144fb --- /dev/null +++ b/docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md @@ -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. diff --git a/docs/research/2026-07-06-137-closeout-render-pair-pickup.md b/docs/research/2026-07-06-137-closeout-render-pair-pickup.md new file mode 100644 index 00000000..bccd8a17 --- /dev/null +++ b/docs/research/2026-07-06-137-closeout-render-pair-pickup.md @@ -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). diff --git a/docs/research/2026-07-06-137-corridor-phantom-pickup-prompt.md b/docs/research/2026-07-06-137-corridor-phantom-pickup-prompt.md new file mode 100644 index 00000000..3c6c96a2 --- /dev/null +++ b/docs/research/2026-07-06-137-corridor-phantom-pickup-prompt.md @@ -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. diff --git a/docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md b/docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md new file mode 100644 index 00000000..5df6df93 --- /dev/null +++ b/docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md @@ -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). diff --git a/docs/research/2026-07-06-176-177-handoff-A7-lighting.md b/docs/research/2026-07-06-176-177-handoff-A7-lighting.md new file mode 100644 index 00000000..54dceac5 --- /dev/null +++ b/docs/research/2026-07-06-176-177-handoff-A7-lighting.md @@ -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. diff --git a/docs/research/2026-07-06-176-177-render-pair-investigation.md b/docs/research/2026-07-06-176-177-render-pair-investigation.md new file mode 100644 index 00000000..238cb533 --- /dev/null +++ b/docs/research/2026-07-06-176-177-render-pair-investigation.md @@ -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.33–0.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 `(1−d/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). diff --git a/docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md b/docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md new file mode 100644 index 00000000..abc4040a --- /dev/null +++ b/docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md @@ -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`), `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 +> 425331–425341 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? 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 | diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 8f07011e..60b38412 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -882,8 +882,17 @@ public sealed class PlayerMovementController // Falls back to simple Z-snap if transition fails. var resolveResult = _physics.ResolveWithTransition( preIntegratePos, postIntegratePos, CellId, - sphereRadius: 0.48f, // human player radius from Setup - sphereHeight: 1.2f, // human player height from Setup + sphereRadius: 0.48f, // human Setup 0x02000001 sphere radius (dat: 0.480) + // #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, stepDownHeight: StepDownHeight, // L.2.3a: from Setup.StepDownHeight isOnGround: _body.OnWalkable, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ca791b08..8f2fc8c4 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3714,7 +3714,8 @@ public sealed class GameWindow : IDisposable ownerId: entity.Id, entityPosition: entity.Position, 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) _lightingSink.RegisterOwnedLight(ls); } @@ -4193,9 +4194,22 @@ public sealed class GameWindow : IDisposable // span the doorway gap, so the player could walk through. With // 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. + // #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( 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 — // the pure builder's 2.0 placeholder works for typical doors @@ -4284,6 +4298,37 @@ public sealed class GameWindow : IDisposable } } + /// + /// #175: the motion table's default-state pose (the closed pose for + /// doors) — the derivation lives in + /// (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. + /// + private IReadOnlyList? MotionTableDefaultPose( + uint motionTableId, int partCount) + { + if (motionTableId == 0u || partCount == 0 || _dats is null) return null; + + var mt = _dats.Get(motionTableId); + if (mt is null) return null; + + var pose = AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames( + mt, id => _dats.Get(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; + } + /// /// R3-W4: one-time per-remote wiring of the animation-dispatch stack — /// the persistent (ObservedOmega turn @@ -4318,7 +4363,20 @@ public sealed class GameWindow : IDisposable rmForSink.ObservedOmega = System.Numerics.Vector3.Zero, }; 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(); // R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions // 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions). @@ -7670,7 +7728,8 @@ public sealed class GameWindow : IDisposable datSetup, ownerId: entity.Id, 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) _lightingSink.RegisterOwnedLight(ls); } @@ -9350,6 +9409,11 @@ public sealed class GameWindow : IDisposable CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null, Camera = camera, 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, PlayerLandblockId = playerLb, AnimatedEntityIds = animatedIds, @@ -10461,14 +10525,16 @@ public sealed class GameWindow : IDisposable if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0) { // 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 // if creatures of wildly different sizes need different // collision profiles. var resolveResult = _physicsEngine.ResolveWithTransition( preIntegratePos, postIntegratePos, rm.CellId, sphereRadius: 0.48f, - sphereHeight: 1.2f, + sphereHeight: 1.835f, stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f // K-fix9 (2026-04-26): mirror the K-fix7 gate — @@ -10496,6 +10562,55 @@ public sealed class GameWindow : IDisposable if (resolveResult.CellId != 0) 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 // detection for airborne remotes. Mirrors // 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 // sequencer — LeaveGround/HitGround strip transition links here // (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 = () => playerSeq.Manager.InitializeState(); _playerController.Motion.CheckForCompletedMotions = diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 7e30609b..634db0e4 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -146,6 +146,12 @@ public sealed class RetailPViewRenderer 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( ctx.ViewProjection, ctx.CameraWorldPosition, @@ -1102,6 +1108,16 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext public Action? DrawUnattachedSceneParticles { get; init; } public Action>? DrawDynamicsParticles { get; init; } public Action? EmitDiagnostics { get; init; } + + /// 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: CObjCell::add_*_to_global_lights walked over + /// CEnvCell::visible_cell_table). 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. + public Action>? RebuildScopedLights { get; init; } } public sealed class RetailPViewFrameResult diff --git a/src/AcDream.Core/Lighting/LightInfoLoader.cs b/src/AcDream.Core/Lighting/LightInfoLoader.cs index f3b02348..b3e9d4a8 100644 --- a/src/AcDream.Core/Lighting/LightInfoLoader.cs +++ b/src/AcDream.Core/Lighting/LightInfoLoader.cs @@ -37,7 +37,8 @@ public static class LightInfoLoader uint ownerId, Vector3 entityPosition, Quaternion entityRotation, - bool isDynamic = false) + bool isDynamic = false, + uint cellId = 0) { var results = new List(); 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), ConeAngle = info.ConeAngle, OwnerId = ownerId, + CellId = cellId, // owning cell — scopes the per-frame visible-cell pool (A7 #176/#177) IsLit = true, IsDynamic = isDynamic, }; diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index e3f7605a..c4e45451 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -176,8 +176,27 @@ public sealed class LightManager public const int MaxLightsPerObject = 8; /// 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 - /// is generous. If exceeded, the nearest-to-camera are kept (cold path). + /// indexes. ⚠️ LOAD-BEARING STOPGAP — read before touching (#176/#177, + /// 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 minimize_object_lighting (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 + /// insert_light 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. public const int MaxGlobalLights = 128; private readonly List _pointSnapshot = new(); @@ -199,11 +218,33 @@ public sealed class LightManager /// per-object selection. /// public void BuildPointLightSnapshot(Vector3 cameraWorldPos) + => BuildPointLightSnapshot(cameraWorldPos, visibleCells: null); + + /// + /// Visible-cell-scoped snapshot build — the faithful port of retail's per-frame + /// light collection (CObjCell::add_*_to_global_lights 0x0052b350/0x0052b390 + /// walked over CEnvCell::visible_cell_table 0x0052d410). When + /// is non-null (an indoor root with a portal + /// flood), a cell-tagged light is a candidate ONLY when its + /// is in the visible set — a cell-less light (CellId == 0: 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 + /// never evicts a visible cell's in-range light (the #176/#177 mechanism). When + /// is null (outdoor root / no flood) the behaviour + /// is unchanged from the legacy full-pool path. + /// + public void BuildPointLightSnapshot(Vector3 cameraWorldPos, IReadOnlySet? visibleCells) { _pointSnapshot.Clear(); foreach (var light in _all) { 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(); _pointSnapshot.Add(light); } @@ -212,6 +253,11 @@ public sealed class LightManager _pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq)); _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) ────────────── diff --git a/src/AcDream.Core/Lighting/LightSource.cs b/src/AcDream.Core/Lighting/LightSource.cs index c07e79b9..3d0bd705 100644 --- a/src/AcDream.Core/Lighting/LightSource.cs +++ b/src/AcDream.Core/Lighting/LightSource.cs @@ -46,6 +46,12 @@ public sealed class LightSource public float Range = 10f; // metres, hard cutoff public float ConeAngle = 0f; // radians, Spot only 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 IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5); // false = static dat-baked bake (1/d³, range×1.3) diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 87c127dc..3538e497 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -1399,26 +1399,35 @@ public static class BSPQuery // ------------------------------------------------------------------------- // 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. // ------------------------------------------------------------------------- /// - /// BSPTree.slide_sphere — apply sliding collision response. + /// BSPTree.slide_sphere — dispatch the real sphere-level slide + /// (CSphere::slide_sphere 0x00537440, ported as + /// ): 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 — + /// GlobalSphere[0].SlideSphere(..., GlobalCurrCenter[0].Center)). /// /// - /// Sets the sliding normal on CollisionInfo so the outer transition loop - /// applies a wall-slide projection. + /// #137 mechanism 2 (2026-07-06): this was a stub that set + /// CollisionInfo.SlidingNormal and returned Slid. Retail's BSP layer + /// never writes the sliding normal — its only in-transition writer is + /// CTransition::validate_transition (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). /// - /// - /// ACE: BSPTree.cs slide_sphere — calls GlobalSphere[0].SlideSphere. /// + /// Collision normal already in world space (the + /// call sites apply L2W; ACE globalizes inside slide_sphere instead). private static TransitionState SlideSphere( Transition transition, - Vector3 collisionNormal) - { - transition.CollisionInfo.SetSlidingNormal(collisionNormal); - return TransitionState.Slid; - } + Vector3 worldNormal) + => transition.SlideSphereInternal( + worldNormal, transition.SpherePath.GlobalCurrCenter[0].Origin); // ------------------------------------------------------------------------- // collide_with_pt — BSPTree level @@ -1894,11 +1903,14 @@ public static class BSPQuery if (engine is not null && !path.StepUp && !path.StepDown) return StepSphereUp(transition, worldNormal, engine); - // No engine OR step-up/step-down already in progress — fall - // back to wall-slide. - collisions.SetCollisionNormal(worldNormal); - collisions.SetSlidingNormal(worldNormal); - return TransitionState.Slid; + // No engine OR step-up/step-down already in progress — the + // real slide response. Retail: a blocked step-up funnels to + // SPHEREPATH::step_up_slide → CSphere::slide_sphere (ACE + // SpherePath.cs:316); the slide records the collision normal + // 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 @@ -1937,9 +1949,10 @@ public static class BSPQuery PhysicsDiagnostics.LastBspHitPoly = hitPoly1; var worldNormal = L2W(hitPoly1!.Plane.Normal); - collisions.SetCollisionNormal(worldNormal); - collisions.SetSlidingNormal(worldNormal); - return TransitionState.Slid; + // Retail head-sphere full hit → BSPTREE::slide_sphere + // (0x0053a697; ACE BSPTree.cs:202) — the real in-frame + // 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. diff --git a/src/AcDream.Core/Physics/Motion/MotionTablePose.cs b/src/AcDream.Core/Physics/Motion/MotionTablePose.cs new file mode 100644 index 00000000..6acddd33 --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/MotionTablePose.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.Core.Physics.Motion; + +/// +/// #175: the motion table's DEFAULT-STATE part pose — the pose an idle +/// entity's parts hold (retail: CMotionTable::SetDefaultState +/// 0x005230a0 installs StyleDefaults[DefaultStyle]'s cycle; the parts +/// then sit at that animation's frames — the live CPhysicsPart pose +/// collision tests against). Used as the BSP shadow-shape part-pose override +/// at server-entity registration (doors: the CLOSED pose). +/// +/// +/// Cycle key arithmetic mirrors 's +/// LookupCycle (CMotionTable.cs:683): (style << 16) | +/// (substate & 0xFFFFFF) — the raw dat Cycles 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). +/// +/// +public static class MotionTablePose +{ + /// + /// 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 — + /// 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). + /// + /// The raw dat motion table (wire MotionTableId). + /// Animation loader (production: + /// id => dats.Get<Animation>(id)). + public static IReadOnlyList? DefaultStatePartFrames( + MotionTable mt, + Func 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; + } +} diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 1090559e..da52716b 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -1085,16 +1085,27 @@ public sealed class PhysicsEngine body.WalkableVertices = null; } - if (ci.SlidingNormalValid - && ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) + // Retail persists sliding state to the body ONLY on transition + // 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; - body.TransientState |= TransientStateFlags.Sliding; - } - else - { - body.SlidingNormal = Vector3.Zero; - body.TransientState &= ~TransientStateFlags.Sliding; + if (ci.SlidingNormalValid + && ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) + { + body.SlidingNormal = ci.SlidingNormal; + body.TransientState |= TransientStateFlags.Sliding; + } + else + { + body.SlidingNormal = Vector3.Zero; + body.TransientState &= ~TransientStateFlags.Sliding; + } } // L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity. diff --git a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs index 5d8d8ee6..8cb30123 100644 --- a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs +++ b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs @@ -38,10 +38,22 @@ public static class ShadowShapeBuilder /// every radius, height, and local offset. /// Predicate: does the GfxObj with this id /// have a non-null PhysicsBSP? Production: id => cache.GetGfxObj(id)?.BSP?.Root is not null. + /// #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 + /// CPhysicsPart 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). public static IReadOnlyList FromSetup( Setup setup, float entScale, - Func hasPhysicsBsp) + Func hasPhysicsBsp, + IReadOnlyList? partPoseOverride = null) { if (setup is null) throw new ArgumentNullException(nameof(setup)); 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. + // Pose priority per part: partPoseOverride (the motion-table + // default-state pose, #175) → placement frame → identity. AnimationFrame? placementFrame = ResolvePlacementFrame(setup); for (int i = 0; i < setup.Parts.Count; i++) { uint gfxId = (uint)setup.Parts[i]; if (!hasPhysicsBsp(gfxId)) continue; - Frame partFrame = placementFrame is not null && i < placementFrame.Frames.Count - ? placementFrame.Frames[i] - : new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; + Frame partFrame; + if (partPoseOverride is not null && i < partPoseOverride.Count) + 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 // at registration time when available. Loose-but-safe broadphase value. diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index a633efd4..20dcdef1 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -1804,6 +1804,21 @@ public sealed class Transition { 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) { var terrainWalkable = engine.SampleTerrainWalkable(footCenter.X, footCenter.Y); @@ -2811,9 +2826,12 @@ public sealed class Transition // Effect (pc:276973-276977): // state_3 = OK_TS ← force passable // collision_normal_valid = 0 ← clear stale slide normal - // Note: Cylinder and Sphere shapes already return OK from their own - // obstruction_ethereal early-out, so this clause only fires in practice - // for the BSP branch, but is written unconditionally as retail does. + // Note: the BSP branch AND (since the 2026-07-05 CCylSphere family + // port) the Cylinder branch rely on this clause for ethereal + // 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 && sp.ObstructionEthereal && !sp.StepDown @@ -3171,168 +3189,453 @@ public sealed class Transition } /// - /// Cylinder collision test for CylSphere objects (tree trunks, rock pillars, NPCs, - /// door foot-colliders). For Contact-grounded movers, attempts to step over short - /// cylinders (retail-faithful CCylSphere::step_sphere_up). For airborne movers, - /// movers already stepping, or cylinders too tall to step over, applies a - /// horizontal wall-slide response. + /// Retail CCylSphere::intersects_sphere dispatcher — verbatim port + /// of 0x0053b440 (acclient_2013_pseudo_c.txt:324558). Full family: + /// 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 + ACE cross-reference notes: + /// docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md. /// /// - /// A6.P6 (2026-05-25): the step-over path matches retail's - /// CCylSphere::step_sphere_up at - /// acclient_2013_pseudo_c.txt:324516-324538. The door's foot - /// cylinder (h=0.20m, r=0.10m) is too tall for the static slide to - /// produce smooth sliding along the slab — the radial push-out - /// fires as a "phantom collision" at the door's center when the - /// sphere is touching the slab face and the cyl is just within reach. - /// Retail steps the sphere over the cyl (succeeds when - /// step_up_height >= sphere.radius + cyl.height - offset.z), - /// which lets the sphere walk past the cyl without the radial push. - /// On step-up failure (cyl too tall, no walkable surface beyond), - /// falls back to step_up_slide — the same crease-projection - /// slide the BSP path uses, which produces smoother behavior than - /// the radial push. + /// Replaces the pre-2026-07-05 hand-rolled approximation (A6.P6 step-up + /// gate + radial wall-slide) which had NO cylinder-TOP support: a + /// grounded mover stepping up onto a WIDE cylinder (the Holtburg + /// town-network portal platform, Setup 0x020019E3, r=2.597 m h=0.256 m) + /// could never validate a landing — the step-up's internal step-down + /// probe needs branch 2 (step_sphere_down → contact plane ON the flat + /// top) — so DoStepUp failed into StepUpSlide and the player orbited the + /// rim forever. Airborne landings on tops (land_on_cylinder + the + /// collide-flag exact-TOI branch) were likewise missing. + /// + /// + /// + /// The already carries the wrapper overload's + /// (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). /// /// private TransitionState CylinderCollision(ShadowEntry obj, SpherePath sp, PhysicsEngine engine) { - // Consume site 2 — CCylSphere::intersects_sphere @ 0x0053b4a0 (pc:324573). - // When obstruction_ethereal is set (target is ETHEREAL-alone, state & 0x4), - // the retail function is void and all paths in the ethereal branch return - // without producing a COLLIDED/Slid result — the player is fully passable. - // We mirror this by returning OK immediately, skipping all blocking paths. - // Retail ref: acclient_2013_pseudo_c.txt:324573. - if (sp.ObstructionEthereal) - return TransitionState.OK; + // Degenerate dat heights: registration sites apply the same fallback; + // kept for entries registered before it (pre-dates this port). + float cylHeight = obj.CylHeight > 0f ? obj.CylHeight : obj.Radius * 4f; + + var s0 = sp.GlobalSphere[0]; + Vector3 disp0 = s0.Origin - obj.Position; + // radsum: ε shaved ONCE in the dispatcher preamble (0x0053b48e) — + // 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; - 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? - float cylTop = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 4f; - float checkZ = sphereCheckPos.Z; - if (checkZ - sphRadius > obj.Position.Z + cylTop || - checkZ + sphRadius < obj.Position.Z) + if (!sp.Collide) + { + // ── Branch 4 (0x0053b701): normal transition sweep. ── + if ((oi.State & (ObjectInfoState.Contact | ObjectInfoState.OnWalkable)) != 0) + { + // 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; + } + + /// + /// Retail CCylSphere::collides_with_sphere (0x0053a880, + /// pc:323943): XY overlap against radsum, then Z-band overlap against the + /// cylinder's [0, height] extent. = sphere center + /// − cylinder base point. + /// + 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; + } + + /// + /// Retail CCylSphere::normal_of_collision (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 . + /// + 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; + } + + /// + /// Retail AC1Legacy::Vector3::normalize_check_small: returns true + /// (degenerate — caller yields Collided) when |v| < F_EPSILON, else + /// normalizes in place. + /// + private static bool NormalizeCheckSmall(ref Vector3 v) + { + float mag = v.Length(); + if (mag < PhysicsGlobals.EPSILON) + return true; + v /= mag; + return false; + } + + /// + /// Retail CCylSphere::step_sphere_up (0x0053b310, pc:324516). + /// Too-tall cylinders slide; otherwise the generic step-up + /// ( = CTransition::step_up) runs — its internal + /// step-down probe lands on the cylinder top via + /// (branch 2), which is what makes + /// stepping ONTO a wide cylinder possible. + /// + 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; - // XY distance from sphere check position to cylinder axis. - 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; + return sp.StepUpSlide(this); + } - if (distSqCheck >= combinedRSq) - return TransitionState.OK; // not overlapping at check position + /// + /// Retail CCylSphere::step_sphere_down (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). + /// + private TransitionState CylStepSphereDown(ShadowEntry obj, SpherePath sp, + float cylHeight, Vector3 disp0, float radsum) + { + var s0 = sp.GlobalSphere[0]; - // ─── Overlap detected ───────────────────────────────────── - // Horizontal outward normal from the cylinder axis to the sphere - // 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) + bool hit = CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius); + if (!hit && sp.NumSphere > 1) { - // Sphere center on cylinder axis — push along reverse movement. - float mxy = MathF.Sqrt(sphMovement.X * sphMovement.X + sphMovement.Y * sphMovement.Y); - if (mxy > PhysicsGlobals.EPSILON) - collisionNormal = new Vector3(-sphMovement.X / mxy, -sphMovement.Y / mxy, 0f); - else - collisionNormal = Vector3.UnitX; + Vector3 disp1 = sp.GlobalSphere[1].Origin - obj.Position; + hit = CylCollidesWithSphere(disp1, radsum, cylHeight, sp.GlobalSphere[1].Radius); } - 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; + } + + /// + /// Retail CCylSphere::slide_sphere (0x0053b2a0, pc:324502): + /// normal_of_collision → CSphere::slide_sphere (our + /// ) with the sliding sphere's own curr center. + /// + 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); + } + + /// + /// Retail CCylSphere::land_on_cylinder (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. + /// + 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; + } + + /// + /// Retail CCylSphere::collide_with_point (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). + /// + 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 - // Contact-grounded movers. acclient_2013_pseudo_c.txt:324516-324538. - // - // Retail check: step_up_height must clear (sphere.radius + cyl.height - // - offset.z) where offset.z is sphere center Z minus cyl low_pt Z. - // Geometrically: the height we need to lift the sphere to clear the - // cyl's top, less the sphere center's current height above the cyl - // 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; + // Retail reads global_curr_center[0] even for the head hit + // (0x0053ad26; ACE agrees) — verbatim. + Vector3 globCenter = sp.GlobalCurrCenter[0].Origin; + // Block offset = 0 (continuous world frame; see branch 5 note). + Vector3 movement = checkSphere.Origin - globCenter; + Vector3 oldDisp = globCenter - obj.Position; + float radsumEps = radsum + PhysicsGlobals.EPSILON; - 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 - // step_up_height, then step-down for walkable surface). - // 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; + n = new Vector3(0f, 0f, -1f); + time = (movement.Z + checkSphere.Radius) / movement.Z * -1f; } else { - float pushDist = (minDist - sDist); - slidPos.X += (sdx / sDist) * pushDist; - slidPos.Y += (sdy / sDist) * pushDist; + n = new Vector3(0f, 0f, 1f); + time = (checkSphere.Radius + cylHeight - movement.Z) / movement.Z; } + 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) - Vector3 delta = slidPos - sphereCheckPos; - sp.AddOffsetToCheckPos(delta); + if (n.Z != 0f) + { + if (MathF.Abs(movement.Z) < PhysicsGlobals.EPSILON) + return TransitionState.Collided; - ci.SetCollisionNormal(collisionNormal); - ci.SetSlidingNormal(collisionNormal); - return TransitionState.Slid; + time = movement.Z > 0f + ? -((oldDisp.Z + checkSphere.Radius) / movement.Z) + : (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) => SlideSphere(collisionNormal, currPos); - private TransitionState SlideSphere(Vector3 collisionNormal, Vector3 currPos) + /// Which path sphere is sliding (retail + /// CSphere::slide_sphere's this is the sphere instance — the head + /// sphere slides by its OWN displacement, 0x0053b843 passes + /// global_sphere[1]). Default 0 preserves every existing call site. + private TransitionState SlideSphere(Vector3 collisionNormal, Vector3 currPos, int sphereNum = 0) { var sp = SpherePath; var ci = CollisionInfo; @@ -3364,7 +3671,7 @@ public sealed class Transition // Degenerate case: zero collision normal — nudge halfway. 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); return TransitionState.Adjusted; } @@ -3374,7 +3681,7 @@ public sealed class Transition // gDelta: displacement from currPos to the current check sphere center. // In the retail code this includes a block offset for cross-landblock // 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). System.Numerics.Plane contactPlane; @@ -3438,15 +3745,25 @@ public sealed class Transition return TransitionState.Slid; } - // Opposing normals: give up, reverse direction. - // Retail returns OK here to allow retry with the reversed normal. + // Opposing normals (collision normal anti-parallel to the contact + // 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; if (reversed.LengthSquared() > PhysicsGlobals.EpsilonSq) { reversed = Vector3.Normalize(reversed); ci.SetCollisionNormal(reversed); } - return TransitionState.OK; + return TransitionState.Collided; } // ----------------------------------------------------------------------- diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index b265cb1c..7142e61e 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -271,6 +271,33 @@ public static class RenderingDiagnostics public static bool ProbeLightEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1"; + /// + /// A7.L1 (2026-07-06) per-cell light SET-COMPOSITION probe — the apparatus the + /// [light] counts could not provide (the #176/#177 discriminator: the bug + /// lived in set MEMBERSHIP, not counts). When true, the scoped + /// LightManager.BuildPointLightSnapshot emits ONE rate-limited + /// [indoor-light] line describing the visible-cell-scoped point-light pool + /// (see ): + /// + /// [indoor-light] visibleCells=<N> pool=<M> cellLess=<K> registered=<R> + /// droppedNonVisible=<R-M> byCell=[0x<id>:<count>,...] + /// + /// This validates the A7 fix's load-bearing assumption end-to-end: + /// + /// cellLess==pool (every pool light is CellId 0) ⇒ + /// cell tagging FAILED (ParentCellId not flowing) — scoping is a silent no-op. + /// pool==cellLess while registered is large in a + /// LIT room ⇒ tagged CellIds never match the visible set (wrong id form) — the + /// room would go dark. + /// droppedNonVisible>0 with byCell tracking the + /// visible rooms ⇒ scoping WORKING (the under-room/through-floor lights are the + /// dropped ones). + /// + /// Output-only, inert when off. Initial state from ACDREAM_PROBE_INDOOR_LIGHT=1. + /// + 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 // 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 @@ -451,6 +478,66 @@ public static class RenderingDiagnostics } } + // Wall-clock rate-limit gate for EmitIndoorLight (shares the 1 s interval). + private static long _lastIndoorLightEmitTicks; + + /// + /// A7.L1 — emit ONE rate-limited [indoor-light] line describing the + /// visible-cell-scoped point-light pool: the SET COMPOSITION the [light] + /// counts can't show. Cheap no-op when is + /// false; otherwise fires at most once per second. Called from the scoped + /// LightManager.BuildPointLightSnapshot (visibleCells != null path). + /// + /// Size of the portal-flood visible-cell set this frame. + /// Every registered light (LightManager._all). + /// The visible-cell-scoped point-light pool just built. + public static void EmitIndoorLight(int visibleCellCount, + IReadOnlyList allRegistered, + IReadOnlyList 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(); + 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 = Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1"; diff --git a/tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs b/tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs new file mode 100644 index 00000000..0bc52627 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs @@ -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; + +/// +/// #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). +/// +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 Flood( + Dictionary cells, uint rootId, Vector3 eye, Vector3 gazeDir) + { + Func 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(frame.OrderedVisibleCells); + result.Sort(); + return result; + } + + private static string CellSetString(IEnumerable 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? 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? prevSet = null; + float prevYaw = 0f; + var perYawSets = new List<(float yaw, List 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}"); + } + + /// + /// THE production lag-window scenario (from launch-137-gate2.log + /// [cell-transit] lines): membership transits fire 0.1–0.6 m PAST the + /// portal plane in the travel direction (016E→017A at x=85.33–85.47 vs + /// the plane at x=85.00; 0182→0183 at 98.56–98.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. + /// + [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? 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}"); + } +} diff --git a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs index e5146d20..1a32b33d 100644 --- a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs +++ b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs @@ -122,7 +122,9 @@ public class PlayerMoveToCutoverTests // the A9 pending_motions node (a null sink orphans it and the // MoveToManager wait-for-anims gate never opens — the live stall). 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.CheckForCompletedMotions = seq.Manager.CheckForCompletedMotions; controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); diff --git a/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs b/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs index 3198da4a..aa80104e 100644 --- a/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs +++ b/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs @@ -103,7 +103,9 @@ public class W6EdgeDrivenMovementTests // dispatch, else its A9 pending_motions node is orphaned). controller.Motion.DefaultSink = new MotionTableDispatchSink(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.CheckForCompletedMotions = s.Manager.CheckForCompletedMotions; controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); diff --git a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs index 264c498c..139e0d7a 100644 --- a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs +++ b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs @@ -6,7 +6,7 @@ namespace AcDream.Core.Tests.Lighting; 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 { Kind = LightKind.Point, @@ -14,6 +14,7 @@ public sealed class LightManagerTests Range = range, IsLit = lit, OwnerId = ownerId, + CellId = cellId, }; [Fact] @@ -176,6 +177,55 @@ public sealed class LightManagerTests 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 { 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 { 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] public void SelectForObject_EmptySnapshot_ReturnsZero() { @@ -256,4 +306,69 @@ public sealed class LightManagerTests Assert.Equal(na, nb); Assert.Equal(a[0], b[0]); } + + /// + /// #176/#177 (2026-07-06) — the end-state pin, via the SHIPPED fix (visible-cell + /// scoping, not "uncap"). Before: BuildPointLightSnapshot kept only the + /// MaxGlobalLights 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 (CObjCell::add_*_to_global_lights over + /// CEnvCell::visible_cell_table), 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 docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md. + /// + [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 { targetRoom }; + Span 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 snapshot, + Span indices, int count, LightSource target) + { + for (int i = 0; i < count; i++) + if (ReferenceEquals(snapshot[indices[i]], target)) return true; + return false; + } + } } diff --git a/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs b/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs index c2474e4e..be9b279a 100644 --- a/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs @@ -699,8 +699,13 @@ public class BSPQueryTests // 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 // reject, pos_hits_sphere returns 1, Path 5 takes the `if (hit0)` - // branch. With engine=null we fall through to the slide fallback - // (SetCollisionNormal + SetSlidingNormal + return Slid). + // branch. With engine=null we fall through to the real slide + // (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 transition = new Transition(); @@ -731,6 +736,9 @@ public class BSPQueryTests Assert.Equal(TransitionState.Slid, state); Assert.True(transition.CollisionInfo.CollisionNormalValid, "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, "Full hit should NOT also fire NegPolyHit — that's the near-miss " + "path only. Retail at acclient_2013_pseudo_c.txt:0053a647 returns " + diff --git a/tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs b/tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs new file mode 100644 index 00000000..57f405a4 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs @@ -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; + +/// +/// Conformance tests for the retail CCylSphere collision family port +/// (2026-07-05) — dispatcher 0x0053b440 + step_sphere_down +/// 0x0053a9b0 + step_sphere_up 0x0053b310 + +/// land_on_cylinder 0x0053b3d0. Pseudocode: +/// docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md. +/// +/// +/// 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. +/// +/// +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; + + /// + /// 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. + /// + [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"); + } + + /// + /// 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). + /// + [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}"); + } + + /// + /// 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. + /// + [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}"); + } + + /// + /// 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. + /// + [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(), + portals: Array.Empty(), + 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, + }; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamInspectionTests.cs new file mode 100644 index 00000000..f901d4ca --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamInspectionTests.cs @@ -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; + +/// +/// #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? +/// +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(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(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}"); + } + } + } + + /// + /// Mechanism-1 follow-up (2026-07-06): being in the CellStruct's + /// PhysicsPolygons TABLE does not mean the physics BSP ever tests a + /// polygon — retail's BSPLEAF::sphere_intersects_poly (0x0053d580) + /// iterates the LEAF's in_polys index list (leaf construction + /// 0x0053d4a0: in_polys[i] = &pack_poly[index]), 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). + /// + [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(envCellId); + Assert.NotNull(envCell); + var environment = dats.Get(0x0D000000u | envCell!.EnvironmentId); + Assert.NotNull(environment); + Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs)); + + var portalPolyIds = new System.Collections.Generic.HashSet(); + 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(); + var portalPolyLeafHits = new System.Collections.Generic.List(); + 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(); + 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}"); + } + } + + /// + /// #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. + /// + [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(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}"); + } + + /// + /// #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. + /// + [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(cellId); + Assert.NotNull(envCell); + var environment = dats.Get(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(); + 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}"); + } + } + } + + /// + /// 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. + /// + [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 + { + 0x8A02016Eu, 0x8A02017Au, + }; + foreach (var seed in new[] { 0x8A02016Eu, 0x8A02017Au }) + { + var seedCell = dats.Get(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(cellId); + if (envCell is null) { _out.WriteLine($"cell 0x{cellId:X8}: NOT FOUND"); continue; } + var environment = dats.Get(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(); + 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)"); + } + + /// + /// 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| > 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. + /// + [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(cellId); + Assert.NotNull(envCell); + var environment = dats.Get(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(); + 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)"); + } + + /// + /// 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. + /// + [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(envCellId); + Assert.NotNull(envCell); + var environment = dats.Get(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}"); + } + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamReplayTests.cs new file mode 100644 index 00000000..3724db77 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamReplayTests.cs @@ -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; + +/// +/// #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`). +/// +/// +/// Dat facts pinned by : +/// 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 +/// (CSphere::slide_sphere 0x00537440 @0x0053762c) returns +/// COLLIDED_TS from that branch; our port returned OK — letting the step +/// complete with full advance and the synthetic normal persisted. +/// +/// +/// +/// 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). +/// +/// +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; + } + + /// + /// Hydrate the two seam cells + every portal-adjacent neighbor into a + /// PhysicsEngine, exactly as the streaming path does (CacheCellStruct + /// with the dat world transform). + /// + private static PhysicsEngine BuildCorridorEngine(DatCollection dats) + { + var engine = new PhysicsEngine(); + engine.DataCache = new PhysicsDataCache(); + + var toLoad = new System.Collections.Generic.HashSet { SeamCellWest, SeamCellEast }; + foreach (var seed in new[] { SeamCellWest, SeamCellEast }) + { + var seedCell = dats.Get(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(toLoad)) + { + var cell = dats.Get(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(cellId); + if (envCell is null) continue; + var environment = dats.Get(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); + + /// + /// 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. + /// + /// + /// 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. + /// + /// + [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."); + } + + /// + /// #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". + /// + [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)."); + } + } + + /// + /// 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. + /// + [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); + } + + /// + /// 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. + /// + [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; + } + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Issue137SlidingNormalLifecycleTests.cs b/tests/AcDream.Core.Tests/Physics/Issue137SlidingNormalLifecycleTests.cs new file mode 100644 index 00000000..98406131 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue137SlidingNormalLifecycleTests.cs @@ -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; + +/// +/// #137 mechanism 2 — the sliding-normal absorbing wedge (2026-07-06). +/// +/// +/// Retail's in-transition collision_info.sliding_normal has exactly ONE +/// writer besides the per-frame seed: CTransition::validate_transition +/// (0x0050ac21-ac30, "if collision_normal_valid → set_sliding_normal"). The +/// BSP collision layer NEVER writes it — BSPTREE::find_collisions' +/// Contact branch dispatches full hits to step_sphere_up (foot, +/// 0x0053a719) / BSPTREE::slide_sphere (head, 0x0053a697), and +/// CSphere::slide_sphere (0x00537440) slides IN-FRAME via +/// add_offset_to_check_pos 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 +/// (CPhysicsObj::SetPositionInternal 0x005154c2, SLIDING_TS bit-4 sync +/// at 0x005154e1) runs only on transition SUCCESS. +/// +/// +/// +/// 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). +/// +/// +public class Issue137SlidingNormalLifecycleTests +{ + // ========================================================================= + // Site-level pins — BSPQuery.FindCollisions Contact branch must not write + // the transition's sliding normal (retail: only validate_transition does). + // ========================================================================= + + /// + /// 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. + /// + /// + /// Retail: a blocked step-up funnels to SPHEREPATH::step_up_slide → + /// CSphere::slide_sphere (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 + /// (< F_EPSILON), and slide_sphere returns COLLIDED_TS (0x00537735). + /// + /// + [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); + } + + /// + /// Contact HEAD-sphere FULL HIT must dispatch BSPTREE::slide_sphere + /// (retail 0x0053a697; ACE BSPTree.cs:202 → 310-316: the real + /// Sphere.SlideSphere 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. + /// + [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(); + + 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); + } + + /// + /// CSphere::slide_sphere'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 COLLIDED_TS — retail 0x00537440 + /// @0x005375d7-0x0053762c: *normal = −gDelta; normalize; + /// set_collision_normal; return 2. 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). + /// + [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(), Array.Empty(), + 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); + + /// + /// 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). + /// + [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); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Issue175HubDoorPoseInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/Issue175HubDoorPoseInspectionTests.cs new file mode 100644 index 00000000..6cd9bf4f --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue175HubDoorPoseInspectionTests.cs @@ -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; + +/// +/// #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. +/// +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(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(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(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(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 ==="); + } + } + + /// + /// #175 derivation conformance — REAL-DAT pin for + /// . + /// The first cut of the derivation looked up Cycles[DefaultStyle] + /// with the bare style word; the dictionary is keyed by the COMBINED + /// (style << 16) | substate 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. + /// + [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(0x09000001u); + Assert.NotNull(mt); + + var pose = AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames( + mt!, id => dats.Get(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; + } + + /// + /// With a motion-table pose override, the BSP part shapes must use it — + /// the closed pose, not the ajar placement pose (the #175 offset). + /// + [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); + } + + /// + /// Null override (no motion table) keeps the pre-#175 placement-frame + /// behavior — landblock statics and table-less entities unchanged. + /// + [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); + } + + /// + /// A short override (fewer frames than parts) falls back to placement + /// frames — a mismatched motion table must not misplace collision. + /// + [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 + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Issue176177SeamTransitLagTests.cs b/tests/AcDream.Core.Tests/Physics/Issue176177SeamTransitLagTests.cs new file mode 100644 index 00000000..33bf9952 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue176177SeamTransitLagTests.cs @@ -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; + +/// +/// #176/#177 membership half: production [cell-transit] lines +/// (launch-137-gate2.log) fire 0.1–0.6 m PAST the portal plane in the travel +/// direction (016E→017A at x=85.33–85.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. +/// +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 { SeamCellWest, SeamCellEast }; + for (int ring = 0; ring < 3; ring++) + { + foreach (var known in new System.Collections.Generic.List(toLoad)) + { + var cell = dats.Get(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(cellId); + if (envCell is null) continue; + var environment = dats.Get(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; + } + + /// + /// 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. + /// + [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. + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/Issue174LinkStripDrainTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/Issue174LinkStripDrainTests.cs new file mode 100644 index 00000000..dadba9a3 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/Issue174LinkStripDrainTests.cs @@ -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; + + /// + /// 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. + /// + [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); + } + + /// + /// 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. + /// + [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"); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs index 1c113938..a1480435 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs @@ -174,7 +174,10 @@ internal sealed class RemoteChaseHarness TurnStopped = () => ObservedOmega = Vector3.Zero, }; 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.CheckForCompletedMotions = Seq.Manager.CheckForCompletedMotions; diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs index 26664ad9..b61d4329 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs @@ -487,9 +487,20 @@ public class PhysicsEngineTests collisionType: ShadowCollisionType.Cylinder, cylHeight: 1.835f); - // Without the gate (movingEntityId == 0): the sweep must self-push. - // This proves the registry actually causes a collision, so the - // following filtered case is not a vacuous pass. + // Without the gate (movingEntityId == 0): the sweep must be + // INTERFERED WITH by the self-entry. This proves the registry + // 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( currentPos: bodyPos, targetPos: targetPos, cellId: 0xA9B40039u, @@ -498,11 +509,11 @@ public class PhysicsEngineTests isOnGround: false, movingEntityId: 0u); - float unfilteredXY = MathF.Sqrt( - (unfiltered.Position.X - targetPos.X) * (unfiltered.Position.X - targetPos.X) + - (unfiltered.Position.Y - targetPos.Y) * (unfiltered.Position.Y - targetPos.Y)); - Assert.True(unfilteredXY > 0.5f, - $"Without movingEntityId, sweep should self-push (got XY drift {unfilteredXY:F3}m)"); + Assert.True(unfiltered.Position.Z < targetPos.Z - 0.01f, + $"Without movingEntityId, the sweep must collide with the mover's own " + + $"ShadowEntry and deny the +Z movement (retail: land_on_cylinder → " + + $"Collide re-test → COLLIDED → stay-put). Got Z={unfiltered.Position.Z:F4}, " + + $"target Z={targetPos.Z:F4}"); // With the gate: the sweep must leave XY unchanged. var filtered = engine.ResolveWithTransition( diff --git a/tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs b/tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs new file mode 100644 index 00000000..e861f5fc --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs @@ -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; + +/// +/// #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. +/// +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(cellId); + if (envCell is null) return null; + var environment = dats.Get(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 WorldVerts( + DatReaderWriter.Types.CellStruct cs, DatReaderWriter.Types.Polygon poly, Matrix4x4 world) + { + var result = new List(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; + } + + /// + /// Mirror of CellMesh.Build's inclusion rules (verts ≥ 3, no NoPos + /// stippling, PosSurface index in range) — the DRAWN verdict per poly. + /// + 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; + + /// + /// (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. + /// + [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(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(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"); + } + + /// + /// (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. + /// + [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)" : "")); + } + } + } + + /// + /// #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. + /// + [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 { 0x8A020165u, 0x8A02016Eu, 0x8A02017Au }; + foreach (var seed in new List(cellIds)) + { + var seedCell = dats.Get(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}"); + } + + /// + /// #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. + /// + [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(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((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((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(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(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)")); + } + } + + /// + /// #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. + /// + [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(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}"); + } + } + + /// + /// The transit-lag question (#176/#177 root-cause fork): production + /// [cell-transit] lines fire 0.1–0.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. + /// + [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); + } + + /// + /// (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? + /// + [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(); // 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}]"); + } + } +}