Merge branch 'main' into claude/eloquent-hugle-42119e
# Conflicts: # .gitignore
This commit is contained in:
commit
093cdb6d57
38 changed files with 5852 additions and 216 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -95,3 +95,7 @@ studio-shots/
|
||||||
# MP1b acdream-bake output — user-machine artifact, never committed
|
# MP1b acdream-bake output — user-machine artifact, never committed
|
||||||
# (docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5).
|
# (docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5).
|
||||||
*.pak
|
*.pak
|
||||||
|
|
||||||
|
# session-local physics capture artifacts (worktree root)
|
||||||
|
/resolve-*.jsonl
|
||||||
|
/launch-*.log
|
||||||
|
|
|
||||||
573
docs/ISSUES.md
573
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)
|
## #171 — Group melee: monsters interpenetrate + facing drifts (sticky melee unbound, arrival radii = 0)
|
||||||
|
|
||||||
**Status:** DONE (2026-07-04) — **user visual gate PASSED** ("Looks good, ship it")
|
**Status:** DONE (2026-07-04) — **user visual gate PASSED** ("Looks good, ship it")
|
||||||
|
|
@ -929,15 +1334,165 @@ walls** in particular. (Symptoms not fully characterized yet: likely walking thr
|
||||||
openings that should block / blocking at openings that should pass, and door collision not
|
openings that should block / blocking at openings that should pass, and door collision not
|
||||||
matching the door's open/closed state.)
|
matching the door's open/closed state.)
|
||||||
|
|
||||||
**Root cause / status (to investigate):** dungeon collision is EnvCell-based — the cell's
|
**✅ CORRIDOR GATE PASSED 2026-07-06 evening (user: "not collision anymore.
|
||||||
collision BSP + portal openings + per-cell static objects (doors). Candidates: door
|
Good.")** — the corridor phantom arc (mechanisms 1–3) is user-verified
|
||||||
apparatus collision in EnvCells (open/closed BSP swap) not fully ported; portal-opening
|
FIXED. REMAINING #137 scope from the same gate session:
|
||||||
(wall gap) collision geometry handled differently from buildings; the per-cell
|
- **Window/opening climb FIXED + GATE PASSED 2026-07-06 (user: "Looks
|
||||||
shadow-object registration (A6.P4, see the physics digest) for dungeon EnvCell statics.
|
good", incl. the taller-capsule regression sweep — doorways/seams/stairs
|
||||||
Related families: #32 (edge-slide), #116 (slide-response), the door-collision saga
|
clean): the player's collision capsule TOPPED OUT AT 1.2 m.** The callers passed
|
||||||
(see `feedback_dedup_keys_after_cardinality_change`, `feedback_retail_per_cell_shadow_list`).
|
`sphereHeight: 1.2f` and `InitPath` places the head sphere center at
|
||||||
Needs a targeted repro (which door / which opening, expected vs actual) before fixing —
|
`height − radius` = 0.72 — the top 0.63 m of a 1.83 m character had NO
|
||||||
oracle-first per the physics digest.
|
collision. The dat human Setup 0x02000001 (dumped in
|
||||||
|
`HumanSetup_CollisionSpheres_DatTruth`): spheres `(0,0,0.475) r=0.48` +
|
||||||
|
`(0,0,1.350) r=0.48` (top 1.83 = Setup.Height 1.835); retail collides
|
||||||
|
with that list verbatim (`CPhysicsObj::transition` 0x00512dc0 →
|
||||||
|
`init_sphere(GetNumSphere, GetSphere, scale)`). At the corridor-end
|
||||||
|
window alcove (0x8A020179 → 0x8A02017E: sill face 0.70 m, opening 1.3 m
|
||||||
|
tall, sloped funnel behind — full-vertex dump in
|
||||||
|
`WindowShaft_FullPolyDump`), the missing head let the step-up's
|
||||||
|
placement pass and the player climbed in head-through-lintel. Fix: both
|
||||||
|
live callers now pass 1.835 (capsule top; head center 1.355 ≈ dat
|
||||||
|
1.350); register TS-46 documents the residual 5 mm scalar
|
||||||
|
approximation. Pins: `WindowOpening_HeadCannotFit_EntryBlocked` (walked
|
||||||
|
approach wall-slides and never enters 0x8A02017E) +
|
||||||
|
`WindowAlcove_RaisedPlacement_HeadInLintelSolid_Collides` (the raised
|
||||||
|
placement rejects on the head-vs-lintel). Captured-input replay
|
||||||
|
fixtures keep their recorded 1.2 inputs — InitPath unchanged.
|
||||||
|
- Doors half (block/pass per open state) — unchanged.
|
||||||
|
Two RENDER issues also observed at the gate (filed separately below as
|
||||||
|
#176/#177): the purple floor flashing at seams is angle/camera-dependent
|
||||||
|
(the floor IS a portal polygon to the under-room — likely the portal
|
||||||
|
surface drawn under some culling state), and a stairs pop-in/out between
|
||||||
|
levels (the #119 visibility class, dungeon edition).
|
||||||
|
|
||||||
|
**SEAM SHAKE FIXED (same day): the stale `footCenter` in `CheckOtherCells`'
|
||||||
|
per-cell loop** — the P2 cellar-lip lesson one loop deeper. A mid-loop
|
||||||
|
other-cell query can MOVE the sphere (the boundary full-hit dispatches
|
||||||
|
step_sphere_up; the successful climb lifts the foot +0.6 mm and returns
|
||||||
|
OK), and the remaining cells were then queried with the by-value
|
||||||
|
pre-climb center — 0.4 mm inside the floor slab, grazing the under-room's
|
||||||
|
ceiling and firing the chain below. Retail's `check_other_cells` reads the
|
||||||
|
LIVE `sphere_path.global_sphere` per cell (pc:272717+). Fix: re-read
|
||||||
|
`footCenter = sp.GlobalSphere[0].Origin` per iteration. All three
|
||||||
|
`Issue137CorridorSeamReplayTests` repros un-skipped and GREEN; full suites
|
||||||
|
green. Visual gate pending. (The step 3 "restore clobbers CheckPos" wording
|
||||||
|
below was the right CLASS but the wrong site — CheckPos was fine; the
|
||||||
|
stale copy was the loop's captured parameter.)
|
||||||
|
|
||||||
|
**GATE 2026-07-06 FAILED — THIRD MECHANISM CHARACTERIZED (the seam shake),
|
||||||
|
deterministic offline repro secured:** with mechanisms 1+2 fixed the dead
|
||||||
|
stop became a SHAKE at cell seams (+ purple floor flashing there — almost
|
||||||
|
certainly the render exposing the same per-frame position/OnWalkable
|
||||||
|
oscillation; re-check after the physics fix). Full chain, every link
|
||||||
|
probe-traced (`launch-137-seam-probes.log`, capture
|
||||||
|
`resolve-137-seam-capture.jsonl` tick 4101 ×46):
|
||||||
|
1. Corridor cells sit above under-rooms; the shared floor slab is
|
||||||
|
double-faced (up-face + underside as separate physics polys) and IS a
|
||||||
|
portal plane (e.g. 0x8A020165's ramp over 0x8A020166). The resting foot
|
||||||
|
sphere is permanently within ±0.5 mm of THREE thresholds there (poly-hit
|
||||||
|
r−ε, walkable r−ε, portal-straddle r+ε).
|
||||||
|
2. Walking across the boundary at the flat-floor height penetrates the
|
||||||
|
ramp slab by ~0.4 mm → foot full-hit on the up-face → StepSphereUp →
|
||||||
|
step-down accepts the ramp (+0.6 mm lift, CheckPos −5.999,
|
||||||
|
`[stepsphereup] stepped=True`).
|
||||||
|
3. **THE BUG: the lifted position is then LOST** — the next pass runs at
|
||||||
|
the UNLIFTED height (GlobalSphere center −5.520 vs the lifted −5.519;
|
||||||
|
the P2 stale-snapshot class, single-slot Save/RestoreCheckPos clobber
|
||||||
|
suspected — retail `CTransition::step_up` 0x0050b6cc restores ONLY on
|
||||||
|
failure) → the re-test at 0.4 mm inside the slab grazes the NEIGHBOR
|
||||||
|
under-room's CEILING (the slab underside, n≈(−0.03,0,−1)) within the
|
||||||
|
near-miss window → recorded (retail records it too — pos_hits_sphere
|
||||||
|
registers geometric hits pre-cull) → neg-poly step-up dispatch with the
|
||||||
|
DOWNWARD normal → the nested step-down finds no walkable at exact
|
||||||
|
tangency → StepUpSlide → slide_sphere(down normal vs up contact plane)
|
||||||
|
→ the opposing branch → reversed-movement collision normal → Collided →
|
||||||
|
validate revert (Contact/OnWalkable stripped) → next step's AdjustOffset
|
||||||
|
zeroes → out==in every frame = the shake. Retail never enters at step 3:
|
||||||
|
its kept step-up lift leaves the sphere ON the surface, no graze.
|
||||||
|
4. Offline repro: `Issue137CorridorSeamReplayTests` (3 tests, currently
|
||||||
|
`Skip="#137 seam shake"`) reproduce the block deterministically — the
|
||||||
|
key was hydrating THREE portal rings (the under-room 0x8A020166 is
|
||||||
|
ring-3; with fewer rings the flood can't add it and everything passes).
|
||||||
|
NEXT: read our `TransitionalInsert` attempt loop against retail
|
||||||
|
0x0050b6f0 to find the restore that clobbers the successful step-up's
|
||||||
|
position; fix; un-skip the three tests.
|
||||||
|
|
||||||
|
**CORRIDOR PHANTOM mechanisms 1+2 FIXED 2026-07-06 (see
|
||||||
|
`docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md`
|
||||||
|
for the full audit):** mechanism 2 = BSPQuery Contact-branch stub slide
|
||||||
|
responses leaked sliding normals retail's BSP layer never writes (fixed:
|
||||||
|
real `slide_sphere` routing + success-gated body writeback). Mechanism 1 as
|
||||||
|
theorized is REFUTED: the recorded wall normal `(−1.00,0.03,−0.03)` matches
|
||||||
|
NO dat polygon (world-space sweep of both seam cells + all portal-adjacent
|
||||||
|
neighbors) — it is the SYNTHETIC negated movement direction from
|
||||||
|
`slide_sphere`'s opposing-normals branch, which our port let survive by
|
||||||
|
returning OK where retail returns COLLIDED_TS (0x0053762c; second fix). The
|
||||||
|
PortalSide polys to 0x011E were a red herring: cell 0x8A02016E has IDENTITY
|
||||||
|
rotation, the polys are ±Y planes perpendicular to the run (directionally
|
||||||
|
culled), retail's physics-BSP leaves reference them too, and the dat's
|
||||||
|
keep-PortalSide/strip-ExactMatch asymmetry reads as intentional (solid
|
||||||
|
window/grate-class portals) — NO portal-poly filter needed, no cdb session
|
||||||
|
needed for this repro. Dat-backed replay
|
||||||
|
(`Issue137CorridorSeamReplayTests`) reproduces the live frame exactly and
|
||||||
|
runs the corridor clean. The issue's DOOR half remains open.
|
||||||
|
|
||||||
|
**CHARACTERIZED 2026-07-05 (Facility Hub corridor repro, probe + dat evidence)
|
||||||
|
— two stacked mechanisms (historical; see the 2026-07-06 resolution above):**
|
||||||
|
1. **PortalSide portal polygons are IN the physics polygon set and we treat
|
||||||
|
them as solid.** Live: running the corridor, the seam crossing
|
||||||
|
`0x8A02016E → 0x8A02017A` (x≈85.25) records a wall hit with normal
|
||||||
|
(−1,0,0) — straight against the movement (`launch-175-verify2.log:42858`).
|
||||||
|
Dat (`Issue137CorridorSeamInspectionTests`): cell 0x8A02016E's portals to
|
||||||
|
0x011E (polys 1/3/5, flags=**PortalSide**, no ExactMatch) are PRESENT in
|
||||||
|
`CellStruct.PhysicsPolygons` — every ExactMatch portal in the same cell is
|
||||||
|
absent from the physics set. The cell's rotation maps those local ±Y portal
|
||||||
|
planes to world ±X — the phantom mid-corridor wall. Retail must honor the
|
||||||
|
portal's SIDE (pass from one side / solid from the other, or pass when the
|
||||||
|
neighbor is loaded); we collide with the raw polygon unconditionally.
|
||||||
|
**Oracle findings so far (2026-07-05 evening — greps done, question
|
||||||
|
OPEN):** `CCellStruct::UnPack` (0x00533d00) loads physics_polygons +
|
||||||
|
physics_bsp verbatim — NO portal-poly stripping at load;
|
||||||
|
`CPolygon::pos_hits_sphere`/`hits_sphere`/`polygon_hits_sphere_slow_but_sure`
|
||||||
|
(0x005394f0/0x00539540/0x00538a10) are pure geometry — no portal check;
|
||||||
|
`CCellPortal` (0x0053bab0) carries portal→CPolygon ptr + portal_side +
|
||||||
|
exact_match but nothing in the BSP test chain consults it. So retail's
|
||||||
|
passability for a PortalSide physics poly is NOT a load filter and NOT a
|
||||||
|
poly-level flag — remaining candidates: the transit/membership order
|
||||||
|
makes the sphere test the NEIGHBOR cell first (never hitting the portal
|
||||||
|
poly from the passable side), or a sidedness interaction
|
||||||
|
(stippling=NoPos + approach direction). NEXT: cdb-attach retail at this
|
||||||
|
exact corridor (0x8A02016E→011E portals) per the CLAUDE.md step −1
|
||||||
|
protocol — the decomp alone hasn't settled it.
|
||||||
|
2. **The stale sliding normal then wedges all forward motion** (the #116
|
||||||
|
slide-response family): after the single seam hit, EVERY subsequent
|
||||||
|
forward resolve returns `ok=False hit=no` with zero advance — the
|
||||||
|
body-persisted SlidingNormal (−1,0,0) projects the +X offset to exactly
|
||||||
|
zero in AdjustOffset, aborting at step 0 BEFORE any collision test could
|
||||||
|
update the state — an ABSORBING wedge escaped only by strafing ("push
|
||||||
|
through on the side"). Retail re-derives slide state per frame
|
||||||
|
(get_object_info pc:279992 governs only the NEXT frame — #116 notes);
|
||||||
|
audit who clears the body's sliding normal when no contact recurs.
|
||||||
|
|
||||||
|
**MECHANISM 2 FIXED 2026-07-06 (audit complete — full lifecycle in
|
||||||
|
`docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md`):**
|
||||||
|
retail's ONLY in-transition sliding-normal writer is
|
||||||
|
`validate_transition` (0x0050ac21); the BSP/sphere layer never writes it,
|
||||||
|
and the body persistence (`SetPositionInternal` 0x005154c2/0x005154e1)
|
||||||
|
is success-only. Our BSPQuery Contact-branch full-hit responses were
|
||||||
|
STUBS (`SetSlidingNormal + return Slid`) where retail dispatches the
|
||||||
|
real `slide_sphere` — the seam hit (a SUCCESSFUL full-advance resolve,
|
||||||
|
`ok=True` in the log, not a failed one) leaked the phantom wall's normal
|
||||||
|
into the body, and the seed absorbed every later forward push. Fix:
|
||||||
|
both stub sites now route through the real
|
||||||
|
`Transition.SlideSphereInternal` (`CSphere::slide_sphere` 0x00537440,
|
||||||
|
in-frame, no sliding write) and the body writeback is gated on
|
||||||
|
transition success. Pins: `Issue137SlidingNormalLifecycleTests` (2 site
|
||||||
|
pins + the persist/absorb/clear wall lifecycle). Register: TS-4 amended
|
||||||
|
(steep-tangent sites still write the normal — documented), TS-45 added
|
||||||
|
(`SphereCollision`'s write — same class, out of blast radius). The
|
||||||
|
absorbed exactly-anti-parallel frame at a REAL wall is retail-faithful
|
||||||
|
(the persisted normal is a "still pressed" cache); only the phantom
|
||||||
|
PROVENANCE was the bug. Corridor re-test rides the mechanism-1 session.
|
||||||
|
|
||||||
**Files:** `src/AcDream.Core/Physics/` (EnvCell collision, CellTransit, the door apparatus),
|
**Files:** `src/AcDream.Core/Physics/` (EnvCell collision, CellTransit, the door apparatus),
|
||||||
`src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (per-cell registration). See
|
`src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (per-cell registration). See
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| AD-22 | Async streamed mesh loading with point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's per-frame meshMissing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:211` | Documented convergence argument: the self-heal makes absence transient, converging the async pipeline to retail's never-absent guarantee | A missing mesh referenced OUTSIDE the dispatcher's walk (a future consumer not touching meshMissing) stays permanently invisible — the #119/#128 broken-stairs class; best case, late pop-in | retail synchronous content load (note at WbMeshAdapter.cs:211) |
|
| AD-22 | Async streamed mesh loading with point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's per-frame meshMissing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:211` | Documented convergence argument: the self-heal makes absence transient, converging the async pipeline to retail's never-absent guarantee | A missing mesh referenced OUTSIDE the dispatcher's walk (a future consumer not touching meshMissing) stays permanently invisible — the #119/#128 broken-stairs class; best case, late pop-in | retail synchronous content load (note at WbMeshAdapter.cs:211) |
|
||||||
| AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal |
|
| AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal |
|
||||||
| AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) |
|
| AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) |
|
||||||
| AD-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding | `src/AcDream.App/Input/PlayerMovementController.cs:874` | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral); at elasticity 0.05 retail's landing bounce is imperceptible; sledding reverts to retail rule | Landing-reflection-dependent behavior (slope-landing momentum, high-elasticity surfaces) won't reproduce; the suppression masks the landing-snap gate fragility and could outlive its reason | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |
|
| AD-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding. **2026-07-05 (#173): the same reflection + suppression now also runs in the remote DR sweep** (remote jumps hitting ceilings reflect like the local player; both sites share the rule and this row) | `src/AcDream.App/Input/PlayerMovementController.cs:874`; `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep post-resolve, #173 block) | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral — both the local and remote landing snaps use that gate); at elasticity 0.05 retail's landing bounce is imperceptible; sledding reverts to retail rule | Landing-reflection-dependent behavior (slope-landing momentum, high-elasticity surfaces) won't reproduce; the suppression masks the landing-snap gate fragility and could outlive its reason | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |
|
||||||
| AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Rendering/GameWindow.cs:12939` (MoveToComplete subscription) + `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete` seam doc) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract (never on cancel) | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) |
|
| AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Rendering/GameWindow.cs:12939` (MoveToComplete subscription) + `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete` seam doc) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract (never on cancel) | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) |
|
||||||
| AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 |
|
| AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 |
|
||||||
| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` |
|
| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` |
|
||||||
|
|
@ -99,7 +99,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Documented approximation (AP) — 73 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin)
|
## 3. Documented approximation (AP) — 73 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin; AP-6 retired 2026-07-05 — full CCylSphere family ported verbatim, residual AP-83 added same commit)
|
||||||
|
|
||||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
|
|
@ -108,7 +108,6 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| AP-3 | Step-down chain triggered only when contact is invalid OR steeper than walkable; retail's `transitional_insert` OK-path ALWAYS runs it | `src/AcDream.Core/Physics/TransitionTypes.cs:1197` | Conditional preserves the observed-to-matter cases (edge departure, steep cliff-slide) without running the chain every step (per pc:273191 agent reports) | Steps where retail runs step-down despite a valid walkable contact (bump maintenance, edge-slide arming) are skipped — float-off or missed edge slides in untested geometry | `transitional_insert` OK-path pc:273191 |
|
| AP-3 | Step-down chain triggered only when contact is invalid OR steeper than walkable; retail's `transitional_insert` OK-path ALWAYS runs it | `src/AcDream.Core/Physics/TransitionTypes.cs:1197` | Conditional preserves the observed-to-matter cases (edge departure, steep cliff-slide) without running the chain every step (per pc:273191 agent reports) | Steps where retail runs step-down despite a valid walkable contact (bump maintenance, edge-slide arming) are skipped — float-off or missed edge slides in untested geometry | `transitional_insert` OK-path pc:273191 |
|
||||||
| AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) |
|
| AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) |
|
||||||
| AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 |
|
| AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 |
|
||||||
| AP-6 | Analytic swept-sphere cylinder collision (XY overlap + step-over + wall-slide) instead of retail CylSphere functions via the 6-path dispatcher; A6.P6 step-over branch ports `step_sphere_up`'s clearance check | `src/AcDream.Core/Physics/TransitionTypes.cs:2601` | Claimed to match retail for the exercised cases (trunks, NPC bodies, door foot-colliders); step-over and step_up_slide fallback retro-fitted from retail when the door phantom surfaced | Unported branches (push direction, interpenetration resolution) differ from retail against cylinder entities — the phantom-collision / sticky-NPC family | `CCylSphere::step_sphere_up` pc:324516-324538 |
|
|
||||||
| AP-7 | `calc_friction` threshold 0.0 with retail's state gate missing; retail uses 0.25 gated by an undecoded state check | `src/AcDream.Core/Physics/PhysicsBody.cs:307` | Bumping the threshold without the gate hammered normal walking (3 → 0.16 m/s); as-read 0.0 kept; locomotion probably state-exempted in retail. Filed L.3c-followup | Friction engages under different conditions — post-landing slides, knockback decay, sledding speeds mismatch retail's deceleration | pc:276702-276705 (state gate + 0.25) |
|
| AP-7 | `calc_friction` threshold 0.0 with retail's state gate missing; retail uses 0.25 gated by an undecoded state check | `src/AcDream.Core/Physics/PhysicsBody.cs:307` | Bumping the threshold without the gate hammered normal walking (3 → 0.16 m/s); as-read 0.0 kept; locomotion probably state-exempted in retail. Filed L.3c-followup | Friction engages under different conditions — post-landing slides, knockback decay, sledding speeds mismatch retail's deceleration | pc:276702-276705 (state gate + 0.25) |
|
||||||
| AP-10 | Dry-corner water depth: retail's 0.1 m allowed sink-in collapsed to 0 | `src/AcDream.Core/Physics/TerrainSurface.cs:481` | The 0.1 offset destabilizes the feet-exactly-on-plane contact-touch check (dist > EPSILON → SetContactPlane never fires → float/fall); retail's ~10 cm sink-in is visually indistinguishable | Masks a contact-touch epsilon fragility — other water-depth values exercising the same instability could oscillate shoreline walkable validation; retail's wet/dry corner sink-in visual absent | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port) |
|
| AP-10 | Dry-corner water depth: retail's 0.1 m allowed sink-in collapsed to 0 | `src/AcDream.Core/Physics/TerrainSurface.cs:481` | The 0.1 offset destabilizes the feet-exactly-on-plane contact-touch check (dist > EPSILON → SetContactPlane never fires → float/fall); retail's ~10 cm sink-in is visually indistinguishable | Masks a contact-touch epsilon fragility — other water-depth values exercising the same instability could oscillate shoreline walkable validation; retail's wet/dry corner sink-in visual absent | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port) |
|
||||||
| AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~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 |
|
| 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-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
|
||||||
| AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) |
|
| AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) |
|
||||||
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
|
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
|
||||||
|
| AP-85 | **⚠️ CORRECTED 2026-07-06: visible-cell scoping SHIPPED (`LightSource.CellId` from `entity.ParentCellId` + `BuildPointLightSnapshot(camPos, visibleCells)` over the portal-flood set; probe-proven to drop ~285 through-floor lights/frame in the Hub; end-state pin un-skipped). The visual gate REFUTED the light-cap #176/#177 attribution stated below — BOTH symptoms were UNCHANGED, so #176 = an intensity-100 purple point light `(0.784,0,0.784)` and #177 = a portal-visibility miss (see digest banner). Residual deviation now: 128 backstop vs retail 40+7 (0x0081ec94/8), no dynamic-priority split — benign (visible-scoped pool is 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)
|
## 4. Temporary stopgap (TS) — 39 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded)
|
||||||
|
|
||||||
|
|
@ -188,7 +190,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| TS-1 | PrecipiceSlide context missing — conservative stop-at-edge instead of retail's EdgeSlide → PrecipiceSlide / CliffSlide | `src/AcDream.Core/Physics/TransitionTypes.cs:1254` | Awaiting the next L.2c slice; a diagnostic records which ingredient (precipice context / steep plane / EdgeSlide flag) is missing | Player stops dead at precipice edges where retail slides along/over — visible mismatch at cliff and roof edges | retail EdgeSlide → PrecipiceSlide chain |
|
| TS-1 | PrecipiceSlide context missing — conservative stop-at-edge instead of retail's EdgeSlide → PrecipiceSlide / CliffSlide | `src/AcDream.Core/Physics/TransitionTypes.cs:1254` | Awaiting the next L.2c slice; a diagnostic records which ingredient (precipice context / steep plane / EdgeSlide flag) is missing | Player stops dead at precipice edges where retail slides along/over — visible mismatch at cliff and roof edges | retail EdgeSlide → PrecipiceSlide chain |
|
||||||
| TS-2 | `BspOnlyDispatch` reduces retail's `(HAS_PHYSICS_BSP_PS && !pvpTargetPlayer && !missileIgnore)` to the flag test alone (M1.5 scope: no PK, no missiles) | `src/AcDream.Core/Physics/TransitionTypes.cs:660` | Both omitted terms are genuinely false pre-M2; comment directs wiring them with PK (M2+) and missiles (F.3) | If PK or missiles land without the terms, flagged entities get BSP-only where retail tests cyl+sphere — pass-through / wrong blocking in PvP/missile interactions | `FindObjCollisions` pc:276861; HAS_PHYSICS_BSP_PS acclient.h:2833 |
|
| TS-2 | `BspOnlyDispatch` reduces retail's `(HAS_PHYSICS_BSP_PS && !pvpTargetPlayer && !missileIgnore)` to the flag test alone (M1.5 scope: no PK, no missiles) | `src/AcDream.Core/Physics/TransitionTypes.cs:660` | Both omitted terms are genuinely false pre-M2; comment directs wiring them with PK (M2+) and missiles (F.3) | If PK or missiles land without the terms, flagged entities get BSP-only where retail tests cyl+sphere — pass-through / wrong blocking in PvP/missile interactions | `FindObjCollisions` pc:276861; HAS_PHYSICS_BSP_PS acclient.h:2833 |
|
||||||
| TS-3 | `FramesStationaryFall` accounting absent (`moved = true` unconditionally in the accepted-move branch) | `src/AcDream.Core/Physics/TransitionTypes.cs:3691` | Explicitly deferred to the full physics port | A body wedged falling-in-place never triggers retail's stuck-fall escalation — indefinite falling-animation wedges | CPhysicsObj frames_stationary_fall |
|
| TS-3 | `FramesStationaryFall` accounting absent (`moved = true` unconditionally in the accepted-move branch) | `src/AcDream.Core/Physics/TransitionTypes.cs:3691` | Explicitly deferred to the full physics port | A body wedged falling-in-place never triggers retail's stuck-fall escalation — indefinite falling-animation wedges | CPhysicsObj frames_stationary_fall |
|
||||||
| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place | `src/AcDream.Core/Physics/BSPQuery.cs:2001` | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge | `BSPTREE::find_collisions` SetCollide pc:323783-323821 |
|
| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place. **Includes a `SetSlidingNormal` write at both sites** — retail's BSP layer never writes `collision_info.sliding_normal` (only `validate_transition` 0x0050ac21 does; the #137 mechanism-2 class), so on transition success the steep-face normal persists to the body and seeds the next frame | `src/AcDream.Core/Physics/BSPQuery.cs` (Path-6 steep branches, `worldNormal.Z < FloorZ`) | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge; a persisted steep-face normal can absorb an exactly-anti-parallel next-frame push (#137 wedge class) until an oblique input clears it | `BSPTREE::find_collisions` SetCollide pc:323783-323821 |
|
||||||
| TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2). R3-W3 extends this row: `IWeenieObject.JumpStaminaCost`/`PlayerWeenie.JumpStaminaCost` are new (feeding `jump_is_allowed`'s verbatim stamina-refusal branch) and are ALSO always-affordable/cost-0 stubs for the same reason | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` (`CanJump`), `:52` (`JumpStaminaCost`, R3-W3) | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry; `jump_is_allowed` 0x005282b0 `JumpStaminaCost` vtable +0x44 |
|
| TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2). R3-W3 extends this row: `IWeenieObject.JumpStaminaCost`/`PlayerWeenie.JumpStaminaCost` are new (feeding `jump_is_allowed`'s verbatim stamina-refusal branch) and are ALSO always-affordable/cost-0 stubs for the same reason | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` (`CanJump`), `:52` (`JumpStaminaCost`, R3-W3) | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry; `jump_is_allowed` 0x005282b0 `JumpStaminaCost` vtable +0x44 |
|
||||||
| TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) |
|
| TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) |
|
||||||
| TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 |
|
| TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 |
|
||||||
|
|
@ -224,6 +226,8 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| TS-41 | Grounded remote NPCs WITHOUT an armed moveto are body-driven by UP-synthesized server velocity (`HasServerVelocity` → the SERVERVEL per-tick leg: `Body.Velocity = ServerVelocity`, `MovementManager.UseTime` (the R5-V5 facade relay, ex-loose `MoveToManager.UseTime`) SKIPPED, stale-decay stop via `ApplyServerControlledVelocityCycle(Zero)`); retail has no wire-velocity leg-driver anywhere — `MovementManager::UseTime` runs UNCONDITIONALLY per tick and between-UP translation comes from the motion state (`get_state_velocity`), UPs only hard-snap. **#170 residual fix narrowed this branch: an ARMED moveto (`MovementTypeState != Invalid`) now always takes the MOVETO leg** — the old arbitration starved the verbatim MoveToManager for exactly the duration of a server-side chase (UPs flowing → UseTime never ran → legs stayed Ready while the body glided = the #170 slide; live funnel 16 arms → 1 run install). **R5-V3 (#171) narrowed it again: a STUCK entity (`PositionManager.GetStickyObjectId() != 0`) also takes the MOVETO leg** — after the sticky arrival the moveto is cleaned (Invalid) but `StickyManager::adjust_offset` owns the between-snap translation; SERVERVEL would glide the body against the sticky steer (same starvation class) | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch, `moveToArmed`+`stickyArmed` gate) | ACE moves some entities by position updates alone (scripted paths, missiles) with no UM/moveto stream — without a velocity fallback they freeze between UPs; entities WITH a moveto now get the retail drive | An entity class that carries BOTH wire velocity and an armed moveto with conflicting truths follows the moveto; UP hard-snaps bound the drift. Non-moveto entities keep the non-retail stale-stop heuristics (AP-80 thresholds) | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` call @0x00515998, unconditional); retire in R6 (full per-tick order) |
|
| TS-41 | Grounded remote NPCs WITHOUT an armed moveto are body-driven by UP-synthesized server velocity (`HasServerVelocity` → the SERVERVEL per-tick leg: `Body.Velocity = ServerVelocity`, `MovementManager.UseTime` (the R5-V5 facade relay, ex-loose `MoveToManager.UseTime`) SKIPPED, stale-decay stop via `ApplyServerControlledVelocityCycle(Zero)`); retail has no wire-velocity leg-driver anywhere — `MovementManager::UseTime` runs UNCONDITIONALLY per tick and between-UP translation comes from the motion state (`get_state_velocity`), UPs only hard-snap. **#170 residual fix narrowed this branch: an ARMED moveto (`MovementTypeState != Invalid`) now always takes the MOVETO leg** — the old arbitration starved the verbatim MoveToManager for exactly the duration of a server-side chase (UPs flowing → UseTime never ran → legs stayed Ready while the body glided = the #170 slide; live funnel 16 arms → 1 run install). **R5-V3 (#171) narrowed it again: a STUCK entity (`PositionManager.GetStickyObjectId() != 0`) also takes the MOVETO leg** — after the sticky arrival the moveto is cleaned (Invalid) but `StickyManager::adjust_offset` owns the between-snap translation; SERVERVEL would glide the body against the sticky steer (same starvation class) | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch, `moveToArmed`+`stickyArmed` gate) | ACE moves some entities by position updates alone (scripted paths, missiles) with no UM/moveto stream — without a velocity fallback they freeze between UPs; entities WITH a moveto now get the retail drive | An entity class that carries BOTH wire velocity and an armed moveto with conflicting truths follows the moveto; UP hard-snaps bound the drift. Non-moveto entities keep the non-retail stale-stop heuristics (AP-80 thresholds) | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` call @0x00515998, unconditional); retire in R6 (full per-tick order) |
|
||||||
| TS-42 | Per-tick DRAIN ORDER inverted vs retail: acdream's `TickAnimations` runs `HandleTargetting` → `Movement.UseTime` (the R5-V5 MovementManager relay, ex-loose `MoveTo.UseTime`) FIRST and the animation-completion drain (Sequencer.Advance → AnimDone hooks → `MotionTableManager.AnimationDone`/`UseTime` → `CMotionInterp.MotionDone` pops) LAST, so every motion-completion-gated decision (`BeginTurnToHeading`'s `motions_pending` wait) sees a queue that is one frame STALE — the unblock after a stop/swing lands one frame later than retail. Retail order (pinned from the named decomp this session): `UpdatePositionInternal` (CPartArray::Update + `process_hooks` @0x00512d3d — the drain) runs BEFORE `TargetManager::HandleTargetting` @0x00515989 → `MovementManager::UseTime` @0x00515998 → `CPartArray::HandleMovement` @0x005159a4 (zero-tick sweep) in `UpdateObjectInternal` | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` per-entity phase order; the R2-Q4 comment already marks the placement "provisional until R6") | Bounded to exactly ONE frame (~16 ms) of extra latency per completion-gated event; every queue eventually drains identically (RemoteChaseEndToEndHarnessTests conformance) | Motion-completion-gated transitions (chase turn start, post-swing re-arm) systematically lag retail by one frame; under compound churn the lag can cost an extra retry cycle | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 + `UpdatePositionInternal` 0x00512c30 (`process_hooks` @0x00512d3d); retire in R6 (retail UpdateObjectInternal order) |
|
| TS-42 | Per-tick DRAIN ORDER inverted vs retail: acdream's `TickAnimations` runs `HandleTargetting` → `Movement.UseTime` (the R5-V5 MovementManager relay, ex-loose `MoveTo.UseTime`) FIRST and the animation-completion drain (Sequencer.Advance → AnimDone hooks → `MotionTableManager.AnimationDone`/`UseTime` → `CMotionInterp.MotionDone` pops) LAST, so every motion-completion-gated decision (`BeginTurnToHeading`'s `motions_pending` wait) sees a queue that is one frame STALE — the unblock after a stop/swing lands one frame later than retail. Retail order (pinned from the named decomp this session): `UpdatePositionInternal` (CPartArray::Update + `process_hooks` @0x00512d3d — the drain) runs BEFORE `TargetManager::HandleTargetting` @0x00515989 → `MovementManager::UseTime` @0x00515998 → `CPartArray::HandleMovement` @0x005159a4 (zero-tick sweep) in `UpdateObjectInternal` | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` per-entity phase order; the R2-Q4 comment already marks the placement "provisional until R6") | Bounded to exactly ONE frame (~16 ms) of extra latency per completion-gated event; every queue eventually drains identically (RemoteChaseEndToEndHarnessTests conformance) | Motion-completion-gated transitions (chase turn start, post-swing re-arm) systematically lag retail by one frame; under compound churn the lag can cost an extra retry cycle | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 + `UpdatePositionInternal` 0x00512c30 (`process_hooks` @0x00512d3d); retire in R6 (retail UpdateObjectInternal order) |
|
||||||
| TS-44 | NPC UpdatePosition hard-snaps (position @`OnLivePositionUpdated` + orientation + velocity/cycle adoption) are SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`) — an adaptation of retail's chain semantics to the legacy snap path: retail routes UP corrections through the InterpolationManager into the SAME per-tick `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES them while armed (0x00555190 order, 0x00555430 assigns m_fOrigin), so a server correction can never fight an armed stick; the legacy NPC path snaps OUTSIDE the chain, producing snap-out/steer-back position flapping + stale-facing stomps (the 2026-07-04 #171 gate residuals). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick, bounded by the 1 s sticky lease | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's mechanism (sticky-overwrites-interp) is unreachable until the NPC path gets the interp-queue architecture (the player-remote branch already has it — the R5-V3 combiner→sticky chain); the gate reproduces the retail-observable behavior on the snap architecture | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target by construction) would drift until unstick+next UP; worst case bounded by the 1 s lease + the next UM re-arm | `PositionManager::adjust_offset` 0x00555190; `InterpolationManager` UP routing (`CPhysicsObj::MoveOrTeleport`); retire when the NPC path unifies onto the interp queue (S6/R6) |
|
| TS-44 | NPC UpdatePosition hard-snaps (position @`OnLivePositionUpdated` + orientation + velocity/cycle adoption) are SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`) — an adaptation of retail's chain semantics to the legacy snap path: retail routes UP corrections through the InterpolationManager into the SAME per-tick `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES them while armed (0x00555190 order, 0x00555430 assigns m_fOrigin), so a server correction can never fight an armed stick; the legacy NPC path snaps OUTSIDE the chain, producing snap-out/steer-back position flapping + stale-facing stomps (the 2026-07-04 #171 gate residuals). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick, bounded by the 1 s sticky lease | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's mechanism (sticky-overwrites-interp) is unreachable until the NPC path gets the interp-queue architecture (the player-remote branch already has it — the R5-V3 combiner→sticky chain); the gate reproduces the retail-observable behavior on the snap architecture | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target by construction) would drift until unstick+next UP; worst case bounded by the 1 s lease + the next UM re-arm | `PositionManager::adjust_offset` 0x00555190; `InterpolationManager` UP routing (`CPhysicsObj::MoveOrTeleport`); retire when the NPC path unifies onto the interp queue (S6/R6) |
|
||||||
|
| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001 spheres `(0,0,0.475) r=0.48`, `(0,0,1.350) r=0.48`). Remotes also use the hardcoded HUMAN dims regardless of creature Setup/scale. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — 0.63 m of headless character; the #137 window climb. Fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`), `src/AcDream.App/Input/PlayerMovementController.cs` + `src/AcDream.App/Rendering/GameWindow.cs` (the two `sphereHeight:` call sites) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold for the human capsule and keeps every captured-input replay fixture byte-identical | 5 mm offsets can flip marginal grazes near the r−ε/r+ε knife edges (today's seam class); creature-scale remotes collide with human-sized capsules until setup-derived dims are plumbed | `CPhysicsObj::transition` 0x00512dc0; dat Setup 0x02000001; retire by plumbing the Setup sphere list into `InitPath` |
|
||||||
|
| TS-45 | `SphereCollision` (the shadow-object Sphere response) is a hand-rolled 3-D wall-slide that ALSO calls `SetSlidingNormal` — retail's `CSphere::intersects_sphere` (0x00537A80) dispatches `CSphere::slide_sphere` (0x00537440), which slides in-frame and never writes `collision_info.sliding_normal` (the only in-transition writer is `validate_transition` 0x0050ac21). Same leak class as the #137 mechanism-2 stubs fixed 2026-07-06 (BSPQuery Contact branch); left in place because the response's blocking semantics for sphere-shaped server objects are untested against the real slide and #171 sticky-melee behavior is freshly gated | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollision`, the `ci.SetSlidingNormal` tail) | The in-frame push-out already moves the check position; the extra sliding normal only persists on transition success, and pure-Sphere shadow shapes are rare (most creatures/statics are CylSphere, which routes through the real `SlideSphere` since #172) | A sphere-shaped object touch persists a normal retail would discard — an exactly-anti-parallel follow-up push absorbs to a zero offset (#137 wedge class) at that object until an oblique input clears it | `CSphere::intersects_sphere` 0x00537A80 → `slide_sphere` 0x00537440 (pc:321678+); fix = route the tail through `Transition.SlideSphere` like `CylSlideSphere` does |
|
||||||
| TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition` → `PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port |
|
| TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition` → `PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -293,14 +293,32 @@ successfully 2026-04-30 for the steep-roof case. Matching binaries
|
||||||
|
|
||||||
#### Phase A7 — Indoor lighting fidelity (RenderDoc + retail-decomp driven)
|
#### Phase A7 — Indoor lighting fidelity (RenderDoc + retail-decomp driven)
|
||||||
|
|
||||||
|
**Now also owns #176/#177 (2026-07-06):** the Facility Hub purple seam
|
||||||
|
flash + stair-room light pop-in are ROOT-CAUSED to this phase's "light
|
||||||
|
visibility culling" layer — a camera-nearest `MaxGlobalLights=128`
|
||||||
|
snapshot cap evicts in-range lights of visible cells (Hub has 366
|
||||||
|
fixtures), so per-cell 8-light sets churn as the camera moves.
|
||||||
|
Uncapping was live-tested and reverted because the full pool exposes the
|
||||||
|
per-cell-reach + fixture-curve defects below (through-floor light,
|
||||||
|
1/d-vs-1/d³). Analysis PRE-PAID — see
|
||||||
|
`docs/research/2026-07-06-176-177-handoff-A7-lighting.md` (the fix order:
|
||||||
|
per-cell `insert_light` registration → static fixture curve → stripe
|
||||||
|
hunt → uncap) + register AP-85.
|
||||||
|
|
||||||
**Hypothesis layers (less mapped than physics):**
|
**Hypothesis layers (less mapped than physics):**
|
||||||
- Per-cell environment-light tag association — indoor cells should
|
- Per-cell environment-light tag association — indoor cells should
|
||||||
inherit only their own env lights, not outdoor day-cycle.
|
inherit only their own env lights, not outdoor day-cycle.
|
||||||
- Light visibility culling — what lights actually contribute to each
|
- Light visibility culling — what lights actually contribute to each
|
||||||
cell's render.
|
cell's render. **CONFIRMED bug here (#176/#177): no per-cell light
|
||||||
|
registration — lights are a flat world-space sphere-overlap pool that
|
||||||
|
reaches through solid floors; retail's `insert_light` 0x0054d1b0
|
||||||
|
scopes each light to its cell.**
|
||||||
- Per-entity light direction transform — held-item-spotlight bug
|
- Per-entity light direction transform — held-item-spotlight bug
|
||||||
(#L-spotlight) is per-entity attribution gone wrong.
|
(#L-spotlight) is per-entity attribution gone wrong.
|
||||||
- Static-stab atmospheric inheritance (#81).
|
- Static-stab atmospheric inheritance (#81).
|
||||||
|
- **Fixture falloff curve (#176/#177): stationary server-spawned
|
||||||
|
fixtures ride the DYNAMIC 1/d path (#143 `isDynamic`); should be the
|
||||||
|
static 1/d³ bake (`calc_point_light` 0x0059c8b0).**
|
||||||
|
|
||||||
**Investigation methodology:** less existing infrastructure than
|
**Investigation methodology:** less existing infrastructure than
|
||||||
physics. Requires:
|
physics. Requires:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
92
docs/research/2026-07-06-137-closeout-render-pair-pickup.md
Normal file
92
docs/research/2026-07-06-137-closeout-render-pair-pickup.md
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# Pickup prompt — post-#137-corridor session: the #176/#177 render pair (paste into a fresh session)
|
||||||
|
|
||||||
|
**Read `claude-memory/project_render_pipeline_digest.md` FIRST** (Option A —
|
||||||
|
one DrawInside(viewer_cell); binding DO-NOT-RETRY table), then **ISSUES #176
|
||||||
|
and #177**, then this file. The physics digest
|
||||||
|
(`claude-memory/project_physics_collision_digest.md`) carries the full
|
||||||
|
2026-07-06 collision saga if background is needed — do NOT reopen it.
|
||||||
|
|
||||||
|
## Where we are (2026-07-06 end of session)
|
||||||
|
|
||||||
|
**The #137 Facility Hub corridor collision arc is DONE, user-gated** ("not
|
||||||
|
collision anymore. Good." / "Looks good"). Branch
|
||||||
|
`claude/vigorous-joliot-f0c3ad` (worktree), 10 commits ahead of `e73e45da`,
|
||||||
|
NOT merged to main. All suites green (Core 2562 / App 713 / UI 425 / Net 385).
|
||||||
|
|
||||||
|
| Commit | Fix |
|
||||||
|
|---|---|
|
||||||
|
| `a11df5b8` | BSPQuery Contact-branch stub slide responses leaked sliding normals (the absorbing wedge). Retail's BSP layer never writes `collision_info.sliding_normal` — only `validate_transition` 0x0050ac21; body persistence success-only (`SetPositionInternal` 0x005154c2). |
|
||||||
|
| `e8651b38` | `slide_sphere` opposing-normals branch returned OK; retail returns COLLIDED_TS (0x0053762c). The "phantom wall" normal was SYNTHETIC (negated movement). PortalSide-poly theory refuted. |
|
||||||
|
| `d4869154` | `CheckOtherCells` queried remaining cells at a stale pre-climb center (P2 lesson one loop deeper) — the seam shake. Per-iteration `footCenter = sp.GlobalSphere[0].Origin` refresh. |
|
||||||
|
| `aa96d7ad` | The collision capsule topped out at 1.2 m (callers passed `sphereHeight: 1.2f`; head sphere center 0.72). Dat Setup 0x02000001: spheres (0,0,0.475)+(0,0,1.350) r=0.48, top 1.83 = Height 1.835. Callers now pass 1.835. Register TS-46. The window climb. |
|
||||||
|
|
||||||
|
**#137 stays OPEN for the DOORS half only** (block/pass per open state).
|
||||||
|
The #175 door-pose fix (2026-07-05) still needs its user gate — ask for it
|
||||||
|
whenever the user is next at the hub double door (closed blocks AT the
|
||||||
|
visual panels from both sides, no embed, no phantom wall).
|
||||||
|
|
||||||
|
## NEXT ARC: #176 + #177 (render, both filed 2026-07-06 from the gate session)
|
||||||
|
|
||||||
|
- **#176 — purple flashing on dungeon floors at cell seams, camera-angle
|
||||||
|
dependent.** Survives all physics fixes → render-side. Magenta/purple =
|
||||||
|
the placeholder-texture class ([[feedback_ui_resolve_zero_magenta]]).
|
||||||
|
- **#177 — stairs between levels pop in/out.** Invisible from the corridor
|
||||||
|
looking into the stair room, appear on entering, vanish on the last step
|
||||||
|
running down. The #119 visibility class, dungeon edition. Anchor cells:
|
||||||
|
the transit `0x8A020182 → 0x8A020183` drops z −6 → −9 on stairs
|
||||||
|
(launch-137-gate2.log).
|
||||||
|
|
||||||
|
**The load-bearing topology fact both issues share (discovered this
|
||||||
|
session):** Facility Hub corridor FLOORS are portal polygons — PortalSide
|
||||||
|
floor-portals to under-rooms (e.g. 0x8A02016E visual polys 1/3/5 → 0x011E,
|
||||||
|
horizontal at z=−6, spanning the whole floor; 0x011E is a hall at z=−12).
|
||||||
|
Level connections run through these floor-portals. "Purple at the seams" is
|
||||||
|
purple exactly where portal surfaces meet, and the stairs' rooms hang off
|
||||||
|
the same portal graph — suspect the render portal-flood/portal-surface
|
||||||
|
handling of HORIZONTAL portals.
|
||||||
|
|
||||||
|
**⚠️ The id-space trap (cost this saga a wrong mechanism):**
|
||||||
|
`CellPortal.PolygonId` indexes the VISUAL polygon table (`CellStruct.Polygons`),
|
||||||
|
NOT `PhysicsPolygons`. Same ids in both tables are UNRELATED polygons.
|
||||||
|
|
||||||
|
## Tooling built this session (reuse, don't rebuild)
|
||||||
|
|
||||||
|
- `Issue137CorridorSeamInspectionTests` — dat-inspection theories (add
|
||||||
|
`InlineData` cells as needed): portal spans (`CorridorCell_PortalPolygonWorldSpans`),
|
||||||
|
full-vertex poly dumps (`WindowShaft_FullPolyDump`), physics-BSP leaf
|
||||||
|
membership, hit-normal candidate sweep (use |align| — winding flips),
|
||||||
|
`HumanSetup_CollisionSpheres_DatTruth`.
|
||||||
|
- `Issue137CorridorSeamReplayTests` — dat-backed `PhysicsEngine` corridor
|
||||||
|
harness (`BuildCorridorEngine`: hydrate THREE portal rings or ring-3
|
||||||
|
cells are invisible to the flood — why clean-room replays kept passing).
|
||||||
|
In-test probe capture pattern: `Console.SetOut(StringWriter)` +
|
||||||
|
`PhysicsDiagnostics.Probe*Enabled = true` → line-diff offline vs live.
|
||||||
|
- Live probe logs (worktree root, PowerShell Tee = UTF-16, `tr -d '\000'`
|
||||||
|
before grep): `launch-137-seam-probes.log` (790 MB, step-level),
|
||||||
|
`launch-137-gate2.log`, `launch-137-gate3.log`,
|
||||||
|
`resolve-137-seam-capture.jsonl` (body snapshots, untracked).
|
||||||
|
|
||||||
|
## Physics DO-NOT-RETRY highlights from today (full table in the digest)
|
||||||
|
|
||||||
|
- No `SetSlidingNormal` in the BSP/sphere layer; opposing branch returns
|
||||||
|
Collided; failed transitions never write body sliding state.
|
||||||
|
- The absorbed exactly-anti-parallel frame against a persisted sliding
|
||||||
|
normal is RETAIL behavior — fix normal PROVENANCE, not the abort.
|
||||||
|
- No height-budget check in the step-down accept — retail's climb cap is
|
||||||
|
`adjust_sphere_to_plane`'s walk_interp −0.5 overshoot bound (0x00538210)
|
||||||
|
+ the placement insert rejecting the HEAD in solids.
|
||||||
|
- Probe-field misreads: `[neg-poly]`/`[neg-poly-dispatch]` print `stepUp=`
|
||||||
|
= NegStepUp (dispatch class), NOT sp.StepUp. `[walkable-nearest]` is a
|
||||||
|
logger, not the decision-maker.
|
||||||
|
- Remaining registered leaks (rows exist, fix later): TS-45
|
||||||
|
(`SphereCollision`'s SetSlidingNormal tail), TS-4 (Path-6 steep-tangent),
|
||||||
|
TS-46 (scalar sphere approximation; remotes use human dims).
|
||||||
|
|
||||||
|
## Launch protocol (unchanged)
|
||||||
|
|
||||||
|
Build green first; PowerShell launch with the env block from CLAUDE.md
|
||||||
|
(+ `ACDREAM_PROBE_RESOLVE=1 ACDREAM_PROBE_CELL=1` for gates), background +
|
||||||
|
Tee to `launch-*.log`. The user manages client lifecycle. Graceful close →
|
||||||
|
ACE session clears in ~5 s; hard kill → ~3 min. The test character may be
|
||||||
|
saved in odd places after collision testing (last session it was inside the
|
||||||
|
window alcove and ACE bounced it to Holtburg — the user portals back).
|
||||||
|
|
@ -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.
|
||||||
115
docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md
Normal file
115
docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# #137 mechanism 2 — the sliding-normal lifecycle audit (2026-07-06)
|
||||||
|
|
||||||
|
The pickup prompt (`2026-07-06-137-corridor-phantom-pickup-prompt.md`) asked:
|
||||||
|
*who writes the body's persisted SlidingNormal, and where does retail CLEAR
|
||||||
|
it when contact does not recur?* This note is the complete decomp-verified
|
||||||
|
answer, the divergence map it produced, and the fix that shipped.
|
||||||
|
|
||||||
|
## Retail lifecycle (every site, named-retail-verified)
|
||||||
|
|
||||||
|
`collision_info.sliding_normal` (per-transition) and
|
||||||
|
`CPhysicsObj::sliding_normal` + `SLIDING_TS` (transient_state bit 4,
|
||||||
|
body-persisted) form a two-level cache:
|
||||||
|
|
||||||
|
| Step | Function | Address / pc line | What it does |
|
||||||
|
|---|---|---|---|
|
||||||
|
| seed | `CPhysicsObj::get_object_info` | 0x00511cc0, seed @0x00511d44 | `if (transient_state & 4) CTransition::init_sliding_normal(&this->sliding_normal)` — last frame's persisted normal seeds the new transition |
|
||||||
|
| consume | `CTransition::adjust_offset` | 0x0050a370 | `dot(offset, sliding_normal) < 0` → project the per-step offset (crease `cross(contact_plane.N, sliding_normal)` when grounded, `offset −= n·dot` otherwise); `dot >= 0` (moving away) → `sliding_normal_valid = 0` |
|
||||||
|
| step gate | `CTransition::find_transitional_position` | 0x0050bdf0, small-offset @0x0050bf83-0x0050bfb7 | adjusted offset `|off|² < EPSILON²` at step 0 → transition FAILS; at step i>0 → succeed iff last validate state OK. **The absorbed frame is retail-faithful** — the persisted normal is a "still pressed against this wall" cache that suppresses re-testing |
|
||||||
|
| per-step clear | same | @0x0050c010 | `sliding_normal_valid = 0` (+ contact plane) BEFORE each step's `transitional_insert` — a step that runs and does not re-collide leaves the transition clean |
|
||||||
|
| in-transition write | `CTransition::validate_transition` | 0x0050aa70, write @0x0050ac21-ac30 | `if (collision_normal_valid) set_sliding_normal(collision_normal)` — **the ONLY in-transition writer**. Fires when a step needed collision handling |
|
||||||
|
| body writeback | `CPhysicsObj::SetPositionInternal` | copy @0x005154c2, bit sync @0x005154e1 | `sliding_normal = transition's; SLIDING_TS ⇔ sliding_normal_valid`. **Success-only** — a failed `find_valid_position` discards the transition whole; the body keeps its prior state |
|
||||||
|
| NOT writers | `CSphere::slide_sphere` 0x00537440, `CCylSphere::slide_sphere` 0x0053b2a0, `BSPTREE::slide_sphere`/`step_sphere_up`/`find_collisions` | pc:321400+, 323700+ | grep-verified: **zero** `sliding_normal` references in the whole sphere/BSP layer (nothing between pc 283518 and 1155326). The sphere-level slide is IN-FRAME (`add_offset_to_check_pos`) |
|
||||||
|
|
||||||
|
So the answer to "where does retail clear it": **the success writeback**
|
||||||
|
(bit 4 syncs to the transition's final `sliding_normal_valid`, which the
|
||||||
|
per-step clear leaves false unless the last step's validate re-recorded a
|
||||||
|
collision) plus `adjust_offset`'s moving-away invalidation. On a FAILED
|
||||||
|
transition nothing clears it — and nothing needs to, because a persisted
|
||||||
|
normal can only have come from a validate write against real geometry
|
||||||
|
(pressed-at-a-wall is a correct absorbed state; any oblique input escapes
|
||||||
|
via the tangential projection remainder and the escape frame's writeback
|
||||||
|
clears the bit).
|
||||||
|
|
||||||
|
ACE mirrors all of it: seed `PhysicsObj.cs:2611`, writeback
|
||||||
|
`PhysicsObj.cs:1249-1251`, validate write `Transition.cs:1027`, the only
|
||||||
|
`SetSlidingNormal` call sites in ACE's whole physics tree.
|
||||||
|
|
||||||
|
## The wedge (live evidence, launch-175-verify2.log:42858)
|
||||||
|
|
||||||
|
The seam-hit frame **succeeded with full advance** (`ok=True`,
|
||||||
|
`out == tgt` in XY, +8 mm step-up settle, crossing 0x8A02016E→0x8A02017A)
|
||||||
|
and still recorded `hit=yes n=(−1.00,0.03,−0.03)`. Retail ending that frame
|
||||||
|
would write back `sliding_normal_valid=0` (no blocked step at the end → the
|
||||||
|
per-step clear wins) and the bit would CLEAR. We persisted a normal anyway —
|
||||||
|
because our BSP Contact branch carried **stub** slide responses
|
||||||
|
(`SetCollisionNormal + SetSlidingNormal + return Slid`) at the sites where
|
||||||
|
retail dispatches the real `slide_sphere`. Every following forward resolve
|
||||||
|
then seeded the stale normal, `adjust_offset` projected the
|
||||||
|
exactly-anti-parallel corridor push to zero, and the step-0 abort returned
|
||||||
|
`ok=False hit=no` with zero advance — before any collision test could
|
||||||
|
refresh the state. An absorbing wedge; strafing escapes because an oblique
|
||||||
|
offset keeps a tangential remainder.
|
||||||
|
|
||||||
|
## Divergence map → what shipped
|
||||||
|
|
||||||
|
| Site | Was | Retail | Action |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `BSPQuery` Contact foot full-hit, step-up unavailable (recursion guard / engine-null) | stub | blocked step-up funnels to `step_up_slide` → `CSphere::slide_sphere` | **FIXED** — routes through `Transition.SlideSphereInternal` (the real port, #116-verified thresholds) |
|
||||||
|
| `BSPQuery` Contact head full-hit | stub | `BSPTREE::slide_sphere` @0x0053a697 (ACE BSPTree.cs:202, 310-316 — slides GlobalSphere[0]) | **FIXED** — same routing; the dead private stub rewritten as the faithful `BSPTREE::slide_sphere` wrapper |
|
||||||
|
| `PhysicsEngine.ResolveWithTransition` sliding writeback | unconditional (ran on `ok=False`) | `SetPositionInternal` success-only | **FIXED** — gated on `ok` (behaviorally latent today: a failed transition's ci always still holds the seed, so gate-vs-rewrite is value-identical; the gate removes the class) |
|
||||||
|
| `BSPQuery` Path-6 steep slide-tangent (2 sites) | in-frame projection + `SetSlidingNormal` | no BSP-layer write | left (documented deviation TS-4 — row amended to name the sliding write); L.5+ retail-strict follow-up |
|
||||||
|
| `Transition.SphereCollision` (shadow Sphere objects) | hand-rolled slide + `SetSlidingNormal` | `CSphere::intersects_sphere` → `slide_sphere`, no write | left — **new register row TS-45**; fix = route the tail through `SlideSphere` like `CylSlideSphere` (#172) does |
|
||||||
|
| seed / step loop / `AdjustOffset` / validate write @TransitionTypes:4317 / real `SlideSphere` port | — | — | verified faithful, unchanged |
|
||||||
|
|
||||||
|
Tests: `Issue137SlidingNormalLifecycleTests` — two site pins (Contact
|
||||||
|
foot-fallback + head full-hit must not write the sliding normal; face-on
|
||||||
|
grounded → `Collided` per the degenerate crease projection) + the
|
||||||
|
engine-level wall lifecycle pin (persist-on-block via validate →
|
||||||
|
absorbed exactly-anti-parallel frame → oblique escape CLEARS the body
|
||||||
|
state). Full solution suite green (Core 2545 / App 713 / UI 425 / Net 385).
|
||||||
|
|
||||||
|
## Mechanism 1 RESOLVED the same session — the "phantom wall" never existed
|
||||||
|
|
||||||
|
Follow-up dat + decomp work (same day) dissolved the PortalSide-poly theory
|
||||||
|
entirely; **no cdb session needed for this repro**:
|
||||||
|
|
||||||
|
1. **The recorded hit normal matches NO polygon.** A world-space sweep of
|
||||||
|
both seam cells + every portal-adjacent neighbor
|
||||||
|
(`Issue137CorridorSeamInspectionTests.CorridorSeam_FindPolygonMatchingLiveHit`)
|
||||||
|
found zero physics polygons within 18° of `(−1.00,0.03,−0.03)` near the
|
||||||
|
hit point. The normal is the player's **negated movement direction** — a
|
||||||
|
SYNTHETIC value from `slide_sphere`'s opposing-normals branch
|
||||||
|
(`reversed = −gDelta` → `set_collision_normal`).
|
||||||
|
2. **The PortalSide polys were a red herring for this hit.** Cell
|
||||||
|
0x8A02016E has IDENTITY rotation (the prior session's "rotation maps
|
||||||
|
them into the −X wall" was wrong); polys 1/3/5 are ±Y-normal planes at
|
||||||
|
world y≈−38.33, 1.4 m beside the player's track and PERPENDICULAR to
|
||||||
|
the +X run — `pos_hits_sphere`'s directional cull (dot ≥ 0 → culled,
|
||||||
|
0x005394f0 tail) rejects them for that movement outright. They ARE
|
||||||
|
referenced by a physics-BSP leaf (`CorridorCell_PhysicsBspLeafMembership`),
|
||||||
|
so retail tests them too when approached INTO their plane — most likely
|
||||||
|
they are legitimately solid one-way/window-class geometry (which is why
|
||||||
|
the dat keeps PortalSide-only portal polys in the physics set while
|
||||||
|
removing every ExactMatch one). The pickup's warning against a blanket
|
||||||
|
"skip portal polys" filter stands — no filter is needed at all.
|
||||||
|
3. **A second slide_sphere port bug found and fixed:** the opposing-normals
|
||||||
|
branch returned OK where retail returns COLLIDED_TS
|
||||||
|
(0x005375d7-0x0053762c: `*normal = −gDelta; normalize;
|
||||||
|
set_collision_normal; return 2`). Our OK let the step complete as-is
|
||||||
|
while carrying the synthetic reversed-movement collision normal —
|
||||||
|
`validate_transition`'s epilogue then converted it into the sliding
|
||||||
|
normal the wedge absorbed on. Fixed at the same TransitionTypes site;
|
||||||
|
pinned by `SlideSphere_OpposingNormals_ReturnsCollided_WithReversedDisplacementNormal`.
|
||||||
|
4. **The dat-backed corridor replay reproduces the live frame and runs
|
||||||
|
clean** (`Issue137CorridorSeamReplayTests`): same input, same full
|
||||||
|
advance to (85.253, −39.776, −5.992), same 016E→017A transit — now
|
||||||
|
`hit=no`, no sliding normal persisted, and six further forward frames
|
||||||
|
advance freely. (The pre-fix code did NOT reproduce the wedge in the
|
||||||
|
replay — the live entry chain involved session state beyond the
|
||||||
|
replay's reach — so the replay is the CLEAN-corridor pin, not a
|
||||||
|
red/green falsification; the site-level pins in
|
||||||
|
`Issue137SlidingNormalLifecycleTests` are the red→green proof.)
|
||||||
|
|
||||||
|
Remaining for #137: the user's corridor re-run (visual gate) + the issue's
|
||||||
|
door half (doors block/pass per open state — separate acceptance).
|
||||||
152
docs/research/2026-07-06-176-177-handoff-A7-lighting.md
Normal file
152
docs/research/2026-07-06-176-177-handoff-A7-lighting.md
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
# Pickup prompt — #176/#177 root-caused, deferred to A7 dungeon lighting (paste into a fresh session)
|
||||||
|
|
||||||
|
**Read `claude-memory/project_render_pipeline_digest.md` FIRST** (top banner
|
||||||
|
is the #176/#177 outcome + DO-NOT-RETRY), then **ISSUES #176 and #177**, then
|
||||||
|
this file. Then read **roadmap Phase A7** (`docs/plans/2026-04-11-roadmap.md`
|
||||||
|
§"Phase A7 — Indoor lighting fidelity") — this session effectively pre-paid
|
||||||
|
A7's analysis for the light-cap slice.
|
||||||
|
|
||||||
|
## Where we are (2026-07-06 end of session)
|
||||||
|
|
||||||
|
**Currently working toward: M1.5 — Indoor world feels right.** Critical path:
|
||||||
|
#137 dungeon collision (DONE, gated), #138 teleport-OUT, **A7 dungeon lighting
|
||||||
|
(#79/#93 + now #176/#177)**.
|
||||||
|
|
||||||
|
HEAD = `d591e3bb` on `main` AND branch `claude/vigorous-joliot-f0c3ad`
|
||||||
|
(fast-forwarded, in sync). Working tree clean. All suites green
|
||||||
|
(Core 2591 + 3 skip / App 719 + 2 skip / UI 425 / Net 385).
|
||||||
|
|
||||||
|
Three commits this session:
|
||||||
|
|
||||||
|
| Commit | What |
|
||||||
|
|---|---|
|
||||||
|
| `b8e9e204` | #176/#177 investigation: 12 mechanisms refuted, apparatus shipped, probe protocol staged |
|
||||||
|
| `4d25e04d` | fix attempt: `MaxGlobalLights` 128→1024 (stops the pops) — **REVERTED** |
|
||||||
|
| `d591e3bb` | revert to 128 + full deferral docs (register AP-85 rewritten, ISSUES back to OPEN, digest DO-NOT-RETRY) |
|
||||||
|
|
||||||
|
A client may still be running (`launch-176-revert-check.log`) — the user
|
||||||
|
manages lifecycle. It is on the reverted 128 baseline (rooms normal, seam
|
||||||
|
flashes present = the still-open issue).
|
||||||
|
|
||||||
|
## THE ROOT CAUSE (confirmed, not a hypothesis)
|
||||||
|
|
||||||
|
**#176 and #177 are ONE bug: per-cell 8-light SET COMPOSITION churning under a
|
||||||
|
camera-nearest snapshot cap.** `LightManager.BuildPointLightSnapshot` keeps only
|
||||||
|
the `MaxGlobalLights=128` point lights nearest THE CAMERA; the Facility Hub
|
||||||
|
registers **366** fixtures, so 238 are evicted per frame by camera distance.
|
||||||
|
`SelectForObject` (the faithful per-object 8-cap, retail
|
||||||
|
`minimize_object_lighting` 0x0054d480) can only choose from the surviving 128 —
|
||||||
|
so an in-range torch of a VISIBLE cell that ranks past the cap drops out of that
|
||||||
|
cell's 8-set, and the cell's per-vertex Gouraud lighting flips as the camera
|
||||||
|
moves.
|
||||||
|
|
||||||
|
- **#176** — the flipping unit is a CELL → discontinuities at exactly cell-seam
|
||||||
|
granularity; camera-angle dependent (the chase boom swings the camera position,
|
||||||
|
re-ranking the 128); the dominant flipping light is the under-room PORTALS'
|
||||||
|
purple → purple flashes on the floor at seams.
|
||||||
|
- **#177** — a stair room whose fixtures ALL rank past the cap renders at bare
|
||||||
|
0.2 ambient (near-black = "not visible from the corridor"); approach
|
||||||
|
re-admits them ("pops into existence"); the eviction boundary sweeping during
|
||||||
|
the descent strips the ramp's lights ("disappears on the last step"). **The
|
||||||
|
geometry never vanished — its LIGHTS did.**
|
||||||
|
|
||||||
|
**How it was confirmed (the discriminator):** the user reproduced the flash
|
||||||
|
while `[light]` (ambient branch — stable 0.2 grey) AND `[pv-input]` (portal
|
||||||
|
flood — zero drops in 54k frames) read provably healthy in the probe log. That
|
||||||
|
eliminated every CPU signal the probes COULD see and left the one they can't:
|
||||||
|
set composition (`[light]` prints counts, not membership). The log's headline
|
||||||
|
number — `registeredLights=366` vs cap 128 — closed it.
|
||||||
|
|
||||||
|
## WHY THE FIX IS DEFERRED (do not re-raise the cap alone)
|
||||||
|
|
||||||
|
Uncapping (128→1024, `4d25e04d`) stopped the pops but the full 366-fixture pool
|
||||||
|
exposed three UNPORTED retail lighting semantics that then dominated the Hub —
|
||||||
|
this is why it was reverted (user: "rooms have no textures" → actually a magenta
|
||||||
|
light wash over intact textures; then "purple stripes… something fighting to
|
||||||
|
draw the purple lightning over the floor"):
|
||||||
|
|
||||||
|
1. **Light-through-solid-floors.** Retail registers lights per-CELL
|
||||||
|
(`insert_light` 0x0054d1b0); a light belongs to a cell and only lights that
|
||||||
|
cell's geometry. Our snapshot is a flat world-space sphere-overlap with NO
|
||||||
|
reach/occlusion notion → the under-room portals' purple light washed the
|
||||||
|
corridors ABOVE them. **This is the big one.** The 128 cap accidentally
|
||||||
|
MASKED it by keeping the pool camera-local (far under-room lights fell off
|
||||||
|
the list before they could reach up).
|
||||||
|
2. **Fixture falloff curve misassignment.** Stationary weenie fixtures
|
||||||
|
(ACE serves dungeon lanterns/braziers as CreateObject weenies) register via
|
||||||
|
the `isDynamic:true` path → D3D 1/d falloff (`LightInfoLoader.cs:89`,
|
||||||
|
GameWindow weenie-light block ~3688). Retail bakes STATIONARY fixture light
|
||||||
|
with the static 1/d³ curve (`calc_point_light` 0x0059c8b0, static_light_factor
|
||||||
|
1.3). 1/d is ~9× stronger at 3 m → every pool over-broad + over-saturated.
|
||||||
|
The `isDynamic` flag should be reserved for genuinely MOVING lights (portal
|
||||||
|
swirls, projectiles); a stationary fixture — even server-spawned — is static.
|
||||||
|
3. **Striped floor z-fight-like artifact.** User's 2nd screenshot: regular
|
||||||
|
magenta bands across one floor region, "like something is fighting to draw
|
||||||
|
the purple over the floor." **NOT attributed.** Ruled out: not coincident dat
|
||||||
|
geometry (the `CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs` sweep
|
||||||
|
found only the legit z=−12 under-hall floor quad-fan, nothing near the −6
|
||||||
|
corridor floor); not a striped texture (all corridor surfaces are plain
|
||||||
|
`Base1Image` stone 0x08000375/6/7/8). Leading guess: two draws of the same
|
||||||
|
floor with DIFFERENT light sets (the per-cell-vs-per-something set assignment
|
||||||
|
splitting), or an MDI instance-order/light-set-index desync exposed only when
|
||||||
|
the purple light is stably present. **Hunt this in A7 with the full pool
|
||||||
|
temporarily on** — it's invisible at cap 128.
|
||||||
|
|
||||||
|
## THE A7 FIX SHAPE (the real fix, in order)
|
||||||
|
|
||||||
|
1. **Port per-cell light registration** (`insert_light` 0x0054d1b0 + the
|
||||||
|
per-cell light list retail keeps). A light lights its OWN cell's geometry +
|
||||||
|
cells reachable through portals — NOT arbitrary world-space overlap. This
|
||||||
|
kills #1 (through-floor) and makes the global pool cap irrelevant (per-cell
|
||||||
|
sets are naturally bounded), which is what actually lets #176/#177 close.
|
||||||
|
2. **Static curve for stationary fixtures.** Decide `isDynamic` by whether the
|
||||||
|
light MOVES, not by dat-static-vs-weenie origin. A server-spawned wall lantern
|
||||||
|
is stationary → static 1/d³. (Register AP-67/AP-44 are the weenie-light path;
|
||||||
|
AP-85 is the pool cap; #143 is the curve-by-path decision to revisit.)
|
||||||
|
3. **Hunt the stripes** with the full pool on (see #3 above).
|
||||||
|
4. **THEN uncap** `MaxGlobalLights` — un-skip
|
||||||
|
`LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`
|
||||||
|
(it asserts the retail end-state: an in-range light of a cell is never
|
||||||
|
camera-evicted).
|
||||||
|
|
||||||
|
## Tooling built this session (reuse, don't rebuild)
|
||||||
|
|
||||||
|
- **`tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs`**
|
||||||
|
— dat truth for the Hub: portal-poly draw verdicts, reciprocal coincidence,
|
||||||
|
stair geometry owner (`0x8A020182`'s ramp shell, vertical portals, ZERO
|
||||||
|
statics), CellBSP containment (partitions exactly at portal planes),
|
||||||
|
under-hall + corridor drawn-poly surface colors, DXT1 alpha histograms (0
|
||||||
|
transparent texels), and `CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs`
|
||||||
|
(the stripe-geometry sweep — came back empty for the −6 floor).
|
||||||
|
- **`tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs`**
|
||||||
|
— production-matched portal-flood replays (approach/descent/gaze-sweep/walk +
|
||||||
|
the ScenarioE incoherent-root sensitivity pin). Flood is HEALTHY — do not
|
||||||
|
re-investigate it for these issues.
|
||||||
|
- **`tests/AcDream.Core.Tests/Physics/Issue176177SeamTransitLagTests.cs`**
|
||||||
|
— resolver cell-flip is plane-exact (membership is NOT the bug).
|
||||||
|
- **`LightManagerTests.PointSnapshot_HubScaleLightCount_*`** — Skip'd
|
||||||
|
end-state pin (RED@128, GREEN@1024).
|
||||||
|
- **Ledger**: `docs/research/2026-07-06-176-177-render-pair-investigation.md`
|
||||||
|
(13 refuted mechanisms + the probe-run discriminator + the OUTCOME banner).
|
||||||
|
|
||||||
|
## Live probe env (all zero-cost off; use for the A7 spike)
|
||||||
|
|
||||||
|
```
|
||||||
|
ACDREAM_PROBE_LIGHT=1 # [light] insideCell/ambient/sun/registeredLights/activeLights — rate-limited
|
||||||
|
ACDREAM_PROBE_PVINPUT=1 # [pv-input] one line/frame: exact flood inputs + count
|
||||||
|
ACDREAM_PROBE_CELL=1 # [cell-transit] timeline anchors
|
||||||
|
ACDREAM_PROBE_TEXFLUSH=1 # [tex-flush] staged-upload drain (proves #105 healthy: after=0)
|
||||||
|
```
|
||||||
|
|
||||||
|
A7.L1's planned `[indoor-light]` probe (per-cell active-light dump: position,
|
||||||
|
color, attenuation, direction) is the natural next apparatus — it prints exactly
|
||||||
|
the SET COMPOSITION the current `[light]` counts can't. Build it FIRST.
|
||||||
|
|
||||||
|
## Launch protocol (unchanged)
|
||||||
|
|
||||||
|
`dotnet build` green first; PowerShell launch with the CLAUDE.md env block
|
||||||
|
(+ the probes above), background + Tee to `launch-*.log`. User manages client
|
||||||
|
lifecycle (graceful close → ACE clears in ~5 s; hard kill → ~3 min). Strip
|
||||||
|
`\000` before grep (PowerShell Tee = UTF-16): `tr -d '\000' < launch.log | grep ...`.
|
||||||
|
Test char spawns near `0x8A020179` (the ramp corridor); the 015E↔017A corridor
|
||||||
|
loop is the #176 repro; look-into + descend the 0178→0182→0183 stairs for #177.
|
||||||
181
docs/research/2026-07-06-176-177-render-pair-investigation.md
Normal file
181
docs/research/2026-07-06-176-177-render-pair-investigation.md
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
# #176/#177 render pair — investigation ledger (2026-07-06, session 2)
|
||||||
|
|
||||||
|
## ✅ OUTCOME (same day, after the probe launch): ROOT CAUSE FOUND + FIX SHIPPED
|
||||||
|
|
||||||
|
**The probe run discriminated it.** The user reproduced the purple floor
|
||||||
|
flash while BOTH surviving CPU theories read provably healthy in the log —
|
||||||
|
`[light]` insideCell/ambient rock-stable (one pre-spawn outdoor line, then
|
||||||
|
flat 0.2 grey through 36 transits), `[pv-input]` flood stable (54k frames,
|
||||||
|
zero collapses). That eliminated T-A and T-B and exposed the one channel
|
||||||
|
the probes were structurally blind to: **per-cell 8-light SET COMPOSITION.**
|
||||||
|
|
||||||
|
The log's own headline number told the story: `registeredLights=366` —
|
||||||
|
against `MaxGlobalLights = 128`. `BuildPointLightSnapshot` kept the 128
|
||||||
|
lights nearest THE CAMERA and evicted 238 every frame; `SelectForObject`
|
||||||
|
(camera-independent, faithfully retail — and unit-PINNED as such) could
|
||||||
|
only choose from the surviving 128. An in-range torch of a VISIBLE cell
|
||||||
|
that ranked past the cap dropped out of that cell's 8-set → the cell's
|
||||||
|
per-vertex Gouraud lighting flipped as the camera moved (the chase boom
|
||||||
|
swings the camera position by meters, re-ranking the 128):
|
||||||
|
|
||||||
|
- **#176:** the flipping unit is A CELL → discontinuity lines at exactly
|
||||||
|
cell-seam granularity; a torch-losing floor drops to dim blue-grey
|
||||||
|
stone (0.2 ambient × stone = the perceived purple); camera-angle
|
||||||
|
dependent by construction.
|
||||||
|
- **#177:** a stair room whose torches ALL ranked past the cap rendered
|
||||||
|
at bare 0.2 ambient — near-black in a dungeon = "not visible from the
|
||||||
|
corridor"; approaching re-admitted them = "pops into existence"; the
|
||||||
|
boundary sweeping during the descent dropped the ramp's lights =
|
||||||
|
"disappears on the last step". The geometry never vanished — its
|
||||||
|
LIGHTS did.
|
||||||
|
|
||||||
|
Retail anchor: `minimize_object_lighting` (0x0054d480) selects from the
|
||||||
|
cell-registered reaching set (`insert_light` 0x0054d1b0) — **no global
|
||||||
|
camera-nearest pool cap exists in retail.**
|
||||||
|
|
||||||
|
**Fix:** `MaxGlobalLights` 128 → 1024 (a non-biting safety valve; the
|
||||||
|
GPU packer grows to fit — 64 B/light). Register row **AP-85**. TDD pin:
|
||||||
|
`LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`
|
||||||
|
(RED at 128 with a Hub-scale 401-light layout, GREEN at 1024). All four
|
||||||
|
suites green. **Pending the user visual gate.**
|
||||||
|
|
||||||
|
Process note: the pre-existing test
|
||||||
|
`SelectForObject_CameraIndependent_DependsOnlyOnObjectCentre` was written
|
||||||
|
to pin "the property that kills the lights-up-as-I-approach popping" — it
|
||||||
|
proved the SELECTOR camera-independent while the SNAPSHOT it selects from
|
||||||
|
was camera-capped. The pop re-entered one stage upstream of the pin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Pre-launch status below (kept as the audit trail): mechanism NOT yet pinned — but the hypothesis space is now razor-thin.**
|
||||||
|
Twelve candidate mechanisms refuted by direct evidence (dat dumps, headless
|
||||||
|
replays, production-log analysis, code reads). Every layer that can be checked
|
||||||
|
offline is verified HEALTHY at the anchor cells. The surviving discriminator
|
||||||
|
requires ONE live probe launch (protocol at the bottom — piggyback on the
|
||||||
|
pending #175 door gate).
|
||||||
|
|
||||||
|
## The issues
|
||||||
|
|
||||||
|
- **#176** — purple flashing on dungeon floors at cell seams, camera-angle
|
||||||
|
dependent (Facility Hub).
|
||||||
|
- **#177** — stairs pop in/out across levels: (a) vanish on the last step
|
||||||
|
running down, (b) invisible looking into the stair room from the corridor,
|
||||||
|
(c) pop into existence on entering.
|
||||||
|
|
||||||
|
## Anchor-cell dat truth (Issue176177DungeonSeamInspectionTests)
|
||||||
|
|
||||||
|
- Corridor `0x8A02016E` floor = three abutting TEXTURED drawn portal polys
|
||||||
|
(polys 1/3/5, surface 0x08000377 → DXT1 tex 0x050026F7, `[PortalSide]`) →
|
||||||
|
under-hall `0x011E` (z=−12). Reciprocal ceiling poly = NoPos (not drawn).
|
||||||
|
- The "stairs" = a RAMP owned by `0x8A020182` (inclined drawn polys, z −9…−6
|
||||||
|
floor + −6…−3 ceiling); `0x0183` = flat lower cell. Connections are
|
||||||
|
VERTICAL wall portals (NOT floor-portals). PortalSide flag asymmetry:
|
||||||
|
0x0182→0x0183 carries PortalSide; the back-portal does not.
|
||||||
|
- **Zero StaticObjects in all five anchor cells** (no #119-class statics, no
|
||||||
|
torch-bearing stabs → no registered point lights except the viewer fill).
|
||||||
|
- CellBSP volumes partition EXACTLY at the portal planes (no overlap zone).
|
||||||
|
- All surfaces resolve; DXT1 textures contain **zero** transparent-mode
|
||||||
|
texels (all 3-color-mode blocks, index 3 never used).
|
||||||
|
|
||||||
|
## REFUTED mechanisms (each by direct evidence — do NOT retry)
|
||||||
|
|
||||||
|
| # | Hypothesis | Killed by |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Placeholder/missing texture (magenta class) | All surfaces resolve; drawn-poly sweep 0 misses |
|
||||||
|
| 2 | Reciprocal portal-poly z-fight | Reciprocal is NoPos (never drawn) |
|
||||||
|
| 3 | Seal depth-stamp z-fights the drawn floor-portal | Seals fire ONLY for `OtherCellId==0xFFFF` (GameWindow:11437); a sealed dungeon draws zero seals |
|
||||||
|
| 4 | Root/eye incoherence (viewer root lags the eye across portal planes) | Production camera sweep publishes coherent pairs; out-cell flips at x=85.001/88.335 — mm-exact at the planes (gate2 log). The flood DOES collapse to 1 cell under artificially incoherent inputs (ScenarioE pin) — but production inputs are coherent |
|
||||||
|
| 5 | Membership transit lag (0.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).
|
||||||
268
docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md
Normal file
268
docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
# A7 dungeon lighting — retail per-cell light model (source-confirmed pseudocode)
|
||||||
|
|
||||||
|
**Date:** 2026-07-06 (continuation of the #176/#177 arc)
|
||||||
|
**Purpose:** the mandated `grep named → decompile → pseudocode → port` step 3 for
|
||||||
|
the A7 per-cell lighting fix. Captures the RETAIL light-selection model exactly as
|
||||||
|
read from `docs/research/named-retail/acclient_2013_pseudo_c.txt`, so the port can
|
||||||
|
match it line-for-line.
|
||||||
|
|
||||||
|
> ⚠️ **This document CORRECTS the #176/#177 handoff's framing.** The handoff
|
||||||
|
> (`2026-07-06-176-177-handoff-A7-lighting.md`) and the digest banner state that
|
||||||
|
> "retail registers lights per-CELL via `insert_light` 0x0054d1b0" and that
|
||||||
|
> "retail's `minimize_object_lighting` has NO global camera-nearest pool cap."
|
||||||
|
> **Both are imprecise.** Reading the source: `insert_light` maintains a GLOBAL
|
||||||
|
> player-nearest sorted pool with a SMALL cap (40 static + 7 dynamic), functionally
|
||||||
|
> analogous to acdream's `BuildPointLightSnapshot`. The real per-cell mechanism is
|
||||||
|
> the *collection phase*: retail rebuilds that global pool **each frame from only
|
||||||
|
> the currently-VISIBLE cells** (`CEnvCell::add_*_lights` walks the portal-flood
|
||||||
|
> `visible_cell_table`). That is why retail's tiny cap never bites — the candidate
|
||||||
|
> pool is pre-scoped by visibility, not by camera distance over the whole dungeon.
|
||||||
|
> This is a *better* fit for acdream than the handoff's framing, because acdream
|
||||||
|
> already computes the visible-cell set every frame (the portal flood).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The retail model, as source-confirmed
|
||||||
|
|
||||||
|
### 1.1 Each cell owns a light list (`CObjCell` / `CEnvCell`)
|
||||||
|
|
||||||
|
- `CObjCell::add_light(this, LIGHTOBJ*)` (`0x0052b1d0`) — appends a light to the
|
||||||
|
cell's own `light_list` (a `DArray<LIGHTOBJ const*>`), `num_lights` counter.
|
||||||
|
Populated at cell load: `CEnvCell::UnPack` (`0x0052d470`) unpacks `num_lights`
|
||||||
|
(line ~310877) and the light list straight from the dat CellStruct; the outdoor
|
||||||
|
path feeds it from the landblock's static object lights (caller at line ~285976,
|
||||||
|
`CObjCell::add_light(cell, lights->lightobj + i)`).
|
||||||
|
- So a light is DATA owned by the cell it sits in — dungeon torches live in the
|
||||||
|
EnvCell's `light_list`; a landblock's lamp-posts live in the LandCell's list.
|
||||||
|
|
||||||
|
### 1.2 A cell pushes its own lights to the global pool
|
||||||
|
|
||||||
|
```
|
||||||
|
CObjCell::add_static_to_global_lights(cell): # 0x0052b350
|
||||||
|
for lightobj in cell.light_list[0 .. cell.num_lights):
|
||||||
|
if (lightobj.flags & 1) != 0: # bit 0 set = STATIC light
|
||||||
|
Render::add_static_light(lightobj.info, cell.m_DID.id, lightobj.frame)
|
||||||
|
|
||||||
|
CObjCell::add_dynamic_to_global_lights(cell): # 0x0052b390
|
||||||
|
for lightobj in cell.light_list[0 .. cell.num_lights):
|
||||||
|
if (lightobj.flags & 1) == 0: # bit 0 clear = DYNAMIC light
|
||||||
|
Render::add_dynamic_light(lightobj.info, cell.m_DID.id, lightobj.frame)
|
||||||
|
```
|
||||||
|
|
||||||
|
The cell id (`cell.m_DID.id`) is passed through as `arg6` so the light carries its
|
||||||
|
owning cell (stored at `+0x6c` on the RenderLight; used by `insert_light` for the
|
||||||
|
block-offset distance math).
|
||||||
|
|
||||||
|
### 1.3 Per frame, ONLY visible cells contribute (the crux)
|
||||||
|
|
||||||
|
```
|
||||||
|
CEnvCell::add_dynamic_lights(): # 0x0052d410
|
||||||
|
for cell in CEnvCell::visible_cell_table: # the PORTAL-FLOOD visible set
|
||||||
|
CObjCell::add_dynamic_to_global_lights(cell)
|
||||||
|
|
||||||
|
# static counterpart — same function that ends at 0x0052def0 (line ~311650):
|
||||||
|
for cell in CEnvCell::visible_cell_table: # SAME visible set
|
||||||
|
cell.init_static_objects()
|
||||||
|
CObjCell::init_objects(cell)
|
||||||
|
CObjCell::add_static_to_global_lights(cell)
|
||||||
|
```
|
||||||
|
|
||||||
|
`visible_cell_table` is the set of cells reached by the portal flood from the
|
||||||
|
viewer's cell (retail `CEnvCell::find_visible_cells` / the `PView` gather). **A
|
||||||
|
dungeon with 366 fixtures but only 5 visible cells contributes only those 5 cells'
|
||||||
|
lights to the global pool.** This is the entire reason retail doesn't churn.
|
||||||
|
|
||||||
|
### 1.4 The global pool is small and player-sorted (`insert_light`)
|
||||||
|
|
||||||
|
```
|
||||||
|
Render::insert_light(maxCount, &num, lights[], sorted[], info, cellId, frame, base): # 0x0054d1b0
|
||||||
|
distsq = 0
|
||||||
|
if info.type == 0: # point light
|
||||||
|
# squared distance from THIS light to the PLAYER, across the cell block offset
|
||||||
|
blockOff = LandDefs::get_block_offset(player_pos.objcell_id, cellId)
|
||||||
|
distsq = |(frame.origin + blockOff) - player_pos.frame.origin|²
|
||||||
|
# ... write RenderLight fields (color/255, intensity, falloff, cone, distancesq=distsq)
|
||||||
|
# insertion-sort into sorted[] ascending by distancesq (nearest player first),
|
||||||
|
# capped at maxCount; when full, evict the farthest-from-player.
|
||||||
|
|
||||||
|
Render::add_static_light(info, cellId, frame): # 0x0054d3e0
|
||||||
|
insert_light(max_static_lights, &world_lights.num_static_lights,
|
||||||
|
world_lights.static_lights, world_lights.sorted_static_lights,
|
||||||
|
info, cellId, frame, max_dynamic_lights + 1)
|
||||||
|
|
||||||
|
Render::add_dynamic_light(info, cellId, frame): # 0x0054d420
|
||||||
|
insert_light(max_dynamic_lights, &world_lights.num_dynamic_lights,
|
||||||
|
world_lights.dynamic_lights, world_lights.sorted_dynamic_lights,
|
||||||
|
info, cellId, frame, 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cap values:** `max_static_lights` / `max_dynamic_lights` (`0x0081ec94` / `0x0081ec98`)
|
||||||
|
init to **0x28 = 40** and **0x7 = 7**. Recomputed in `Render::SetDegradeLevelInternal`
|
||||||
|
(`0x0054c3c0`) as a function of the graphics degrade level (constants 25/50/8/16) —
|
||||||
|
always small (tens of static, single-digit dynamic). Retail deliberately keeps the
|
||||||
|
global pool tiny; it can, because §1.3 pre-scopes the input by visibility.
|
||||||
|
|
||||||
|
### 1.5 Per-object selection (`minimize_object_lighting`) — this IS acdream's `SelectForObject`
|
||||||
|
|
||||||
|
```
|
||||||
|
Render::minimize_object_lighting(): # 0x0054d480
|
||||||
|
reset_active_lights_state()
|
||||||
|
used = 0
|
||||||
|
# DYNAMIC lights first (priority), pre-sorted nearest-player:
|
||||||
|
for i in 0 .. num_dynamic_lights:
|
||||||
|
if used < 8 and remove_object_light(sorted_dynamic_lights[i].info) == keep:
|
||||||
|
add_active_light(i, 2); used += 1
|
||||||
|
else: dynamic_light_used[i] = 0
|
||||||
|
# STATIC lights fill remaining slots:
|
||||||
|
for i in 0 .. num_static_lights:
|
||||||
|
if used >= 8: static_light_used[i] = 0; continue
|
||||||
|
L = sorted_static_lights[i]
|
||||||
|
if L.info.type != 0: # non-point (directional): always use
|
||||||
|
add_active_light(i, 1); used += 1
|
||||||
|
else: # point: sphere-overlap test
|
||||||
|
reach = L.range + local_object_radius
|
||||||
|
if |L.pos - local_object_center|² - reach² < 0.0002: # spheres overlap
|
||||||
|
add_active_light(i, 1); used += 1
|
||||||
|
else: static_light_used[i] = 0
|
||||||
|
enable_active_lights()
|
||||||
|
```
|
||||||
|
|
||||||
|
acdream's `LightManager.SelectForObject` already does the sphere-overlap + 8-cap.
|
||||||
|
The one fidelity gap: retail fills **dynamic-first (priority), then static**, from two
|
||||||
|
separate player-sorted arrays; acdream selects from one camera-sorted snapshot.
|
||||||
|
Minor — parity item, not the #176/#177 cause.
|
||||||
|
|
||||||
|
### 1.6 Static falloff curve (`calc_point_light`) — fix #2 reference
|
||||||
|
|
||||||
|
`calc_point_light` (`0x0059c8b0`) is retail's CPU per-vertex software lighting for
|
||||||
|
static geometry (accumulates into `CUSTOM_D3D_VERTEX2` r/g/b). Structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
calc_point_light(vertex, &r, &g, &b, info):
|
||||||
|
d = |info.offset.origin - vertex.pos|
|
||||||
|
range = info.falloff * static_light_factor # static_light_factor ≈ 1.3
|
||||||
|
if d < range:
|
||||||
|
# N·L diffuse gate: 0.5*d + dot(vertex.normal, info.pos - vertex.pos) > 0
|
||||||
|
if faces_light:
|
||||||
|
atten = <1/d-ish curve, x87 — SEE WARNING>
|
||||||
|
f = atten * (1 - d/range) * info.intensity
|
||||||
|
r += clamp(f * info.color.r, .. info.color.r) # per-channel clamp to the light's own colour
|
||||||
|
g += clamp(f * info.color.g, ..)
|
||||||
|
b += clamp(f * info.color.b, ..)
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **Do NOT port the exact `atten` curve from this BN pseudo-C.** Lines
|
||||||
|
> 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<uint>? visibleCells)`** —
|
||||||
|
a light joins the pool iff `CellId == 0` OR `visibleCells == null` (outdoor) OR
|
||||||
|
`visibleCells.Contains(CellId)`. The 128 cap stays as a now-non-biting backstop.
|
||||||
|
5. **The seam.** The per-frame order is `UpdateViewerLight → Tick → BuildPointLightSnapshot
|
||||||
|
(null-scope) → SceneLightingUbo.Build → Upload` (`GameWindow.cs:9058-9095`), and the
|
||||||
|
portal flood + all cell/entity draws happen LATER, INSIDE
|
||||||
|
`RetailPViewRenderer.DrawInside`. So the scoped rebuild is threaded via a new context
|
||||||
|
callback: `RetailPViewDrawContext.RebuildScopedLights`, invoked in `DrawInside` right
|
||||||
|
after `prepareCells` (every cell drawn this frame) is finalized and BEFORE
|
||||||
|
`PrepareRenderBatches` / the draws (`RetailPViewRenderer.cs:~131`). GameWindow wires it
|
||||||
|
to `visible => Lighting.BuildPointLightSnapshot(camPos, visible)` (`GameWindow.cs:~9371`).
|
||||||
|
The renderers hold a reference to the same `_pointSnapshot` list (rebuilt in place), and
|
||||||
|
`EnvCellRenderer._cellLightSetCache` is `.Clear()`'d every pass, so no stale indices.
|
||||||
|
`SceneLightingUbo.Build` reads `lights.Active` (Tick), not the snapshot, so it is
|
||||||
|
unaffected by the relocation. The outdoor `else` path (clipRoot == null: pre-login /
|
||||||
|
fly) never invokes the callback and keeps the legacy null-scope full pool.
|
||||||
|
6. **Validation apparatus** — `ACDREAM_PROBE_INDOOR_LIGHT=1` → one rate-limited
|
||||||
|
`[indoor-light]` line per second with the scoped-pool SET COMPOSITION
|
||||||
|
(`RenderingDiagnostics.EmitIndoorLight`): `visibleCells / pool / cellLess / registered /
|
||||||
|
droppedNonVisible / byCell[]`. This is the discriminator the `[light]` COUNTS couldn't
|
||||||
|
give (#176/#177 lived in set membership).
|
||||||
|
|
||||||
|
Fixes #2 (static curve) + #3 (stripe hunt) + the cap decision are follow-on slices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Source anchors (for the register + future sessions)
|
||||||
|
|
||||||
|
| Retail fn | Addr | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| `CObjCell::add_light` | 0x0052b1d0 | append light to a cell's own list |
|
||||||
|
| `CObjCell::add_static_to_global_lights` | 0x0052b350 | push a cell's static lights to the global pool |
|
||||||
|
| `CObjCell::add_dynamic_to_global_lights` | 0x0052b390 | push a cell's dynamic lights to the global pool |
|
||||||
|
| `CEnvCell::add_dynamic_lights` | 0x0052d410 | per-frame: walk `visible_cell_table`, collect dynamic |
|
||||||
|
| (static collector, ends) | 0x0052def0 | per-frame: walk `visible_cell_table`, collect static |
|
||||||
|
| `CEnvCell::UnPack` | 0x0052d470 | unpack a cell's `num_lights` + `light_list` from dat |
|
||||||
|
| `Render::insert_light` | 0x0054d1b0 | player-nearest sorted insert into `world_lights`, capped |
|
||||||
|
| `Render::add_static_light` / `add_dynamic_light` | 0x0054d3e0 / 0x0054d420 | thin wrappers → insert_light |
|
||||||
|
| `Render::minimize_object_lighting` | 0x0054d480 | per-object ≤8 pick (dynamic-priority, then static sphere-overlap) |
|
||||||
|
| `Render::SetDegradeLevelInternal` | 0x0054c3c0 | recomputes `max_static/dynamic_lights` from degrade level |
|
||||||
|
| `calc_point_light` | 0x0059c8b0 | CPU per-vertex static light curve (fix #2 ref) |
|
||||||
|
| `max_static_lights` / `max_dynamic_lights` | 0x0081ec94 / 0x0081ec98 | init 40 / 7 |
|
||||||
|
|
@ -882,8 +882,17 @@ public sealed class PlayerMovementController
|
||||||
// Falls back to simple Z-snap if transition fails.
|
// Falls back to simple Z-snap if transition fails.
|
||||||
var resolveResult = _physics.ResolveWithTransition(
|
var resolveResult = _physics.ResolveWithTransition(
|
||||||
preIntegratePos, postIntegratePos, CellId,
|
preIntegratePos, postIntegratePos, CellId,
|
||||||
sphereRadius: 0.48f, // human player radius from Setup
|
sphereRadius: 0.48f, // human Setup 0x02000001 sphere radius (dat: 0.480)
|
||||||
sphereHeight: 1.2f, // human player height from Setup
|
// #137 window climb (2026-07-06): sphereHeight is the CAPSULE TOP
|
||||||
|
// (InitPath places the head sphere center at height − radius). The
|
||||||
|
// dat human Setup 0x02000001 has Spheres[1].Origin.Z = 1.350
|
||||||
|
// (top 1.830) and Height = 1.835 — retail collides with that
|
||||||
|
// sphere list verbatim (CPhysicsObj::transition 0x00512dc0 →
|
||||||
|
// init_sphere(GetNumSphere, GetSphere, scale)). The old 1.2f put
|
||||||
|
// the head sphere center at 0.72 — the top 0.63 m of the
|
||||||
|
// character had NO collision, letting the player climb into a
|
||||||
|
// 1.3 m window alcove head-through-lintel. Register TS-46.
|
||||||
|
sphereHeight: 1.835f,
|
||||||
stepUpHeight: StepUpHeight,
|
stepUpHeight: StepUpHeight,
|
||||||
stepDownHeight: StepDownHeight, // L.2.3a: from Setup.StepDownHeight
|
stepDownHeight: StepDownHeight, // L.2.3a: from Setup.StepDownHeight
|
||||||
isOnGround: _body.OnWalkable,
|
isOnGround: _body.OnWalkable,
|
||||||
|
|
|
||||||
|
|
@ -3714,7 +3714,8 @@ public sealed class GameWindow : IDisposable
|
||||||
ownerId: entity.Id,
|
ownerId: entity.Id,
|
||||||
entityPosition: entity.Position,
|
entityPosition: entity.Position,
|
||||||
entityRotation: entity.Rotation,
|
entityRotation: entity.Rotation,
|
||||||
isDynamic: true); // #143: server-object lights take the D3D dynamic path (1/d att, range×1.5)
|
isDynamic: true, // #143: server-object lights take the D3D dynamic path (1/d att, range×1.5)
|
||||||
|
cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility
|
||||||
foreach (var ls in loaded)
|
foreach (var ls in loaded)
|
||||||
_lightingSink.RegisterOwnedLight(ls);
|
_lightingSink.RegisterOwnedLight(ls);
|
||||||
}
|
}
|
||||||
|
|
@ -4193,9 +4194,22 @@ public sealed class GameWindow : IDisposable
|
||||||
// span the doorway gap, so the player could walk through. With
|
// span the doorway gap, so the player could walk through. With
|
||||||
// this change the door also registers the part-0 BSP slab
|
// this change the door also registers the part-0 BSP slab
|
||||||
// (1.9 × 0.26 × 2.5 m) that retail uses for the real block.
|
// (1.9 × 0.26 × 2.5 m) that retail uses for the real block.
|
||||||
|
// #175 (2026-07-05): BSP part shapes must pose at the motion table's
|
||||||
|
// DEFAULT-STATE frame (the closed pose — what the sequencer renders
|
||||||
|
// for an idle entity and what retail's live CPhysicsPart pose is),
|
||||||
|
// not the Setup's placement frame. The Facility Hub double door
|
||||||
|
// (Setup 0x02000C9D) places its panels AJAR in the placement frame
|
||||||
|
// (yaw −150°/−30°, −0.44 m behind the doorway) while rendering
|
||||||
|
// closed — the user embedded into the visual door on one side and
|
||||||
|
// hit a phantom slab on the other. Null (no motion table / no
|
||||||
|
// cycle / part-count mismatch) falls back to placement frames.
|
||||||
|
var closedPose = MotionTableDefaultPose(
|
||||||
|
spawn.MotionTableId ?? 0u, setup.Parts.Count);
|
||||||
|
|
||||||
var raw = AcDream.Core.Physics.ShadowShapeBuilder.FromSetup(
|
var raw = AcDream.Core.Physics.ShadowShapeBuilder.FromSetup(
|
||||||
setup, entScale,
|
setup, entScale,
|
||||||
id => _physicsDataCache.GetGfxObj(id)?.BSP?.Root is not null);
|
id => _physicsDataCache.GetGfxObj(id)?.BSP?.Root is not null,
|
||||||
|
partPoseOverride: closedPose);
|
||||||
|
|
||||||
// Substitute the real bounding-sphere radius for BSP shapes —
|
// Substitute the real bounding-sphere radius for BSP shapes —
|
||||||
// the pure builder's 2.0 placeholder works for typical doors
|
// the pure builder's 2.0 placeholder works for typical doors
|
||||||
|
|
@ -4284,6 +4298,37 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #175: the motion table's default-state pose (the closed pose for
|
||||||
|
/// doors) — the derivation lives in
|
||||||
|
/// <see cref="AcDream.Core.Physics.Motion.MotionTablePose"/> (Core,
|
||||||
|
/// dat-conformance-tested; the first cut here used a bare-style cycle
|
||||||
|
/// key, always missed, and silently no-oped — the "175 is not fixed"
|
||||||
|
/// report). Returns null → placement-frame fallback.
|
||||||
|
/// </summary>
|
||||||
|
private IReadOnlyList<DatReaderWriter.Types.Frame>? MotionTableDefaultPose(
|
||||||
|
uint motionTableId, int partCount)
|
||||||
|
{
|
||||||
|
if (motionTableId == 0u || partCount == 0 || _dats is null) return null;
|
||||||
|
|
||||||
|
var mt = _dats.Get<DatReaderWriter.DBObjs.MotionTable>(motionTableId);
|
||||||
|
if (mt is null) return null;
|
||||||
|
|
||||||
|
var pose = AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames(
|
||||||
|
mt, id => _dats.Get<DatReaderWriter.DBObjs.Animation>(id));
|
||||||
|
|
||||||
|
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||||
|
{
|
||||||
|
string desc = pose is null ? "null->placement-fallback"
|
||||||
|
: System.FormattableString.Invariant(
|
||||||
|
$"part0=({pose[0].Origin.X:F2},{pose[0].Origin.Y:F2},{pose[0].Origin.Z:F2})");
|
||||||
|
Console.WriteLine(System.FormattableString.Invariant(
|
||||||
|
$"[shape-pose] mt=0x{motionTableId:X8} parts={partCount} {desc}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return pose;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// R3-W4: one-time per-remote wiring of the animation-dispatch stack —
|
/// R3-W4: one-time per-remote wiring of the animation-dispatch stack —
|
||||||
/// the persistent <see cref="RemoteMotion.Sink"/> (ObservedOmega turn
|
/// the persistent <see cref="RemoteMotion.Sink"/> (ObservedOmega turn
|
||||||
|
|
@ -4318,7 +4363,20 @@ public sealed class GameWindow : IDisposable
|
||||||
rmForSink.ObservedOmega = System.Numerics.Vector3.Zero,
|
rmForSink.ObservedOmega = System.Numerics.Vector3.Zero,
|
||||||
};
|
};
|
||||||
rm.Motion.DefaultSink = rm.Sink;
|
rm.Motion.DefaultSink = rm.Sink;
|
||||||
rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations;
|
// #174 (2026-07-05): the RemoveLinkAnimations seam is retail
|
||||||
|
// CPhysicsObj::RemoveLinkAnimations 0x0050fe20 — a TAILCALL to
|
||||||
|
// CPartArray::HandleEnterWorld 0x00517d70 →
|
||||||
|
// MotionTableManager::HandleEnterWorld 0x0051bdd0: strip the
|
||||||
|
// sequence's link animations AND drain pending_animations
|
||||||
|
// completely (each pop fires MotionDone → CMotionInterp pops its
|
||||||
|
// pending_motions node in lockstep). The previous binding was the
|
||||||
|
// bare sequence strip — every LeaveGround (jump) stripped the
|
||||||
|
// animations that queued manager nodes were counting down on,
|
||||||
|
// orphaning them (NumAnims>0, anims gone) and permanently damming
|
||||||
|
// BOTH queues: MotionsPending() never drained again, so every
|
||||||
|
// armed moveto (ACE's walk-to-door mt-6, the close-range use turn)
|
||||||
|
// starved — the "door only works on a fresh session" bug.
|
||||||
|
rm.Motion.RemoveLinkAnimations = () => ae.Sequencer.Manager.HandleEnterWorld();
|
||||||
rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState();
|
rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState();
|
||||||
// R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions
|
// R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions
|
||||||
// 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions).
|
// 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions).
|
||||||
|
|
@ -7670,7 +7728,8 @@ public sealed class GameWindow : IDisposable
|
||||||
datSetup,
|
datSetup,
|
||||||
ownerId: entity.Id,
|
ownerId: entity.Id,
|
||||||
entityPosition: entity.Position,
|
entityPosition: entity.Position,
|
||||||
entityRotation: entity.Rotation);
|
entityRotation: entity.Rotation,
|
||||||
|
cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility
|
||||||
foreach (var ls in loaded)
|
foreach (var ls in loaded)
|
||||||
_lightingSink.RegisterOwnedLight(ls);
|
_lightingSink.RegisterOwnedLight(ls);
|
||||||
}
|
}
|
||||||
|
|
@ -9350,6 +9409,11 @@ public sealed class GameWindow : IDisposable
|
||||||
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
|
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
|
||||||
Camera = camera,
|
Camera = camera,
|
||||||
CameraWorldPosition = camPos,
|
CameraWorldPosition = camPos,
|
||||||
|
// A7 #176/#177: once DrawInside has resolved the visible-cell set,
|
||||||
|
// rebuild the point-light pool from ONLY those cells' lights (retail's
|
||||||
|
// per-frame add_*_lights over visible_cell_table). The renderers hold a
|
||||||
|
// reference to the same PointSnapshot list, rebuilt in place here.
|
||||||
|
RebuildScopedLights = visible => Lighting.BuildPointLightSnapshot(camPos, visible),
|
||||||
Frustum = frustum,
|
Frustum = frustum,
|
||||||
PlayerLandblockId = playerLb,
|
PlayerLandblockId = playerLb,
|
||||||
AnimatedEntityIds = animatedIds,
|
AnimatedEntityIds = animatedIds,
|
||||||
|
|
@ -10461,14 +10525,16 @@ public sealed class GameWindow : IDisposable
|
||||||
if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0)
|
if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0)
|
||||||
{
|
{
|
||||||
// Sphere dims match local-player defaults (human Setup
|
// Sphere dims match local-player defaults (human Setup
|
||||||
// bounds — ~0.48m radius, ~1.2m height). Good enough for
|
// 0x02000001: sphere radius 0.480, capsule top 1.835 =
|
||||||
|
// Setup.Height; see the #137 TS-46 note at the
|
||||||
|
// PlayerMovementController call). Good enough for
|
||||||
// grounded humanoid remotes; can be setup-derived later
|
// grounded humanoid remotes; can be setup-derived later
|
||||||
// if creatures of wildly different sizes need different
|
// if creatures of wildly different sizes need different
|
||||||
// collision profiles.
|
// collision profiles.
|
||||||
var resolveResult = _physicsEngine.ResolveWithTransition(
|
var resolveResult = _physicsEngine.ResolveWithTransition(
|
||||||
preIntegratePos, postIntegratePos, rm.CellId,
|
preIntegratePos, postIntegratePos, rm.CellId,
|
||||||
sphereRadius: 0.48f,
|
sphereRadius: 0.48f,
|
||||||
sphereHeight: 1.2f,
|
sphereHeight: 1.835f,
|
||||||
stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f
|
stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f
|
||||||
stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f
|
stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f
|
||||||
// K-fix9 (2026-04-26): mirror the K-fix7 gate —
|
// K-fix9 (2026-04-26): mirror the K-fix7 gate —
|
||||||
|
|
@ -10496,6 +10562,55 @@ public sealed class GameWindow : IDisposable
|
||||||
if (resolveResult.CellId != 0)
|
if (resolveResult.CellId != 0)
|
||||||
rm.CellId = resolveResult.CellId;
|
rm.CellId = resolveResult.CellId;
|
||||||
|
|
||||||
|
// #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions
|
||||||
|
// (pc:282699-282715) runs after EVERY SetPositionInternal —
|
||||||
|
// remote objects included; a VectorUpdate-launched jump arc
|
||||||
|
// is ordinary object physics in retail. acdream ported the
|
||||||
|
// velocity reflection for the LOCAL player only (L.3a,
|
||||||
|
// PlayerMovementController ~:940), so a remote jumping into
|
||||||
|
// a dungeon ceiling had its POSITION pinned by the sweep
|
||||||
|
// while its +Z velocity kept integrating — the char hovered
|
||||||
|
// at the roof until gravity burned the arc off, landing
|
||||||
|
// late (user report, 0x0007 dungeon). Mirror the local
|
||||||
|
// site exactly:
|
||||||
|
// v_new = v − (1 + elasticity)·dot(v, n)·n
|
||||||
|
// with the AD-25 suppression (bounce only when airborne
|
||||||
|
// before AND after — corridor slides and landings don't
|
||||||
|
// reflect; the landing snap below keeps its
|
||||||
|
// `Velocity.Z <= 0` gate intact). Inelastic movers
|
||||||
|
// (missiles, later) zero out instead.
|
||||||
|
if (resolveResult.CollisionNormalValid)
|
||||||
|
{
|
||||||
|
bool prevOnWalkable = rm.Body.OnWalkable;
|
||||||
|
bool nowOnWalkable = resolveResult.IsOnGround;
|
||||||
|
bool applyBounce = rm.Body.State.HasFlag(
|
||||||
|
AcDream.Core.Physics.PhysicsStateFlags.Sledding)
|
||||||
|
? !(prevOnWalkable && nowOnWalkable)
|
||||||
|
: (!prevOnWalkable && !nowOnWalkable);
|
||||||
|
if (applyBounce)
|
||||||
|
{
|
||||||
|
if (rm.Body.State.HasFlag(
|
||||||
|
AcDream.Core.Physics.PhysicsStateFlags.Inelastic))
|
||||||
|
{
|
||||||
|
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var vRem = rm.Body.Velocity;
|
||||||
|
var nRem = resolveResult.CollisionNormal;
|
||||||
|
float dotVN = System.Numerics.Vector3.Dot(vRem, nRem);
|
||||||
|
if (dotVN < 0f)
|
||||||
|
{
|
||||||
|
rm.Body.Velocity =
|
||||||
|
vRem + nRem * (-(dotVN * (rm.Body.Elasticity + 1f)));
|
||||||
|
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||||
|
Console.WriteLine(
|
||||||
|
$"VU.bounce guid=0x{serverGuid:X8} n=({nRem.X:F2},{nRem.Y:F2},{nRem.Z:F2}) vZ {vRem.Z:F2}->{rm.Body.Velocity.Z:F2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// K-fix15 (2026-04-26): post-resolve landing
|
// K-fix15 (2026-04-26): post-resolve landing
|
||||||
// detection for airborne remotes. Mirrors
|
// detection for airborne remotes. Mirrors
|
||||||
// PlayerMovementController's local-player landing
|
// PlayerMovementController's local-player landing
|
||||||
|
|
@ -13699,7 +13814,14 @@ public sealed class GameWindow : IDisposable
|
||||||
// R3-W4: bind the player interp's retail seams to the player
|
// R3-W4: bind the player interp's retail seams to the player
|
||||||
// sequencer — LeaveGround/HitGround strip transition links here
|
// sequencer — LeaveGround/HitGround strip transition links here
|
||||||
// (the K-fix18 replacement).
|
// (the K-fix18 replacement).
|
||||||
_playerController.Motion.RemoveLinkAnimations = playerSeq.RemoveAllLinkAnimations;
|
// #174 (2026-07-05): the seam is HandleEnterWorld (strip + FULL
|
||||||
|
// queue drain), not the bare sequence strip — see the remote
|
||||||
|
// bind site's comment (retail 0x0050fe20 → 0x00517d70 →
|
||||||
|
// 0x0051bdd0). The bare strip orphaned every pending manager
|
||||||
|
// node on each jump's LeaveGround; the local player's
|
||||||
|
// pending_motions then never drained and every armed moveto
|
||||||
|
// starved (#174: doors unusable after the first jump/run).
|
||||||
|
_playerController.Motion.RemoveLinkAnimations = () => playerSeq.Manager.HandleEnterWorld();
|
||||||
_playerController.Motion.InitializeMotionTables =
|
_playerController.Motion.InitializeMotionTables =
|
||||||
() => playerSeq.Manager.InitializeState();
|
() => playerSeq.Manager.InitializeState();
|
||||||
_playerController.Motion.CheckForCompletedMotions =
|
_playerController.Motion.CheckForCompletedMotions =
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,12 @@ public sealed class RetailPViewRenderer
|
||||||
prepareCells = _lookInPrepareScratch;
|
prepareCells = _lookInPrepareScratch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A7 #176/#177: scope this frame's point-light pool to the cells actually being
|
||||||
|
// drawn, NOW that the flood has resolved the visible set (retail collects lights
|
||||||
|
// per-frame over visible_cell_table). Must run before the cell/entity draws below
|
||||||
|
// that select from LightManager.PointSnapshot.
|
||||||
|
ctx.RebuildScopedLights?.Invoke(prepareCells);
|
||||||
|
|
||||||
_envCells.PrepareRenderBatches(
|
_envCells.PrepareRenderBatches(
|
||||||
ctx.ViewProjection,
|
ctx.ViewProjection,
|
||||||
ctx.CameraWorldPosition,
|
ctx.CameraWorldPosition,
|
||||||
|
|
@ -1102,6 +1108,16 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
|
||||||
public Action? DrawUnattachedSceneParticles { get; init; }
|
public Action? DrawUnattachedSceneParticles { get; init; }
|
||||||
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
|
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
|
||||||
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
|
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
|
||||||
|
|
||||||
|
/// <summary>A7 #176/#177: rebuild the point-light snapshot scoped to the cells
|
||||||
|
/// this frame actually draws — invoked AFTER the portal flood resolves the visible
|
||||||
|
/// set and BEFORE any cell/entity draw (the faithful port of retail's per-frame
|
||||||
|
/// light collection: <c>CObjCell::add_*_to_global_lights</c> walked over
|
||||||
|
/// <c>CEnvCell::visible_cell_table</c>). The argument is every cell drawn this frame
|
||||||
|
/// (main flood + interior-root look-ins). A cell-less light (viewer fill) is kept
|
||||||
|
/// regardless. Null-safe: outdoor/no-flood callers leave it unset and keep the
|
||||||
|
/// legacy full-pool snapshot.</summary>
|
||||||
|
public Action<IReadOnlySet<uint>>? RebuildScopedLights { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class RetailPViewFrameResult
|
public sealed class RetailPViewFrameResult
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,8 @@ public static class LightInfoLoader
|
||||||
uint ownerId,
|
uint ownerId,
|
||||||
Vector3 entityPosition,
|
Vector3 entityPosition,
|
||||||
Quaternion entityRotation,
|
Quaternion entityRotation,
|
||||||
bool isDynamic = false)
|
bool isDynamic = false,
|
||||||
|
uint cellId = 0)
|
||||||
{
|
{
|
||||||
var results = new List<LightSource>();
|
var results = new List<LightSource>();
|
||||||
if (setup?.Lights is null || setup.Lights.Count == 0) return results;
|
if (setup?.Lights is null || setup.Lights.Count == 0) return results;
|
||||||
|
|
@ -89,6 +90,7 @@ public static class LightInfoLoader
|
||||||
Range = info.Falloff * (isDynamic ? 1.5f : 1.3f),
|
Range = info.Falloff * (isDynamic ? 1.5f : 1.3f),
|
||||||
ConeAngle = info.ConeAngle,
|
ConeAngle = info.ConeAngle,
|
||||||
OwnerId = ownerId,
|
OwnerId = ownerId,
|
||||||
|
CellId = cellId, // owning cell — scopes the per-frame visible-cell pool (A7 #176/#177)
|
||||||
IsLit = true,
|
IsLit = true,
|
||||||
IsDynamic = isDynamic,
|
IsDynamic = isDynamic,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -176,8 +176,27 @@ public sealed class LightManager
|
||||||
public const int MaxLightsPerObject = 8;
|
public const int MaxLightsPerObject = 8;
|
||||||
|
|
||||||
/// <summary>Hard cap on the per-frame global point-light snapshot the shader
|
/// <summary>Hard cap on the per-frame global point-light snapshot the shader
|
||||||
/// indexes. AC scenes rarely exceed a few dozen lit point lights in view; 128
|
/// indexes. ⚠️ LOAD-BEARING STOPGAP — read before touching (#176/#177,
|
||||||
/// is generous. If exceeded, the nearest-to-camera are kept (cold path).</summary>
|
/// 2026-07-06): this cap BITES in the Facility Hub (366 registered fixtures →
|
||||||
|
/// 238 camera-distance evictions/frame), and the eviction is the CONFIRMED
|
||||||
|
/// mechanism of the #176 purple seam flash + the #177 stair-room light
|
||||||
|
/// pop-in — an in-range torch of a visible cell that ranks past the cap
|
||||||
|
/// drops out of that cell's 8-set, so per-cell Gouraud lighting pops as the
|
||||||
|
/// camera moves. Retail's <c>minimize_object_lighting</c> (0x0054d480) has
|
||||||
|
/// NO global camera-nearest cap. HOWEVER: raising the cap to 1024 was
|
||||||
|
/// live-tested 2026-07-06 and REVERTED — with the full pool active, three
|
||||||
|
/// unported retail lighting semantics dominate the frame: (a) lights reach
|
||||||
|
/// THROUGH solid floors/walls (retail registers lights per-CELL via
|
||||||
|
/// <c>insert_light</c> 0x0054d1b0 — a portal's purple light below never
|
||||||
|
/// touches the corridor above; our flat sphere-overlap selection has no
|
||||||
|
/// reach/occlusion notion), (b) stationary weenie fixtures ride the DYNAMIC
|
||||||
|
/// 1/d falloff (~9× stronger at 3 m than retail's static 1/d³ bake curve),
|
||||||
|
/// (c) an unexplained striped z-fight-like artifact on lit floor regions
|
||||||
|
/// (user screenshot, launch-176-texflush session). The proper fix is the
|
||||||
|
/// A7 dungeon-lighting arc: per-cell light registration + the static curve
|
||||||
|
/// for fixtures + the stripe hunt, THEN uncap. Register row AP-85; desired
|
||||||
|
/// end-state pin (currently Skip):
|
||||||
|
/// LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant.</summary>
|
||||||
public const int MaxGlobalLights = 128;
|
public const int MaxGlobalLights = 128;
|
||||||
|
|
||||||
private readonly List<LightSource> _pointSnapshot = new();
|
private readonly List<LightSource> _pointSnapshot = new();
|
||||||
|
|
@ -199,11 +218,33 @@ public sealed class LightManager
|
||||||
/// per-object selection.
|
/// per-object selection.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void BuildPointLightSnapshot(Vector3 cameraWorldPos)
|
public void BuildPointLightSnapshot(Vector3 cameraWorldPos)
|
||||||
|
=> BuildPointLightSnapshot(cameraWorldPos, visibleCells: null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Visible-cell-scoped snapshot build — the faithful port of retail's per-frame
|
||||||
|
/// light collection (<c>CObjCell::add_*_to_global_lights</c> 0x0052b350/0x0052b390
|
||||||
|
/// walked over <c>CEnvCell::visible_cell_table</c> 0x0052d410). When
|
||||||
|
/// <paramref name="visibleCells"/> is non-null (an indoor root with a portal
|
||||||
|
/// flood), a cell-tagged light is a candidate ONLY when its <see cref="LightSource.CellId"/>
|
||||||
|
/// is in the visible set — a cell-less light (<c>CellId == 0</c>: the viewer fill,
|
||||||
|
/// global lights) is always included. This is what (a) stops an under-room light
|
||||||
|
/// washing THROUGH a solid floor (its cell isn't visibly flooded → excluded) and
|
||||||
|
/// (b) bounds the pool to the handful of visible cells so <see cref="MaxGlobalLights"/>
|
||||||
|
/// never evicts a visible cell's in-range light (the #176/#177 mechanism). When
|
||||||
|
/// <paramref name="visibleCells"/> is null (outdoor root / no flood) the behaviour
|
||||||
|
/// is unchanged from the legacy full-pool path.
|
||||||
|
/// </summary>
|
||||||
|
public void BuildPointLightSnapshot(Vector3 cameraWorldPos, IReadOnlySet<uint>? visibleCells)
|
||||||
{
|
{
|
||||||
_pointSnapshot.Clear();
|
_pointSnapshot.Clear();
|
||||||
foreach (var light in _all)
|
foreach (var light in _all)
|
||||||
{
|
{
|
||||||
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
|
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
|
||||||
|
// Visible-cell scoping (retail add_*_lights over visible_cell_table). A
|
||||||
|
// cell-less light (CellId == 0: viewer fill / global) is always a candidate;
|
||||||
|
// a cell-tagged light joins the pool ONLY when its cell is visibly flooded.
|
||||||
|
if (visibleCells is not null && light.CellId != 0 && !visibleCells.Contains(light.CellId))
|
||||||
|
continue;
|
||||||
light.DistSq = (light.WorldPosition - cameraWorldPos).LengthSquared();
|
light.DistSq = (light.WorldPosition - cameraWorldPos).LengthSquared();
|
||||||
_pointSnapshot.Add(light);
|
_pointSnapshot.Add(light);
|
||||||
}
|
}
|
||||||
|
|
@ -212,6 +253,11 @@ public sealed class LightManager
|
||||||
_pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq));
|
_pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq));
|
||||||
_pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights);
|
_pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A7.L1 SET-COMPOSITION probe — only meaningful on the scoped (indoor) path.
|
||||||
|
// Inert unless ACDREAM_PROBE_INDOOR_LIGHT=1; the flag check keeps it zero-cost off.
|
||||||
|
if (visibleCells is not null && AcDream.Core.Rendering.RenderingDiagnostics.ProbeIndoorLightEnabled)
|
||||||
|
AcDream.Core.Rendering.RenderingDiagnostics.EmitIndoorLight(visibleCells.Count, _all, _pointSnapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Viewer light — retail SmartBox::set_viewer (0x00452c40) ──────────────
|
// ── Viewer light — retail SmartBox::set_viewer (0x00452c40) ──────────────
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,12 @@ public sealed class LightSource
|
||||||
public float Range = 10f; // metres, hard cutoff
|
public float Range = 10f; // metres, hard cutoff
|
||||||
public float ConeAngle = 0f; // radians, Spot only
|
public float ConeAngle = 0f; // radians, Spot only
|
||||||
public uint OwnerId; // attached entity id; 0 = world-global
|
public uint OwnerId; // attached entity id; 0 = world-global
|
||||||
|
public uint CellId; // owning cell id (0xLLLLNNNN); 0 = cell-less/global (viewer fill, sun).
|
||||||
|
// Retail carries this on the RenderLight (insert_light arg6, +0x6c) so the
|
||||||
|
// per-frame pool can be built from only the VISIBLE cells' lights
|
||||||
|
// (CObjCell::add_*_to_global_lights over CEnvCell::visible_cell_table).
|
||||||
|
// acdream uses it to scope BuildPointLightSnapshot — a cell-tagged light is
|
||||||
|
// only a candidate when its cell is visibly flooded (#176/#177 A7 fix).
|
||||||
public bool IsLit = true; // SetLightHook latch
|
public bool IsLit = true; // SetLightHook latch
|
||||||
public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5);
|
public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5);
|
||||||
// false = static dat-baked bake (1/d³, range×1.3)
|
// false = static dat-baked bake (1/d³, range×1.3)
|
||||||
|
|
|
||||||
|
|
@ -1399,26 +1399,35 @@ public static class BSPQuery
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// slide_sphere — BSPTree level
|
// slide_sphere — BSPTree level
|
||||||
// ACE: BSPTree.cs slide_sphere
|
// Retail: BSPTREE::slide_sphere (find_collisions Contact head-hit dispatch
|
||||||
|
// at 0x0053a697). ACE: BSPTree.cs:310-316.
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// BSPTree.slide_sphere — apply sliding collision response.
|
/// BSPTree.slide_sphere — dispatch the real sphere-level slide
|
||||||
|
/// (<c>CSphere::slide_sphere</c> 0x00537440, ported as
|
||||||
|
/// <see cref="Transition.SlideSphereInternal"/>): slide IN-FRAME along the
|
||||||
|
/// crease between the collision normal and the contact plane, applied to
|
||||||
|
/// sphere 0's check position (ACE BSPTree.cs:315 —
|
||||||
|
/// <c>GlobalSphere[0].SlideSphere(..., GlobalCurrCenter[0].Center)</c>).
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Sets the sliding normal on CollisionInfo so the outer transition loop
|
/// #137 mechanism 2 (2026-07-06): this was a stub that set
|
||||||
/// applies a wall-slide projection.
|
/// <c>CollisionInfo.SlidingNormal</c> and returned Slid. Retail's BSP layer
|
||||||
|
/// never writes the sliding normal — its only in-transition writer is
|
||||||
|
/// <c>CTransition::validate_transition</c> (0x0050ac21) — so the stub's
|
||||||
|
/// leaked normal survived to the body writeback and absorbed the next
|
||||||
|
/// frame's exactly-anti-parallel offset: the Facility Hub corridor
|
||||||
|
/// phantom's dead-stop half (ISSUES #137).
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
|
||||||
/// <para>ACE: BSPTree.cs slide_sphere — calls GlobalSphere[0].SlideSphere.</para>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="worldNormal">Collision normal already in world space (the
|
||||||
|
/// call sites apply L2W; ACE globalizes inside slide_sphere instead).</param>
|
||||||
private static TransitionState SlideSphere(
|
private static TransitionState SlideSphere(
|
||||||
Transition transition,
|
Transition transition,
|
||||||
Vector3 collisionNormal)
|
Vector3 worldNormal)
|
||||||
{
|
=> transition.SlideSphereInternal(
|
||||||
transition.CollisionInfo.SetSlidingNormal(collisionNormal);
|
worldNormal, transition.SpherePath.GlobalCurrCenter[0].Origin);
|
||||||
return TransitionState.Slid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// collide_with_pt — BSPTree level
|
// collide_with_pt — BSPTree level
|
||||||
|
|
@ -1894,11 +1903,14 @@ public static class BSPQuery
|
||||||
if (engine is not null && !path.StepUp && !path.StepDown)
|
if (engine is not null && !path.StepUp && !path.StepDown)
|
||||||
return StepSphereUp(transition, worldNormal, engine);
|
return StepSphereUp(transition, worldNormal, engine);
|
||||||
|
|
||||||
// No engine OR step-up/step-down already in progress — fall
|
// No engine OR step-up/step-down already in progress — the
|
||||||
// back to wall-slide.
|
// real slide response. Retail: a blocked step-up funnels to
|
||||||
collisions.SetCollisionNormal(worldNormal);
|
// SPHEREPATH::step_up_slide → CSphere::slide_sphere (ACE
|
||||||
collisions.SetSlidingNormal(worldNormal);
|
// SpherePath.cs:316); the slide records the collision normal
|
||||||
return TransitionState.Slid;
|
// itself and never writes the sliding normal (#137 mechanism
|
||||||
|
// 2 — the old SetSlidingNormal stub here leaked a normal that
|
||||||
|
// wedged the next frame's forward offset).
|
||||||
|
return SlideSphere(transition, worldNormal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sphere 0 didn't fully hit. Per retail, the head-sphere test AND
|
// Sphere 0 didn't fully hit. Per retail, the head-sphere test AND
|
||||||
|
|
@ -1937,9 +1949,10 @@ public static class BSPQuery
|
||||||
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
|
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
|
||||||
|
|
||||||
var worldNormal = L2W(hitPoly1!.Plane.Normal);
|
var worldNormal = L2W(hitPoly1!.Plane.Normal);
|
||||||
collisions.SetCollisionNormal(worldNormal);
|
// Retail head-sphere full hit → BSPTREE::slide_sphere
|
||||||
collisions.SetSlidingNormal(worldNormal);
|
// (0x0053a697; ACE BSPTree.cs:202) — the real in-frame
|
||||||
return TransitionState.Slid;
|
// slide, no sliding-normal write (#137 mechanism 2).
|
||||||
|
return SlideSphere(transition, worldNormal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sphere 1 (head) near-miss → neg_poly_hit, neg_step_up = false → outer slide.
|
// Sphere 1 (head) near-miss → neg_poly_hit, neg_step_up = false → outer slide.
|
||||||
|
|
|
||||||
65
src/AcDream.Core/Physics/Motion/MotionTablePose.cs
Normal file
65
src/AcDream.Core/Physics/Motion/MotionTablePose.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Types;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Physics.Motion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #175: the motion table's DEFAULT-STATE part pose — the pose an idle
|
||||||
|
/// entity's parts hold (retail: <c>CMotionTable::SetDefaultState</c>
|
||||||
|
/// 0x005230a0 installs <c>StyleDefaults[DefaultStyle]</c>'s cycle; the parts
|
||||||
|
/// then sit at that animation's frames — the live <c>CPhysicsPart</c> pose
|
||||||
|
/// collision tests against). Used as the BSP shadow-shape part-pose override
|
||||||
|
/// at server-entity registration (doors: the CLOSED pose).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Cycle key arithmetic mirrors <see cref="CMotionTable"/>'s
|
||||||
|
/// <c>LookupCycle</c> (CMotionTable.cs:683): <c>(style << 16) |
|
||||||
|
/// (substate & 0xFFFFFF)</c> — the raw dat <c>Cycles</c> dictionary is
|
||||||
|
/// keyed by the COMBINED word, not the bare style (the first cut of this
|
||||||
|
/// helper looked up the bare style, always missed, and silently fell back
|
||||||
|
/// to placement frames — the #175 "not fixed" report).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class MotionTablePose
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve the default-state pose frames. Returns null (callers fall
|
||||||
|
/// back to placement frames) when the table has no default-style
|
||||||
|
/// substate, no matching cycle, or no animation. A pose covering FEWER
|
||||||
|
/// parts than the Setup is returned as-is —
|
||||||
|
/// <see cref="ShadowShapeBuilder"/> falls back to the placement frame
|
||||||
|
/// PER PART beyond the override's length (e.g. a door anim that poses
|
||||||
|
/// only the panel parts, not the BSP-less frame header).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="mt">The raw dat motion table (wire MotionTableId).</param>
|
||||||
|
/// <param name="loadAnimation">Animation loader (production:
|
||||||
|
/// <c>id => dats.Get<Animation>(id)</c>).</param>
|
||||||
|
public static IReadOnlyList<Frame>? DefaultStatePartFrames(
|
||||||
|
MotionTable mt,
|
||||||
|
Func<uint, Animation?> loadAnimation)
|
||||||
|
{
|
||||||
|
if (mt is null) return null;
|
||||||
|
|
||||||
|
// SetDefaultState: StyleDefaults[DefaultStyle] → the default substate.
|
||||||
|
if (!mt.StyleDefaults.TryGetValue(mt.DefaultStyle, out var defaultSubstateCmd))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// LookupCycle key (CMotionTable.cs:683 — same wrap semantics).
|
||||||
|
uint style = (uint)mt.DefaultStyle;
|
||||||
|
uint substate = (uint)defaultSubstateCmd;
|
||||||
|
int key = (int)((style << 16) | (substate & 0xFFFFFFu));
|
||||||
|
|
||||||
|
if (!mt.Cycles.TryGetValue(key, out var cycle) || cycle.Anims.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var animRef = cycle.Anims[0];
|
||||||
|
var anim = loadAnimation(animRef.AnimId);
|
||||||
|
if (anim is null || anim.PartFrames.Count == 0) return null;
|
||||||
|
|
||||||
|
int idx = Math.Clamp((int)animRef.LowFrame, 0, anim.PartFrames.Count - 1);
|
||||||
|
var frames = anim.PartFrames[idx].Frames;
|
||||||
|
return frames.Count > 0 ? frames : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1085,16 +1085,27 @@ public sealed class PhysicsEngine
|
||||||
body.WalkableVertices = null;
|
body.WalkableVertices = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ci.SlidingNormalValid
|
// Retail persists sliding state to the body ONLY on transition
|
||||||
&& ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
|
// SUCCESS: CPhysicsObj::SetPositionInternal copies the normal at
|
||||||
|
// 0x005154c2 and syncs SLIDING_TS (bit 4) from the transition's
|
||||||
|
// final sliding_normal_valid at 0x005154e1 — and SetPositionInternal
|
||||||
|
// is unreachable when find_valid_position fails (the transition is
|
||||||
|
// discarded whole; the body keeps its prior state). #137 mechanism
|
||||||
|
// 2: an unconditional writeback here could persist a normal retail
|
||||||
|
// would discard.
|
||||||
|
if (ok)
|
||||||
{
|
{
|
||||||
body.SlidingNormal = ci.SlidingNormal;
|
if (ci.SlidingNormalValid
|
||||||
body.TransientState |= TransientStateFlags.Sliding;
|
&& ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
|
||||||
}
|
{
|
||||||
else
|
body.SlidingNormal = ci.SlidingNormal;
|
||||||
{
|
body.TransientState |= TransientStateFlags.Sliding;
|
||||||
body.SlidingNormal = Vector3.Zero;
|
}
|
||||||
body.TransientState &= ~TransientStateFlags.Sliding;
|
else
|
||||||
|
{
|
||||||
|
body.SlidingNormal = Vector3.Zero;
|
||||||
|
body.TransientState &= ~TransientStateFlags.Sliding;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity.
|
// L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity.
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,22 @@ public static class ShadowShapeBuilder
|
||||||
/// every radius, height, and local offset.</param>
|
/// every radius, height, and local offset.</param>
|
||||||
/// <param name="hasPhysicsBsp">Predicate: does the GfxObj with this id
|
/// <param name="hasPhysicsBsp">Predicate: does the GfxObj with this id
|
||||||
/// have a non-null PhysicsBSP? Production: <c>id => cache.GetGfxObj(id)?.BSP?.Root is not null</c>.</param>
|
/// have a non-null PhysicsBSP? Production: <c>id => cache.GetGfxObj(id)?.BSP?.Root is not null</c>.</param>
|
||||||
|
/// <param name="partPoseOverride">#175: per-part pose override for the
|
||||||
|
/// BSP part shapes — the entity's motion-table DEFAULT-STATE pose (the
|
||||||
|
/// closed pose for doors). Retail collision tests each part's LIVE
|
||||||
|
/// <c>CPhysicsPart</c> pose, which for an idle entity is the motion
|
||||||
|
/// table's default state, NOT the Setup's placement frame — the two
|
||||||
|
/// differ on e.g. the Facility Hub double door (Setup 0x02000C9D:
|
||||||
|
/// placement poses the panels AJAR at yaw −150°/−30°, y −0.44 m; the
|
||||||
|
/// closed pose is straight). Null / short lists fall back to the
|
||||||
|
/// placement frame per part (entities with no motion table, and the
|
||||||
|
/// CylSphere/Sphere shapes, are unaffected — retail poses those from
|
||||||
|
/// the setup too).</param>
|
||||||
public static IReadOnlyList<ShadowShape> FromSetup(
|
public static IReadOnlyList<ShadowShape> FromSetup(
|
||||||
Setup setup,
|
Setup setup,
|
||||||
float entScale,
|
float entScale,
|
||||||
Func<uint, bool> hasPhysicsBsp)
|
Func<uint, bool> hasPhysicsBsp,
|
||||||
|
IReadOnlyList<Frame>? partPoseOverride = null)
|
||||||
{
|
{
|
||||||
if (setup is null) throw new ArgumentNullException(nameof(setup));
|
if (setup is null) throw new ArgumentNullException(nameof(setup));
|
||||||
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
|
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
|
||||||
|
|
@ -84,15 +96,21 @@ public static class ShadowShapeBuilder
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Parts — one BSP shape per part with a non-null PhysicsBSP.
|
// 3. Parts — one BSP shape per part with a non-null PhysicsBSP.
|
||||||
|
// Pose priority per part: partPoseOverride (the motion-table
|
||||||
|
// default-state pose, #175) → placement frame → identity.
|
||||||
AnimationFrame? placementFrame = ResolvePlacementFrame(setup);
|
AnimationFrame? placementFrame = ResolvePlacementFrame(setup);
|
||||||
for (int i = 0; i < setup.Parts.Count; i++)
|
for (int i = 0; i < setup.Parts.Count; i++)
|
||||||
{
|
{
|
||||||
uint gfxId = (uint)setup.Parts[i];
|
uint gfxId = (uint)setup.Parts[i];
|
||||||
if (!hasPhysicsBsp(gfxId)) continue;
|
if (!hasPhysicsBsp(gfxId)) continue;
|
||||||
|
|
||||||
Frame partFrame = placementFrame is not null && i < placementFrame.Frames.Count
|
Frame partFrame;
|
||||||
? placementFrame.Frames[i]
|
if (partPoseOverride is not null && i < partPoseOverride.Count)
|
||||||
: new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
|
partFrame = partPoseOverride[i];
|
||||||
|
else if (placementFrame is not null && i < placementFrame.Frames.Count)
|
||||||
|
partFrame = placementFrame.Frames[i];
|
||||||
|
else
|
||||||
|
partFrame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
|
||||||
|
|
||||||
// BSP radius default; caller substitutes the real BoundingSphere.Radius
|
// BSP radius default; caller substitutes the real BoundingSphere.Radius
|
||||||
// at registration time when available. Loose-but-safe broadphase value.
|
// at registration time when available. Loose-but-safe broadphase value.
|
||||||
|
|
|
||||||
|
|
@ -1804,6 +1804,21 @@ public sealed class Transition
|
||||||
{
|
{
|
||||||
if (cellId == sp.CheckCellId) continue;
|
if (cellId == sp.CheckCellId) continue;
|
||||||
|
|
||||||
|
// #137 seam shake (2026-07-06): a mid-loop query can MOVE the
|
||||||
|
// sphere — a Path-5 full hit dispatches step_sphere_up, and a
|
||||||
|
// successful climb lifts the foot (the 0.6 mm ramp-slab lift at
|
||||||
|
// the Facility Hub boundaries) yet returns OK, so the loop
|
||||||
|
// continues. Retail's check_other_cells reads the LIVE
|
||||||
|
// sphere_path.global_sphere for every cell (each cell's
|
||||||
|
// find_collisions receives the transition itself, pc:272717+);
|
||||||
|
// querying the remaining cells with the pre-climb center re-tests
|
||||||
|
// the boundary ~0.4 mm inside the floor slab and grazes the
|
||||||
|
// under-room's CEILING (the slab underside) — the neg-poly
|
||||||
|
// step-up-with-a-downward-normal chain that shook the player at
|
||||||
|
// every corridor seam. Same lesson as the P2 cellar-lip fix
|
||||||
|
// (RunCheckOtherCellsAndAdvance's entry refresh), one loop deeper.
|
||||||
|
footCenter = sp.GlobalSphere[0].Origin;
|
||||||
|
|
||||||
if ((cellId & 0xFFFFu) < 0x0100u)
|
if ((cellId & 0xFFFFu) < 0x0100u)
|
||||||
{
|
{
|
||||||
var terrainWalkable = engine.SampleTerrainWalkable(footCenter.X, footCenter.Y);
|
var terrainWalkable = engine.SampleTerrainWalkable(footCenter.X, footCenter.Y);
|
||||||
|
|
@ -2811,9 +2826,12 @@ public sealed class Transition
|
||||||
// Effect (pc:276973-276977):
|
// Effect (pc:276973-276977):
|
||||||
// state_3 = OK_TS ← force passable
|
// state_3 = OK_TS ← force passable
|
||||||
// collision_normal_valid = 0 ← clear stale slide normal
|
// collision_normal_valid = 0 ← clear stale slide normal
|
||||||
// Note: Cylinder and Sphere shapes already return OK from their own
|
// Note: the BSP branch AND (since the 2026-07-05 CCylSphere family
|
||||||
// obstruction_ethereal early-out, so this clause only fires in practice
|
// port) the Cylinder branch rely on this clause for ethereal
|
||||||
// for the BSP branch, but is written unconditionally as retail does.
|
// passability — CylinderCollision's branch 1 returns Collided on
|
||||||
|
// overlap like retail, and THIS override clears it for non-static
|
||||||
|
// targets. Only the Sphere branch still early-outs on
|
||||||
|
// obstruction_ethereal (consume site 1).
|
||||||
if (result != TransitionState.OK
|
if (result != TransitionState.OK
|
||||||
&& sp.ObstructionEthereal
|
&& sp.ObstructionEthereal
|
||||||
&& !sp.StepDown
|
&& !sp.StepDown
|
||||||
|
|
@ -3171,168 +3189,453 @@ public sealed class Transition
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cylinder collision test for CylSphere objects (tree trunks, rock pillars, NPCs,
|
/// Retail <c>CCylSphere::intersects_sphere</c> dispatcher — verbatim port
|
||||||
/// door foot-colliders). For Contact-grounded movers, attempts to step over short
|
/// of <c>0x0053b440</c> (acclient_2013_pseudo_c.txt:324558). Full family:
|
||||||
/// cylinders (retail-faithful CCylSphere::step_sphere_up). For airborne movers,
|
/// <c>collides_with_sphere</c> 0x0053a880, <c>normal_of_collision</c>
|
||||||
/// movers already stepping, or cylinders too tall to step over, applies a
|
/// 0x0053ab50, <c>collide_with_point</c> 0x0053acb0, <c>slide_sphere</c>
|
||||||
/// horizontal wall-slide response.
|
/// 0x0053b2a0, <c>step_sphere_up</c> 0x0053b310, <c>land_on_cylinder</c>
|
||||||
|
/// 0x0053b3d0, <c>step_sphere_down</c> 0x0053a9b0. Pseudocode + settled
|
||||||
|
/// BN ambiguities + ACE cross-reference notes:
|
||||||
|
/// docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A6.P6 (2026-05-25): the step-over path matches retail's
|
/// Replaces the pre-2026-07-05 hand-rolled approximation (A6.P6 step-up
|
||||||
/// <c>CCylSphere::step_sphere_up</c> at
|
/// gate + radial wall-slide) which had NO cylinder-TOP support: a
|
||||||
/// <c>acclient_2013_pseudo_c.txt:324516-324538</c>. The door's foot
|
/// grounded mover stepping up onto a WIDE cylinder (the Holtburg
|
||||||
/// cylinder (h=0.20m, r=0.10m) is too tall for the static slide to
|
/// town-network portal platform, Setup 0x020019E3, r=2.597 m h=0.256 m)
|
||||||
/// produce smooth sliding along the slab — the radial push-out
|
/// could never validate a landing — the step-up's internal step-down
|
||||||
/// fires as a "phantom collision" at the door's center when the
|
/// probe needs branch 2 (step_sphere_down → contact plane ON the flat
|
||||||
/// sphere is touching the slab face and the cyl is just within reach.
|
/// top) — so DoStepUp failed into StepUpSlide and the player orbited the
|
||||||
/// Retail steps the sphere over the cyl (succeeds when
|
/// rim forever. Airborne landings on tops (land_on_cylinder + the
|
||||||
/// <c>step_up_height >= sphere.radius + cyl.height - offset.z</c>),
|
/// collide-flag exact-TOI branch) were likewise missing.
|
||||||
/// which lets the sphere walk past the cyl without the radial push.
|
/// </para>
|
||||||
/// On step-up failure (cyl too tall, no walkable surface beyond),
|
///
|
||||||
/// falls back to <c>step_up_slide</c> — the same crease-projection
|
/// <para>
|
||||||
/// slide the BSP path uses, which produces smoother behavior than
|
/// The <see cref="ShadowEntry"/> already carries the wrapper overload's
|
||||||
/// the radial push.
|
/// (0x0053b8f0) work: Position = globalized low_pt (entity frame applied
|
||||||
|
/// at registration), Radius/CylHeight pre-scaled; the cylinder axis stays
|
||||||
|
/// world-Z. Ethereal targets: branch 1 returns Collided on overlap and
|
||||||
|
/// the caller's Layer-2 override (pc:276961-276989) clears it for
|
||||||
|
/// non-static targets — the retail passability mechanism (#150); the
|
||||||
|
/// step-down pass never reaches here for ethereal targets (pc:276799
|
||||||
|
/// skip).
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private TransitionState CylinderCollision(ShadowEntry obj, SpherePath sp, PhysicsEngine engine)
|
private TransitionState CylinderCollision(ShadowEntry obj, SpherePath sp, PhysicsEngine engine)
|
||||||
{
|
{
|
||||||
// Consume site 2 — CCylSphere::intersects_sphere @ 0x0053b4a0 (pc:324573).
|
// Degenerate dat heights: registration sites apply the same fallback;
|
||||||
// When obstruction_ethereal is set (target is ETHEREAL-alone, state & 0x4),
|
// kept for entries registered before it (pre-dates this port).
|
||||||
// the retail function is void and all paths in the ethereal branch return
|
float cylHeight = obj.CylHeight > 0f ? obj.CylHeight : obj.Radius * 4f;
|
||||||
// without producing a COLLIDED/Slid result — the player is fully passable.
|
|
||||||
// We mirror this by returning OK immediately, skipping all blocking paths.
|
var s0 = sp.GlobalSphere[0];
|
||||||
// Retail ref: acclient_2013_pseudo_c.txt:324573.
|
Vector3 disp0 = s0.Origin - obj.Position;
|
||||||
if (sp.ObstructionEthereal)
|
// radsum: ε shaved ONCE in the dispatcher preamble (0x0053b48e) —
|
||||||
return TransitionState.OK;
|
// this is what lets a sphere REST exactly on the top without
|
||||||
|
// re-colliding every frame.
|
||||||
|
float radsum = obj.Radius - PhysicsGlobals.EPSILON + s0.Radius;
|
||||||
|
|
||||||
|
bool hasHead = sp.NumSphere > 1;
|
||||||
|
Vector3 disp1 = default;
|
||||||
|
float headRadius = 0f;
|
||||||
|
if (hasHead)
|
||||||
|
{
|
||||||
|
disp1 = sp.GlobalSphere[1].Origin - obj.Position;
|
||||||
|
headRadius = sp.GlobalSphere[1].Radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Branch 1 (0x0053b4a0): placement / ethereal — detection only. ──
|
||||||
|
if (sp.InsertType == InsertType.Placement || sp.ObstructionEthereal)
|
||||||
|
{
|
||||||
|
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
|
||||||
|
return TransitionState.Collided;
|
||||||
|
if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))
|
||||||
|
return TransitionState.Collided;
|
||||||
|
return TransitionState.OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Branch 2 (0x0053b4c0): step-down probe — land on the top. ──
|
||||||
|
if (sp.StepDown)
|
||||||
|
return CylStepSphereDown(obj, sp, cylHeight, disp0, radsum);
|
||||||
|
|
||||||
|
// ── Branch 3 (0x0053b4d7): walkable probe — occupancy blocks. ──
|
||||||
|
if (sp.CheckWalkable)
|
||||||
|
{
|
||||||
|
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
|
||||||
|
return TransitionState.Collided;
|
||||||
|
if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))
|
||||||
|
return TransitionState.Collided;
|
||||||
|
return TransitionState.OK;
|
||||||
|
}
|
||||||
|
|
||||||
var ci = CollisionInfo;
|
|
||||||
var oi = ObjectInfo;
|
var oi = ObjectInfo;
|
||||||
Vector3 sphereCurrPos = sp.GlobalCurrCenter[0].Origin;
|
|
||||||
Vector3 sphereCheckPos = sp.GlobalSphere[0].Origin;
|
|
||||||
float sphRadius = sp.GlobalSphere[0].Radius;
|
|
||||||
Vector3 sphMovement = sphereCheckPos - sphereCurrPos;
|
|
||||||
|
|
||||||
// Vertical check: does sphere reach the cylinder's height range at all?
|
if (!sp.Collide)
|
||||||
float cylTop = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 4f;
|
{
|
||||||
float checkZ = sphereCheckPos.Z;
|
// ── Branch 4 (0x0053b701): normal transition sweep. ──
|
||||||
if (checkZ - sphRadius > obj.Position.Z + cylTop ||
|
if ((oi.State & (ObjectInfoState.Contact | ObjectInfoState.OnWalkable)) != 0)
|
||||||
checkZ + sphRadius < obj.Position.Z)
|
{
|
||||||
|
// Grounded mover: foot hit → step over / onto; head hit → slide.
|
||||||
|
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
|
||||||
|
return CylStepSphereUp(obj, sp, engine, cylHeight, disp0, radsum);
|
||||||
|
if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))
|
||||||
|
// Retail 0x0053b843 passes the HEAD disp (its x_2); ACE's
|
||||||
|
// port passes the foot disp here — retail wins (pseudocode
|
||||||
|
// doc §8.2).
|
||||||
|
return CylSlideSphere(obj, sp, cylHeight, disp1, radsum, 1);
|
||||||
|
}
|
||||||
|
else if ((oi.State & ObjectInfoState.PathClipped) != 0)
|
||||||
|
{
|
||||||
|
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
|
||||||
|
return CylCollideWithPoint(obj, sp, cylHeight, s0, disp0, radsum, 0);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Airborne: foot hit → land on the top; head hit → point hit.
|
||||||
|
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
|
||||||
|
return CylLandOnCylinder(obj, sp, cylHeight, disp0, radsum);
|
||||||
|
if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))
|
||||||
|
return CylCollideWithPoint(obj, sp, cylHeight, sp.GlobalSphere[1], disp1, radsum, 1);
|
||||||
|
}
|
||||||
|
return TransitionState.OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Branch 5 (0x0053b525): collide-flag re-test — exact-TOI cap landing. ──
|
||||||
|
// Runs on the attempt after land_on_cylinder set sp.Collide: rewinds
|
||||||
|
// the sphere along the REVERSE movement to rest exactly on the top,
|
||||||
|
// sets the contact plane, and consumes walk_interp.
|
||||||
|
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius)
|
||||||
|
|| (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius)))
|
||||||
|
{
|
||||||
|
// movement = curr − check. Retail subtracts the cur→check
|
||||||
|
// landblock offset (get_curr_pos_check_pos_block_offset); our
|
||||||
|
// physics frame is continuous world-space → zero (same standing
|
||||||
|
// adaptation as SlideSphere's gDelta).
|
||||||
|
Vector3 movement = sp.GlobalCurrCenter[0].Origin - s0.Origin;
|
||||||
|
if (MathF.Abs(movement.Z) < PhysicsGlobals.EPSILON)
|
||||||
|
return TransitionState.Collided;
|
||||||
|
|
||||||
|
float timecheck = (cylHeight + s0.Radius - disp0.Z) / movement.Z;
|
||||||
|
Vector3 offset = movement * timecheck;
|
||||||
|
|
||||||
|
Vector3 sum = offset + disp0;
|
||||||
|
if (radsum * radsum < sum.X * sum.X + sum.Y * sum.Y)
|
||||||
|
return TransitionState.OK; // rewound point is off the cap — not a top landing
|
||||||
|
|
||||||
|
float t = (1f - timecheck) * sp.WalkInterp;
|
||||||
|
if (t >= sp.WalkInterp || t < -0.1f)
|
||||||
|
return TransitionState.Collided;
|
||||||
|
|
||||||
|
Vector3 pt = s0.Origin + offset;
|
||||||
|
pt.Z -= s0.Radius;
|
||||||
|
// is_water=1 is verbatim retail (0x0053b6b9 → set_contact_plane
|
||||||
|
// arg3 = contact_plane_is_water, 0x00509db1). Do not "fix".
|
||||||
|
var contactPlane = new Plane(Vector3.UnitZ, -pt.Z);
|
||||||
|
CollisionInfo.SetContactPlane(contactPlane, sp.CheckCellId, isWater: true);
|
||||||
|
sp.WalkInterp = t;
|
||||||
|
sp.AddOffsetToCheckPos(offset);
|
||||||
|
return TransitionState.Adjusted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TransitionState.OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>CCylSphere::collides_with_sphere</c> (0x0053a880,
|
||||||
|
/// pc:323943): XY overlap against radsum, then Z-band overlap against the
|
||||||
|
/// cylinder's [0, height] extent. <paramref name="disp"/> = sphere center
|
||||||
|
/// − cylinder base point.
|
||||||
|
/// </summary>
|
||||||
|
private static bool CylCollidesWithSphere(Vector3 disp, float radsum, float cylHeight, float sphereRadius)
|
||||||
|
{
|
||||||
|
if (disp.X * disp.X + disp.Y * disp.Y <= radsum * radsum)
|
||||||
|
{
|
||||||
|
float halfH = cylHeight * 0.5f;
|
||||||
|
if (sphereRadius - PhysicsGlobals.EPSILON + halfH >= MathF.Abs(halfH - disp.Z))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>CCylSphere::normal_of_collision</c> (0x0053ab50, pc:324102).
|
||||||
|
/// Discriminates on where the sphere was at the START of the step
|
||||||
|
/// (GlobalCurrCenter): XY-outside the combined radius → radial side
|
||||||
|
/// normal; XY-inside the footprint → cap normal (descending → top +Z,
|
||||||
|
/// ascending → underside −Z; polarity settled by ACE + geometry — BN's
|
||||||
|
/// x87 branch rendering is untrustworthy here). Returns "definite":
|
||||||
|
/// false when a radial contact could actually be a diagonal cap hit
|
||||||
|
/// (curr was outside the Z band AND there is vertical movement) —
|
||||||
|
/// consumed only by <see cref="CylCollideWithPoint"/>.
|
||||||
|
/// </summary>
|
||||||
|
private bool CylNormalOfCollision(ShadowEntry obj, SpherePath sp, float cylHeight,
|
||||||
|
Vector3 dispCheck, float radsum, float sphereRadius, int sphereNum, out Vector3 normal)
|
||||||
|
{
|
||||||
|
Vector3 dispCurr = sp.GlobalCurrCenter[sphereNum].Origin - obj.Position;
|
||||||
|
if (radsum * radsum < dispCurr.X * dispCurr.X + dispCurr.Y * dispCurr.Y)
|
||||||
|
{
|
||||||
|
normal = new Vector3(dispCurr.X, dispCurr.Y, 0f);
|
||||||
|
float halfH = cylHeight * 0.5f;
|
||||||
|
bool zBandOverlapAtCurr =
|
||||||
|
sphereRadius - PhysicsGlobals.EPSILON + halfH >= MathF.Abs(halfH - dispCurr.Z);
|
||||||
|
bool noZMovement = MathF.Abs(dispCurr.Z - dispCheck.Z) <= PhysicsGlobals.EPSILON;
|
||||||
|
return zBandOverlapAtCurr || noZMovement;
|
||||||
|
}
|
||||||
|
normal = new Vector3(0f, 0f, dispCheck.Z - dispCurr.Z <= 0f ? 1f : -1f);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>AC1Legacy::Vector3::normalize_check_small</c>: returns true
|
||||||
|
/// (degenerate — caller yields Collided) when |v| < F_EPSILON, else
|
||||||
|
/// normalizes in place.
|
||||||
|
/// </summary>
|
||||||
|
private static bool NormalizeCheckSmall(ref Vector3 v)
|
||||||
|
{
|
||||||
|
float mag = v.Length();
|
||||||
|
if (mag < PhysicsGlobals.EPSILON)
|
||||||
|
return true;
|
||||||
|
v /= mag;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>CCylSphere::step_sphere_up</c> (0x0053b310, pc:324516).
|
||||||
|
/// Too-tall cylinders slide; otherwise the generic step-up
|
||||||
|
/// (<see cref="DoStepUp"/> = CTransition::step_up) runs — its internal
|
||||||
|
/// step-down probe lands on the cylinder top via
|
||||||
|
/// <see cref="CylStepSphereDown"/> (branch 2), which is what makes
|
||||||
|
/// stepping ONTO a wide cylinder possible.
|
||||||
|
/// </summary>
|
||||||
|
private TransitionState CylStepSphereUp(ShadowEntry obj, SpherePath sp, PhysicsEngine engine,
|
||||||
|
float cylHeight, Vector3 disp0, float radsum)
|
||||||
|
{
|
||||||
|
var s0 = sp.GlobalSphere[0];
|
||||||
|
|
||||||
|
// step_up_height must clear (sphere.radius + height − disp.z) — the
|
||||||
|
// lift needed so the sphere bottom rests on the top (0x0053b323).
|
||||||
|
if (ObjectInfo.StepUpHeight < s0.Radius + cylHeight - disp0.Z)
|
||||||
|
return CylSlideSphere(obj, sp, cylHeight, disp0, radsum, 0);
|
||||||
|
|
||||||
|
// Retail computes the normal and ignores the definite flag here.
|
||||||
|
CylNormalOfCollision(obj, sp, cylHeight, disp0, radsum, s0.Radius, 0, out var n);
|
||||||
|
if (NormalizeCheckSmall(ref n))
|
||||||
|
return TransitionState.Collided;
|
||||||
|
|
||||||
|
// Retail rotates the normal by the target OBJECT's frame
|
||||||
|
// (localtoglobalvec via the wrapper's cached localspace_pos,
|
||||||
|
// 0x0053b38d) before CTransition::step_up. Yaw-only AC frames leave
|
||||||
|
// vertical normals unchanged; radial normals pick up the yaw.
|
||||||
|
var nWorld = Vector3.Transform(n, obj.Rotation);
|
||||||
|
|
||||||
|
// engine==null only in bare unit-test transitions — no step-up
|
||||||
|
// machinery available; the retail-faithful fallback is the slide.
|
||||||
|
if (engine is not null && DoStepUp(nWorld, engine))
|
||||||
return TransitionState.OK;
|
return TransitionState.OK;
|
||||||
|
|
||||||
// XY distance from sphere check position to cylinder axis.
|
return sp.StepUpSlide(this);
|
||||||
float dxCheck = sphereCheckPos.X - obj.Position.X;
|
}
|
||||||
float dyCheck = sphereCheckPos.Y - obj.Position.Y;
|
|
||||||
float distSqCheck = dxCheck * dxCheck + dyCheck * dyCheck;
|
|
||||||
float combinedR = sphRadius + obj.Radius;
|
|
||||||
float combinedRSq = combinedR * combinedR;
|
|
||||||
|
|
||||||
if (distSqCheck >= combinedRSq)
|
/// <summary>
|
||||||
return TransitionState.OK; // not overlapping at check position
|
/// Retail <c>CCylSphere::step_sphere_down</c> (0x0053a9b0, pc:324032):
|
||||||
|
/// during a step-down probe, land the foot sphere ON the cylinder's flat
|
||||||
|
/// top — contact plane (0,0,1) through the top, walk_interp consumed,
|
||||||
|
/// CheckPos lifted so the sphere bottom rests exactly on it. THE piece
|
||||||
|
/// whose absence made every step-up onto a wide cylinder fail (the
|
||||||
|
/// portal-platform rim orbit).
|
||||||
|
/// </summary>
|
||||||
|
private TransitionState CylStepSphereDown(ShadowEntry obj, SpherePath sp,
|
||||||
|
float cylHeight, Vector3 disp0, float radsum)
|
||||||
|
{
|
||||||
|
var s0 = sp.GlobalSphere[0];
|
||||||
|
|
||||||
// ─── Overlap detected ─────────────────────────────────────
|
bool hit = CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius);
|
||||||
// Horizontal outward normal from the cylinder axis to the sphere
|
if (!hit && sp.NumSphere > 1)
|
||||||
// check position. For the degenerate case where the sphere center
|
|
||||||
// is exactly on the axis, use the movement direction as a fallback
|
|
||||||
// (pushes the sphere back out along the way it came in).
|
|
||||||
float distCheck = MathF.Sqrt(distSqCheck);
|
|
||||||
Vector3 collisionNormal;
|
|
||||||
if (distCheck < PhysicsGlobals.EPSILON)
|
|
||||||
{
|
{
|
||||||
// Sphere center on cylinder axis — push along reverse movement.
|
Vector3 disp1 = sp.GlobalSphere[1].Origin - obj.Position;
|
||||||
float mxy = MathF.Sqrt(sphMovement.X * sphMovement.X + sphMovement.Y * sphMovement.Y);
|
hit = CylCollidesWithSphere(disp1, radsum, cylHeight, sp.GlobalSphere[1].Radius);
|
||||||
if (mxy > PhysicsGlobals.EPSILON)
|
|
||||||
collisionNormal = new Vector3(-sphMovement.X / mxy, -sphMovement.Y / mxy, 0f);
|
|
||||||
else
|
|
||||||
collisionNormal = Vector3.UnitX;
|
|
||||||
}
|
}
|
||||||
else
|
if (!hit)
|
||||||
|
return TransitionState.OK;
|
||||||
|
|
||||||
|
float stepScale = sp.StepDownAmt * sp.WalkInterp;
|
||||||
|
if (MathF.Abs(stepScale) < PhysicsGlobals.EPSILON)
|
||||||
|
return TransitionState.Collided;
|
||||||
|
|
||||||
|
// Lift so the foot sphere's bottom rests on the top disc. The
|
||||||
|
// (1 − deltaZ/stepScale) divisor is stepScale — BN garbled it;
|
||||||
|
// settled via ACE CylSphere.StepSphereDown (pseudocode doc §4).
|
||||||
|
float deltaZ = cylHeight + s0.Radius - disp0.Z;
|
||||||
|
float interp = (1f - deltaZ / stepScale) * sp.WalkInterp;
|
||||||
|
if (interp >= sp.WalkInterp || interp < -0.1f)
|
||||||
|
return TransitionState.Collided;
|
||||||
|
|
||||||
|
float topZ = s0.Origin.Z + deltaZ - s0.Radius;
|
||||||
|
// is_water=1 verbatim retail (0x0053aae2). Do not "fix".
|
||||||
|
var contactPlane = new Plane(Vector3.UnitZ, -topZ);
|
||||||
|
CollisionInfo.SetContactPlane(contactPlane, sp.CheckCellId, isWater: true);
|
||||||
|
sp.WalkInterp = interp;
|
||||||
|
sp.AddOffsetToCheckPos(new Vector3(0f, 0f, deltaZ));
|
||||||
|
return TransitionState.Adjusted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>CCylSphere::slide_sphere</c> (0x0053b2a0, pc:324502):
|
||||||
|
/// normal_of_collision → CSphere::slide_sphere (our
|
||||||
|
/// <see cref="SlideSphere"/>) with the sliding sphere's own curr center.
|
||||||
|
/// </summary>
|
||||||
|
private TransitionState CylSlideSphere(ShadowEntry obj, SpherePath sp,
|
||||||
|
float cylHeight, Vector3 disp, float radsum, int sphereNum)
|
||||||
|
{
|
||||||
|
CylNormalOfCollision(obj, sp, cylHeight, disp, radsum,
|
||||||
|
sp.GlobalSphere[sphereNum].Radius, sphereNum, out var n);
|
||||||
|
if (NormalizeCheckSmall(ref n))
|
||||||
|
return TransitionState.Collided;
|
||||||
|
|
||||||
|
return SlideSphere(n, sp.GlobalCurrCenter[sphereNum].Origin, sphereNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>CCylSphere::land_on_cylinder</c> (0x0053b3d0, pc:324542):
|
||||||
|
/// airborne foot hit — arm the Collide re-test (backup + flag) and relax
|
||||||
|
/// the walkable allowance to LandingZ. The NEXT attempt's branch 5 then
|
||||||
|
/// rests the sphere on the top with the exact time-of-impact.
|
||||||
|
/// </summary>
|
||||||
|
private TransitionState CylLandOnCylinder(ShadowEntry obj, SpherePath sp,
|
||||||
|
float cylHeight, Vector3 disp0, float radsum)
|
||||||
|
{
|
||||||
|
CylNormalOfCollision(obj, sp, cylHeight, disp0, radsum,
|
||||||
|
sp.GlobalSphere[0].Radius, 0, out var n);
|
||||||
|
if (NormalizeCheckSmall(ref n))
|
||||||
|
return TransitionState.Collided;
|
||||||
|
|
||||||
|
sp.SetCollide(n);
|
||||||
|
sp.WalkableAllowance = PhysicsGlobals.LandingZ; // 0.0871557 (0x0053b41f)
|
||||||
|
return TransitionState.Adjusted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>CCylSphere::collide_with_point</c> (0x0053acb0, pc:324173):
|
||||||
|
/// PathClipped movers + airborne head-sphere hits. Non-PerfectClip movers
|
||||||
|
/// record the collision normal and hard-stop; PerfectClip movers get the
|
||||||
|
/// exact time-of-impact reposition. TOI sub-branches ported per ACE
|
||||||
|
/// CylSphere.CollideWithPoint (BN mush too heavy in 0x0053adb6+); no
|
||||||
|
/// PerfectClip mover exists in M1.5 (players never set it), so only the
|
||||||
|
/// Collided path is load-bearing today — revisit against Ghidra if
|
||||||
|
/// missiles ever arm PerfectClip (pseudocode doc §7).
|
||||||
|
/// </summary>
|
||||||
|
private TransitionState CylCollideWithPoint(ShadowEntry obj, SpherePath sp,
|
||||||
|
float cylHeight, Sphere checkSphere, Vector3 disp, float radsum, int sphereNum)
|
||||||
|
{
|
||||||
|
bool definite = CylNormalOfCollision(obj, sp, cylHeight, disp, radsum,
|
||||||
|
checkSphere.Radius, sphereNum, out var n);
|
||||||
|
if (NormalizeCheckSmall(ref n))
|
||||||
|
return TransitionState.Collided;
|
||||||
|
|
||||||
|
if (!ObjectInfo.State.HasFlag(ObjectInfoState.PerfectClip))
|
||||||
{
|
{
|
||||||
collisionNormal = new Vector3(dxCheck / distCheck, dyCheck / distCheck, 0f);
|
CollisionInfo.SetCollisionNormal(n);
|
||||||
|
return TransitionState.Collided;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A6.P6 (2026-05-25): retail-faithful CCylSphere::step_sphere_up for
|
// Retail reads global_curr_center[0] even for the head hit
|
||||||
// Contact-grounded movers. acclient_2013_pseudo_c.txt:324516-324538.
|
// (0x0053ad26; ACE agrees) — verbatim.
|
||||||
//
|
Vector3 globCenter = sp.GlobalCurrCenter[0].Origin;
|
||||||
// Retail check: step_up_height must clear (sphere.radius + cyl.height
|
// Block offset = 0 (continuous world frame; see branch 5 note).
|
||||||
// - offset.z) where offset.z is sphere center Z minus cyl low_pt Z.
|
Vector3 movement = checkSphere.Origin - globCenter;
|
||||||
// Geometrically: the height we need to lift the sphere to clear the
|
Vector3 oldDisp = globCenter - obj.Position;
|
||||||
// cyl's top, less the sphere center's current height above the cyl
|
float radsumEps = radsum + PhysicsGlobals.EPSILON;
|
||||||
// base, equals cyl top minus sphere bottom (positive when sphere
|
|
||||||
// currently below cyl top).
|
|
||||||
//
|
|
||||||
// For the door's foot cyl (h=0.20m, sphere radius 0.48m, step_up 0.60m)
|
|
||||||
// at standing height (offset.z ~0.38m): cyl_clearance =
|
|
||||||
// 0.48 + 0.20 - 0.38 = 0.30m, step_up_height = 0.60m → step over OK.
|
|
||||||
if (oi.Contact && !sp.StepUp && !sp.StepDown && engine is not null)
|
|
||||||
{
|
|
||||||
float offsetZ = sphereCheckPos.Z - obj.Position.Z;
|
|
||||||
float cylClearance = sphRadius + cylTop - offsetZ;
|
|
||||||
|
|
||||||
if (oi.StepUpHeight >= cylClearance)
|
float xyMoveLenSq = movement.X * movement.X + movement.Y * movement.Y;
|
||||||
|
float dot2d = movement.X * oldDisp.X + movement.Y * oldDisp.Y;
|
||||||
|
float xyDiff = -dot2d;
|
||||||
|
float oldDispXYSq = oldDisp.X * oldDisp.X + oldDisp.Y * oldDisp.Y;
|
||||||
|
float diffSq = xyDiff * xyDiff - (oldDispXYSq - radsumEps * radsumEps) * xyMoveLenSq;
|
||||||
|
|
||||||
|
float time;
|
||||||
|
Vector3 scaledMovement;
|
||||||
|
|
||||||
|
if (!definite)
|
||||||
|
{
|
||||||
|
if (MathF.Abs(movement.Z) < PhysicsGlobals.EPSILON)
|
||||||
|
return TransitionState.Collided;
|
||||||
|
if (movement.Z > 0f)
|
||||||
{
|
{
|
||||||
// Try step-up over the cyl (DoStepUp probes upward by
|
n = new Vector3(0f, 0f, -1f);
|
||||||
// step_up_height, then step-down for walkable surface).
|
time = (movement.Z + checkSphere.Radius) / movement.Z * -1f;
|
||||||
// On success: sphere is repositioned past/over the cyl,
|
|
||||||
// ContactPlane updated, returns OK.
|
|
||||||
if (DoStepUp(collisionNormal, engine))
|
|
||||||
return TransitionState.OK;
|
|
||||||
|
|
||||||
// Step-up failed — sphere couldn't find a walkable surface
|
|
||||||
// beyond the cyl (e.g., a wall behind it). Fall back to
|
|
||||||
// step_up_slide which uses the SlideSphereInternal crease
|
|
||||||
// projection — smoother than the radial push-out below
|
|
||||||
// because it follows the contact-plane / cyl-normal crease
|
|
||||||
// direction.
|
|
||||||
return sp.StepUpSlide(this);
|
|
||||||
}
|
|
||||||
// else: cyl too tall to step over — fall through to radial slide
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Fallback: airborne / non-Contact / cyl-too-tall — wall-slide ───
|
|
||||||
|
|
||||||
// Wall-slide position (in world space):
|
|
||||||
// curr = sphereCurrPos (pre-step)
|
|
||||||
// movement = sphMovement
|
|
||||||
// projected = movement - (movement · normal) * normal
|
|
||||||
// slidPos = curr + projected
|
|
||||||
// Then push outward if still inside the cylinder radius.
|
|
||||||
Vector3 horizMovement = new Vector3(sphMovement.X, sphMovement.Y, 0f);
|
|
||||||
float movementIntoWall = Vector3.Dot(horizMovement, collisionNormal);
|
|
||||||
Vector3 projectedMovement = horizMovement - collisionNormal * movementIntoWall;
|
|
||||||
// Preserve vertical movement component (jumping/falling).
|
|
||||||
projectedMovement.Z = sphMovement.Z;
|
|
||||||
|
|
||||||
Vector3 slidPos = sphereCurrPos + projectedMovement;
|
|
||||||
|
|
||||||
// Ensure slid position is outside the cylinder radius horizontally.
|
|
||||||
float sdx = slidPos.X - obj.Position.X;
|
|
||||||
float sdy = slidPos.Y - obj.Position.Y;
|
|
||||||
float sDistSq = sdx * sdx + sdy * sdy;
|
|
||||||
float minDist = combinedR + 0.01f;
|
|
||||||
if (sDistSq < minDist * minDist)
|
|
||||||
{
|
|
||||||
float sDist = MathF.Sqrt(sDistSq);
|
|
||||||
if (sDist < PhysicsGlobals.EPSILON)
|
|
||||||
{
|
|
||||||
// Degenerate: push out along collisionNormal
|
|
||||||
slidPos.X = obj.Position.X + collisionNormal.X * minDist;
|
|
||||||
slidPos.Y = obj.Position.Y + collisionNormal.Y * minDist;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
float pushDist = (minDist - sDist);
|
n = new Vector3(0f, 0f, 1f);
|
||||||
slidPos.X += (sdx / sDist) * pushDist;
|
time = (checkSphere.Radius + cylHeight - movement.Z) / movement.Z;
|
||||||
slidPos.Y += (sdy / sDist) * pushDist;
|
|
||||||
}
|
}
|
||||||
|
scaledMovement = movement * time;
|
||||||
|
|
||||||
|
Vector3 landed = scaledMovement + oldDisp;
|
||||||
|
if (landed.X * landed.X + landed.Y * landed.Y >= radsumEps * radsumEps)
|
||||||
|
{
|
||||||
|
if (MathF.Abs(xyMoveLenSq) < PhysicsGlobals.EPSILON)
|
||||||
|
return TransitionState.Collided;
|
||||||
|
if (diffSq >= 0f && xyMoveLenSq > PhysicsGlobals.EPSILON)
|
||||||
|
{
|
||||||
|
float diff = MathF.Sqrt(diffSq);
|
||||||
|
time = xyDiff - diff < 0f
|
||||||
|
? (diff - dot2d) / xyMoveLenSq
|
||||||
|
: (xyDiff - diff) / xyMoveLenSq;
|
||||||
|
scaledMovement = movement * time;
|
||||||
|
}
|
||||||
|
n = (scaledMovement + globCenter - obj.Position) / radsumEps;
|
||||||
|
n.Z = 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (time < 0f || time > 1f)
|
||||||
|
return TransitionState.Collided;
|
||||||
|
|
||||||
|
Vector3 offsetOut = globCenter - scaledMovement - checkSphere.Origin;
|
||||||
|
sp.AddOffsetToCheckPos(offsetOut);
|
||||||
|
CollisionInfo.SetCollisionNormal(n);
|
||||||
|
return TransitionState.Adjusted;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply the offset (difference between slid and current CheckPos)
|
if (n.Z != 0f)
|
||||||
Vector3 delta = slidPos - sphereCheckPos;
|
{
|
||||||
sp.AddOffsetToCheckPos(delta);
|
if (MathF.Abs(movement.Z) < PhysicsGlobals.EPSILON)
|
||||||
|
return TransitionState.Collided;
|
||||||
|
|
||||||
ci.SetCollisionNormal(collisionNormal);
|
time = movement.Z > 0f
|
||||||
ci.SetSlidingNormal(collisionNormal);
|
? -((oldDisp.Z + checkSphere.Radius) / movement.Z)
|
||||||
return TransitionState.Slid;
|
: (checkSphere.Radius + cylHeight - oldDisp.Z) / movement.Z;
|
||||||
|
scaledMovement = movement * time;
|
||||||
|
|
||||||
|
if (time < 0f || time > 1f)
|
||||||
|
return TransitionState.Collided;
|
||||||
|
|
||||||
|
Vector3 offsetOut = globCenter + scaledMovement - checkSphere.Origin;
|
||||||
|
sp.AddOffsetToCheckPos(offsetOut);
|
||||||
|
CollisionInfo.SetCollisionNormal(n);
|
||||||
|
return TransitionState.Adjusted;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffSq < 0f || xyMoveLenSq < PhysicsGlobals.EPSILON)
|
||||||
|
return TransitionState.Collided;
|
||||||
|
|
||||||
|
{
|
||||||
|
float diff = MathF.Sqrt(diffSq);
|
||||||
|
time = xyDiff - diff < 0f
|
||||||
|
? (diff - dot2d) / xyMoveLenSq
|
||||||
|
: (xyDiff - diff) / xyMoveLenSq;
|
||||||
|
scaledMovement = movement * time;
|
||||||
|
|
||||||
|
if (time < 0f || time > 1f)
|
||||||
|
return TransitionState.Collided;
|
||||||
|
|
||||||
|
n = (scaledMovement + globCenter - obj.Position) / radsumEps;
|
||||||
|
n.Z = 0f;
|
||||||
|
|
||||||
|
Vector3 offsetOut = globCenter + scaledMovement - checkSphere.Origin;
|
||||||
|
sp.AddOffsetToCheckPos(offsetOut);
|
||||||
|
CollisionInfo.SetCollisionNormal(n);
|
||||||
|
return TransitionState.Adjusted;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
@ -3356,7 +3659,11 @@ public sealed class Transition
|
||||||
internal TransitionState SlideSphereInternal(Vector3 collisionNormal, Vector3 currPos)
|
internal TransitionState SlideSphereInternal(Vector3 collisionNormal, Vector3 currPos)
|
||||||
=> SlideSphere(collisionNormal, currPos);
|
=> SlideSphere(collisionNormal, currPos);
|
||||||
|
|
||||||
private TransitionState SlideSphere(Vector3 collisionNormal, Vector3 currPos)
|
/// <param name="sphereNum">Which path sphere is sliding (retail
|
||||||
|
/// CSphere::slide_sphere's <c>this</c> is the sphere instance — the head
|
||||||
|
/// sphere slides by its OWN displacement, 0x0053b843 passes
|
||||||
|
/// global_sphere[1]). Default 0 preserves every existing call site.</param>
|
||||||
|
private TransitionState SlideSphere(Vector3 collisionNormal, Vector3 currPos, int sphereNum = 0)
|
||||||
{
|
{
|
||||||
var sp = SpherePath;
|
var sp = SpherePath;
|
||||||
var ci = CollisionInfo;
|
var ci = CollisionInfo;
|
||||||
|
|
@ -3364,7 +3671,7 @@ public sealed class Transition
|
||||||
// Degenerate case: zero collision normal — nudge halfway.
|
// Degenerate case: zero collision normal — nudge halfway.
|
||||||
if (collisionNormal.LengthSquared() < PhysicsGlobals.EpsilonSq)
|
if (collisionNormal.LengthSquared() < PhysicsGlobals.EpsilonSq)
|
||||||
{
|
{
|
||||||
Vector3 halfOffset = (currPos - sp.GlobalSphere[0].Origin) * 0.5f;
|
Vector3 halfOffset = (currPos - sp.GlobalSphere[sphereNum].Origin) * 0.5f;
|
||||||
sp.AddOffsetToCheckPos(halfOffset);
|
sp.AddOffsetToCheckPos(halfOffset);
|
||||||
return TransitionState.Adjusted;
|
return TransitionState.Adjusted;
|
||||||
}
|
}
|
||||||
|
|
@ -3374,7 +3681,7 @@ public sealed class Transition
|
||||||
// gDelta: displacement from currPos to the current check sphere center.
|
// gDelta: displacement from currPos to the current check sphere center.
|
||||||
// In the retail code this includes a block offset for cross-landblock
|
// In the retail code this includes a block offset for cross-landblock
|
||||||
// transitions; for outdoor single-landblock movement this is zero.
|
// transitions; for outdoor single-landblock movement this is zero.
|
||||||
Vector3 gDelta = sp.GlobalSphere[0].Origin - currPos;
|
Vector3 gDelta = sp.GlobalSphere[sphereNum].Origin - currPos;
|
||||||
|
|
||||||
// Get the contact plane (prefer current, fall back to last known).
|
// Get the contact plane (prefer current, fall back to last known).
|
||||||
System.Numerics.Plane contactPlane;
|
System.Numerics.Plane contactPlane;
|
||||||
|
|
@ -3438,15 +3745,25 @@ public sealed class Transition
|
||||||
return TransitionState.Slid;
|
return TransitionState.Slid;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opposing normals: give up, reverse direction.
|
// Opposing normals (collision normal anti-parallel to the contact
|
||||||
// Retail returns OK here to allow retry with the reversed normal.
|
// plane, e.g. a ceiling-facing normal while grounded): record the
|
||||||
|
// REVERSED displacement as the collision normal and return COLLIDED.
|
||||||
|
// Retail CSphere::slide_sphere 0x00537440 @0x005375d7-0x0053762c:
|
||||||
|
// `*normal = -gDelta; normalize_check_small; set_collision_normal;
|
||||||
|
// return 2 (COLLIDED_TS)`. #137 (2026-07-06): this previously
|
||||||
|
// returned OK ("to allow retry with the reversed normal" — a decomp
|
||||||
|
// misread), which let the step complete as-is carrying a SYNTHETIC
|
||||||
|
// reversed-movement collision normal — the live corridor hit's
|
||||||
|
// `n=(-1.00,0.03,-0.03)` (= the negated run direction) matched no
|
||||||
|
// dat polygon; validate's epilogue then turned it into a persisted
|
||||||
|
// sliding normal and wedged all forward motion.
|
||||||
Vector3 reversed = -gDelta;
|
Vector3 reversed = -gDelta;
|
||||||
if (reversed.LengthSquared() > PhysicsGlobals.EpsilonSq)
|
if (reversed.LengthSquared() > PhysicsGlobals.EpsilonSq)
|
||||||
{
|
{
|
||||||
reversed = Vector3.Normalize(reversed);
|
reversed = Vector3.Normalize(reversed);
|
||||||
ci.SetCollisionNormal(reversed);
|
ci.SetCollisionNormal(reversed);
|
||||||
}
|
}
|
||||||
return TransitionState.OK;
|
return TransitionState.Collided;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,33 @@ public static class RenderingDiagnostics
|
||||||
public static bool ProbeLightEnabled { get; set; } =
|
public static bool ProbeLightEnabled { get; set; } =
|
||||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1";
|
Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A7.L1 (2026-07-06) per-cell light SET-COMPOSITION probe — the apparatus the
|
||||||
|
/// <c>[light]</c> counts could not provide (the #176/#177 discriminator: the bug
|
||||||
|
/// lived in set MEMBERSHIP, not counts). When true, the scoped
|
||||||
|
/// <c>LightManager.BuildPointLightSnapshot</c> emits ONE rate-limited
|
||||||
|
/// <c>[indoor-light]</c> line describing the visible-cell-scoped point-light pool
|
||||||
|
/// (see <see cref="EmitIndoorLight"/>):
|
||||||
|
/// <code>
|
||||||
|
/// [indoor-light] visibleCells=<N> pool=<M> cellLess=<K> registered=<R>
|
||||||
|
/// droppedNonVisible=<R-M> byCell=[0x<id>:<count>,...]
|
||||||
|
/// </code>
|
||||||
|
/// This validates the A7 fix's load-bearing assumption end-to-end:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><c>cellLess==pool</c> (every pool light is CellId 0) ⇒
|
||||||
|
/// cell tagging FAILED (ParentCellId not flowing) — scoping is a silent no-op.</description></item>
|
||||||
|
/// <item><description><c>pool==cellLess</c> while <c>registered</c> is large in a
|
||||||
|
/// LIT room ⇒ tagged CellIds never match the visible set (wrong id form) — the
|
||||||
|
/// room would go dark.</description></item>
|
||||||
|
/// <item><description><c>droppedNonVisible>0</c> with <c>byCell</c> tracking the
|
||||||
|
/// visible rooms ⇒ scoping WORKING (the under-room/through-floor lights are the
|
||||||
|
/// dropped ones).</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// Output-only, inert when off. Initial state from <c>ACDREAM_PROBE_INDOOR_LIGHT=1</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static bool ProbeIndoorLightEnabled { get; set; } =
|
||||||
|
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LIGHT") == "1";
|
||||||
|
|
||||||
// Cell-change gate for EmitVis. The probe fires once per distinct root cell
|
// Cell-change gate for EmitVis. The probe fires once per distinct root cell
|
||||||
// so launch.log stays readable under motion (the per-frame call is a no-op
|
// so launch.log stays readable under motion (the per-frame call is a no-op
|
||||||
// when the root is unchanged). Sentinel 0 = "no root yet" — the first real
|
// when the root is unchanged). Sentinel 0 = "no root yet" — the first real
|
||||||
|
|
@ -451,6 +478,66 @@ public static class RenderingDiagnostics
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wall-clock rate-limit gate for EmitIndoorLight (shares the 1 s interval).
|
||||||
|
private static long _lastIndoorLightEmitTicks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A7.L1 — emit ONE rate-limited <c>[indoor-light]</c> line describing the
|
||||||
|
/// visible-cell-scoped point-light pool: the SET COMPOSITION the <c>[light]</c>
|
||||||
|
/// counts can't show. Cheap no-op when <see cref="ProbeIndoorLightEnabled"/> is
|
||||||
|
/// false; otherwise fires at most once per second. Called from the scoped
|
||||||
|
/// <c>LightManager.BuildPointLightSnapshot</c> (visibleCells != null path).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="visibleCellCount">Size of the portal-flood visible-cell set this frame.</param>
|
||||||
|
/// <param name="allRegistered">Every registered light (<c>LightManager._all</c>).</param>
|
||||||
|
/// <param name="scopedSnapshot">The visible-cell-scoped point-light pool just built.</param>
|
||||||
|
public static void EmitIndoorLight(int visibleCellCount,
|
||||||
|
IReadOnlyList<AcDream.Core.Lighting.LightSource> allRegistered,
|
||||||
|
IReadOnlyList<AcDream.Core.Lighting.LightSource> scopedSnapshot)
|
||||||
|
{
|
||||||
|
if (!ProbeIndoorLightEnabled) return;
|
||||||
|
|
||||||
|
long now = DateTime.UtcNow.Ticks;
|
||||||
|
if (_lastIndoorLightEmitTicks != 0 && (now - _lastIndoorLightEmitTicks) < LightEmitIntervalTicks)
|
||||||
|
return;
|
||||||
|
_lastIndoorLightEmitTicks = now;
|
||||||
|
|
||||||
|
int registeredLitPoints = 0;
|
||||||
|
foreach (var l in allRegistered)
|
||||||
|
if (l.IsLit && l.Kind != AcDream.Core.Lighting.LightKind.Directional) registeredLitPoints++;
|
||||||
|
|
||||||
|
int pool = scopedSnapshot.Count;
|
||||||
|
int cellLess = 0;
|
||||||
|
var hist = new Dictionary<uint, int>();
|
||||||
|
foreach (var l in scopedSnapshot)
|
||||||
|
{
|
||||||
|
if (l.CellId == 0) cellLess++;
|
||||||
|
hist.TryGetValue(l.CellId, out var c);
|
||||||
|
hist[l.CellId] = c + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new StringBuilder(220);
|
||||||
|
sb.Append("[indoor-light] visibleCells=").Append(visibleCellCount);
|
||||||
|
sb.Append(" pool=").Append(pool);
|
||||||
|
sb.Append(" cellLess=").Append(cellLess);
|
||||||
|
sb.Append(" registered=").Append(registeredLitPoints);
|
||||||
|
// Lights excluded by visibility scoping (retail: cells not in visible_cell_table
|
||||||
|
// contribute nothing) — the through-floor/under-room lights kept out of the pool.
|
||||||
|
sb.Append(" droppedNonVisible=").Append(registeredLitPoints - pool);
|
||||||
|
sb.Append(" byCell=[");
|
||||||
|
const int MaxCells = 12;
|
||||||
|
int shown = 0;
|
||||||
|
foreach (var kv in hist)
|
||||||
|
{
|
||||||
|
if (shown >= MaxCells) { sb.Append(",..."); break; }
|
||||||
|
if (shown > 0) sb.Append(',');
|
||||||
|
sb.Append("0x").Append(kv.Key.ToString("X8")).Append(':').Append(kv.Value);
|
||||||
|
shown++;
|
||||||
|
}
|
||||||
|
sb.Append(']');
|
||||||
|
Console.WriteLine(sb.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
private static bool _probeEnvCellEnabled =
|
private static bool _probeEnvCellEnabled =
|
||||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1";
|
Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,280 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #176 (purple flashing at corridor seams, camera-angle dependent) + #177
|
||||||
|
/// (stairs pop in/out) — headless portal-flood replay in the Facility Hub
|
||||||
|
/// landblock 0x8A02. The unified hypothesis after the dat + draw-path reads:
|
||||||
|
/// both artifacts are FLOOD ADMISSION instability (a cell dropping out of
|
||||||
|
/// PortalVisibilityBuilder's admitted set paints the fog-purple clear color
|
||||||
|
/// where its geometry was; stair cells failing admission = the pop).
|
||||||
|
///
|
||||||
|
/// Production-matched inputs: Build(root, eye, lookup, viewProj,
|
||||||
|
/// buildingMembership: null, drawLiftZ: ShellDrawLiftZ) — the drawLiftZ
|
||||||
|
/// mirrors RetailPViewRenderer.DrawInside.
|
||||||
|
///
|
||||||
|
/// Scenarios:
|
||||||
|
/// A. #177 approach — stand in corridor 0x0178, look +X at the stair ramp
|
||||||
|
/// (0x0182) and the lower cell (0x0183): are they admitted?
|
||||||
|
/// B. #177 descent — eye path down the ramp crossing into 0x0183: does
|
||||||
|
/// 0x0182 (the ramp geometry's owner) drop near the transit?
|
||||||
|
/// C. #176 gaze sweep — eye parked in 0x016E near the 0x017A seam, yaw
|
||||||
|
/// sweep at several pitches: any cell admitted at angle k, gone at k+1,
|
||||||
|
/// back at k+2 (the bistability signature)?
|
||||||
|
/// D. #176 walk — eye tracks down the corridor across two seams, gaze
|
||||||
|
/// locked +X: per-step admitted-set diffs (drop-for-one-step churn).
|
||||||
|
/// </summary>
|
||||||
|
public class Issue176177FacilityHubFloodReplayTests
|
||||||
|
{
|
||||||
|
private const uint FacilityHub = 0x8A020000u;
|
||||||
|
|
||||||
|
private readonly ITestOutputHelper _out;
|
||||||
|
public Issue176177FacilityHubFloodReplayTests(ITestOutputHelper output) => _out = output;
|
||||||
|
|
||||||
|
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 gazeDir)
|
||||||
|
{
|
||||||
|
var view = Matrix4x4.CreateLookAt(eye, eye + gazeDir, Vector3.UnitZ);
|
||||||
|
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f);
|
||||||
|
return view * proj;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<uint> Flood(
|
||||||
|
Dictionary<uint, LoadedCell> cells, uint rootId, Vector3 eye, Vector3 gazeDir)
|
||||||
|
{
|
||||||
|
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||||
|
var frame = PortalVisibilityBuilder.Build(
|
||||||
|
cells[rootId], eye, lookup, ViewProjFor(eye, gazeDir),
|
||||||
|
buildingMembership: null,
|
||||||
|
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
|
||||||
|
var result = new List<uint>(frame.OrderedVisibleCells);
|
||||||
|
result.Sort();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CellSetString(IEnumerable<uint> ids)
|
||||||
|
=> string.Join(" ", ids.Select(id => $"{id & 0xFFFFu:X4}"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScenarioA_StairApproach_AdmissionsFromCorridor()
|
||||||
|
{
|
||||||
|
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||||
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||||
|
Assert.True(cells.ContainsKey(FacilityHub | 0x0178u), "0x0178 not loaded");
|
||||||
|
|
||||||
|
// 0x0178 spans x<=95 at z −6..−3 (floor −6); the ramp cell 0x0182 runs
|
||||||
|
// x 95→98.33 descending; 0x0183 continues at z −9 beyond x=98.33.
|
||||||
|
// Eye at standing height (~1.7 m above the −6 floor), approaching the
|
||||||
|
// stair portal at x=95, gazing +X with a slight downward pitch (the
|
||||||
|
// natural look at a descending stair).
|
||||||
|
foreach (float eyeX in new[] { 88f, 90f, 92f, 94f, 94.9f })
|
||||||
|
{
|
||||||
|
var eye = new Vector3(eyeX, -40f, -4.3f);
|
||||||
|
foreach (var (gaze, label) in new (Vector3, string)[]
|
||||||
|
{
|
||||||
|
(new Vector3(1f, 0f, 0f), "level"),
|
||||||
|
(Vector3.Normalize(new Vector3(1f, 0f, -0.35f)), "pitch-19"),
|
||||||
|
(Vector3.Normalize(new Vector3(1f, 0f, -0.7f)), "pitch-35"),
|
||||||
|
})
|
||||||
|
{
|
||||||
|
var visible = Flood(cells, FacilityHub | 0x0178u, eye, gaze);
|
||||||
|
bool ramp = visible.Contains(FacilityHub | 0x0182u);
|
||||||
|
bool lower = visible.Contains(FacilityHub | 0x0183u);
|
||||||
|
_out.WriteLine($"eyeX={eyeX,5:F1} gaze={label,-8} flood={visible.Count,2} " +
|
||||||
|
$"ramp0182={(ramp ? "Y" : "MISSING")} lower0183={(lower ? "Y" : "MISSING")} " +
|
||||||
|
$"[{CellSetString(visible)}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScenarioB_StairDescent_RampCellRetention()
|
||||||
|
{
|
||||||
|
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||||
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||||
|
|
||||||
|
// Descend the ramp: floor z goes −6 (x=95) → −9 (x=98.33), then flat.
|
||||||
|
// Eye rides ~1.7 m above the local floor. Root = the cell containing
|
||||||
|
// the eye by x-range (0x0178 x<95, ramp 0x0182 95..98.33, 0x0183 after).
|
||||||
|
// Gaze: forward and slightly down (running down stairs).
|
||||||
|
var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.4f));
|
||||||
|
List<uint>? prev = null;
|
||||||
|
for (float x = 94.0f; x <= 100.5f; x += 0.1f)
|
||||||
|
{
|
||||||
|
float floorZ = x < 95f ? -6f
|
||||||
|
: x < 98.333f ? -6f - 3f * (x - 95f) / 3.333f
|
||||||
|
: -9f;
|
||||||
|
var eye = new Vector3(x, -40f, floorZ + 1.7f);
|
||||||
|
uint rootId = x < 95f ? FacilityHub | 0x0178u
|
||||||
|
: x < 98.333f ? FacilityHub | 0x0182u
|
||||||
|
: FacilityHub | 0x0183u;
|
||||||
|
|
||||||
|
var visible = Flood(cells, rootId, eye, gazeDir);
|
||||||
|
bool ramp = visible.Contains(FacilityHub | 0x0182u);
|
||||||
|
bool upper = visible.Contains(FacilityHub | 0x0178u);
|
||||||
|
|
||||||
|
string diff = "";
|
||||||
|
if (prev is not null)
|
||||||
|
{
|
||||||
|
var removed = prev.Except(visible).ToList();
|
||||||
|
var added = visible.Except(prev).ToList();
|
||||||
|
if (removed.Count > 0) diff += $" REMOVED=[{CellSetString(removed)}]";
|
||||||
|
if (added.Count > 0) diff += $" added=[{CellSetString(added)}]";
|
||||||
|
}
|
||||||
|
_out.WriteLine($"x={x,6:F1} root={rootId & 0xFFFFu:X4} eyeZ={eye.Z,6:F2} flood={visible.Count,2} " +
|
||||||
|
$"ramp0182={(ramp ? "Y" : "MISSING")} up0178={(upper ? "Y" : "-")}{diff}");
|
||||||
|
prev = visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScenarioC_CorridorSeamGazeSweep_Bistability()
|
||||||
|
{
|
||||||
|
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||||
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||||
|
|
||||||
|
// Parked eye in 0x016E near the 0x017A seam (x=85), standing height.
|
||||||
|
var eye = new Vector3(83.5f, -40f, -4.3f);
|
||||||
|
uint rootId = FacilityHub | 0x016Eu;
|
||||||
|
|
||||||
|
int churnEvents = 0;
|
||||||
|
foreach (float pitchZ in new[] { 0f, -0.35f, -0.75f, -1.2f })
|
||||||
|
{
|
||||||
|
List<uint>? prevSet = null;
|
||||||
|
float prevYaw = 0f;
|
||||||
|
var perYawSets = new List<(float yaw, List<uint> set)>();
|
||||||
|
for (float yawDeg = -180f; yawDeg <= 180f; yawDeg += 2f)
|
||||||
|
{
|
||||||
|
float rad = yawDeg * MathF.PI / 180f;
|
||||||
|
var gaze = Vector3.Normalize(new Vector3(MathF.Cos(rad), MathF.Sin(rad), pitchZ));
|
||||||
|
var visible = Flood(cells, rootId, eye, gaze);
|
||||||
|
perYawSets.Add((yawDeg, visible));
|
||||||
|
|
||||||
|
if (prevSet is not null)
|
||||||
|
{
|
||||||
|
var removed = prevSet.Except(visible).ToList();
|
||||||
|
var added = visible.Except(prevSet).ToList();
|
||||||
|
if (removed.Count > 0 || added.Count > 0)
|
||||||
|
{
|
||||||
|
_out.WriteLine($"pitch={pitchZ,5:F2} yaw {prevYaw,6:F0}->{yawDeg,6:F0}: " +
|
||||||
|
$"flood {prevSet.Count}->{visible.Count}" +
|
||||||
|
(removed.Count > 0 ? $" REMOVED=[{CellSetString(removed)}]" : "") +
|
||||||
|
(added.Count > 0 ? $" added=[{CellSetString(added)}]" : ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevSet = visible;
|
||||||
|
prevYaw = yawDeg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bistability: a cell present at yaw k, absent at k+1, present at k+2.
|
||||||
|
for (int i = 2; i < perYawSets.Count; i++)
|
||||||
|
{
|
||||||
|
var flicker = perYawSets[i - 2].set
|
||||||
|
.Intersect(perYawSets[i].set)
|
||||||
|
.Except(perYawSets[i - 1].set)
|
||||||
|
.ToList();
|
||||||
|
if (flicker.Count > 0)
|
||||||
|
{
|
||||||
|
churnEvents++;
|
||||||
|
_out.WriteLine($">>> BISTABLE pitch={pitchZ:F2} yaw={perYawSets[i - 1].yaw:F0}: " +
|
||||||
|
$"cells [{CellSetString(flicker)}] dropped for ONE 2-degree step");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_out.WriteLine($"bistable one-step drop events: {churnEvents}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// THE production lag-window scenario (from launch-137-gate2.log
|
||||||
|
/// [cell-transit] lines): membership transits fire 0.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.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x016Eu, 0x017Au, 85.00f, -4.3f)] // corridor seam, plane x=85
|
||||||
|
[InlineData(0x0182u, 0x0183u, 98.333f, -7.3f)] // stair-bottom transit, plane x=98.33
|
||||||
|
public void ScenarioE_RootLagWindow_ForwardChainRetention(
|
||||||
|
uint rootLow, uint forwardLow, float planeX, float eyeZ)
|
||||||
|
{
|
||||||
|
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||||
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||||
|
|
||||||
|
uint rootId = FacilityHub | rootLow;
|
||||||
|
uint forwardId = FacilityHub | forwardLow;
|
||||||
|
var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.3f));
|
||||||
|
|
||||||
|
_out.WriteLine($"root=0x{rootLow:X4} forward=0x{forwardLow:X4} plane x={planeX:F2} " +
|
||||||
|
"(eye sweeps across; root HELD at the old cell = the production lag window)");
|
||||||
|
foreach (float dx in new[] { -0.30f, -0.10f, -0.02f, 0.00f, 0.02f, 0.05f, 0.10f, 0.20f, 0.30f, 0.45f, 0.60f })
|
||||||
|
{
|
||||||
|
var eye = new Vector3(planeX + dx, -40f, eyeZ);
|
||||||
|
var visible = Flood(cells, rootId, eye, gazeDir);
|
||||||
|
bool fwd = visible.Contains(forwardId);
|
||||||
|
_out.WriteLine($" eyeX=plane{(dx >= 0 ? "+" : "")}{dx:F2} flood={visible.Count,2} " +
|
||||||
|
$"forward={(fwd ? "Y" : ">>> DROPPED <<<")} [{CellSetString(visible)}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScenarioD_CorridorWalk_PerStepChurn()
|
||||||
|
{
|
||||||
|
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||||
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||||
|
|
||||||
|
// Walk 0x0165 → 0x016E → 0x017A along y=−40 (seams at x=75 and x=85),
|
||||||
|
// gaze locked +X, slight downward pitch (the running view). Root flips
|
||||||
|
// by x-range at the seams (the camera transits the same portals).
|
||||||
|
var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.3f));
|
||||||
|
List<uint>? prev = null;
|
||||||
|
int churn = 0;
|
||||||
|
for (float x = 71f; x <= 89f; x += 0.05f)
|
||||||
|
{
|
||||||
|
uint rootId = x < 75f ? FacilityHub | 0x0165u
|
||||||
|
: x < 85f ? FacilityHub | 0x016Eu
|
||||||
|
: FacilityHub | 0x017Au;
|
||||||
|
if (!cells.ContainsKey(rootId)) continue;
|
||||||
|
var eye = new Vector3(x, -40f, -4.3f);
|
||||||
|
var visible = Flood(cells, rootId, eye, gazeDir);
|
||||||
|
|
||||||
|
if (prev is not null)
|
||||||
|
{
|
||||||
|
var removed = prev.Except(visible).ToList();
|
||||||
|
var added = visible.Except(prev).ToList();
|
||||||
|
if (removed.Count > 0 || added.Count > 0)
|
||||||
|
{
|
||||||
|
churn++;
|
||||||
|
_out.WriteLine($"x={x,6:F2} root={rootId & 0xFFFFu:X4} flood={visible.Count,2}" +
|
||||||
|
(removed.Count > 0 ? $" REMOVED=[{CellSetString(removed)}]" : "") +
|
||||||
|
(added.Count > 0 ? $" added=[{CellSetString(added)}]" : ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev = visible;
|
||||||
|
}
|
||||||
|
_out.WriteLine($"admitted-set change events over the walk: {churn}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -122,7 +122,9 @@ public class PlayerMoveToCutoverTests
|
||||||
// the A9 pending_motions node (a null sink orphans it and the
|
// the A9 pending_motions node (a null sink orphans it and the
|
||||||
// MoveToManager wait-for-anims gate never opens — the live stall).
|
// MoveToManager wait-for-anims gate never opens — the live stall).
|
||||||
controller.Motion.DefaultSink = new MotionTableDispatchSink(seq);
|
controller.Motion.DefaultSink = new MotionTableDispatchSink(seq);
|
||||||
controller.Motion.RemoveLinkAnimations = seq.RemoveAllLinkAnimations;
|
// #174: production wiring — HandleEnterWorld (strip + drain), not
|
||||||
|
// the bare sequence strip (which orphaned pending manager nodes).
|
||||||
|
controller.Motion.RemoveLinkAnimations = () => seq.Manager.HandleEnterWorld();
|
||||||
controller.Motion.InitializeMotionTables = () => seq.Manager.InitializeState();
|
controller.Motion.InitializeMotionTables = () => seq.Manager.InitializeState();
|
||||||
controller.Motion.CheckForCompletedMotions = seq.Manager.CheckForCompletedMotions;
|
controller.Motion.CheckForCompletedMotions = seq.Manager.CheckForCompletedMotions;
|
||||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,9 @@ public class W6EdgeDrivenMovementTests
|
||||||
// dispatch, else its A9 pending_motions node is orphaned).
|
// dispatch, else its A9 pending_motions node is orphaned).
|
||||||
controller.Motion.DefaultSink = new MotionTableDispatchSink(seq);
|
controller.Motion.DefaultSink = new MotionTableDispatchSink(seq);
|
||||||
var s = seq;
|
var s = seq;
|
||||||
controller.Motion.RemoveLinkAnimations = s.RemoveAllLinkAnimations;
|
// #174: production wiring — HandleEnterWorld (strip + drain), not
|
||||||
|
// the bare sequence strip (which orphaned pending manager nodes).
|
||||||
|
controller.Motion.RemoveLinkAnimations = () => s.Manager.HandleEnterWorld();
|
||||||
controller.Motion.InitializeMotionTables = () => s.Manager.InitializeState();
|
controller.Motion.InitializeMotionTables = () => s.Manager.InitializeState();
|
||||||
controller.Motion.CheckForCompletedMotions = s.Manager.CheckForCompletedMotions;
|
controller.Motion.CheckForCompletedMotions = s.Manager.CheckForCompletedMotions;
|
||||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ namespace AcDream.Core.Tests.Lighting;
|
||||||
|
|
||||||
public sealed class LightManagerTests
|
public sealed class LightManagerTests
|
||||||
{
|
{
|
||||||
private static LightSource MakePoint(Vector3 pos, float range, uint ownerId = 0, bool lit = true)
|
private static LightSource MakePoint(Vector3 pos, float range, uint ownerId = 0, bool lit = true, uint cellId = 0)
|
||||||
=> new LightSource
|
=> new LightSource
|
||||||
{
|
{
|
||||||
Kind = LightKind.Point,
|
Kind = LightKind.Point,
|
||||||
|
|
@ -14,6 +14,7 @@ public sealed class LightManagerTests
|
||||||
Range = range,
|
Range = range,
|
||||||
IsLit = lit,
|
IsLit = lit,
|
||||||
OwnerId = ownerId,
|
OwnerId = ownerId,
|
||||||
|
CellId = cellId,
|
||||||
};
|
};
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -176,6 +177,55 @@ public sealed class LightManagerTests
|
||||||
Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3);
|
Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Visible-cell scoping (retail: add_*_lights over visible_cell_table) ────
|
||||||
|
// A7 #176/#177: the per-frame pool is built from ONLY the lights of currently-
|
||||||
|
// visible cells (plus cell-less globals), not a flat world-space set.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildPointLightSnapshot_VisibleScope_ExcludesLightsOfNonVisibleCells()
|
||||||
|
{
|
||||||
|
var mgr = new LightManager();
|
||||||
|
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAA0101u)); // visible cell
|
||||||
|
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); // under-room, NOT visible
|
||||||
|
|
||||||
|
var visible = new System.Collections.Generic.HashSet<uint> { 0xAAAA0101u };
|
||||||
|
mgr.BuildPointLightSnapshot(Vector3.Zero, visible);
|
||||||
|
|
||||||
|
// Only the visible cell's light survives — the under-room light can't wash
|
||||||
|
// through the floor (retail: its cell isn't in visible_cell_table).
|
||||||
|
Assert.Single(mgr.PointSnapshot);
|
||||||
|
Assert.Equal(0xAAAA0101u, mgr.PointSnapshot[0].CellId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildPointLightSnapshot_VisibleScope_AlwaysIncludesCellLessGlobals()
|
||||||
|
{
|
||||||
|
var mgr = new LightManager();
|
||||||
|
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0u)); // viewer/global — CellId 0
|
||||||
|
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); // non-visible cell
|
||||||
|
|
||||||
|
var visible = new System.Collections.Generic.HashSet<uint> { 0xAAAA0101u }; // does NOT contain 0102
|
||||||
|
mgr.BuildPointLightSnapshot(Vector3.Zero, visible);
|
||||||
|
|
||||||
|
// The cell-less light (viewer fill) is always a candidate; the non-visible
|
||||||
|
// cell's light is dropped.
|
||||||
|
Assert.Single(mgr.PointSnapshot);
|
||||||
|
Assert.Equal(0u, mgr.PointSnapshot[0].CellId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildPointLightSnapshot_NullScope_KeepsFullPool()
|
||||||
|
{
|
||||||
|
var mgr = new LightManager();
|
||||||
|
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAA0101u));
|
||||||
|
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u));
|
||||||
|
|
||||||
|
// Null visible set = outdoor root / no flood → legacy full-pool behaviour.
|
||||||
|
mgr.BuildPointLightSnapshot(Vector3.Zero, visibleCells: null);
|
||||||
|
|
||||||
|
Assert.Equal(2, mgr.PointSnapshot.Count);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SelectForObject_EmptySnapshot_ReturnsZero()
|
public void SelectForObject_EmptySnapshot_ReturnsZero()
|
||||||
{
|
{
|
||||||
|
|
@ -256,4 +306,69 @@ public sealed class LightManagerTests
|
||||||
Assert.Equal(na, nb);
|
Assert.Equal(na, nb);
|
||||||
Assert.Equal(a[0], b[0]);
|
Assert.Equal(a[0], b[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #176/#177 (2026-07-06) — the end-state pin, via the SHIPPED fix (visible-cell
|
||||||
|
/// scoping, not "uncap"). Before: <c>BuildPointLightSnapshot</c> kept only the
|
||||||
|
/// <c>MaxGlobalLights</c> nearest THE CAMERA over the WHOLE registered set, so in
|
||||||
|
/// the Facility Hub (366 fixtures) an in-range torch of a VISIBLE cell could rank
|
||||||
|
/// past the cap and be evicted → the cell's 8-set (and its Gouraud vertex lighting)
|
||||||
|
/// flipped as the camera moved (#176 seam flash / #177 stair-room pop-in). The fix
|
||||||
|
/// is retail's per-frame collection: the pool is built from ONLY the lights of the
|
||||||
|
/// currently-VISIBLE cells (<c>CObjCell::add_*_to_global_lights</c> over
|
||||||
|
/// <c>CEnvCell::visible_cell_table</c>), so the visible pool is a handful of cells,
|
||||||
|
/// the cap never bites, and a visible cell's in-range light is never camera-evicted.
|
||||||
|
/// The same scoping keeps a NON-visible cell's light out of the pool entirely
|
||||||
|
/// (through-floor prevention). See <c>docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md</c>.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant()
|
||||||
|
{
|
||||||
|
var mgr = new LightManager();
|
||||||
|
|
||||||
|
// 400 fixtures clustered near the origin, all in the UNDER-ROOM cell (not
|
||||||
|
// visible from the target room). These would have filled every low
|
||||||
|
// camera-distance rank under the old camera-nearest cap.
|
||||||
|
const uint underRoom = 0xAAAA0102u;
|
||||||
|
for (int i = 0; i < 400; i++)
|
||||||
|
mgr.Register(MakePoint(new Vector3(i * 0.05f, 0, 0), range: 5f, ownerId: (uint)(i + 1), cellId: underRoom));
|
||||||
|
|
||||||
|
// The target torch: far from the origin-side camera, in the VISIBLE room
|
||||||
|
// cell, squarely in range of the target object around (200, 0, 0).
|
||||||
|
const uint targetRoom = 0xAAAA0101u;
|
||||||
|
var torch = MakePoint(new Vector3(198f, 0, 0), range: 15f, ownerId: 0xF00DF00Du, cellId: targetRoom);
|
||||||
|
mgr.Register(torch);
|
||||||
|
|
||||||
|
// The portal flood says only the target room is visible.
|
||||||
|
var visible = new System.Collections.Generic.HashSet<uint> { targetRoom };
|
||||||
|
Span<int> sel = stackalloc int[LightManager.MaxLightsPerObject];
|
||||||
|
|
||||||
|
// Camera parked at the origin end — the torch must still light the visible cell.
|
||||||
|
mgr.BuildPointLightSnapshot(cameraWorldPos: Vector3.Zero, visible);
|
||||||
|
int n1 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
|
||||||
|
bool torchSelectedFar = SelectedContains(mgr.PointSnapshot, sel, n1, torch);
|
||||||
|
// The 400 under-room lights are NOT in the pool (their cell isn't visible).
|
||||||
|
int underRoomInPool = 0;
|
||||||
|
foreach (var l in mgr.PointSnapshot) if (l.CellId == underRoom) underRoomInPool++;
|
||||||
|
|
||||||
|
// Camera next to the cell — the reference behaviour.
|
||||||
|
mgr.BuildPointLightSnapshot(cameraWorldPos: new Vector3(200f, 0, 0), visible);
|
||||||
|
int n2 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
|
||||||
|
bool torchSelectedNear = SelectedContains(mgr.PointSnapshot, sel, n2, torch);
|
||||||
|
|
||||||
|
Assert.True(torchSelectedNear, "sanity: the torch reaches the cell when the camera is beside it");
|
||||||
|
Assert.True(torchSelectedFar,
|
||||||
|
"an in-range light of a VISIBLE cell was evicted by the snapshot cap — " +
|
||||||
|
"per-cell lighting would pop with camera movement (the #176/#177 mechanism)");
|
||||||
|
Assert.Equal(0, underRoomInPool); // through-floor prevention: non-visible cell's lights excluded
|
||||||
|
|
||||||
|
static bool SelectedContains(
|
||||||
|
System.Collections.Generic.IReadOnlyList<LightSource> snapshot,
|
||||||
|
Span<int> indices, int count, LightSource target)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
if (ReferenceEquals(snapshot[indices[i]], target)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -699,8 +699,13 @@ public class BSPQueryTests
|
||||||
// Regression guard for the FULL-HIT case in the same Path 5 branch.
|
// Regression guard for the FULL-HIT case in the same Path 5 branch.
|
||||||
// Sphere overlaps wall AND moves INTO it: moveDot < 0, cull does NOT
|
// Sphere overlaps wall AND moves INTO it: moveDot < 0, cull does NOT
|
||||||
// reject, pos_hits_sphere returns 1, Path 5 takes the `if (hit0)`
|
// reject, pos_hits_sphere returns 1, Path 5 takes the `if (hit0)`
|
||||||
// branch. With engine=null we fall through to the slide fallback
|
// branch. With engine=null we fall through to the real slide
|
||||||
// (SetCollisionNormal + SetSlidingNormal + return Slid).
|
// (CSphere::slide_sphere via Transition.SlideSphereInternal). No
|
||||||
|
// contact plane is seeded on this bare Transition, so the slide takes
|
||||||
|
// the wall-only branch (project out the into-wall displacement,
|
||||||
|
// return Slid) — and per retail it must NOT write the sliding normal
|
||||||
|
// (#137 mechanism 2; validate_transition 0x0050ac21 is the only
|
||||||
|
// in-transition writer).
|
||||||
var (root, resolved) = BuildSingleWallBsp();
|
var (root, resolved) = BuildSingleWallBsp();
|
||||||
|
|
||||||
var transition = new Transition();
|
var transition = new Transition();
|
||||||
|
|
@ -731,6 +736,9 @@ public class BSPQueryTests
|
||||||
Assert.Equal(TransitionState.Slid, state);
|
Assert.Equal(TransitionState.Slid, state);
|
||||||
Assert.True(transition.CollisionInfo.CollisionNormalValid,
|
Assert.True(transition.CollisionInfo.CollisionNormalValid,
|
||||||
"Full hit should set the collision normal (slide fallback).");
|
"Full hit should set the collision normal (slide fallback).");
|
||||||
|
Assert.False(transition.CollisionInfo.SlidingNormalValid,
|
||||||
|
"find_collisions must not write the sliding normal — retail's " +
|
||||||
|
"only in-transition writer is validate_transition (#137).");
|
||||||
Assert.False(transition.SpherePath.NegPolyHit,
|
Assert.False(transition.SpherePath.NegPolyHit,
|
||||||
"Full hit should NOT also fire NegPolyHit — that's the near-miss " +
|
"Full hit should NOT also fire NegPolyHit — that's the near-miss " +
|
||||||
"path only. Retail at acclient_2013_pseudo_c.txt:0053a647 returns " +
|
"path only. Retail at acclient_2013_pseudo_c.txt:0053a647 returns " +
|
||||||
|
|
|
||||||
287
tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs
Normal file
287
tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
using System;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
using Plane = System.Numerics.Plane;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Conformance tests for the retail <c>CCylSphere</c> collision family port
|
||||||
|
/// (2026-07-05) — dispatcher <c>0x0053b440</c> + <c>step_sphere_down</c>
|
||||||
|
/// <c>0x0053a9b0</c> + <c>step_sphere_up</c> <c>0x0053b310</c> +
|
||||||
|
/// <c>land_on_cylinder</c> <c>0x0053b3d0</c>. Pseudocode:
|
||||||
|
/// docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The driving repro: the Holtburg town-network portal platform (stab
|
||||||
|
/// 0xC0A9B465, Setup 0x020019E3) registers a WIDE LOW cylinder
|
||||||
|
/// (r=2.597 m, h=0.256 m). Retail steps a grounded player UP ONTO its flat
|
||||||
|
/// top; the pre-port approximation could only radial-slide, so the player
|
||||||
|
/// orbited the rim forever (launch-137-repro.log, 2026-07-05). These tests
|
||||||
|
/// pin the three retail behaviors the family provides: grounded
|
||||||
|
/// step-up-onto-top, too-tall side slide, and the airborne top landing.
|
||||||
|
/// Synthetic cylinders only — no dat dependency.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class CylSphereFamilyTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _out;
|
||||||
|
public CylSphereFamilyTests(ITestOutputHelper output) => _out = output;
|
||||||
|
|
||||||
|
private const uint TestLandblockId = 0xA9B40000u;
|
||||||
|
private const uint TestCellId = TestLandblockId | 0x0001u; // landcell (0,0)
|
||||||
|
|
||||||
|
private const float SphereRadius = 0.48f; // retail player capsule radius
|
||||||
|
private const float SphereHeight = 1.20f;
|
||||||
|
private const float StepUpHeight = 0.60f;
|
||||||
|
private const float StepDownHeight = 0.04f;
|
||||||
|
|
||||||
|
// The live platform's registered shape ([cyl-test] launch-137-repro.log).
|
||||||
|
private const float PlatformRadius = 2.597f;
|
||||||
|
private const float PlatformHeight = 0.256f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The portal-platform repro: a grounded player walking into the wide low
|
||||||
|
/// cylinder must STEP UP onto its flat top (retail
|
||||||
|
/// grounded branch → step_sphere_up → CTransition::step_up, whose
|
||||||
|
/// step-down probe lands via step_sphere_down's top-disc contact plane) —
|
||||||
|
/// not slide around the rim.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Grounded_WalkIntoWideLowCylinder_StepsUpOntoTop()
|
||||||
|
{
|
||||||
|
var engine = BuildEngine(out _);
|
||||||
|
RegisterCylinder(engine, entityId: 0xCAFEu,
|
||||||
|
worldPos: new Vector3(12f, 14f, 0f),
|
||||||
|
radius: PlatformRadius, height: PlatformHeight);
|
||||||
|
|
||||||
|
var body = MakeGroundedBody(new Vector3(12f, 10.4f, 0f));
|
||||||
|
Vector3 pos = body.Position;
|
||||||
|
uint cellId = TestCellId;
|
||||||
|
bool grounded = true;
|
||||||
|
var perTick = new Vector3(0f, 0.10f, 0f);
|
||||||
|
|
||||||
|
for (int tick = 0; tick < 40; tick++)
|
||||||
|
{
|
||||||
|
var result = engine.ResolveWithTransition(
|
||||||
|
pos, pos + perTick, cellId,
|
||||||
|
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
|
||||||
|
grounded,
|
||||||
|
body: body,
|
||||||
|
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||||
|
movingEntityId: 0);
|
||||||
|
|
||||||
|
body.Position = result.Position;
|
||||||
|
pos = result.Position;
|
||||||
|
cellId = result.CellId;
|
||||||
|
grounded = result.IsOnGround;
|
||||||
|
}
|
||||||
|
|
||||||
|
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) grounded={grounded}");
|
||||||
|
|
||||||
|
// Rim contact is at Y ≈ 14 − 2.597 − 0.48 = 10.92. Pre-port the player
|
||||||
|
// pinned there (Z stayed 0, Y never passed the rim). Post-port the
|
||||||
|
// player must be standing ON the platform top.
|
||||||
|
Assert.True(pos.Y > 11.5f,
|
||||||
|
$"Player must advance past the rim contact (pre-port it pinned at Y≈10.9); got Y={pos.Y:F3}");
|
||||||
|
Assert.True(MathF.Abs(pos.Z - PlatformHeight) < 0.05f,
|
||||||
|
$"Player must stand ON the platform top (Z≈{PlatformHeight:F3}); got Z={pos.Z:F3}");
|
||||||
|
Assert.True(grounded, "Player must remain grounded after stepping onto the platform");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A tall thin cylinder (the Holtburg torch shape, r=0.2 h=2.2 — #149)
|
||||||
|
/// exceeds step_up_height: the grounded dead-center approach must NOT
|
||||||
|
/// step up and must NOT pass through — retail slides (dead-center the
|
||||||
|
/// crease projection degenerates to a hard stop).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Grounded_WalkIntoTallCylinder_BlocksBeforeAxis()
|
||||||
|
{
|
||||||
|
var engine = BuildEngine(out _);
|
||||||
|
RegisterCylinder(engine, entityId: 0xF00Du,
|
||||||
|
worldPos: new Vector3(12f, 14f, 0f),
|
||||||
|
radius: 0.2f, height: 2.2f);
|
||||||
|
|
||||||
|
var body = MakeGroundedBody(new Vector3(12f, 12.6f, 0f));
|
||||||
|
Vector3 pos = body.Position;
|
||||||
|
uint cellId = TestCellId;
|
||||||
|
bool grounded = true;
|
||||||
|
var perTick = new Vector3(0f, 0.10f, 0f);
|
||||||
|
|
||||||
|
for (int tick = 0; tick < 30; tick++)
|
||||||
|
{
|
||||||
|
var result = engine.ResolveWithTransition(
|
||||||
|
pos, pos + perTick, cellId,
|
||||||
|
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
|
||||||
|
grounded,
|
||||||
|
body: body,
|
||||||
|
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||||
|
movingEntityId: 0);
|
||||||
|
|
||||||
|
body.Position = result.Position;
|
||||||
|
pos = result.Position;
|
||||||
|
cellId = result.CellId;
|
||||||
|
grounded = result.IsOnGround;
|
||||||
|
}
|
||||||
|
|
||||||
|
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) grounded={grounded}");
|
||||||
|
|
||||||
|
// Surface contact at Y = 14 − 0.2 − 0.48 = 13.32.
|
||||||
|
Assert.True(pos.Y < 13.4f,
|
||||||
|
$"Tall cylinder must block the dead-center approach; got Y={pos.Y:F3}");
|
||||||
|
Assert.True(pos.Z < 0.5f,
|
||||||
|
$"Player must NOT end up on top of a 2.2 m cylinder; got Z={pos.Z:F3}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Airborne landing: a falling sphere over the platform center must land
|
||||||
|
/// ON the flat top (land_on_cylinder → Collide re-test → branch-5
|
||||||
|
/// exact-TOI rest + top-disc contact plane), not fall through to the
|
||||||
|
/// terrain inside the footprint.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Airborne_FallOntoWideCylinder_LandsOnTop()
|
||||||
|
{
|
||||||
|
var engine = BuildEngine(out _);
|
||||||
|
RegisterCylinder(engine, entityId: 0xCAFEu,
|
||||||
|
worldPos: new Vector3(12f, 14f, 0f),
|
||||||
|
radius: PlatformRadius, height: PlatformHeight);
|
||||||
|
|
||||||
|
Vector3 pos = new(12f, 14f, 1.0f); // 1 m above the base, over the center
|
||||||
|
uint cellId = TestCellId;
|
||||||
|
bool grounded = false;
|
||||||
|
var perTick = new Vector3(0f, 0f, -0.25f);
|
||||||
|
|
||||||
|
int landedTick = -1;
|
||||||
|
for (int tick = 0; tick < 20; tick++)
|
||||||
|
{
|
||||||
|
var result = engine.ResolveWithTransition(
|
||||||
|
pos, pos + perTick, cellId,
|
||||||
|
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
|
||||||
|
grounded,
|
||||||
|
body: null,
|
||||||
|
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||||
|
movingEntityId: 0);
|
||||||
|
|
||||||
|
pos = result.Position;
|
||||||
|
cellId = result.CellId;
|
||||||
|
grounded = result.IsOnGround;
|
||||||
|
|
||||||
|
if (grounded) { landedTick = tick; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) grounded={grounded} landedTick={landedTick}");
|
||||||
|
|
||||||
|
Assert.True(grounded, "Falling sphere must land (ground) on the platform top");
|
||||||
|
Assert.True(MathF.Abs(pos.Z - PlatformHeight) < 0.05f,
|
||||||
|
$"Landing must rest on the top disc (Z≈{PlatformHeight:F3}), not the terrain " +
|
||||||
|
$"(Z=0) inside the footprint; got Z={pos.Z:F3}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ethereal cylinders stay fully passable through the caller's Layer-2
|
||||||
|
/// override (pc:276961-276989) — branch 1 detects, the override clears.
|
||||||
|
/// Guards the #150 door behavior against the branch-1 change from the
|
||||||
|
/// old early-OK consume.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Grounded_EtherealCylinder_IsFullyPassable()
|
||||||
|
{
|
||||||
|
var engine = BuildEngine(out _);
|
||||||
|
RegisterCylinder(engine, entityId: 0xE7E7u,
|
||||||
|
worldPos: new Vector3(12f, 14f, 0f),
|
||||||
|
radius: 0.2f, height: 2.2f,
|
||||||
|
state: 0x4u); // ETHEREAL_PS, non-static
|
||||||
|
|
||||||
|
var body = MakeGroundedBody(new Vector3(12f, 12.6f, 0f));
|
||||||
|
Vector3 pos = body.Position;
|
||||||
|
uint cellId = TestCellId;
|
||||||
|
bool grounded = true;
|
||||||
|
var perTick = new Vector3(0f, 0.10f, 0f);
|
||||||
|
|
||||||
|
for (int tick = 0; tick < 30; tick++)
|
||||||
|
{
|
||||||
|
var result = engine.ResolveWithTransition(
|
||||||
|
pos, pos + perTick, cellId,
|
||||||
|
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
|
||||||
|
grounded,
|
||||||
|
body: body,
|
||||||
|
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||||
|
movingEntityId: 0);
|
||||||
|
|
||||||
|
body.Position = result.Position;
|
||||||
|
pos = result.Position;
|
||||||
|
cellId = result.CellId;
|
||||||
|
grounded = result.IsOnGround;
|
||||||
|
}
|
||||||
|
|
||||||
|
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3})");
|
||||||
|
|
||||||
|
Assert.True(pos.Y > 14.5f,
|
||||||
|
$"Ethereal cylinder must not block (walked from 12.6 to past the axis); got Y={pos.Y:F3}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
|
// Harness
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static PhysicsEngine BuildEngine(out PhysicsDataCache cache)
|
||||||
|
{
|
||||||
|
cache = new PhysicsDataCache();
|
||||||
|
var engine = new PhysicsEngine { DataCache = cache };
|
||||||
|
|
||||||
|
// Flat terrain at Z=0 across the whole landblock.
|
||||||
|
var heights = new byte[81];
|
||||||
|
var heightTable = new float[256]; // all zero → terrain Z = 0
|
||||||
|
engine.AddLandblock(
|
||||||
|
landblockId: TestLandblockId,
|
||||||
|
terrain: new TerrainSurface(heights, heightTable),
|
||||||
|
cells: Array.Empty<CellSurface>(),
|
||||||
|
portals: Array.Empty<PortalPlane>(),
|
||||||
|
worldOffsetX: 0f,
|
||||||
|
worldOffsetY: 0f);
|
||||||
|
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RegisterCylinder(PhysicsEngine engine, uint entityId,
|
||||||
|
Vector3 worldPos, float radius, float height, uint state = 0u)
|
||||||
|
{
|
||||||
|
engine.ShadowObjects.Register(
|
||||||
|
entityId, gfxObjId: 0u,
|
||||||
|
worldPos, Quaternion.Identity, radius,
|
||||||
|
worldOffsetX: 0f, worldOffsetY: 0f, landblockId: TestLandblockId,
|
||||||
|
collisionType: ShadowCollisionType.Cylinder,
|
||||||
|
cylHeight: height,
|
||||||
|
state: state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PhysicsBody MakeGroundedBody(Vector3 position)
|
||||||
|
{
|
||||||
|
var floorPlane = new Plane(Vector3.UnitZ, 0f);
|
||||||
|
var floorVerts = new[]
|
||||||
|
{
|
||||||
|
new Vector3(-100f, -100f, 0f),
|
||||||
|
new Vector3( 100f, -100f, 0f),
|
||||||
|
new Vector3( 100f, 100f, 0f),
|
||||||
|
new Vector3(-100f, 100f, 0f),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new PhysicsBody
|
||||||
|
{
|
||||||
|
Position = position,
|
||||||
|
Orientation = Quaternion.Identity,
|
||||||
|
ContactPlaneValid = true,
|
||||||
|
ContactPlane = floorPlane,
|
||||||
|
ContactPlaneCellId = TestCellId,
|
||||||
|
WalkablePolygonValid = true,
|
||||||
|
WalkablePlane = floorPlane,
|
||||||
|
WalkableVertices = floorVerts,
|
||||||
|
WalkableUp = Vector3.UnitZ,
|
||||||
|
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,518 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
using Env = System.Environment;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #137 corridor-seam inspection (2026-07-05, Facility Hub). Live probe
|
||||||
|
/// evidence (launch-175-verify2.log:42858): crossing corridor cells
|
||||||
|
/// 0x8A02016E → 0x8A02017A at world x≈85.25 records a wall hit with normal
|
||||||
|
/// (−1,0,0) — pointing straight back against the movement — after which the
|
||||||
|
/// stale sliding normal wedges all forward motion (ok=False hit=no, offset
|
||||||
|
/// projected to zero). Question this dump answers: does cell 0x8A02017A's
|
||||||
|
/// PHYSICS polygon set contain a portal-spanning polygon at its entry plane
|
||||||
|
/// (normal ≈ ±X at the portal's local X) — i.e., are portal-sealing polys in
|
||||||
|
/// our collision set where retail filters them?
|
||||||
|
/// </summary>
|
||||||
|
public class Issue137CorridorSeamInspectionTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _out;
|
||||||
|
public Issue137CorridorSeamInspectionTests(ITestOutputHelper output) => _out = output;
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x8A02016Eu)]
|
||||||
|
[InlineData(0x8A02017Au)]
|
||||||
|
[InlineData(0x8A02011Eu)] // the under-floor room the corridor's floor-portals lead to
|
||||||
|
[InlineData(0x8A020179u)] // the ramp corridor cell with the window (the #137 window-climb repro)
|
||||||
|
[InlineData(0x8A02017Eu)] // the cell beyond the window the player climbed into
|
||||||
|
public void CorridorCell_PhysicsPolysAndPortals_DatInspection(uint envCellId)
|
||||||
|
{
|
||||||
|
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
|
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||||
|
"Documents", "Asheron's Call");
|
||||||
|
if (!Directory.Exists(datDir))
|
||||||
|
{
|
||||||
|
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
||||||
|
var envCell = dats.Get<EnvCell>(envCellId);
|
||||||
|
Assert.NotNull(envCell);
|
||||||
|
_out.WriteLine($"=== EnvCell 0x{envCellId:X8} ===");
|
||||||
|
_out.WriteLine($" pos=({envCell!.Position.Origin.X:F2},{envCell.Position.Origin.Y:F2},{envCell.Position.Origin.Z:F2}) " +
|
||||||
|
$"rot=({envCell.Position.Orientation.X:F3},{envCell.Position.Orientation.Y:F3},{envCell.Position.Orientation.Z:F3},{envCell.Position.Orientation.W:F3})");
|
||||||
|
_out.WriteLine($" EnvironmentId=0x{envCell.EnvironmentId:X4} CellStructure={envCell.CellStructure}");
|
||||||
|
_out.WriteLine($" CellPortals={envCell.CellPortals.Count}");
|
||||||
|
foreach (var p in envCell.CellPortals)
|
||||||
|
_out.WriteLine($" portal poly={p.PolygonId} other=0x{p.OtherCellId:X4} flags={p.Flags}");
|
||||||
|
|
||||||
|
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
|
||||||
|
Assert.NotNull(environment);
|
||||||
|
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs));
|
||||||
|
|
||||||
|
_out.WriteLine($" PhysicsPolygons={cs!.PhysicsPolygons.Count} (portal-relevant normals below)");
|
||||||
|
foreach (var (id, poly) in cs.PhysicsPolygons)
|
||||||
|
{
|
||||||
|
// Compute the face normal from the vertex fan (same math as
|
||||||
|
// PhysicsDataCache.ResolvePolygons).
|
||||||
|
var verts = poly.VertexIds;
|
||||||
|
if (verts.Count < 3) continue;
|
||||||
|
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[0], out var v0)) continue;
|
||||||
|
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[1], out var v1)) continue;
|
||||||
|
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[2], out var v2)) continue;
|
||||||
|
var n = System.Numerics.Vector3.Normalize(System.Numerics.Vector3.Cross(
|
||||||
|
v1.Origin - v0.Origin, v2.Origin - v0.Origin));
|
||||||
|
|
||||||
|
// Only print near-horizontal-normal polys (walls) — the seam wall
|
||||||
|
// candidates; floors/ceilings are noise here.
|
||||||
|
if (MathF.Abs(n.Z) > 0.3f) continue;
|
||||||
|
_out.WriteLine($" poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) v0=({v0.Origin.X:F2},{v0.Origin.Y:F2},{v0.Origin.Z:F2}) verts={verts.Count} sides={poly.SidesType} stip={poly.Stippling}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The portal polygons live in the VISUAL polygon set — print their
|
||||||
|
// ids so overlap with the physics set (same id space?) is visible.
|
||||||
|
_out.WriteLine($" VisualPolygons={cs.Polygons.Count}");
|
||||||
|
foreach (var p in envCell.CellPortals)
|
||||||
|
{
|
||||||
|
if (cs.Polygons.TryGetValue((ushort)p.PolygonId, out var vp))
|
||||||
|
{
|
||||||
|
_out.WriteLine($" portal-poly {p.PolygonId} IS in the visual set (verts={vp.VertexIds.Count})");
|
||||||
|
bool inPhysics = cs.PhysicsPolygons.ContainsKey((ushort)p.PolygonId);
|
||||||
|
_out.WriteLine($" portal-poly {p.PolygonId} in PHYSICS set: {inPhysics}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mechanism-1 follow-up (2026-07-06): being in the CellStruct's
|
||||||
|
/// <c>PhysicsPolygons</c> TABLE does not mean the physics BSP ever tests a
|
||||||
|
/// polygon — retail's <c>BSPLEAF::sphere_intersects_poly</c> (0x0053d580)
|
||||||
|
/// iterates the LEAF's <c>in_polys</c> index list (leaf construction
|
||||||
|
/// 0x0053d4a0: <c>in_polys[i] = &pack_poly[index]</c>), and our
|
||||||
|
/// BSPQuery walks the dat's PhysicsBSP leaves the same way. This dump
|
||||||
|
/// answers: do the physics-BSP LEAVES of the corridor cells reference the
|
||||||
|
/// portal polygons? If yes, retail's own BSP query would test them too
|
||||||
|
/// (→ the passable mechanism must be transit/approach-side — the cdb
|
||||||
|
/// question). If no, our collision is testing polys retail never reaches
|
||||||
|
/// (→ a desk-fixable acdream divergence).
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x8A02016Eu)]
|
||||||
|
[InlineData(0x8A02017Au)]
|
||||||
|
public void CorridorCell_PhysicsBspLeafMembership_OfPortalPolys(uint envCellId)
|
||||||
|
{
|
||||||
|
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
|
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||||
|
"Documents", "Asheron's Call");
|
||||||
|
if (!Directory.Exists(datDir))
|
||||||
|
{
|
||||||
|
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
||||||
|
var envCell = dats.Get<EnvCell>(envCellId);
|
||||||
|
Assert.NotNull(envCell);
|
||||||
|
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell!.EnvironmentId);
|
||||||
|
Assert.NotNull(environment);
|
||||||
|
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs));
|
||||||
|
|
||||||
|
var portalPolyIds = new System.Collections.Generic.HashSet<ushort>();
|
||||||
|
foreach (var p in envCell.CellPortals)
|
||||||
|
portalPolyIds.Add((ushort)p.PolygonId);
|
||||||
|
|
||||||
|
_out.WriteLine($"=== EnvCell 0x{envCellId:X8} — physics BSP leaf membership ===");
|
||||||
|
_out.WriteLine($" Env=0x{envCell.EnvironmentId:X4} struct={envCell.CellStructure} " +
|
||||||
|
$"portalPolyIds=[{string.Join(",", portalPolyIds)}] " +
|
||||||
|
$"physicsTable=[{string.Join(",", cs!.PhysicsPolygons.Keys)}]");
|
||||||
|
|
||||||
|
var root = cs.PhysicsBSP?.Root;
|
||||||
|
Assert.NotNull(root);
|
||||||
|
|
||||||
|
int leafCount = 0;
|
||||||
|
var leafPolyIds = new System.Collections.Generic.HashSet<ushort>();
|
||||||
|
var portalPolyLeafHits = new System.Collections.Generic.List<string>();
|
||||||
|
var stack = new System.Collections.Generic.Stack<(DatReaderWriter.Types.PhysicsBSPNode Node, string Path)>();
|
||||||
|
stack.Push((root!, "R"));
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
var (n, path) = stack.Pop();
|
||||||
|
if (n.Polygons is { Count: > 0 })
|
||||||
|
{
|
||||||
|
leafCount++;
|
||||||
|
foreach (var pid in n.Polygons)
|
||||||
|
{
|
||||||
|
leafPolyIds.Add(pid);
|
||||||
|
if (portalPolyIds.Contains(pid))
|
||||||
|
portalPolyLeafHits.Add($"poly {pid} in leaf@{path} (type={n.Type}, polys=[{string.Join(",", n.Polygons)}])");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (n.PosNode is not null) stack.Push((n.PosNode, path + "+"));
|
||||||
|
if (n.NegNode is not null) stack.Push((n.NegNode, path + "-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
_out.WriteLine($" BSP leaves-with-polys={leafCount} distinctLeafPolyIds=[{string.Join(",", leafPolyIds)}]");
|
||||||
|
var tableNotInLeaves = new System.Collections.Generic.List<ushort>();
|
||||||
|
foreach (var pid in cs.PhysicsPolygons.Keys)
|
||||||
|
if (!leafPolyIds.Contains(pid))
|
||||||
|
tableNotInLeaves.Add(pid);
|
||||||
|
_out.WriteLine($" physics-table polys NOT referenced by any BSP leaf: [{string.Join(",", tableNotInLeaves)}]");
|
||||||
|
|
||||||
|
if (portalPolyLeafHits.Count == 0)
|
||||||
|
{
|
||||||
|
_out.WriteLine(" >>> NO portal polygon is referenced by any physics-BSP leaf — " +
|
||||||
|
"retail's sphere_intersects_poly never tests them from this cell's BSP.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var hit in portalPolyLeafHits)
|
||||||
|
_out.WriteLine($" >>> PORTAL POLY IN PHYSICS LEAF: {hit}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #137 window climb: the dat truth for the player's collision spheres.
|
||||||
|
/// Our InitPath places the head sphere at (sphereHeight − radius) = 0.72
|
||||||
|
/// (capsule top 1.2 m); retail collides with the Setup's SPHERE LIST
|
||||||
|
/// verbatim (CPhysicsObj::transition → init_sphere(GetNumSphere,
|
||||||
|
/// GetSphere, scale)). Print human Setup 0x02000001's spheres.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void HumanSetup_CollisionSpheres_DatTruth()
|
||||||
|
{
|
||||||
|
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
|
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||||
|
"Documents", "Asheron's Call");
|
||||||
|
if (!Directory.Exists(datDir))
|
||||||
|
{
|
||||||
|
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var setup = dats.Get<DatReaderWriter.DBObjs.Setup>(0x02000001u);
|
||||||
|
Assert.NotNull(setup);
|
||||||
|
|
||||||
|
_out.WriteLine($"Setup 0x02000001: Height={setup!.Height:F3} Radius={setup.Radius:F3} " +
|
||||||
|
$"StepUp={setup.StepUpHeight:F3} StepDown={setup.StepDownHeight:F3}");
|
||||||
|
_out.WriteLine($"Spheres ({setup.Spheres.Count}):");
|
||||||
|
foreach (var s in setup.Spheres)
|
||||||
|
_out.WriteLine($" origin=({s.Origin.X:F3},{s.Origin.Y:F3},{s.Origin.Z:F3}) r={s.Radius:F3}");
|
||||||
|
_out.WriteLine($"CylSpheres ({setup.CylSpheres.Count}):");
|
||||||
|
foreach (var c in setup.CylSpheres)
|
||||||
|
_out.WriteLine($" origin=({c.Origin.X:F3},{c.Origin.Y:F3},{c.Origin.Z:F3}) r={c.Radius:F3} h={c.Height:F3}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #137 window-climb geometry (2026-07-06): full world-space vertex dump
|
||||||
|
/// of the shaft cell 0x8A02017E (all physics polys) and 0x8A020179's
|
||||||
|
/// south-wall family — the opening's lintel/ceiling spans decide where
|
||||||
|
/// retail blocks the head.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void WindowShaft_FullPolyDump()
|
||||||
|
{
|
||||||
|
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
|
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||||
|
"Documents", "Asheron's Call");
|
||||||
|
if (!Directory.Exists(datDir))
|
||||||
|
{
|
||||||
|
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
||||||
|
foreach (var cellId in new[] { 0x8A02017Eu, 0x8A020179u })
|
||||||
|
{
|
||||||
|
var envCell = dats.Get<EnvCell>(cellId);
|
||||||
|
Assert.NotNull(envCell);
|
||||||
|
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell!.EnvironmentId);
|
||||||
|
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs));
|
||||||
|
|
||||||
|
var rot = new System.Numerics.Quaternion(
|
||||||
|
envCell.Position.Orientation.X, envCell.Position.Orientation.Y,
|
||||||
|
envCell.Position.Orientation.Z, envCell.Position.Orientation.W);
|
||||||
|
var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot)
|
||||||
|
* System.Numerics.Matrix4x4.CreateTranslation(
|
||||||
|
envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z);
|
||||||
|
|
||||||
|
_out.WriteLine($"=== 0x{cellId:X8} full physics polys (world verts) ===");
|
||||||
|
foreach (var (id, poly) in cs!.PhysicsPolygons)
|
||||||
|
{
|
||||||
|
var verts = poly.VertexIds;
|
||||||
|
if (verts.Count < 3) continue;
|
||||||
|
var w = new System.Collections.Generic.List<System.Numerics.Vector3>();
|
||||||
|
foreach (var vid in verts)
|
||||||
|
if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v))
|
||||||
|
w.Add(System.Numerics.Vector3.Transform(v.Origin, world));
|
||||||
|
var n = System.Numerics.Vector3.Normalize(
|
||||||
|
System.Numerics.Vector3.Cross(w[1] - w[0], w[2] - w[0]));
|
||||||
|
|
||||||
|
// 017E: everything. 0179: south-wall family + ceilings only.
|
||||||
|
if (cellId == 0x8A020179u && MathF.Abs(n.Y) < 0.3f && n.Z > -0.3f) continue;
|
||||||
|
|
||||||
|
var vs = string.Join(" ", w.ConvertAll(p => $"({p.X:F2},{p.Y:F2},{p.Z:F2})"));
|
||||||
|
_out.WriteLine($" poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) verts={vs}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mechanism-1 re-characterization (2026-07-06): the live hit normal
|
||||||
|
/// (−1.00, 0.03, −0.03) at world (85.253, −39.776, −5.992) matches NO
|
||||||
|
/// physics polygon of either corridor cell — 0x8A02016E (identity
|
||||||
|
/// rotation) and 0x8A02017A (180° Z) both have only ±Y-normal wall polys,
|
||||||
|
/// and the PortalSide portals to 0x011E (polys 1/3/5) are ±Y planes
|
||||||
|
/// 1.4 m north of the player's track, perpendicular to the +X run — the
|
||||||
|
/// pos_hits_sphere directional cull rejects them for this movement. This
|
||||||
|
/// sweep hunts the ACTUAL culprit: every physics poly of the seam cell +
|
||||||
|
/// all portal-adjacent neighbors, world-transformed, scored against the
|
||||||
|
/// hit point + normal.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void CorridorSeam_FindPolygonMatchingLiveHit()
|
||||||
|
{
|
||||||
|
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
|
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||||
|
"Documents", "Asheron's Call");
|
||||||
|
if (!Directory.Exists(datDir))
|
||||||
|
{
|
||||||
|
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
||||||
|
// Live evidence (launch-175-verify2.log:42858).
|
||||||
|
var hitPoint = new System.Numerics.Vector3(85.253f, -39.776f, -5.992f);
|
||||||
|
var hitNormal = new System.Numerics.Vector3(-1.00f, 0.03f, -0.03f);
|
||||||
|
hitNormal = System.Numerics.Vector3.Normalize(hitNormal);
|
||||||
|
const float sphereRadius = 0.48f;
|
||||||
|
|
||||||
|
// Seam cells + every portal-adjacent neighbor of both.
|
||||||
|
var cellIds = new System.Collections.Generic.HashSet<uint>
|
||||||
|
{
|
||||||
|
0x8A02016Eu, 0x8A02017Au,
|
||||||
|
};
|
||||||
|
foreach (var seed in new[] { 0x8A02016Eu, 0x8A02017Au })
|
||||||
|
{
|
||||||
|
var seedCell = dats.Get<EnvCell>(seed);
|
||||||
|
if (seedCell is null) continue;
|
||||||
|
foreach (var p in seedCell.CellPortals)
|
||||||
|
cellIds.Add(0x8A020000u | p.OtherCellId);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var cellId in cellIds)
|
||||||
|
{
|
||||||
|
var envCell = dats.Get<EnvCell>(cellId);
|
||||||
|
if (envCell is null) { _out.WriteLine($"cell 0x{cellId:X8}: NOT FOUND"); continue; }
|
||||||
|
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
|
||||||
|
if (environment is null || !environment.Cells.TryGetValue(envCell.CellStructure, out var cs))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var rot = new System.Numerics.Quaternion(
|
||||||
|
envCell.Position.Orientation.X, envCell.Position.Orientation.Y,
|
||||||
|
envCell.Position.Orientation.Z, envCell.Position.Orientation.W);
|
||||||
|
var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot)
|
||||||
|
* System.Numerics.Matrix4x4.CreateTranslation(
|
||||||
|
envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z);
|
||||||
|
|
||||||
|
var portalPolyIds = new System.Collections.Generic.HashSet<ushort>();
|
||||||
|
foreach (var p in envCell.CellPortals) portalPolyIds.Add((ushort)p.PolygonId);
|
||||||
|
|
||||||
|
foreach (var (id, poly) in cs!.PhysicsPolygons)
|
||||||
|
{
|
||||||
|
var verts = poly.VertexIds;
|
||||||
|
if (verts.Count < 3) continue;
|
||||||
|
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[0], out var v0)) continue;
|
||||||
|
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[1], out var v1)) continue;
|
||||||
|
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[2], out var v2)) continue;
|
||||||
|
|
||||||
|
var w0 = System.Numerics.Vector3.Transform(v0.Origin, world);
|
||||||
|
var w1 = System.Numerics.Vector3.Transform(v1.Origin, world);
|
||||||
|
var w2 = System.Numerics.Vector3.Transform(v2.Origin, world);
|
||||||
|
var n = System.Numerics.Vector3.Normalize(
|
||||||
|
System.Numerics.Vector3.Cross(w1 - w0, w2 - w0));
|
||||||
|
|
||||||
|
float align = System.Numerics.Vector3.Dot(n, hitNormal);
|
||||||
|
// |align|: the vertex-fan winding convention can flip the
|
||||||
|
// computed normal vs the physics plane's true facing — accept
|
||||||
|
// both signs (2026-07-06 sweep flaw fix).
|
||||||
|
if (MathF.Abs(align) < 0.95f) continue; // within ~18° of the recorded normal
|
||||||
|
|
||||||
|
// Plane distance from the hit point.
|
||||||
|
float d = -System.Numerics.Vector3.Dot(n, w0);
|
||||||
|
float dist = System.Numerics.Vector3.Dot(n, hitPoint) + d;
|
||||||
|
if (MathF.Abs(dist) > sphereRadius + 0.1f) continue;
|
||||||
|
|
||||||
|
// Rough proximity: hit point near the polygon's vertex span.
|
||||||
|
float minX = MathF.Min(w0.X, MathF.Min(w1.X, w2.X)) - 1f;
|
||||||
|
float maxX = MathF.Max(w0.X, MathF.Max(w1.X, w2.X)) + 1f;
|
||||||
|
float minY = MathF.Min(w0.Y, MathF.Min(w1.Y, w2.Y)) - 1f;
|
||||||
|
float maxY = MathF.Max(w0.Y, MathF.Max(w1.Y, w2.Y)) + 1f;
|
||||||
|
if (hitPoint.X < minX || hitPoint.X > maxX ||
|
||||||
|
hitPoint.Y < minY || hitPoint.Y > maxY) continue;
|
||||||
|
|
||||||
|
_out.WriteLine(
|
||||||
|
$">>> CANDIDATE cell=0x{cellId:X8} poly={id} " +
|
||||||
|
$"worldN=({n.X:F3},{n.Y:F3},{n.Z:F3}) align={align:F3} planeDist={dist:F3} " +
|
||||||
|
$"isPortalPoly={portalPolyIds.Contains(id)} " +
|
||||||
|
$"w0=({w0.X:F2},{w0.Y:F2},{w0.Z:F2}) w1=({w1.X:F2},{w1.Y:F2},{w1.Z:F2}) w2=({w2.X:F2},{w2.Y:F2},{w2.Z:F2}) " +
|
||||||
|
$"verts={verts.Count} sides={poly.SidesType} stip={poly.Stippling}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_out.WriteLine("(sweep complete)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entry-poly hunt: the synthetic reversed-movement collision normal is
|
||||||
|
/// produced by slide_sphere's opposing-normals branch, which needs an
|
||||||
|
/// INPUT collision normal anti-parallel to the grounded contact plane —
|
||||||
|
/// i.e., a DOWNWARD-facing polygon (lintel / arch underside). Those were
|
||||||
|
/// filtered out of the wall dump (|n.Z| > 0.3). Sweep both corridor
|
||||||
|
/// cells for downward polys near the seam column and print where their
|
||||||
|
/// planes sit relative to the player's head sphere.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void CorridorSeam_DownwardPolysNearSeam()
|
||||||
|
{
|
||||||
|
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
|
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||||
|
"Documents", "Asheron's Call");
|
||||||
|
if (!Directory.Exists(datDir))
|
||||||
|
{
|
||||||
|
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
||||||
|
foreach (var cellId in new[] { 0x8A02016Eu, 0x8A02017Au })
|
||||||
|
{
|
||||||
|
var envCell = dats.Get<EnvCell>(cellId);
|
||||||
|
Assert.NotNull(envCell);
|
||||||
|
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell!.EnvironmentId);
|
||||||
|
Assert.NotNull(environment);
|
||||||
|
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs));
|
||||||
|
|
||||||
|
var rot = new System.Numerics.Quaternion(
|
||||||
|
envCell.Position.Orientation.X, envCell.Position.Orientation.Y,
|
||||||
|
envCell.Position.Orientation.Z, envCell.Position.Orientation.W);
|
||||||
|
var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot)
|
||||||
|
* System.Numerics.Matrix4x4.CreateTranslation(
|
||||||
|
envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z);
|
||||||
|
|
||||||
|
_out.WriteLine($"=== 0x{cellId:X8} downward physics polys (n.Z < -0.3) ===");
|
||||||
|
foreach (var (id, poly) in cs!.PhysicsPolygons)
|
||||||
|
{
|
||||||
|
var verts = poly.VertexIds;
|
||||||
|
if (verts.Count < 3) continue;
|
||||||
|
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[0], out var v0)) continue;
|
||||||
|
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[1], out var v1)) continue;
|
||||||
|
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[2], out var v2)) continue;
|
||||||
|
|
||||||
|
var w0 = System.Numerics.Vector3.Transform(v0.Origin, world);
|
||||||
|
var w1 = System.Numerics.Vector3.Transform(v1.Origin, world);
|
||||||
|
var w2 = System.Numerics.Vector3.Transform(v2.Origin, world);
|
||||||
|
var n = System.Numerics.Vector3.Normalize(
|
||||||
|
System.Numerics.Vector3.Cross(w1 - w0, w2 - w0));
|
||||||
|
if (n.Z > -0.3f) continue;
|
||||||
|
|
||||||
|
// Only near the seam column the player crossed.
|
||||||
|
float minX = MathF.Min(w0.X, MathF.Min(w1.X, w2.X));
|
||||||
|
float maxX = MathF.Max(w0.X, MathF.Max(w1.X, w2.X));
|
||||||
|
if (maxX < 83.5f || minX > 87.0f) continue;
|
||||||
|
|
||||||
|
var allW = new System.Collections.Generic.List<System.Numerics.Vector3>();
|
||||||
|
foreach (var vid in verts)
|
||||||
|
if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var vv))
|
||||||
|
allW.Add(System.Numerics.Vector3.Transform(vv.Origin, world));
|
||||||
|
float minZ = float.MaxValue, maxZ = float.MinValue, minY = float.MaxValue, maxY = float.MinValue;
|
||||||
|
foreach (var w in allW)
|
||||||
|
{
|
||||||
|
minZ = MathF.Min(minZ, w.Z); maxZ = MathF.Max(maxZ, w.Z);
|
||||||
|
minY = MathF.Min(minY, w.Y); maxY = MathF.Max(maxY, w.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
_out.WriteLine(
|
||||||
|
$" poly {id}: worldN=({n.X:F2},{n.Y:F2},{n.Z:F2}) x=[{minX:F2},{maxX:F2}] " +
|
||||||
|
$"y=[{minY:F2},{maxY:F2}] z=[{minZ:F2},{maxZ:F2}] verts={verts.Count} " +
|
||||||
|
$"sides={poly.SidesType} stip={poly.Stippling}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_out.WriteLine("(downward sweep complete)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 2026-07-06 gate-session follow-up: seam crossings SUCCEED at
|
||||||
|
/// y≈−40.8..−41.2 and BLOCK at y≈−39.5..−39.8 (cell-transit log,
|
||||||
|
/// launch-137-corridor-gate.log). A y-dependent boundary with no physics
|
||||||
|
/// polygon culprit points at the PORTAL POLYGONS — if the doorway
|
||||||
|
/// openings don't span the full corridor width, the transit/membership
|
||||||
|
/// machinery only hands the sphere to the neighbor inside the portal
|
||||||
|
/// poly's span. Dump every portal polygon's world-space vertex extent.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x8A02016Eu)]
|
||||||
|
[InlineData(0x8A02017Au)]
|
||||||
|
public void CorridorCell_PortalPolygonWorldSpans(uint envCellId)
|
||||||
|
{
|
||||||
|
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
|
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||||
|
"Documents", "Asheron's Call");
|
||||||
|
if (!Directory.Exists(datDir))
|
||||||
|
{
|
||||||
|
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
||||||
|
var envCell = dats.Get<EnvCell>(envCellId);
|
||||||
|
Assert.NotNull(envCell);
|
||||||
|
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell!.EnvironmentId);
|
||||||
|
Assert.NotNull(environment);
|
||||||
|
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs));
|
||||||
|
|
||||||
|
var rot = new System.Numerics.Quaternion(
|
||||||
|
envCell.Position.Orientation.X, envCell.Position.Orientation.Y,
|
||||||
|
envCell.Position.Orientation.Z, envCell.Position.Orientation.W);
|
||||||
|
var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot)
|
||||||
|
* System.Numerics.Matrix4x4.CreateTranslation(
|
||||||
|
envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z);
|
||||||
|
|
||||||
|
_out.WriteLine($"=== 0x{envCellId:X8} portal polygons (world spans) ===");
|
||||||
|
foreach (var p in envCell.CellPortals)
|
||||||
|
{
|
||||||
|
if (!cs!.Polygons.TryGetValue((ushort)p.PolygonId, out var poly))
|
||||||
|
{
|
||||||
|
_out.WriteLine($" portal poly {p.PolygonId} -> 0x{p.OtherCellId:X4} {p.Flags}: NOT in visual set");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var min = new System.Numerics.Vector3(float.MaxValue);
|
||||||
|
var max = new System.Numerics.Vector3(float.MinValue);
|
||||||
|
foreach (var vid in poly.VertexIds)
|
||||||
|
{
|
||||||
|
if (!cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v)) continue;
|
||||||
|
var w = System.Numerics.Vector3.Transform(v.Origin, world);
|
||||||
|
min = System.Numerics.Vector3.Min(min, w);
|
||||||
|
max = System.Numerics.Vector3.Max(max, w);
|
||||||
|
}
|
||||||
|
_out.WriteLine(
|
||||||
|
$" portal poly {p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] " +
|
||||||
|
$"x=[{min.X:F2},{max.X:F2}] y=[{min.Y:F2},{max.Y:F2}] z=[{min.Z:F2},{max.Z:F2}] " +
|
||||||
|
$"verts={poly.VertexIds.Count}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,500 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Numerics;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
using Env = System.Environment;
|
||||||
|
using Plane = System.Numerics.Plane;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #137 corridor-seam replay (2026-07-06) — dat-backed reproduction of the
|
||||||
|
/// Facility Hub phantom hit (launch-175-verify2.log:42858): running +X down
|
||||||
|
/// the corridor, crossing 0x8A02016E → 0x8A02017A at x≈85.25, the live
|
||||||
|
/// client recorded `ok=True hit=yes n=(−1.00,0.03,−0.03)` with full advance,
|
||||||
|
/// persisted the sliding normal, and every later forward resolve absorbed to
|
||||||
|
/// zero (`ok=False hit=no`).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Dat facts pinned by <see cref="Issue137CorridorSeamInspectionTests"/>:
|
||||||
|
/// neither corridor cell (nor any portal-adjacent neighbor) has a physics
|
||||||
|
/// polygon whose plane matches that normal near the hit point — the recorded
|
||||||
|
/// normal is SYNTHETIC (the negated movement direction), which is exactly
|
||||||
|
/// what slide_sphere's opposing-normals branch records. Retail
|
||||||
|
/// (<c>CSphere::slide_sphere</c> 0x00537440 @0x0053762c) returns
|
||||||
|
/// COLLIDED_TS from that branch; our port returned OK — letting the step
|
||||||
|
/// complete with full advance and the synthetic normal persisted.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// This replay drives the real engine over the real dat cells with the
|
||||||
|
/// live-log positions and player dimensions, and pins: the seam crossing
|
||||||
|
/// must complete WITHOUT persisting a sliding normal, and continued forward
|
||||||
|
/// running must keep advancing (no absorbing wedge).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class Issue137CorridorSeamReplayTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _out;
|
||||||
|
public Issue137CorridorSeamReplayTests(ITestOutputHelper output) => _out = output;
|
||||||
|
|
||||||
|
private const uint SeamCellWest = 0x8A02016Eu;
|
||||||
|
private const uint SeamCellEast = 0x8A02017Au;
|
||||||
|
|
||||||
|
private static string? FindDatDir()
|
||||||
|
{
|
||||||
|
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
|
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||||
|
"Documents", "Asheron's Call");
|
||||||
|
return Directory.Exists(datDir) ? datDir : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hydrate the two seam cells + every portal-adjacent neighbor into a
|
||||||
|
/// PhysicsEngine, exactly as the streaming path does (CacheCellStruct
|
||||||
|
/// with the dat world transform).
|
||||||
|
/// </summary>
|
||||||
|
private static PhysicsEngine BuildCorridorEngine(DatCollection dats)
|
||||||
|
{
|
||||||
|
var engine = new PhysicsEngine();
|
||||||
|
engine.DataCache = new PhysicsDataCache();
|
||||||
|
|
||||||
|
var toLoad = new System.Collections.Generic.HashSet<uint> { SeamCellWest, SeamCellEast };
|
||||||
|
foreach (var seed in new[] { SeamCellWest, SeamCellEast })
|
||||||
|
{
|
||||||
|
var seedCell = dats.Get<EnvCell>(seed);
|
||||||
|
Assert.NotNull(seedCell);
|
||||||
|
foreach (var p in seedCell!.CellPortals)
|
||||||
|
toLoad.Add(0x8A020000u | p.OtherCellId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand three portal rings — the live collision cell array reaches
|
||||||
|
// cells three hops out (0x8A020166, the under-ramp room whose ceiling
|
||||||
|
// is the ramp slab's underside, is ring-3 in the 2026-07-06
|
||||||
|
// seam-shake trace; with only two rings the offline flood can never
|
||||||
|
// add it and the shake does not reproduce).
|
||||||
|
for (int ring = 0; ring < 3; ring++)
|
||||||
|
{
|
||||||
|
foreach (var known in new System.Collections.Generic.List<uint>(toLoad))
|
||||||
|
{
|
||||||
|
var cell = dats.Get<EnvCell>(known);
|
||||||
|
if (cell is null) continue;
|
||||||
|
foreach (var p in cell.CellPortals)
|
||||||
|
toLoad.Add(0x8A020000u | p.OtherCellId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var cellId in toLoad)
|
||||||
|
{
|
||||||
|
var envCell = dats.Get<EnvCell>(cellId);
|
||||||
|
if (envCell is null) continue;
|
||||||
|
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
|
||||||
|
if (environment is null) continue;
|
||||||
|
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) continue;
|
||||||
|
|
||||||
|
var rot = new Quaternion(
|
||||||
|
envCell.Position.Orientation.X, envCell.Position.Orientation.Y,
|
||||||
|
envCell.Position.Orientation.Z, envCell.Position.Orientation.W);
|
||||||
|
var world = Matrix4x4.CreateFromQuaternion(rot)
|
||||||
|
* Matrix4x4.CreateTranslation(
|
||||||
|
envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z);
|
||||||
|
|
||||||
|
engine.DataCache.CacheCellStruct(cellId, envCell, cs!, world);
|
||||||
|
}
|
||||||
|
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PhysicsBody GroundedBody()
|
||||||
|
{
|
||||||
|
var body = new PhysicsBody();
|
||||||
|
body.ContactPlaneValid = true;
|
||||||
|
// Corridor floor at world z = −6 → n·p + d = 0 with n = +Z, d = 6.
|
||||||
|
body.ContactPlane = new Plane(Vector3.UnitZ, 6f);
|
||||||
|
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||||||
|
// The live session carried a walkable polygon (walkable=True on every
|
||||||
|
// [resolve] line) — seed the corridor floor slab so the transition's
|
||||||
|
// SetWalkable path runs like live.
|
||||||
|
body.WalkablePolygonValid = true;
|
||||||
|
body.WalkablePlane = new Plane(Vector3.UnitZ, 6f);
|
||||||
|
body.WalkableUp = Vector3.UnitZ;
|
||||||
|
body.WalkableVertices = new[]
|
||||||
|
{
|
||||||
|
new Vector3(75f, -41.67f, -6f),
|
||||||
|
new Vector3(85f, -41.67f, -6f),
|
||||||
|
new Vector3(85f, -38.33f, -6f),
|
||||||
|
new Vector3(75f, -38.33f, -6f),
|
||||||
|
};
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResolveResult Resolve(PhysicsEngine engine, PhysicsBody body,
|
||||||
|
Vector3 from, Vector3 to, uint cellId)
|
||||||
|
=> engine.ResolveWithTransition(
|
||||||
|
currentPos: from,
|
||||||
|
targetPos: to,
|
||||||
|
cellId: cellId,
|
||||||
|
sphereRadius: 0.48f, // human player, PlayerMovementController:885
|
||||||
|
sphereHeight: 1.2f, // human player, PlayerMovementController:886
|
||||||
|
stepUpHeight: 0.4f, // PlayerMovementController defaults
|
||||||
|
stepDownHeight: 0.4f,
|
||||||
|
isOnGround: true,
|
||||||
|
body: body,
|
||||||
|
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 2026-07-06 seam-shake repro, snapshot-exact (probe session
|
||||||
|
/// launch-137-seam-probes.log + resolve-137-seam-capture.jsonl tick 4101,
|
||||||
|
/// repeated ×46): running WEST across the x=75 boundary
|
||||||
|
/// (0x8A02016E → 0x8A020165, the ramp cell) from (75.287, −40.035, −6)
|
||||||
|
/// toward (74.685, −39.988, −6), the resolve blocks with the SYNTHETIC
|
||||||
|
/// reversed-movement normal (0.997, −0.078, −0.002) and out==in — every
|
||||||
|
/// frame — the "shaking at the seam" report.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Probe-traced chain: the foot sphere (tangent to the floor) crosses
|
||||||
|
/// onto 0165's ramp floor; the ramp slab is double-faced and the
|
||||||
|
/// UNDERSIDE face (poly 0, n=(−0.03,0,−1)) grazes the sphere within the
|
||||||
|
/// hit threshold → recorded as a foot near-miss → neg-poly step-up
|
||||||
|
/// dispatch with a downward normal → the nested step-up's walkable probe
|
||||||
|
/// rejects the exactly-tangent ramp floor ([walkable-nearest]
|
||||||
|
/// gap=−0.0000 overlapsSphere=False) → StepUpSlide →
|
||||||
|
/// slide_sphere(downward normal vs up-facing contact plane) → the
|
||||||
|
/// opposing-normals branch → Collided → revert. Repeat.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SeamShake_WestBoundary_SnapshotExact_Advances()
|
||||||
|
{
|
||||||
|
var datDir = FindDatDir();
|
||||||
|
if (datDir is null)
|
||||||
|
{
|
||||||
|
_out.WriteLine("SKIP: dat directory not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var engine = BuildCorridorEngine(dats);
|
||||||
|
|
||||||
|
// Body seeded EXACTLY from the capture's bodyBefore (tick 4101).
|
||||||
|
var body = new PhysicsBody();
|
||||||
|
body.ContactPlaneValid = true;
|
||||||
|
body.ContactPlane = new Plane(Vector3.UnitZ, 6f);
|
||||||
|
body.ContactPlaneCellId = SeamCellWest;
|
||||||
|
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||||||
|
body.WalkablePolygonValid = true;
|
||||||
|
body.WalkablePlane = new Plane(Vector3.UnitZ, 6f);
|
||||||
|
body.WalkableUp = Vector3.UnitZ;
|
||||||
|
body.WalkableVertices = new[]
|
||||||
|
{
|
||||||
|
new Vector3(75f, -38.33333f, -6f),
|
||||||
|
new Vector3(75f, -41.66667f, -6f),
|
||||||
|
new Vector3(78.33333f, -41.66667f, -6f),
|
||||||
|
new Vector3(78.33333f, -38.33333f, -6f),
|
||||||
|
};
|
||||||
|
|
||||||
|
var from = new Vector3(75.28674f, -40.03537f, -6f);
|
||||||
|
var to = new Vector3(74.6854f, -39.988018f, -6f);
|
||||||
|
|
||||||
|
// Emit the same step-level probes the live session logged so the
|
||||||
|
// offline trace can be line-diffed against launch-137-seam-probes.log
|
||||||
|
// — the first divergent line names the state the replay is missing.
|
||||||
|
var probeBuffer = new System.IO.StringWriter();
|
||||||
|
var prevOut = Console.Out;
|
||||||
|
ResolveResult r1;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Console.SetOut(probeBuffer);
|
||||||
|
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
|
||||||
|
PhysicsDiagnostics.ProbePushBackEnabled = true;
|
||||||
|
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
|
||||||
|
|
||||||
|
r1 = engine.ResolveWithTransition(
|
||||||
|
currentPos: from,
|
||||||
|
targetPos: to,
|
||||||
|
cellId: SeamCellWest,
|
||||||
|
sphereRadius: 0.48f,
|
||||||
|
sphereHeight: 1.2f,
|
||||||
|
stepUpHeight: 0.6f, // live Setup values from the capture
|
||||||
|
stepDownHeight: 1.5f,
|
||||||
|
isOnGround: true,
|
||||||
|
body: body,
|
||||||
|
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
|
||||||
|
PhysicsDiagnostics.ProbePushBackEnabled = false;
|
||||||
|
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
|
||||||
|
Console.SetOut(prevOut);
|
||||||
|
}
|
||||||
|
_out.WriteLine(probeBuffer.ToString());
|
||||||
|
|
||||||
|
_out.WriteLine($"r1: ok={r1.Ok} out=({r1.Position.X:F3},{r1.Position.Y:F3},{r1.Position.Z:F3}) " +
|
||||||
|
$"cell=0x{r1.CellId:X8} hit={r1.CollisionNormalValid} " +
|
||||||
|
$"n=({r1.CollisionNormal.X:F2},{r1.CollisionNormal.Y:F2},{r1.CollisionNormal.Z:F2}) " +
|
||||||
|
$"bodySliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)}");
|
||||||
|
|
||||||
|
Assert.True(r1.Position.X < from.X - 0.3f,
|
||||||
|
$"The westward boundary crossing onto the ramp must advance " +
|
||||||
|
$"({from.X:F3} → {r1.Position.X:F3}, target {to.X:F3}); zero " +
|
||||||
|
$"advance with the reversed-movement normal = the seam shake.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #137 window-climb repro (2026-07-06 gate 2, launch-137-gate2.log):
|
||||||
|
/// running from the ramp top in 0x8A020179 into the corridor-end opening
|
||||||
|
/// (the portal to the 0x8A02017E shaft, wall plane world y=−41.67), the
|
||||||
|
/// player stepped INTO the niche — `in=(89.531,−41.506,−5.112) →
|
||||||
|
/// out=(90.209,−41.774,−5.209) cell=0x8A02017E` — ending with the head
|
||||||
|
/// (and camera) through the opening's roof. The opening is ~1.3 m tall
|
||||||
|
/// (z −5.2..−3.9); a 1.68 m character cannot fit — retail blocks entry
|
||||||
|
/// (the raised probe's HEAD sphere hits the lintel/ceiling). User axiom:
|
||||||
|
/// "should not be able to run into it".
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void WindowOpening_HeadCannotFit_EntryBlocked()
|
||||||
|
{
|
||||||
|
var datDir = FindDatDir();
|
||||||
|
if (datDir is null)
|
||||||
|
{
|
||||||
|
_out.WriteLine("SKIP: dat directory not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var engine = BuildCorridorEngine(dats);
|
||||||
|
|
||||||
|
var body = new PhysicsBody();
|
||||||
|
body.ContactPlaneValid = true;
|
||||||
|
body.ContactPlane = new Plane(Vector3.UnitZ, 5.112f); // ramp-top level
|
||||||
|
body.ContactPlaneCellId = 0x8A020179u;
|
||||||
|
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||||||
|
|
||||||
|
// Walk the live approach (ramp-top toward the corridor-end opening)
|
||||||
|
// so the engine self-accumulates its contact-plane/walkable state,
|
||||||
|
// then push into the opening for several held-key frames (the live
|
||||||
|
// climb happened under a held key, not a single resolve).
|
||||||
|
var pos = new Vector3(88.60f, -41.10f, -5.05f);
|
||||||
|
uint cell = 0x8A020179u;
|
||||||
|
ResolveResult r = default;
|
||||||
|
bool probeFrames = Env.GetEnvironmentVariable("ACDREAM_TEST_WINDOW_PROBE") == "1";
|
||||||
|
for (int i = 0; i < 22; i++)
|
||||||
|
{
|
||||||
|
var dir = Vector3.Normalize(new Vector3(90.209f, -41.809f, 0f) - new Vector3(pos.X, pos.Y, 0f));
|
||||||
|
var step = new Vector3(dir.X, dir.Y, 0f) * 0.13f;
|
||||||
|
|
||||||
|
var probeBuffer = new System.IO.StringWriter();
|
||||||
|
var prevOut = Console.Out;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (probeFrames && i >= 9)
|
||||||
|
{
|
||||||
|
Console.SetOut(probeBuffer);
|
||||||
|
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
|
||||||
|
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
|
||||||
|
}
|
||||||
|
r = engine.ResolveWithTransition(
|
||||||
|
currentPos: pos,
|
||||||
|
targetPos: pos + step,
|
||||||
|
cellId: cell,
|
||||||
|
sphereRadius: 0.48f,
|
||||||
|
// #137: the corrected capsule top (dat Setup 0x02000001,
|
||||||
|
// head sphere center 1.350 → top 1.830; Height 1.835).
|
||||||
|
// The live climb happened under the old 1.2f (head top
|
||||||
|
// 1.2 m — no head collision at the lintel).
|
||||||
|
sphereHeight: 1.835f,
|
||||||
|
stepUpHeight: 0.6f,
|
||||||
|
stepDownHeight: 1.5f,
|
||||||
|
isOnGround: true,
|
||||||
|
body: body,
|
||||||
|
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (probeFrames && i >= 9)
|
||||||
|
{
|
||||||
|
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
|
||||||
|
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
|
||||||
|
Console.SetOut(prevOut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (probeFrames && i >= 9 && i <= 10)
|
||||||
|
_out.WriteLine(probeBuffer.ToString());
|
||||||
|
_out.WriteLine($"r{i}: ok={r.Ok} out=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) " +
|
||||||
|
$"cell=0x{r.CellId:X8} hit={r.CollisionNormalValid} " +
|
||||||
|
$"n=({r.CollisionNormal.X:F2},{r.CollisionNormal.Y:F2},{r.CollisionNormal.Z:F2})");
|
||||||
|
pos = r.Position;
|
||||||
|
cell = r.CellId;
|
||||||
|
|
||||||
|
Assert.NotEqual(0x8A02017Eu, r.CellId);
|
||||||
|
Assert.True(r.Position.Y > -41.6f,
|
||||||
|
$"A 1.68 m character must not enter the 1.3 m-tall opening " +
|
||||||
|
$"(wall plane y=−41.67); frame {i} got Y={r.Position.Y:F3} " +
|
||||||
|
$"cell=0x{r.CellId:X8} (live bug: ended at −41.774 inside " +
|
||||||
|
$"0x8A02017E, head through the roof).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The window-climb's placement half, pinned at the exact site: at the
|
||||||
|
/// step-up's raised position on the alcove sill (foot −5.019), the HEAD
|
||||||
|
/// sphere (center −3.339, span −3.82..−2.86) pokes ~6 cm past the south
|
||||||
|
/// wall plane into the SOLID rock above the alcove ceiling (0x8A020179's
|
||||||
|
/// lintel band, polys 14/15 at y=−41.67 z∈[−3.90,−3.00]). Retail's
|
||||||
|
/// step-down placement insert (CTransition::step_down 0x0050b3b3 →
|
||||||
|
/// placement transitional_insert → BSPTREE::sphere_intersects_solid
|
||||||
|
/// 0x0053d5f0) REJECTS — that's what makes the 0.7 m sill unclimbable.
|
||||||
|
/// Our placement passed (the live + offline climb), so our Path-1 solid
|
||||||
|
/// test misses the head-vs-solid overlap.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void WindowAlcove_RaisedPlacement_HeadInLintelSolid_Collides()
|
||||||
|
{
|
||||||
|
var datDir = FindDatDir();
|
||||||
|
if (datDir is null)
|
||||||
|
{
|
||||||
|
_out.WriteLine("SKIP: dat directory not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var engine = BuildCorridorEngine(dats);
|
||||||
|
|
||||||
|
var cell = engine.DataCache!.GetCellStruct(0x8A020179u);
|
||||||
|
Assert.NotNull(cell);
|
||||||
|
Assert.NotNull(cell!.BSP?.Root);
|
||||||
|
|
||||||
|
// The raised (post-sill-climb) pose from the offline repro's r9.
|
||||||
|
var footWorld = new Vector3(89.683f, -41.247f, -4.539f); // foot sphere CENTER
|
||||||
|
var headWorld = new Vector3(89.683f, -41.247f, -3.339f); // head sphere CENTER
|
||||||
|
|
||||||
|
var footLocal = Vector3.Transform(footWorld, cell.InverseWorldTransform);
|
||||||
|
var headLocal = Vector3.Transform(headWorld, cell.InverseWorldTransform);
|
||||||
|
|
||||||
|
var t = new Transition();
|
||||||
|
t.SpherePath.InitPath(
|
||||||
|
new Vector3(89.683f, -41.247f, -5.019f),
|
||||||
|
new Vector3(89.683f, -41.247f, -5.019f),
|
||||||
|
0x8A020179u, 0.48f, 1.2f);
|
||||||
|
t.SpherePath.InsertType = InsertType.Placement;
|
||||||
|
|
||||||
|
Matrix4x4.Decompose(cell.WorldTransform, out _, out var cellRot, out var cellOrigin);
|
||||||
|
|
||||||
|
var result = BSPQuery.FindCollisions(
|
||||||
|
cell.BSP!.Root,
|
||||||
|
cell.Resolved,
|
||||||
|
t,
|
||||||
|
new DatReaderWriter.Types.Sphere { Origin = footLocal, Radius = 0.48f },
|
||||||
|
new DatReaderWriter.Types.Sphere { Origin = headLocal, Radius = 0.48f },
|
||||||
|
footLocal,
|
||||||
|
Vector3.UnitZ,
|
||||||
|
1.0f,
|
||||||
|
cellRot,
|
||||||
|
engine,
|
||||||
|
worldOrigin: cellOrigin);
|
||||||
|
|
||||||
|
_out.WriteLine($"placement result={result} footLocal=({footLocal.X:F3},{footLocal.Y:F3},{footLocal.Z:F3}) " +
|
||||||
|
$"headLocal=({headLocal.X:F3},{headLocal.Y:F3},{headLocal.Z:F3})");
|
||||||
|
|
||||||
|
Assert.Equal(TransitionState.Collided, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 2026-07-06 gate session repro (launch-137-corridor-gate.log): standing
|
||||||
|
/// at (84.851, −39.764, −6.000) — the foot sphere already straddling the
|
||||||
|
/// x=85 cell boundary by 0.33 m — the first move attempt toward
|
||||||
|
/// (85.453, −39.782) blocked with the synthetic reversed-movement normal
|
||||||
|
/// (−1.00, 0.03, −0.02), out==in, cp lost (cp=none), and repeated every
|
||||||
|
/// frame (the "shaking at the seam" report). The deeper straddle start is
|
||||||
|
/// what the original replay frame (84.638 → 85.253) didn't cover.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SeamCrossing_FromDeepStraddleStart_Advances()
|
||||||
|
{
|
||||||
|
var datDir = FindDatDir();
|
||||||
|
if (datDir is null)
|
||||||
|
{
|
||||||
|
_out.WriteLine("SKIP: dat directory not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var engine = BuildCorridorEngine(dats);
|
||||||
|
var body = GroundedBody();
|
||||||
|
|
||||||
|
var from = new Vector3(84.851f, -39.764f, -6.000f);
|
||||||
|
var to = new Vector3(85.453f, -39.782f, -6.000f);
|
||||||
|
|
||||||
|
var r1 = Resolve(engine, body, from, to, SeamCellWest);
|
||||||
|
_out.WriteLine($"r1: ok={r1.Ok} out=({r1.Position.X:F3},{r1.Position.Y:F3},{r1.Position.Z:F3}) " +
|
||||||
|
$"cell=0x{r1.CellId:X8} hit={r1.CollisionNormalValid} " +
|
||||||
|
$"n=({r1.CollisionNormal.X:F2},{r1.CollisionNormal.Y:F2},{r1.CollisionNormal.Z:F2}) " +
|
||||||
|
$"bodySliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)} " +
|
||||||
|
$"bodyCpValid={body.ContactPlaneValid}");
|
||||||
|
|
||||||
|
Assert.True(r1.Position.X > from.X + 0.2f,
|
||||||
|
$"The straddling-start seam crossing must advance " +
|
||||||
|
$"({from.X:F3} → {r1.Position.X:F3}); zero advance with a " +
|
||||||
|
$"reversed-movement normal = the 2026-07-06 seam shake.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SeamCrossing_DoesNotPersistSyntheticSlidingNormal_AndRunContinues()
|
||||||
|
{
|
||||||
|
var datDir = FindDatDir();
|
||||||
|
if (datDir is null)
|
||||||
|
{
|
||||||
|
_out.WriteLine("SKIP: dat directory not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var engine = BuildCorridorEngine(dats);
|
||||||
|
var body = GroundedBody();
|
||||||
|
|
||||||
|
// ── The live hit frame verbatim (launch-175-verify2.log:42858) ──
|
||||||
|
var from = new Vector3(84.638f, -39.758f, -6.000f);
|
||||||
|
var to = new Vector3(85.253f, -39.776f, -6.000f);
|
||||||
|
|
||||||
|
var r1 = Resolve(engine, body, from, to, SeamCellWest);
|
||||||
|
_out.WriteLine($"r1: ok={r1.Ok} out=({r1.Position.X:F3},{r1.Position.Y:F3},{r1.Position.Z:F3}) " +
|
||||||
|
$"cell=0x{r1.CellId:X8} hit={r1.CollisionNormalValid} " +
|
||||||
|
$"n=({r1.CollisionNormal.X:F2},{r1.CollisionNormal.Y:F2},{r1.CollisionNormal.Z:F2}) " +
|
||||||
|
$"bodySliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)} " +
|
||||||
|
$"slidingN=({body.SlidingNormal.X:F2},{body.SlidingNormal.Y:F2},{body.SlidingNormal.Z:F2})");
|
||||||
|
|
||||||
|
// The corridor is straight and open: the crossing must not leave the
|
||||||
|
// body carrying a sliding normal (there is no wall to slide on —
|
||||||
|
// Issue137CorridorSeamInspectionTests proved no polygon matches the
|
||||||
|
// live-recorded normal; retail's slide_sphere opposing branch returns
|
||||||
|
// COLLIDED and its validate handling never lets a synthetic
|
||||||
|
// reversed-movement normal survive a clean corridor run).
|
||||||
|
Assert.False(body.TransientState.HasFlag(TransientStateFlags.Sliding),
|
||||||
|
"Crossing the open corridor seam must not persist a sliding " +
|
||||||
|
"normal — the live wedge's entry state (#137 mechanism 2).");
|
||||||
|
|
||||||
|
// ── Keep running +X (the live session's held-W frames) ──────────
|
||||||
|
var pos = r1.Position;
|
||||||
|
var cell = r1.CellId;
|
||||||
|
for (int i = 0; i < 6; i++)
|
||||||
|
{
|
||||||
|
var step = new Vector3(0.13f, -0.004f, 0f); // ~run speed per tick, same heading
|
||||||
|
var r = Resolve(engine, body, pos, pos + step, cell);
|
||||||
|
_out.WriteLine($"r{i + 2}: ok={r.Ok} out=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) " +
|
||||||
|
$"cell=0x{r.CellId:X8} hit={r.CollisionNormalValid} " +
|
||||||
|
$"bodySliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)}");
|
||||||
|
Assert.True(r.Position.X > pos.X + 0.05f,
|
||||||
|
$"Forward run must keep advancing through the open corridor " +
|
||||||
|
$"(frame {i + 2}: {pos.X:F3} → {r.Position.X:F3}) — zero advance " +
|
||||||
|
$"= the #137 absorbing wedge.");
|
||||||
|
pos = r.Position;
|
||||||
|
cell = r.CellId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,343 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
|
using DatReaderWriter.Enums;
|
||||||
|
using DatReaderWriter.Types;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using Xunit;
|
||||||
|
using Plane = System.Numerics.Plane;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #137 mechanism 2 — the sliding-normal absorbing wedge (2026-07-06).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Retail's in-transition <c>collision_info.sliding_normal</c> has exactly ONE
|
||||||
|
/// writer besides the per-frame seed: <c>CTransition::validate_transition</c>
|
||||||
|
/// (0x0050ac21-ac30, "if collision_normal_valid → set_sliding_normal"). The
|
||||||
|
/// BSP collision layer NEVER writes it — <c>BSPTREE::find_collisions</c>'
|
||||||
|
/// Contact branch dispatches full hits to <c>step_sphere_up</c> (foot,
|
||||||
|
/// 0x0053a719) / <c>BSPTREE::slide_sphere</c> (head, 0x0053a697), and
|
||||||
|
/// <c>CSphere::slide_sphere</c> (0x00537440) slides IN-FRAME via
|
||||||
|
/// <c>add_offset_to_check_pos</c> without touching sliding_normal
|
||||||
|
/// (grep-verified: zero sliding_normal references between 0x005155 and
|
||||||
|
/// 0x00841f in acclient_2013_pseudo_c.txt). ACE mirrors this: the only
|
||||||
|
/// SetSlidingNormal call sites are CollisionInfo.cs:58 (the setter) and
|
||||||
|
/// Transition.cs:1027 (validate). The body-side persistence
|
||||||
|
/// (<c>CPhysicsObj::SetPositionInternal</c> 0x005154c2, SLIDING_TS bit-4 sync
|
||||||
|
/// at 0x005154e1) runs only on transition SUCCESS.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// acdream's BSPQuery Contact branch carried stub fallbacks
|
||||||
|
/// (SetCollisionNormal + SetSlidingNormal + return Slid) instead of the real
|
||||||
|
/// slide. The leaked sliding normal survived to the transition end, the
|
||||||
|
/// unconditional body writeback persisted it, and the next frame's seed
|
||||||
|
/// projected an exactly-anti-parallel push to zero — aborting at step 0
|
||||||
|
/// BEFORE any collision test could refresh the state. Live shape: the
|
||||||
|
/// Facility Hub corridor phantom (launch-175-verify2.log:42858 — one wall
|
||||||
|
/// hit at the 0x8A02016E→0x8A02017A seam, then endless ok=False hit=no
|
||||||
|
/// zero-advance resolves; strafe escapes).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class Issue137SlidingNormalLifecycleTests
|
||||||
|
{
|
||||||
|
// =========================================================================
|
||||||
|
// Site-level pins — BSPQuery.FindCollisions Contact branch must not write
|
||||||
|
// the transition's sliding normal (retail: only validate_transition does).
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Contact foot-sphere FULL HIT with the step-up recursion unavailable
|
||||||
|
/// (engine=null / step-up already in progress) must dispatch the real
|
||||||
|
/// sphere slide — never the SetSlidingNormal stub.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Retail: a blocked step-up funnels to <c>SPHEREPATH::step_up_slide</c> →
|
||||||
|
/// <c>CSphere::slide_sphere</c> (ACE SpherePath.cs:316 → Sphere.cs:558) —
|
||||||
|
/// in-frame slide, no sliding_normal write. Face-on into a vertical wall
|
||||||
|
/// while grounded: the crease projection (cross(wallN, floorN)) has no
|
||||||
|
/// component along the movement, the slide offset is degenerate
|
||||||
|
/// (< F_EPSILON), and slide_sphere returns COLLIDED_TS (0x00537735).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ContactFootFullHit_StepUpUnavailable_RealSlide_NoSlidingNormalWrite()
|
||||||
|
{
|
||||||
|
var (root, resolved) = BSPStepUpFixtures.TallWall();
|
||||||
|
|
||||||
|
// Grounded mover pushing face-on (+X) into the 5 m wall at x=0.5
|
||||||
|
// (normal −X). Sphere center reach 0.35+0.2=0.55 penetrates the wall.
|
||||||
|
var from = new Vector3(0.10f, 0f, BSPStepUpFixtures.SphereRadius);
|
||||||
|
var to = new Vector3(0.35f, 0f, BSPStepUpFixtures.SphereRadius);
|
||||||
|
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to);
|
||||||
|
|
||||||
|
var localSphere = new DatReaderWriter.Types.Sphere
|
||||||
|
{
|
||||||
|
Origin = to,
|
||||||
|
Radius = BSPStepUpFixtures.SphereRadius,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = BSPQuery.FindCollisions(
|
||||||
|
root, resolved, t, localSphere, null,
|
||||||
|
from, Vector3.UnitZ, 1.0f);
|
||||||
|
|
||||||
|
Assert.False(t.CollisionInfo.SlidingNormalValid,
|
||||||
|
"find_collisions must not write collision_info.sliding_normal — " +
|
||||||
|
"retail's only in-transition writer is validate_transition " +
|
||||||
|
"(0x0050ac21). A sliding normal leaked here survives to the body " +
|
||||||
|
"writeback and absorbs the next frame's forward offset (#137).");
|
||||||
|
Assert.True(t.CollisionInfo.CollisionNormalValid,
|
||||||
|
"The real slide records the collision normal (CSphere::slide_sphere " +
|
||||||
|
"→ set_collision_normal).");
|
||||||
|
Assert.Equal(TransitionState.Collided, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Contact HEAD-sphere FULL HIT must dispatch <c>BSPTREE::slide_sphere</c>
|
||||||
|
/// (retail 0x0053a697; ACE BSPTree.cs:202 → 310-316: the real
|
||||||
|
/// <c>Sphere.SlideSphere</c> on GlobalSphere[0]) — never the stub.
|
||||||
|
/// The corridor phantom's portal-side polys span head height; this is the
|
||||||
|
/// path that recorded the (−1,0,0) normal the wedge absorbed on.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ContactHeadFullHit_RealSlide_NoSlidingNormalWrite()
|
||||||
|
{
|
||||||
|
// Raised wall: z ∈ [0.6, 5] at x=0.5, normal −X. The foot sphere
|
||||||
|
// (center z=0.2, r=0.2 → z-span [0, 0.4]) passes under it; the head
|
||||||
|
// sphere (center z=0.8 → z-span [0.6, 1.0]) fully hits it.
|
||||||
|
var resolved = new Dictionary<ushort, ResolvedPolygon>();
|
||||||
|
|
||||||
|
var floorVerts = new[]
|
||||||
|
{
|
||||||
|
new Vector3(-2f, -1f, 0f), new Vector3(2f, -1f, 0f),
|
||||||
|
new Vector3(2f, 1f, 0f), new Vector3(-2f, 1f, 0f),
|
||||||
|
};
|
||||||
|
resolved[1] = new ResolvedPolygon
|
||||||
|
{
|
||||||
|
Vertices = floorVerts,
|
||||||
|
Plane = new Plane(Vector3.UnitZ, 0f),
|
||||||
|
NumPoints = 4,
|
||||||
|
SidesType = CullMode.None,
|
||||||
|
};
|
||||||
|
|
||||||
|
var wallNormal = new Vector3(-1f, 0f, 0f);
|
||||||
|
var wallVerts = new[]
|
||||||
|
{
|
||||||
|
new Vector3(0.5f, -1f, 0.6f),
|
||||||
|
new Vector3(0.5f, -1f, 5f),
|
||||||
|
new Vector3(0.5f, 1f, 5f),
|
||||||
|
new Vector3(0.5f, 1f, 0.6f),
|
||||||
|
};
|
||||||
|
resolved[2] = new ResolvedPolygon
|
||||||
|
{
|
||||||
|
Vertices = wallVerts,
|
||||||
|
Plane = new Plane(wallNormal, 0.5f), // n·p + d = 0 at x=0.5
|
||||||
|
NumPoints = 4,
|
||||||
|
SidesType = CullMode.None,
|
||||||
|
};
|
||||||
|
|
||||||
|
var leaf = new PhysicsBSPNode
|
||||||
|
{
|
||||||
|
Type = BSPNodeType.Leaf,
|
||||||
|
BoundingSphere = new DatReaderWriter.Types.Sphere { Origin = new Vector3(0f, 0f, 2.5f), Radius = 10f },
|
||||||
|
};
|
||||||
|
leaf.Polygons.Add(1);
|
||||||
|
leaf.Polygons.Add(2);
|
||||||
|
|
||||||
|
var from = new Vector3(0.10f, 0f, BSPStepUpFixtures.SphereRadius);
|
||||||
|
var to = new Vector3(0.35f, 0f, BSPStepUpFixtures.SphereRadius);
|
||||||
|
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to);
|
||||||
|
|
||||||
|
var footSphere = new DatReaderWriter.Types.Sphere
|
||||||
|
{
|
||||||
|
Origin = to,
|
||||||
|
Radius = BSPStepUpFixtures.SphereRadius,
|
||||||
|
};
|
||||||
|
var headSphere = new DatReaderWriter.Types.Sphere
|
||||||
|
{
|
||||||
|
Origin = new Vector3(to.X, to.Y, 0.8f),
|
||||||
|
Radius = BSPStepUpFixtures.SphereRadius,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = BSPQuery.FindCollisions(
|
||||||
|
leaf, resolved, t, footSphere, headSphere,
|
||||||
|
from, Vector3.UnitZ, 1.0f);
|
||||||
|
|
||||||
|
Assert.False(t.CollisionInfo.SlidingNormalValid,
|
||||||
|
"Head full hit must go through the real BSPTREE::slide_sphere — " +
|
||||||
|
"no sliding_normal write at the BSP layer (retail 0x0053a697).");
|
||||||
|
Assert.True(t.CollisionInfo.CollisionNormalValid);
|
||||||
|
Assert.Equal(TransitionState.Collided, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>CSphere::slide_sphere</c>'s opposing-normals branch (collision
|
||||||
|
/// normal anti-parallel to the contact plane — e.g. a ceiling-facing
|
||||||
|
/// normal while grounded) records the REVERSED displacement as the
|
||||||
|
/// collision normal and returns <b>COLLIDED_TS</b> — retail 0x00537440
|
||||||
|
/// @0x005375d7-0x0053762c: <c>*normal = −gDelta; normalize;
|
||||||
|
/// set_collision_normal; return 2</c>. Our port returned OK (its comment
|
||||||
|
/// even claimed "retail returns OK here"), letting the step complete
|
||||||
|
/// as-is with a synthetic reversed-movement collision normal — the exact
|
||||||
|
/// signature of the live corridor hit (`hit=yes n=(−1.00,0.03,−0.03)` =
|
||||||
|
/// the negated run direction, matching NO dat polygon).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SlideSphere_OpposingNormals_ReturnsCollided_WithReversedDisplacementNormal()
|
||||||
|
{
|
||||||
|
var t = new Transition();
|
||||||
|
t.SpherePath.InitPath(
|
||||||
|
new Vector3(0f, 0f, 0.2f), new Vector3(0.3f, 0f, 0.2f),
|
||||||
|
0xA9B40001u, BSPStepUpFixtures.SphereRadius);
|
||||||
|
t.CollisionInfo.SetContactPlane(new Plane(Vector3.UnitZ, 0f), 0xA9B40001u, false);
|
||||||
|
|
||||||
|
// Make gDelta exactly (0.4, 0, 0): currPos = check sphere − (0.4,0,0).
|
||||||
|
var currPos = t.SpherePath.GlobalSphere[0].Origin - new Vector3(0.4f, 0f, 0f);
|
||||||
|
|
||||||
|
// Downward collision normal vs the +Z contact plane → cross ≈ 0
|
||||||
|
// (parallel), dot = −1 < 0 (opposing) → the reverse branch.
|
||||||
|
var result = t.SlideSphereInternal(new Vector3(0f, 0f, -1f), currPos);
|
||||||
|
|
||||||
|
Assert.Equal(TransitionState.Collided, result);
|
||||||
|
Assert.True(t.CollisionInfo.CollisionNormalValid);
|
||||||
|
Assert.True(t.CollisionInfo.CollisionNormal.X < -0.99f,
|
||||||
|
$"Collision normal must be the normalized reversed displacement " +
|
||||||
|
$"(−1,0,0); got ({t.CollisionInfo.CollisionNormal.X:F3}," +
|
||||||
|
$"{t.CollisionInfo.CollisionNormal.Y:F3},{t.CollisionInfo.CollisionNormal.Z:F3}).");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Engine-level lifecycle pin — the retail persist/absorb/clear cycle at a
|
||||||
|
// REAL wall. Guards the fix against regressing wall behavior, and
|
||||||
|
// documents where retail CLEARS the body's sliding state (the successful
|
||||||
|
// transition's writeback, when no step re-records a collision).
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private const uint CellId = 0xA9B40157u;
|
||||||
|
|
||||||
|
private static PhysicsEngine BuildWallEngine()
|
||||||
|
{
|
||||||
|
var (wallRoot, wallResolved) = BSPStepUpFixtures.TallWall();
|
||||||
|
|
||||||
|
var cell = new CellPhysics
|
||||||
|
{
|
||||||
|
BSP = new PhysicsBSPTree { Root = wallRoot },
|
||||||
|
WorldTransform = Matrix4x4.Identity,
|
||||||
|
InverseWorldTransform = Matrix4x4.Identity,
|
||||||
|
Resolved = wallResolved,
|
||||||
|
CellBSP = new CellBSPTree
|
||||||
|
{
|
||||||
|
Root = new CellBSPNode { Type = BSPNodeType.Leaf },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var engine = new PhysicsEngine();
|
||||||
|
engine.DataCache = new PhysicsDataCache();
|
||||||
|
|
||||||
|
// Flat terrain strip so the outdoor fall-through has something to
|
||||||
|
// sample if it ever fires (same shape as FindEnvCollisionsMultiCellTests).
|
||||||
|
var heights = new byte[81];
|
||||||
|
Array.Fill(heights, (byte)0);
|
||||||
|
engine.AddLandblock(0xA9B4FFFFu, new TerrainSurface(heights, BuildHeightTable()),
|
||||||
|
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
|
||||||
|
worldOffsetX: 0f, worldOffsetY: 0f);
|
||||||
|
|
||||||
|
engine.DataCache.RegisterCellStructForTest(CellId, cell);
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float[] BuildHeightTable()
|
||||||
|
{
|
||||||
|
var ht = new float[256];
|
||||||
|
for (int i = 0; i < 256; i++) ht[i] = i * 1.0f;
|
||||||
|
return ht;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PhysicsBody GroundedBody()
|
||||||
|
{
|
||||||
|
var body = new PhysicsBody();
|
||||||
|
body.ContactPlaneValid = true;
|
||||||
|
body.ContactPlane = new Plane(Vector3.UnitZ, 0f);
|
||||||
|
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResolveResult ResolveForward(PhysicsEngine engine, PhysicsBody body,
|
||||||
|
Vector3 from, Vector3 to)
|
||||||
|
=> engine.ResolveWithTransition(
|
||||||
|
currentPos: from,
|
||||||
|
targetPos: to,
|
||||||
|
cellId: CellId,
|
||||||
|
sphereRadius: BSPStepUpFixtures.SphereRadius,
|
||||||
|
sphereHeight: 0f, // single sphere — keeps the scenario deterministic
|
||||||
|
stepUpHeight: 0.04f, // cannot scale the 5 m wall
|
||||||
|
stepDownHeight: 0.04f,
|
||||||
|
isOnGround: true,
|
||||||
|
body: body);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The full retail lifecycle at a real wall:
|
||||||
|
/// (1) a blocked face-on push persists the validate-recorded sliding
|
||||||
|
/// normal via the SUCCESS writeback (SetPositionInternal bit-4 sync,
|
||||||
|
/// 0x005154e1);
|
||||||
|
/// (2) the next exactly-anti-parallel push is absorbed by the seed
|
||||||
|
/// (get_object_info 0x00511d44 → adjust_offset projects to zero →
|
||||||
|
/// find_transitional_position's step-0 small-offset abort) — the
|
||||||
|
/// retail cache semantics: "still pressed against this wall";
|
||||||
|
/// (3) an oblique push escapes along the wall tangent, the step runs
|
||||||
|
/// without re-recording a collision, and the successful writeback
|
||||||
|
/// CLEARS the body's sliding state (sliding_normal_valid==0 → bit
|
||||||
|
/// 4 cleared).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void WallLifecycle_PersistOnBlock_AbsorbExactAntiParallel_ClearOnEscape()
|
||||||
|
{
|
||||||
|
var engine = BuildWallEngine();
|
||||||
|
var body = GroundedBody();
|
||||||
|
|
||||||
|
// ── 1. Face-on +X into the wall at x=0.5 (normal −X) ─────────────
|
||||||
|
var r1 = ResolveForward(engine, body,
|
||||||
|
from: new Vector3(0.10f, 0f, 0f),
|
||||||
|
to: new Vector3(0.35f, 0f, 0f));
|
||||||
|
|
||||||
|
Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding),
|
||||||
|
"A blocked push must persist the validate-recorded sliding normal " +
|
||||||
|
"(retail SetPositionInternal 0x005154c2 on transition success).");
|
||||||
|
Assert.True(body.SlidingNormal.X < -0.9f,
|
||||||
|
$"Persisted normal should face the mover (−X); got {body.SlidingNormal}.");
|
||||||
|
Assert.True(r1.Position.X + BSPStepUpFixtures.SphereRadius
|
||||||
|
<= 0.5f + PhysicsGlobals.EPSILON * 20f,
|
||||||
|
$"The 5 m wall must block the sphere; reach={r1.Position.X + BSPStepUpFixtures.SphereRadius:F4}.");
|
||||||
|
|
||||||
|
// ── 2. Exactly-anti-parallel push again: absorbed frame ──────────
|
||||||
|
var r2 = ResolveForward(engine, body,
|
||||||
|
from: r1.Position,
|
||||||
|
to: r1.Position + new Vector3(0.15f, 0f, 0f));
|
||||||
|
|
||||||
|
Assert.False(r2.Ok,
|
||||||
|
"The seeded sliding normal projects the exactly-anti-parallel " +
|
||||||
|
"offset to zero → step-0 abort (retail find_transitional_position " +
|
||||||
|
"0050bfb7/0050c0ef). Faithful absorbed frame at a REAL wall.");
|
||||||
|
Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding),
|
||||||
|
"A failed transition leaves the body's sliding state untouched " +
|
||||||
|
"(retail: SetPositionInternal never runs on failure).");
|
||||||
|
|
||||||
|
// ── 3. Oblique push escapes and CLEARS the persisted state ───────
|
||||||
|
var r3 = ResolveForward(engine, body,
|
||||||
|
from: r2.Position,
|
||||||
|
to: r2.Position + new Vector3(0.10f, 0.15f, 0f));
|
||||||
|
|
||||||
|
Assert.True(r3.Ok, "Oblique push must escape along the wall tangent.");
|
||||||
|
Assert.True(r3.Position.Y > r2.Position.Y + 0.05f,
|
||||||
|
$"Expected tangential advance along +Y; got Y={r3.Position.Y:F4} " +
|
||||||
|
$"(from {r2.Position.Y:F4}).");
|
||||||
|
Assert.False(body.TransientState.HasFlag(TransientStateFlags.Sliding),
|
||||||
|
"A successful transition whose steps re-record no collision must " +
|
||||||
|
"CLEAR the body's sliding state (retail SetPositionInternal " +
|
||||||
|
"0x005154e1: bit 4 synced from the transition's final " +
|
||||||
|
"sliding_normal_valid, which each step clears before its insert).");
|
||||||
|
Assert.Equal(Vector3.Zero, body.SlidingNormal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
using DatReaderWriter.Types;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
using Env = System.Environment;
|
||||||
|
using Placement = DatReaderWriter.Enums.Placement;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #175 (2026-07-05) — read-only dat inspection for the Facility Hub door
|
||||||
|
/// (Setup 0x02000C9D, guid 0x78A020C7 in the live session). User report:
|
||||||
|
/// the door's COLLISION sits displaced to the far side of the VISUAL panel
|
||||||
|
/// (embed from one side deep enough to camera-clip; a phantom wall on the
|
||||||
|
/// other side that can push the player out of use radius).
|
||||||
|
///
|
||||||
|
/// Hypothesis under test: collision registers from the Setup's
|
||||||
|
/// PlacementFrames (ShadowShapeBuilder.FromSetup — Resting|Default|first)
|
||||||
|
/// while the rendered panel poses from the motion table's default/closed
|
||||||
|
/// state through the sequencer; retail tests the part's LIVE pose
|
||||||
|
/// (CPhysicsPart), so a door whose placement frame differs from its
|
||||||
|
/// motion-table closed pose shows exactly this offset. This test dumps both
|
||||||
|
/// poses so the divergence (or its absence) is a dat fact, not a theory.
|
||||||
|
///
|
||||||
|
/// SKIP when the dat directory is absent (CI); local runs have it.
|
||||||
|
/// </summary>
|
||||||
|
public class Issue175HubDoorPoseInspectionTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _out;
|
||||||
|
public Issue175HubDoorPoseInspectionTests(ITestOutputHelper output) => _out = output;
|
||||||
|
|
||||||
|
private const uint HubDoorSetupId = 0x02000C9Du;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HubDoorSetup_PlacementVsMotionPose_DatInspection()
|
||||||
|
{
|
||||||
|
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
|
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||||
|
"Documents", "Asheron's Call");
|
||||||
|
if (!Directory.Exists(datDir))
|
||||||
|
{
|
||||||
|
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
||||||
|
var setup = dats.Get<Setup>(HubDoorSetupId);
|
||||||
|
Assert.NotNull(setup);
|
||||||
|
|
||||||
|
_out.WriteLine($"=== Setup 0x{HubDoorSetupId:X8} ===");
|
||||||
|
_out.WriteLine($" Flags = {setup!.Flags} (0x{(uint)setup.Flags:X8})");
|
||||||
|
_out.WriteLine($" Parts = {setup.Parts.Count}");
|
||||||
|
for (int i = 0; i < setup.Parts.Count; i++)
|
||||||
|
_out.WriteLine($" [{i}] gfxObj=0x{setup.Parts[i]:X8}");
|
||||||
|
_out.WriteLine($" DefaultAnimation = 0x{setup.DefaultAnimation:X8}");
|
||||||
|
_out.WriteLine($" DefaultScript = 0x{setup.DefaultScript:X8}");
|
||||||
|
_out.WriteLine($" DefaultMotionTable = 0x{setup.DefaultMotionTable:X8}");
|
||||||
|
_out.WriteLine($" CylSpheres={setup.CylSpheres.Count} Spheres={setup.Spheres.Count} Radius={setup.Radius:F3}");
|
||||||
|
foreach (var c in setup.CylSpheres)
|
||||||
|
_out.WriteLine($" cyl r={c.Radius:F3} h={c.Height:F3} origin=({c.Origin.X:F3},{c.Origin.Y:F3},{c.Origin.Z:F3})");
|
||||||
|
|
||||||
|
_out.WriteLine($" PlacementFrames = {setup.PlacementFrames.Count}");
|
||||||
|
foreach (var kv in setup.PlacementFrames)
|
||||||
|
{
|
||||||
|
_out.WriteLine($" [{kv.Key}] frames={kv.Value.Frames.Count}");
|
||||||
|
for (int i = 0; i < kv.Value.Frames.Count; i++)
|
||||||
|
{
|
||||||
|
var f = kv.Value.Frames[i];
|
||||||
|
_out.WriteLine(
|
||||||
|
$" part[{i}] pos=({f.Origin.X:F3},{f.Origin.Y:F3},{f.Origin.Z:F3}) " +
|
||||||
|
$"rot=({f.Orientation.X:F3},{f.Orientation.Y:F3},{f.Orientation.Z:F3},{f.Orientation.W:F3})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Part 0's physics BSP bounds — where the slab actually is in
|
||||||
|
// PART-LOCAL space (composed with the poses above for world).
|
||||||
|
foreach (uint gfxId in setup.Parts.Distinct())
|
||||||
|
{
|
||||||
|
var gfx = dats.Get<GfxObj>(gfxId);
|
||||||
|
_out.WriteLine($"=== GfxObj 0x{gfxId:X8} ===");
|
||||||
|
if (gfx is null) { _out.WriteLine(" NULL"); continue; }
|
||||||
|
var root = gfx.PhysicsBSP?.Root;
|
||||||
|
_out.WriteLine($" PhysicsBSP.Root = {(root is null ? "NULL" : "non-null")}");
|
||||||
|
if (root?.BoundingSphere is { } bs)
|
||||||
|
_out.WriteLine($" BSP bounds = ({bs.Origin.X:F3},{bs.Origin.Y:F3},{bs.Origin.Z:F3}) r={bs.Radius:F3}");
|
||||||
|
if (gfx.PhysicsPolygons is { } pp && gfx.VertexArray?.Vertices is { } verts)
|
||||||
|
{
|
||||||
|
float minX = float.MaxValue, maxX = float.MinValue;
|
||||||
|
float minY = float.MaxValue, maxY = float.MinValue;
|
||||||
|
float minZ = float.MaxValue, maxZ = float.MinValue;
|
||||||
|
foreach (var poly in pp.Values)
|
||||||
|
foreach (var vid in poly.VertexIds)
|
||||||
|
{
|
||||||
|
if (!verts.TryGetValue((ushort)vid, out var sv)) continue;
|
||||||
|
minX = Math.Min(minX, sv.Origin.X); maxX = Math.Max(maxX, sv.Origin.X);
|
||||||
|
minY = Math.Min(minY, sv.Origin.Y); maxY = Math.Max(maxY, sv.Origin.Y);
|
||||||
|
minZ = Math.Min(minZ, sv.Origin.Z); maxZ = Math.Max(maxZ, sv.Origin.Z);
|
||||||
|
}
|
||||||
|
_out.WriteLine($" Physics AABB (part-local) = X[{minX:F3},{maxX:F3}] Y[{minY:F3},{maxY:F3}] Z[{minZ:F3},{maxZ:F3}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The motion-table default (closed) pose, if the setup names one:
|
||||||
|
// frame 0 of the default style's default cycle — what the sequencer
|
||||||
|
// renders for an idle closed door.
|
||||||
|
if (setup.DefaultMotionTable != 0)
|
||||||
|
{
|
||||||
|
var mt = dats.Get<MotionTable>(setup.DefaultMotionTable);
|
||||||
|
_out.WriteLine($"=== MotionTable 0x{setup.DefaultMotionTable:X8} ===");
|
||||||
|
if (mt is null) { _out.WriteLine(" NULL"); return; }
|
||||||
|
_out.WriteLine($" DefaultStyle = 0x{(uint)mt.DefaultStyle:X8}");
|
||||||
|
if (mt.Cycles.TryGetValue((int)mt.DefaultStyle, out var defCycle)
|
||||||
|
&& defCycle.Anims.Count > 0)
|
||||||
|
{
|
||||||
|
var animRef = defCycle.Anims[0];
|
||||||
|
_out.WriteLine($" default cycle anim[0] id=0x{animRef.AnimId:X8} lo={animRef.LowFrame} hi={animRef.HighFrame}");
|
||||||
|
var anim = dats.Get<Animation>(animRef.AnimId);
|
||||||
|
if (anim is not null && anim.PartFrames.Count > 0)
|
||||||
|
{
|
||||||
|
var f0 = anim.PartFrames[Math.Clamp((int)animRef.LowFrame, 0, anim.PartFrames.Count - 1)];
|
||||||
|
for (int i = 0; i < f0.Frames.Count; i++)
|
||||||
|
{
|
||||||
|
var f = f0.Frames[i];
|
||||||
|
_out.WriteLine(
|
||||||
|
$" anim frame0 part[{i}] pos=({f.Origin.X:F3},{f.Origin.Y:F3},{f.Origin.Z:F3}) " +
|
||||||
|
$"rot=({f.Orientation.X:F3},{f.Orientation.Y:F3},{f.Orientation.Z:F3},{f.Orientation.W:F3})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_out.WriteLine(" anim NULL or no PartFrames");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_out.WriteLine(" no default-style cycle");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_out.WriteLine("=== no DefaultMotionTable on the setup ===");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #175 derivation conformance — REAL-DAT pin for
|
||||||
|
/// <see cref="AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames"/>.
|
||||||
|
/// The first cut of the derivation looked up <c>Cycles[DefaultStyle]</c>
|
||||||
|
/// with the bare style word; the dictionary is keyed by the COMBINED
|
||||||
|
/// <c>(style << 16) | substate</c> word (CMotionTable.cs:683), so it
|
||||||
|
/// always missed and the #175 fix silently no-oped. This pin loads the
|
||||||
|
/// human motion table (0x09000001 — guaranteed present, default state
|
||||||
|
/// NonCombat/Ready) and asserts the derivation actually resolves a pose.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void MotionTablePose_DefaultState_ResolvesOnRealTable()
|
||||||
|
{
|
||||||
|
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
|
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||||
|
"Documents", "Asheron's Call");
|
||||||
|
if (!Directory.Exists(datDir))
|
||||||
|
{
|
||||||
|
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var mt = dats.Get<MotionTable>(0x09000001u);
|
||||||
|
Assert.NotNull(mt);
|
||||||
|
|
||||||
|
var pose = AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames(
|
||||||
|
mt!, id => dats.Get<Animation>(id));
|
||||||
|
|
||||||
|
Assert.NotNull(pose);
|
||||||
|
_out.WriteLine($"human MT default pose parts={pose!.Count} " +
|
||||||
|
$"part0=({pose[0].Origin.X:F3},{pose[0].Origin.Y:F3},{pose[0].Origin.Z:F3})");
|
||||||
|
Assert.True(pose.Count >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── #175 fix pins: ShadowShapeBuilder partPoseOverride ──────────────
|
||||||
|
|
||||||
|
private static Setup MakeTwoPartSetup()
|
||||||
|
{
|
||||||
|
var setup = new Setup();
|
||||||
|
setup.Parts.Add(0x01000001u);
|
||||||
|
setup.Parts.Add(0x01000002u);
|
||||||
|
var placement = new AnimationFrame(2);
|
||||||
|
placement.Frames.Clear();
|
||||||
|
placement.Frames.Add(new Frame { Origin = new Vector3(0.88f, -0.44f, 1.37f),
|
||||||
|
Orientation = new Quaternion(0f, 0f, -0.966f, 0.259f) });
|
||||||
|
placement.Frames.Add(new Frame { Origin = new Vector3(-0.88f, -0.44f, 1.37f),
|
||||||
|
Orientation = new Quaternion(0f, 0f, -0.259f, 0.966f) });
|
||||||
|
setup.PlacementFrames[Placement.Default] = placement;
|
||||||
|
return setup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// With a motion-table pose override, the BSP part shapes must use it —
|
||||||
|
/// the closed pose, not the ajar placement pose (the #175 offset).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void FromSetup_PartPoseOverride_ReplacesPlacementFrames()
|
||||||
|
{
|
||||||
|
var setup = MakeTwoPartSetup();
|
||||||
|
var closed = new[]
|
||||||
|
{
|
||||||
|
new Frame { Origin = new Vector3(0.85f, 0f, 1.37f), Orientation = Quaternion.Identity },
|
||||||
|
new Frame { Origin = new Vector3(-0.85f, 0f, 1.37f), Orientation = Quaternion.Identity },
|
||||||
|
};
|
||||||
|
|
||||||
|
var shapes = ShadowShapeBuilder.FromSetup(
|
||||||
|
setup, entScale: 1f, hasPhysicsBsp: _ => true, partPoseOverride: closed);
|
||||||
|
|
||||||
|
Assert.Equal(2, shapes.Count);
|
||||||
|
Assert.Equal(new Vector3(0.85f, 0f, 1.37f), shapes[0].LocalPosition);
|
||||||
|
Assert.Equal(Quaternion.Identity, shapes[0].LocalRotation);
|
||||||
|
Assert.Equal(new Vector3(-0.85f, 0f, 1.37f), shapes[1].LocalPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Null override (no motion table) keeps the pre-#175 placement-frame
|
||||||
|
/// behavior — landblock statics and table-less entities unchanged.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void FromSetup_NoOverride_KeepsPlacementFrames()
|
||||||
|
{
|
||||||
|
var setup = MakeTwoPartSetup();
|
||||||
|
|
||||||
|
var shapes = ShadowShapeBuilder.FromSetup(
|
||||||
|
setup, entScale: 1f, hasPhysicsBsp: _ => true);
|
||||||
|
|
||||||
|
Assert.Equal(2, shapes.Count);
|
||||||
|
Assert.Equal(new Vector3(0.88f, -0.44f, 1.37f), shapes[0].LocalPosition);
|
||||||
|
Assert.Equal(new Quaternion(0f, 0f, -0.966f, 0.259f), shapes[0].LocalRotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A short override (fewer frames than parts) falls back to placement
|
||||||
|
/// frames — a mismatched motion table must not misplace collision.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void FromSetup_ShortOverride_FallsBackPerPart()
|
||||||
|
{
|
||||||
|
var setup = MakeTwoPartSetup();
|
||||||
|
var shortOverride = new[]
|
||||||
|
{
|
||||||
|
new Frame { Origin = new Vector3(0.85f, 0f, 1.37f), Orientation = Quaternion.Identity },
|
||||||
|
};
|
||||||
|
|
||||||
|
var shapes = ShadowShapeBuilder.FromSetup(
|
||||||
|
setup, entScale: 1f, hasPhysicsBsp: _ => true, partPoseOverride: shortOverride);
|
||||||
|
|
||||||
|
Assert.Equal(2, shapes.Count);
|
||||||
|
Assert.Equal(new Vector3(0.85f, 0f, 1.37f), shapes[0].LocalPosition); // override
|
||||||
|
Assert.Equal(new Vector3(-0.88f, -0.44f, 1.37f), shapes[1].LocalPosition); // placement fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
using Env = System.Environment;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #176/#177 membership half: production [cell-transit] lines
|
||||||
|
/// (launch-137-gate2.log) fire 0.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.
|
||||||
|
/// </summary>
|
||||||
|
public class Issue176177SeamTransitLagTests
|
||||||
|
{
|
||||||
|
private const uint SeamCellWest = 0x8A02016Eu; // x 75..85
|
||||||
|
private const uint SeamCellEast = 0x8A02017Au; // x 85..88.33
|
||||||
|
|
||||||
|
private readonly ITestOutputHelper _out;
|
||||||
|
public Issue176177SeamTransitLagTests(ITestOutputHelper output) => _out = output;
|
||||||
|
|
||||||
|
private static string? ResolveDatDir()
|
||||||
|
{
|
||||||
|
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
|
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||||
|
"Documents", "Asheron's Call");
|
||||||
|
return Directory.Exists(datDir) ? datDir : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PhysicsEngine BuildEngine(DatCollection dats)
|
||||||
|
{
|
||||||
|
var engine = new PhysicsEngine();
|
||||||
|
engine.DataCache = new PhysicsDataCache();
|
||||||
|
|
||||||
|
var toLoad = new System.Collections.Generic.HashSet<uint> { SeamCellWest, SeamCellEast };
|
||||||
|
for (int ring = 0; ring < 3; ring++)
|
||||||
|
{
|
||||||
|
foreach (var known in new System.Collections.Generic.List<uint>(toLoad))
|
||||||
|
{
|
||||||
|
var cell = dats.Get<EnvCell>(known);
|
||||||
|
if (cell is null) continue;
|
||||||
|
foreach (var p in cell.CellPortals)
|
||||||
|
toLoad.Add(0x8A020000u | p.OtherCellId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var cellId in toLoad)
|
||||||
|
{
|
||||||
|
var envCell = dats.Get<EnvCell>(cellId);
|
||||||
|
if (envCell is null) continue;
|
||||||
|
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
|
||||||
|
if (environment is null) continue;
|
||||||
|
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) continue;
|
||||||
|
|
||||||
|
var rot = new Quaternion(
|
||||||
|
envCell.Position.Orientation.X, envCell.Position.Orientation.Y,
|
||||||
|
envCell.Position.Orientation.Z, envCell.Position.Orientation.W);
|
||||||
|
var world = Matrix4x4.CreateFromQuaternion(rot)
|
||||||
|
* Matrix4x4.CreateTranslation(
|
||||||
|
envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z);
|
||||||
|
|
||||||
|
engine.DataCache.CacheCellStruct(cellId, envCell, cs!, world);
|
||||||
|
}
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PhysicsBody GroundedBody()
|
||||||
|
{
|
||||||
|
var body = new PhysicsBody();
|
||||||
|
body.ContactPlaneValid = true;
|
||||||
|
body.ContactPlane = new Plane(Vector3.UnitZ, 6f);
|
||||||
|
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||||||
|
body.WalkablePolygonValid = true;
|
||||||
|
body.WalkablePlane = new Plane(Vector3.UnitZ, 6f);
|
||||||
|
body.WalkableUp = Vector3.UnitZ;
|
||||||
|
body.WalkableVertices = new[]
|
||||||
|
{
|
||||||
|
new Vector3(75f, -41.67f, -6f),
|
||||||
|
new Vector3(85f, -41.67f, -6f),
|
||||||
|
new Vector3(85f, -38.33f, -6f),
|
||||||
|
new Vector3(75f, -38.33f, -6f),
|
||||||
|
};
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Run +X across the x=85 seam at run-speed tick steps (13.5 cm/tick ≈
|
||||||
|
/// 4 m/s at 30 Hz) and record where ResolveWithTransition's CellId flips.
|
||||||
|
/// Retail (center-only point_in_cell, exact-partition CellBSP) flips on
|
||||||
|
/// the first tick whose END position is past x=85.00 — any flip later
|
||||||
|
/// than one step past the plane is OUR lag.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(+1)] // west → east across x=85
|
||||||
|
[InlineData(-1)] // east → west back across
|
||||||
|
public void RunAcrossSeam_CellFlipPosition(int direction)
|
||||||
|
{
|
||||||
|
var datDir = ResolveDatDir();
|
||||||
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var engine = BuildEngine(dats);
|
||||||
|
var body = GroundedBody();
|
||||||
|
|
||||||
|
const float step = 0.135f;
|
||||||
|
float startX = direction > 0 ? 83.8f : 86.2f;
|
||||||
|
uint cell = direction > 0 ? SeamCellWest : SeamCellEast;
|
||||||
|
var pos = new Vector3(startX, -40f, -6f);
|
||||||
|
|
||||||
|
float? flipX = null;
|
||||||
|
for (int tick = 0; tick < 26; tick++)
|
||||||
|
{
|
||||||
|
var target = pos + new Vector3(direction * step, 0f, 0f);
|
||||||
|
var r = engine.ResolveWithTransition(
|
||||||
|
currentPos: pos,
|
||||||
|
targetPos: target,
|
||||||
|
cellId: cell,
|
||||||
|
sphereRadius: 0.48f,
|
||||||
|
sphereHeight: 1.835f,
|
||||||
|
stepUpHeight: 0.4f,
|
||||||
|
stepDownHeight: 0.4f,
|
||||||
|
isOnGround: true,
|
||||||
|
body: body,
|
||||||
|
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
|
||||||
|
|
||||||
|
bool flipped = r.CellId != cell;
|
||||||
|
_out.WriteLine($"tick={tick,2} pos=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) " +
|
||||||
|
$"cell=0x{r.CellId:X8} ok={r.Ok}{(flipped ? " <<< FLIP" : "")}");
|
||||||
|
if (flipped && flipX is null)
|
||||||
|
flipX = r.Position.X;
|
||||||
|
|
||||||
|
cell = r.CellId;
|
||||||
|
pos = r.Position;
|
||||||
|
if (direction > 0 && pos.X > 86.4f) break;
|
||||||
|
if (direction < 0 && pos.X < 83.6f) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.NotNull(flipX);
|
||||||
|
float lag = direction > 0 ? flipX!.Value - 85.00f : 85.00f - flipX!.Value;
|
||||||
|
_out.WriteLine($"flip at x={flipX:F3} → lag past the plane = {lag:F3} m " +
|
||||||
|
$"(one-tick quantization bound = {step:F3} m)");
|
||||||
|
// Diagnostic, not a pin: the finding is the printed lag. A lag beyond
|
||||||
|
// one tick step is the divergence under investigation.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Physics.Motion;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics.Motion;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// #174 pin (2026-07-05): the RemoveLinkAnimations seam must be retail
|
||||||
|
// CPhysicsObj::RemoveLinkAnimations 0x0050fe20 — a TAILCALL to
|
||||||
|
// CPartArray::HandleEnterWorld 0x00517d70 → MotionTableManager::
|
||||||
|
// HandleEnterWorld 0x0051bdd0: CSequence::remove_all_link_animations PLUS a
|
||||||
|
// full pending_animations drain (`while (head) AnimationDone(0)`), each pop
|
||||||
|
// relaying MotionDone → CMotionInterp pops its pending_motions node in
|
||||||
|
// lockstep.
|
||||||
|
//
|
||||||
|
// The pre-fix binding was the bare sequence strip: every LeaveGround (jump)
|
||||||
|
// removed the link animations that queued MotionTableManager nodes were
|
||||||
|
// counting down on, orphaning them (NumAnims > 0, animations gone). Both
|
||||||
|
// queues then dammed permanently — MotionsPending() never drained at rest —
|
||||||
|
// and BeginTurnToHeading/BeginMoveForward (retail 0x00529b90 motions_pending
|
||||||
|
// gate) starved every armed moveto: ACE's walk-to-door mt-6 armed but the
|
||||||
|
// body never walked; the close-range Use turn never completed so the
|
||||||
|
// deferred action was silently eaten. Live evidence: launch-174-autowalk.log
|
||||||
|
// (last player pending=False at the first MovementJump press; old jump
|
||||||
|
// motions still completing at rest minutes later).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
public class Issue174LinkStripDrainTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _out;
|
||||||
|
public Issue174LinkStripDrainTests(ITestOutputHelper output) => _out = output;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The jam repro: queue motions (link + cycle nodes land in BOTH the
|
||||||
|
/// interp's pending_motions and the manager's pending_animations), then
|
||||||
|
/// fire the LeaveGround-side seam. With the retail HandleEnterWorld
|
||||||
|
/// binding both queues drain to empty; the pre-fix bare-strip binding
|
||||||
|
/// left both non-empty forever.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void RemoveLinkAnimationsSeam_DrainsBothQueues()
|
||||||
|
{
|
||||||
|
var h = new RemoteChaseHarness(_out);
|
||||||
|
|
||||||
|
// Drive a motion burst — walk, run, stop — the shape a player's
|
||||||
|
// pre-jump input produces. Each successful dispatch pairs an interp
|
||||||
|
// node with a manager node.
|
||||||
|
var p = new MovementParameters();
|
||||||
|
h.Interp.DoMotion(MotionCommand.WalkForward, p);
|
||||||
|
h.Interp.set_hold_run(true, interrupt: false);
|
||||||
|
h.Interp.StopMotion(MotionCommand.WalkForward, p);
|
||||||
|
|
||||||
|
Assert.True(h.Interp.MotionsPending(),
|
||||||
|
"precondition: the burst must leave pending interp nodes");
|
||||||
|
Assert.NotEmpty(h.Seq.Manager.PendingAnimations);
|
||||||
|
|
||||||
|
// The LeaveGround seam (retail CMotionInterp::LeaveGround 0x00528b00
|
||||||
|
// fires CPhysicsObj::RemoveLinkAnimations).
|
||||||
|
h.Interp.RemoveLinkAnimations!.Invoke();
|
||||||
|
|
||||||
|
Assert.False(h.Interp.MotionsPending(),
|
||||||
|
"HandleEnterWorld's drain must pop every pending interp node " +
|
||||||
|
"(retail: each AnimationDone(0) relays MotionDone)");
|
||||||
|
Assert.Empty(h.Seq.Manager.PendingAnimations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The post-jump livability pin: after the seam fires mid-activity, a
|
||||||
|
/// NEW moveto-style dispatch must be able to queue and complete — the
|
||||||
|
/// #174 symptom was that BeginTurnToHeading's motions_pending gate never
|
||||||
|
/// re-opened after a jump, permanently starving armed movetos.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void AfterSeamDrain_NewMotionsQueueAndComplete()
|
||||||
|
{
|
||||||
|
var h = new RemoteChaseHarness(_out);
|
||||||
|
var p = new MovementParameters();
|
||||||
|
|
||||||
|
// Pre-jump activity, then the jump's LeaveGround strip+drain.
|
||||||
|
h.Interp.DoMotion(MotionCommand.WalkForward, p);
|
||||||
|
h.Interp.RemoveLinkAnimations!.Invoke();
|
||||||
|
Assert.False(h.Interp.MotionsPending());
|
||||||
|
|
||||||
|
// A fresh dispatch (the armed moveto's turn) queues...
|
||||||
|
h.Interp.DoMotion(MotionCommand.TurnRight, p);
|
||||||
|
Assert.True(h.Interp.MotionsPending());
|
||||||
|
|
||||||
|
// ...and the normal completion path (the manager's queue feeding
|
||||||
|
// MotionDone) drains it — the gate re-opens.
|
||||||
|
while (h.Seq.Manager.PendingAnimations.GetEnumerator() is var e && e.MoveNext())
|
||||||
|
h.Seq.Manager.AnimationDone(success: true);
|
||||||
|
|
||||||
|
h.Seq.Manager.CheckForCompletedMotions();
|
||||||
|
Assert.False(h.Interp.MotionsPending(),
|
||||||
|
"the normal AnimationDone → MotionDone chain must drain the new node");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -174,7 +174,10 @@ internal sealed class RemoteChaseHarness
|
||||||
TurnStopped = () => ObservedOmega = Vector3.Zero,
|
TurnStopped = () => ObservedOmega = Vector3.Zero,
|
||||||
};
|
};
|
||||||
Interp.DefaultSink = Sink;
|
Interp.DefaultSink = Sink;
|
||||||
Interp.RemoveLinkAnimations = Seq.RemoveAllLinkAnimations;
|
// #174: production binds the seam to Manager.HandleEnterWorld
|
||||||
|
// (strip + full queue drain, retail 0x0050fe20 → 0x0051bdd0) —
|
||||||
|
// the bare sequence strip orphaned pending manager nodes.
|
||||||
|
Interp.RemoveLinkAnimations = () => Seq.Manager.HandleEnterWorld();
|
||||||
Interp.InitializeMotionTables = () => Seq.Manager.InitializeState();
|
Interp.InitializeMotionTables = () => Seq.Manager.InitializeState();
|
||||||
Interp.CheckForCompletedMotions = Seq.Manager.CheckForCompletedMotions;
|
Interp.CheckForCompletedMotions = Seq.Manager.CheckForCompletedMotions;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -487,9 +487,20 @@ public class PhysicsEngineTests
|
||||||
collisionType: ShadowCollisionType.Cylinder,
|
collisionType: ShadowCollisionType.Cylinder,
|
||||||
cylHeight: 1.835f);
|
cylHeight: 1.835f);
|
||||||
|
|
||||||
// Without the gate (movingEntityId == 0): the sweep must self-push.
|
// Without the gate (movingEntityId == 0): the sweep must be
|
||||||
// This proves the registry actually causes a collision, so the
|
// INTERFERED WITH by the self-entry. This proves the registry
|
||||||
// following filtered case is not a vacuous pass.
|
// actually causes a collision, so the following filtered case is not
|
||||||
|
// a vacuous pass.
|
||||||
|
//
|
||||||
|
// Observable updated for the 2026-07-05 CCylSphere family port: the
|
||||||
|
// old hand-rolled response radial-pushed the sphere ~1 m sideways
|
||||||
|
// (the original #42 symptom this test asserted). Retail's dispatcher
|
||||||
|
// (0x0053b440) resolves this geometry — airborne, dead-center on the
|
||||||
|
// cylinder axis, moving up — through land_on_cylinder → the Collide
|
||||||
|
// re-test, whose interp gate hard-stops (COLLIDED); ValidateTransition
|
||||||
|
// then reverts to a stay-put (no sideways teleport, Ok=true). The
|
||||||
|
// response-model-independent interference signal is the DENIED +Z
|
||||||
|
// movement: the sweep must NOT reach the +0.022 target.
|
||||||
var unfiltered = engine.ResolveWithTransition(
|
var unfiltered = engine.ResolveWithTransition(
|
||||||
currentPos: bodyPos, targetPos: targetPos,
|
currentPos: bodyPos, targetPos: targetPos,
|
||||||
cellId: 0xA9B40039u,
|
cellId: 0xA9B40039u,
|
||||||
|
|
@ -498,11 +509,11 @@ public class PhysicsEngineTests
|
||||||
isOnGround: false,
|
isOnGround: false,
|
||||||
movingEntityId: 0u);
|
movingEntityId: 0u);
|
||||||
|
|
||||||
float unfilteredXY = MathF.Sqrt(
|
Assert.True(unfiltered.Position.Z < targetPos.Z - 0.01f,
|
||||||
(unfiltered.Position.X - targetPos.X) * (unfiltered.Position.X - targetPos.X) +
|
$"Without movingEntityId, the sweep must collide with the mover's own " +
|
||||||
(unfiltered.Position.Y - targetPos.Y) * (unfiltered.Position.Y - targetPos.Y));
|
$"ShadowEntry and deny the +Z movement (retail: land_on_cylinder → " +
|
||||||
Assert.True(unfilteredXY > 0.5f,
|
$"Collide re-test → COLLIDED → stay-put). Got Z={unfiltered.Position.Z:F4}, " +
|
||||||
$"Without movingEntityId, sweep should self-push (got XY drift {unfilteredXY:F3}m)");
|
$"target Z={targetPos.Z:F4}");
|
||||||
|
|
||||||
// With the gate: the sweep must leave XY unchanged.
|
// With the gate: the sweep must leave XY unchanged.
|
||||||
var filtered = engine.ResolveWithTransition(
|
var filtered = engine.ResolveWithTransition(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,624 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Numerics;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
using Env = System.Environment;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #176 (purple flashing on dungeon floors at cell seams) + #177 (stairs pop
|
||||||
|
/// in/out across levels) — dat-truth inspection for the Facility Hub anchor
|
||||||
|
/// cells. The load-bearing topology fact from the #137 arc: corridor FLOORS
|
||||||
|
/// are portal polygons (PortalSide floor-portals to under-rooms, e.g.
|
||||||
|
/// 0x8A02016E visual polys 1/3/5 → 0x011E). These dumps answer:
|
||||||
|
///
|
||||||
|
/// (a) are the floor-portal VISUAL polys textured (drawn by CellMesh.Build)
|
||||||
|
/// or NoPos-stippled (skipped)? Same question for the RECIPROCAL portal
|
||||||
|
/// polys in the other cell — two textured coincident planes would
|
||||||
|
/// z-fight (#176's angle-dependent flash candidate);
|
||||||
|
/// (b) which cell owns the actual stair-step geometry at the
|
||||||
|
/// 0x8A020182 → 0x8A020183 level transit (#177's pop-in subject);
|
||||||
|
/// (c) do any drawn polys reference surfaces that fail to resolve
|
||||||
|
/// (the magenta-placeholder class)?
|
||||||
|
///
|
||||||
|
/// ⚠️ id-space trap (cost the #137 saga a wrong mechanism):
|
||||||
|
/// CellPortal.PolygonId indexes CellStruct.Polygons (VISUAL), never
|
||||||
|
/// PhysicsPolygons — same ids in both tables are unrelated polygons.
|
||||||
|
/// </summary>
|
||||||
|
public class Issue176177DungeonSeamInspectionTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _out;
|
||||||
|
public Issue176177DungeonSeamInspectionTests(ITestOutputHelper output) => _out = output;
|
||||||
|
|
||||||
|
private static string? ResolveDatDir()
|
||||||
|
{
|
||||||
|
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
|
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||||
|
"Documents", "Asheron's Call");
|
||||||
|
return Directory.Exists(datDir) ? datDir : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Matrix4x4 WorldTransform(EnvCell cell)
|
||||||
|
{
|
||||||
|
var rot = new Quaternion(
|
||||||
|
cell.Position.Orientation.X, cell.Position.Orientation.Y,
|
||||||
|
cell.Position.Orientation.Z, cell.Position.Orientation.W);
|
||||||
|
return Matrix4x4.CreateFromQuaternion(rot)
|
||||||
|
* Matrix4x4.CreateTranslation(
|
||||||
|
cell.Position.Origin.X, cell.Position.Origin.Y, cell.Position.Origin.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (EnvCell cell, DatReaderWriter.Types.CellStruct cs)? LoadCell(DatCollection dats, uint cellId)
|
||||||
|
{
|
||||||
|
var envCell = dats.Get<EnvCell>(cellId);
|
||||||
|
if (envCell is null) return null;
|
||||||
|
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
|
||||||
|
if (environment is null) return null;
|
||||||
|
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) return null;
|
||||||
|
return (envCell, cs!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Vector3> WorldVerts(
|
||||||
|
DatReaderWriter.Types.CellStruct cs, DatReaderWriter.Types.Polygon poly, Matrix4x4 world)
|
||||||
|
{
|
||||||
|
var result = new List<Vector3>(poly.VertexIds.Count);
|
||||||
|
foreach (var vid in poly.VertexIds)
|
||||||
|
if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v))
|
||||||
|
result.Add(Vector3.Transform(v.Origin, world));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mirror of CellMesh.Build's inclusion rules (verts ≥ 3, no NoPos
|
||||||
|
/// stippling, PosSurface index in range) — the DRAWN verdict per poly.
|
||||||
|
/// </summary>
|
||||||
|
private static bool WouldDraw(DatReaderWriter.Types.Polygon poly, EnvCell cell) =>
|
||||||
|
poly.VertexIds.Count >= 3
|
||||||
|
&& !poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.NoPos)
|
||||||
|
&& poly.PosSurface >= 0
|
||||||
|
&& poly.PosSurface < cell.Surfaces.Count;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// (a)+(c): every CellPortal of the cell — the visual portal poly's
|
||||||
|
/// stippling/sides/surface, world plane, span, DRAWN verdict, and whether
|
||||||
|
/// the referenced Surface resolves in the dat.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x8A02016Eu)] // corridor with floor-portals 1/3/5 → 0x011E (#176 anchor)
|
||||||
|
[InlineData(0x8A02011Eu)] // the under-hall at z=−12 those portals lead to
|
||||||
|
[InlineData(0x8A02017Au)] // adjacent corridor cell (the #137 seam partner)
|
||||||
|
[InlineData(0x8A020182u)] // stair transit upper cell, z −6 (#177 anchor)
|
||||||
|
[InlineData(0x8A020183u)] // stair transit lower cell, z −9 (#177 anchor)
|
||||||
|
public void PortalPolys_SurfaceAndDrawVerdict_Dump(uint cellId)
|
||||||
|
{
|
||||||
|
var datDir = ResolveDatDir();
|
||||||
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
||||||
|
var loaded = LoadCell(dats, cellId);
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
var (cell, cs) = loaded!.Value;
|
||||||
|
var world = WorldTransform(cell);
|
||||||
|
|
||||||
|
_out.WriteLine($"=== 0x{cellId:X8} Env=0x{cell.EnvironmentId:X4} struct={cell.CellStructure} " +
|
||||||
|
$"pos=({cell.Position.Origin.X:F2},{cell.Position.Origin.Y:F2},{cell.Position.Origin.Z:F2}) ===");
|
||||||
|
_out.WriteLine($" Surfaces ({cell.Surfaces.Count}): " +
|
||||||
|
string.Join(" ", cell.Surfaces.ConvertAll(s => $"0x{0x08000000u | (uint)s:X8}")));
|
||||||
|
_out.WriteLine($" visualPolys={cs.Polygons.Count} physicsPolys={cs.PhysicsPolygons.Count} portals={cell.CellPortals.Count}");
|
||||||
|
|
||||||
|
// #177 pivot check: dungeon staircases are often EnvCell STATICS (the
|
||||||
|
// #119 tower class) — if one lives here, the vanish subject is the
|
||||||
|
// static's cull, not the shell flood.
|
||||||
|
_out.WriteLine($" StaticObjects={cell.StaticObjects.Count}");
|
||||||
|
foreach (var so in cell.StaticObjects)
|
||||||
|
_out.WriteLine($" static id=0x{so.Id:X8} at ({so.Frame.Origin.X:F2},{so.Frame.Origin.Y:F2},{so.Frame.Origin.Z:F2})");
|
||||||
|
|
||||||
|
foreach (var p in cell.CellPortals)
|
||||||
|
{
|
||||||
|
if (!cs.Polygons.TryGetValue((ushort)p.PolygonId, out var poly))
|
||||||
|
{
|
||||||
|
_out.WriteLine($" portal poly={p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] NOT IN VISUAL TABLE");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var w = WorldVerts(cs, poly, world);
|
||||||
|
var n = w.Count >= 3
|
||||||
|
? Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0]))
|
||||||
|
: Vector3.Zero;
|
||||||
|
var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue);
|
||||||
|
foreach (var v in w) { min = Vector3.Min(min, v); max = Vector3.Max(max, v); }
|
||||||
|
|
||||||
|
bool drawn = WouldDraw(poly, cell);
|
||||||
|
string surfInfo = "posSurf=OUT-OF-RANGE";
|
||||||
|
if (poly.PosSurface >= 0 && poly.PosSurface < cell.Surfaces.Count)
|
||||||
|
{
|
||||||
|
uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface];
|
||||||
|
var surface = dats.Get<Surface>(surfaceId);
|
||||||
|
surfInfo = surface is null
|
||||||
|
? $"posSurf[{poly.PosSurface}]=0x{surfaceId:X8} SURFACE-MISS"
|
||||||
|
: $"posSurf[{poly.PosSurface}]=0x{surfaceId:X8} type={surface.Type} origTex=0x{(uint)surface.OrigTextureId:X8}";
|
||||||
|
}
|
||||||
|
|
||||||
|
_out.WriteLine(
|
||||||
|
$" portal poly={p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] " +
|
||||||
|
$"stip={poly.Stippling} sides={poly.SidesType} verts={poly.VertexIds.Count} " +
|
||||||
|
$"n=({n.X:F2},{n.Y:F2},{n.Z:F2}) " +
|
||||||
|
$"x=[{min.X:F2},{max.X:F2}] y=[{min.Y:F2},{max.Y:F2}] z=[{min.Z:F2},{max.Z:F2}] " +
|
||||||
|
$"{surfInfo} DRAWN={drawn}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// (c) sweep: any DRAWN poly in the whole cell whose surface misses.
|
||||||
|
int drawnCount = 0, missCount = 0;
|
||||||
|
foreach (var (id, poly) in cs.Polygons)
|
||||||
|
{
|
||||||
|
if (!WouldDraw(poly, cell)) continue;
|
||||||
|
drawnCount++;
|
||||||
|
uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface];
|
||||||
|
if (dats.Get<Surface>(surfaceId) is null)
|
||||||
|
{
|
||||||
|
missCount++;
|
||||||
|
_out.WriteLine($" >>> DRAWN poly {id} has MISSING surface 0x{surfaceId:X8}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_out.WriteLine($" drawn-poly sweep: {drawnCount} drawn, {missCount} with missing surfaces");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// (a) reciprocal check: for each anchor pair, world-transform BOTH
|
||||||
|
/// sides' portal polys and test plane coincidence + both-drawn — the
|
||||||
|
/// #176 z-fight candidate is a coincident pair with DRAWN=true twice.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x8A02016Eu, 0x8A02011Eu)]
|
||||||
|
[InlineData(0x8A02016Eu, 0x8A02017Au)]
|
||||||
|
[InlineData(0x8A020182u, 0x8A020183u)]
|
||||||
|
public void ReciprocalPortalPolys_CoincidenceAndDrawVerdict(uint cellA, uint cellB)
|
||||||
|
{
|
||||||
|
var datDir = ResolveDatDir();
|
||||||
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
||||||
|
var la = LoadCell(dats, cellA);
|
||||||
|
var lb = LoadCell(dats, cellB);
|
||||||
|
Assert.NotNull(la);
|
||||||
|
Assert.NotNull(lb);
|
||||||
|
var (ca, csa) = la!.Value;
|
||||||
|
var (cb, csb) = lb!.Value;
|
||||||
|
var wa = WorldTransform(ca);
|
||||||
|
var wb = WorldTransform(cb);
|
||||||
|
|
||||||
|
ushort lowA = (ushort)(cellA & 0xFFFFu);
|
||||||
|
ushort lowB = (ushort)(cellB & 0xFFFFu);
|
||||||
|
|
||||||
|
_out.WriteLine($"=== reciprocal pair 0x{cellA:X8} <-> 0x{cellB:X8} ===");
|
||||||
|
foreach (var pa in ca.CellPortals)
|
||||||
|
{
|
||||||
|
if (pa.OtherCellId != lowB) continue;
|
||||||
|
if (!csa.Polygons.TryGetValue((ushort)pa.PolygonId, out var polyA)) continue;
|
||||||
|
var va = WorldVerts(csa, polyA, wa);
|
||||||
|
if (va.Count < 3) continue;
|
||||||
|
var na = Vector3.Normalize(Vector3.Cross(va[1] - va[0], va[2] - va[0]));
|
||||||
|
float da = Vector3.Dot(na, va[0]);
|
||||||
|
bool drawnA = WouldDraw(polyA, ca);
|
||||||
|
|
||||||
|
_out.WriteLine($" A poly={pa.PolygonId} [{pa.Flags}] n=({na.X:F2},{na.Y:F2},{na.Z:F2}) planeD={da:F2} " +
|
||||||
|
$"stip={polyA.Stippling} sides={polyA.SidesType} DRAWN={drawnA}");
|
||||||
|
|
||||||
|
foreach (var pb in cb.CellPortals)
|
||||||
|
{
|
||||||
|
if (pb.OtherCellId != lowA) continue;
|
||||||
|
if (!csb.Polygons.TryGetValue((ushort)pb.PolygonId, out var polyB)) continue;
|
||||||
|
var vb = WorldVerts(csb, polyB, wb);
|
||||||
|
if (vb.Count < 3) continue;
|
||||||
|
var nb = Vector3.Normalize(Vector3.Cross(vb[1] - vb[0], vb[2] - vb[0]));
|
||||||
|
float db = Vector3.Dot(nb, vb[0]);
|
||||||
|
bool drawnB = WouldDraw(polyB, cb);
|
||||||
|
|
||||||
|
float align = Vector3.Dot(na, nb);
|
||||||
|
// Coincident planes: |align|≈1 and same plane offset (sign per normal direction).
|
||||||
|
bool coincident = MathF.Abs(align) > 0.99f
|
||||||
|
&& MathF.Abs(MathF.Abs(da) - MathF.Abs(db)) < 0.05f;
|
||||||
|
|
||||||
|
_out.WriteLine($" B poly={pb.PolygonId} [{pb.Flags}] n=({nb.X:F2},{nb.Y:F2},{nb.Z:F2}) planeD={db:F2} " +
|
||||||
|
$"stip={polyB.Stippling} sides={polyB.SidesType} DRAWN={drawnB} " +
|
||||||
|
$"align={align:F3} coincident={coincident}" +
|
||||||
|
(coincident && drawnA && drawnB ? " >>> Z-FIGHT CANDIDATE (both drawn, same plane)" : ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #176 THE STRIPES (user screenshot, 2026-07-06 evening): a floor region
|
||||||
|
/// z-fights in regular bands between a purple-lit copy and an unlit copy —
|
||||||
|
/// two COINCIDENT DRAWN surfaces with different per-cell light sets. This
|
||||||
|
/// sweep hunts the pair in the dat: every pair of DRAWN polys across the
|
||||||
|
/// corridor neighborhood that is coplanar AND overlapping in area. Before
|
||||||
|
/// the light-cap fix both copies were usually equally unlit (the purple
|
||||||
|
/// portal light was cap-evicted) so the fight was invisible; the stable
|
||||||
|
/// light exposed it.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs()
|
||||||
|
{
|
||||||
|
var datDir = ResolveDatDir();
|
||||||
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
||||||
|
// Seed cells around the screenshot location (the 016E/017A seam) +
|
||||||
|
// one portal ring.
|
||||||
|
var cellIds = new HashSet<uint> { 0x8A020165u, 0x8A02016Eu, 0x8A02017Au };
|
||||||
|
foreach (var seed in new List<uint>(cellIds))
|
||||||
|
{
|
||||||
|
var seedCell = dats.Get<EnvCell>(seed);
|
||||||
|
if (seedCell is null) continue;
|
||||||
|
foreach (var p in seedCell.CellPortals)
|
||||||
|
cellIds.Add(0x8A020000u | p.OtherCellId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all DRAWN polys world-space per cell.
|
||||||
|
var drawn = new List<(uint CellId, ushort PolyId, Vector3 N, float D,
|
||||||
|
Vector3 Min, Vector3 Max, uint SurfaceId)>();
|
||||||
|
foreach (var cellId in cellIds)
|
||||||
|
{
|
||||||
|
var loaded = LoadCell(dats, cellId);
|
||||||
|
if (loaded is null) continue;
|
||||||
|
var (cell, cs) = loaded.Value;
|
||||||
|
var world = WorldTransform(cell);
|
||||||
|
|
||||||
|
foreach (var (id, poly) in cs.Polygons)
|
||||||
|
{
|
||||||
|
if (!WouldDraw(poly, cell)) continue;
|
||||||
|
var w = WorldVerts(cs, poly, world);
|
||||||
|
if (w.Count < 3) continue;
|
||||||
|
var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0]));
|
||||||
|
float d = Vector3.Dot(n, w[0]);
|
||||||
|
var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue);
|
||||||
|
foreach (var v in w) { min = Vector3.Min(min, v); max = Vector3.Max(max, v); }
|
||||||
|
uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface];
|
||||||
|
drawn.Add((cellId, id, n, d, min, max, surfaceId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_out.WriteLine($"cells={cellIds.Count} drawnPolys={drawn.Count}");
|
||||||
|
|
||||||
|
int pairs = 0;
|
||||||
|
for (int i = 0; i < drawn.Count; i++)
|
||||||
|
{
|
||||||
|
for (int j = i + 1; j < drawn.Count; j++)
|
||||||
|
{
|
||||||
|
var a = drawn[i]; var b = drawn[j];
|
||||||
|
if (a.CellId == b.CellId && a.PolyId == b.PolyId) continue;
|
||||||
|
|
||||||
|
float align = Vector3.Dot(a.N, b.N);
|
||||||
|
if (MathF.Abs(align) < 0.999f) continue;
|
||||||
|
float dB = align > 0 ? b.D : -b.D;
|
||||||
|
if (MathF.Abs(a.D - dB) > 0.02f) continue; // same plane within 2 cm
|
||||||
|
|
||||||
|
// Overlap in world AABB, with meaningful area in the plane.
|
||||||
|
float ox = MathF.Min(a.Max.X, b.Max.X) - MathF.Max(a.Min.X, b.Min.X);
|
||||||
|
float oy = MathF.Min(a.Max.Y, b.Max.Y) - MathF.Max(a.Min.Y, b.Min.Y);
|
||||||
|
float oz = MathF.Min(a.Max.Z, b.Max.Z) - MathF.Max(a.Min.Z, b.Min.Z);
|
||||||
|
if (ox < 0.05f || oy < 0.05f) continue;
|
||||||
|
// For horizontal planes require XY overlap area; for walls allow thin Z.
|
||||||
|
bool horizontal = MathF.Abs(a.N.Z) > 0.85f;
|
||||||
|
if (horizontal && ox * oy < 0.05f) continue;
|
||||||
|
if (!horizontal && oz < 0.05f) continue;
|
||||||
|
|
||||||
|
pairs++;
|
||||||
|
_out.WriteLine(
|
||||||
|
$">>> COPLANAR-OVERLAP {(a.CellId == b.CellId ? "SAME-CELL" : "CROSS-CELL")}: " +
|
||||||
|
$"0x{a.CellId:X8} poly {a.PolyId} (surf 0x{a.SurfaceId:X8}) <-> " +
|
||||||
|
$"0x{b.CellId:X8} poly {b.PolyId} (surf 0x{b.SurfaceId:X8}) " +
|
||||||
|
$"n=({a.N.X:F2},{a.N.Y:F2},{a.N.Z:F2}) align={align:F3} " +
|
||||||
|
$"overlap x={ox:F2} y={oy:F2} z=[{MathF.Max(a.Min.Z, b.Min.Z):F2}..{MathF.Min(a.Max.Z, b.Max.Z):F2}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_out.WriteLine($"coplanar overlapping drawn pairs: {pairs}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #176 candidate (A2C): the opaque pass derives GL_SAMPLE_ALPHA_TO_COVERAGE
|
||||||
|
/// from the sampled texture alpha (mesh_modern.frag uRenderPass==0 keeps
|
||||||
|
/// alpha as-sampled). If the corridor floor texture decodes with alpha
|
||||||
|
/// below 1.0, MSAA coverage punches see-through holes in the floor —
|
||||||
|
/// fog-purple clear color — worst at grazing angles (mip-level dependent
|
||||||
|
/// → camera-angle dependent, far floor = at seams). Decode the floor
|
||||||
|
/// surface chain and histogram the alpha channel.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x08000377u)] // corridor floor (portal strips + plain floors)
|
||||||
|
[InlineData(0x08000376u)]
|
||||||
|
[InlineData(0x08000375u)]
|
||||||
|
[InlineData(0x08000378u)]
|
||||||
|
[InlineData(0x08000379u)] // under-level walls
|
||||||
|
[InlineData(0x080000DFu)] // stair-transit cells' 5th surface
|
||||||
|
public void FloorSurface_DecodedAlphaHistogram(uint surfaceId)
|
||||||
|
{
|
||||||
|
var datDir = ResolveDatDir();
|
||||||
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
||||||
|
var surface = dats.Get<Surface>(surfaceId);
|
||||||
|
Assert.NotNull(surface);
|
||||||
|
_out.WriteLine($"Surface 0x{surfaceId:X8}: type={surface!.Type} origTex=0x{(uint)surface.OrigTextureId:X8} " +
|
||||||
|
$"transl={surface.Translucency:F2}");
|
||||||
|
if (surface.OrigTextureId == 0) { _out.WriteLine(" (no texture — solid color surface)"); return; }
|
||||||
|
|
||||||
|
var surfTex = dats.Get<SurfaceTexture>((uint)surface.OrigTextureId);
|
||||||
|
Assert.NotNull(surfTex);
|
||||||
|
_out.WriteLine($" SurfaceTexture 0x{(uint)surface.OrigTextureId:X8}: {surfTex!.Textures.Count} textures " +
|
||||||
|
$"[{string.Join(" ", surfTex.Textures.ConvertAll(t => $"0x{t:X8}"))}]");
|
||||||
|
|
||||||
|
foreach (var texId in surfTex.Textures)
|
||||||
|
{
|
||||||
|
var rs = dats.Get<RenderSurface>((uint)texId);
|
||||||
|
if (rs is null) { _out.WriteLine($" RenderSurface 0x{texId:X8}: MISS"); continue; }
|
||||||
|
|
||||||
|
// Decode with the production Core helpers (same paths the WB atlas uses).
|
||||||
|
var data = new byte[rs.Width * rs.Height * 4];
|
||||||
|
bool decodedOk = true;
|
||||||
|
switch (rs.Format)
|
||||||
|
{
|
||||||
|
case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16:
|
||||||
|
{
|
||||||
|
var pal = dats.Get<Palette>(rs.DefaultPaletteId);
|
||||||
|
if (pal is null) { _out.WriteLine($" RenderSurface 0x{texId:X8}: INDEX16 with no palette 0x{rs.DefaultPaletteId:X8}"); decodedOk = false; break; }
|
||||||
|
AcDream.Core.Rendering.Wb.TextureHelpers.FillIndex16(rs.SourceData, pal, data, rs.Width, rs.Height);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case DatReaderWriter.Enums.PixelFormat.PFID_P8:
|
||||||
|
{
|
||||||
|
var pal = dats.Get<Palette>(rs.DefaultPaletteId);
|
||||||
|
if (pal is null) { _out.WriteLine($" RenderSurface 0x{texId:X8}: P8 with no palette"); decodedOk = false; break; }
|
||||||
|
AcDream.Core.Rendering.Wb.TextureHelpers.FillP8(rs.SourceData, pal, data, rs.Width, rs.Height);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5:
|
||||||
|
AcDream.Core.Rendering.Wb.TextureHelpers.FillR5G6B5(rs.SourceData, data, rs.Width, rs.Height);
|
||||||
|
break;
|
||||||
|
case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4:
|
||||||
|
AcDream.Core.Rendering.Wb.TextureHelpers.FillA4R4G4B4(rs.SourceData, data, rs.Width, rs.Height);
|
||||||
|
break;
|
||||||
|
case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8:
|
||||||
|
AcDream.Core.Rendering.Wb.TextureHelpers.FillA8R8G8B8(rs.SourceData, data, rs.Width, rs.Height);
|
||||||
|
break;
|
||||||
|
case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8:
|
||||||
|
AcDream.Core.Rendering.Wb.TextureHelpers.FillR8G8B8(rs.SourceData, data, rs.Width, rs.Height);
|
||||||
|
break;
|
||||||
|
case DatReaderWriter.Enums.PixelFormat.PFID_DXT1:
|
||||||
|
{
|
||||||
|
// DXT1/BC1: 8-byte blocks — c0 (u16 LE), c1 (u16 LE), 16×2-bit
|
||||||
|
// indices. c0 <= c1 selects 3-COLOR mode where index 3 decodes
|
||||||
|
// to TRANSPARENT BLACK (alpha=0). Our atlas uploads DXT1 as the
|
||||||
|
// RGBA variant (TextureFormatExtensions.ToCompressedGL), so any
|
||||||
|
// such texel reaches the shader with alpha=0 — and the opaque
|
||||||
|
// pass discards alpha<0.05 fragments. Count them.
|
||||||
|
int blocks = rs.SourceData.Length / 8;
|
||||||
|
int threeColorBlocks = 0;
|
||||||
|
long transparentTexels = 0;
|
||||||
|
for (int b = 0; b < blocks; b++)
|
||||||
|
{
|
||||||
|
int o = b * 8;
|
||||||
|
ushort c0 = (ushort)(rs.SourceData[o] | (rs.SourceData[o + 1] << 8));
|
||||||
|
ushort c1 = (ushort)(rs.SourceData[o + 2] | (rs.SourceData[o + 3] << 8));
|
||||||
|
if (c0 > c1) continue; // 4-color opaque mode
|
||||||
|
threeColorBlocks++;
|
||||||
|
for (int bi = 0; bi < 4; bi++)
|
||||||
|
{
|
||||||
|
byte row = rs.SourceData[o + 4 + bi];
|
||||||
|
for (int t = 0; t < 4; t++)
|
||||||
|
if (((row >> (t * 2)) & 0x3) == 3)
|
||||||
|
transparentTexels++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_out.WriteLine($" RenderSurface 0x{texId:X8} DXT1 {rs.Width}x{rs.Height}: blocks={blocks} " +
|
||||||
|
$"threeColorBlocks={threeColorBlocks} alpha0Texels={transparentTexels}" +
|
||||||
|
(transparentTexels > 0
|
||||||
|
? " >>> ALPHA=0 TEXELS PRESENT (opaque-pass discard punches holes)"
|
||||||
|
: " (no transparent-mode texels)"));
|
||||||
|
decodedOk = false; // histogram printed above; skip the RGBA path
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
_out.WriteLine($" RenderSurface 0x{texId:X8}: fmt={rs.Format} (not decoded by this test)");
|
||||||
|
decodedOk = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!decodedOk) continue;
|
||||||
|
|
||||||
|
// Alpha histogram over the decoded RGBA bytes (stride 4, alpha at +3).
|
||||||
|
int n = data.Length / 4;
|
||||||
|
int a255 = 0, aHigh = 0, aMid = 0, aLow = 0, a0 = 0;
|
||||||
|
byte minA = 255;
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
byte a = data[i * 4 + 3];
|
||||||
|
if (a < minA) minA = a;
|
||||||
|
if (a == 255) a255++;
|
||||||
|
else if (a >= 243) aHigh++; // ≥0.95 — safe for A2C
|
||||||
|
else if (a >= 13) aMid++; // 0.05..0.95 — partial coverage
|
||||||
|
else if (a > 0) aLow++;
|
||||||
|
else a0++;
|
||||||
|
}
|
||||||
|
_out.WriteLine($" RenderSurface 0x{texId:X8} fmt={rs.Format} {rs.Width}x{rs.Height}: " +
|
||||||
|
$"alpha histogram n={n} a=255:{a255} 243-254:{aHigh} 13-242:{aMid} 1-12:{aLow} 0:{a0} minA={minA}" +
|
||||||
|
(aMid + aLow + a0 > 0 ? " >>> SUB-OPAQUE ALPHA PRESENT (A2C hole candidate)" : " (fully opaque)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #176 candidate: the under-hall 0x011E floods in at down-pitches and
|
||||||
|
/// its surface list carries 0x08000034 (Base1Solid|Translucent — a
|
||||||
|
/// COLORED translucent solid). If its drawn polys sit at z=−6 (coplanar
|
||||||
|
/// with the corridor floor), the transparent pass blends that color over
|
||||||
|
/// the floor whenever the under-hall is admitted — angle-dependent
|
||||||
|
/// purple at seams. Dump every DRAWN poly (plane, z-span, surface, and
|
||||||
|
/// the surface's ColorValue) of the under-hall + its under-level
|
||||||
|
/// neighbors.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x8A02011Eu)]
|
||||||
|
[InlineData(0x8A020119u)]
|
||||||
|
[InlineData(0x8A02011Du)]
|
||||||
|
[InlineData(0x8A020122u)]
|
||||||
|
[InlineData(0x8A02011Fu)]
|
||||||
|
[InlineData(0x8A02016Eu)] // corridor cells — the striped-floor screenshot area
|
||||||
|
[InlineData(0x8A02017Au)]
|
||||||
|
[InlineData(0x8A020165u)]
|
||||||
|
public void UnderHall_DrawnPolys_SurfaceColors(uint cellId)
|
||||||
|
{
|
||||||
|
var datDir = ResolveDatDir();
|
||||||
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
||||||
|
var loaded = LoadCell(dats, cellId);
|
||||||
|
if (loaded is null) { _out.WriteLine($"0x{cellId:X8} NOT FOUND"); return; }
|
||||||
|
var (cell, cs) = loaded.Value;
|
||||||
|
var world = WorldTransform(cell);
|
||||||
|
|
||||||
|
_out.WriteLine($"=== 0x{cellId:X8} drawn polys ===");
|
||||||
|
foreach (var (id, poly) in cs.Polygons)
|
||||||
|
{
|
||||||
|
if (!WouldDraw(poly, cell)) continue;
|
||||||
|
var w = WorldVerts(cs, poly, world);
|
||||||
|
if (w.Count < 3) continue;
|
||||||
|
var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0]));
|
||||||
|
float minZ = float.MaxValue, maxZ = float.MinValue;
|
||||||
|
foreach (var v in w) { minZ = MathF.Min(minZ, v.Z); maxZ = MathF.Max(maxZ, v.Z); }
|
||||||
|
|
||||||
|
uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface];
|
||||||
|
var surface = dats.Get<Surface>(surfaceId);
|
||||||
|
string surfInfo = surface is null
|
||||||
|
? $"0x{surfaceId:X8} MISS"
|
||||||
|
: $"0x{surfaceId:X8} type={surface.Type} color=0x{surface.ColorValue:X8} origTex=0x{(uint)surface.OrigTextureId:X8} lum={surface.Luminosity:F2} transl={surface.Translucency:F2}";
|
||||||
|
_out.WriteLine($" poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) z=[{minZ:F2},{maxZ:F2}] " +
|
||||||
|
$"verts={poly.VertexIds.Count} surf={surfInfo}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The transit-lag question (#176/#177 root-cause fork): production
|
||||||
|
/// [cell-transit] lines fire 0.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.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x8A02016Eu, 0x8A02017Au, 85.00f, -40f, -5.0f)]
|
||||||
|
[InlineData(0x8A020182u, 0x8A020183u, 98.333f, -40f, -7.5f)]
|
||||||
|
public void SeamCells_CellBspContainment_AcrossPortalPlane(
|
||||||
|
uint cellAId, uint cellBId, float planeX, float y, float z)
|
||||||
|
{
|
||||||
|
var datDir = ResolveDatDir();
|
||||||
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
||||||
|
var la = LoadCell(dats, cellAId);
|
||||||
|
var lb = LoadCell(dats, cellBId);
|
||||||
|
Assert.NotNull(la);
|
||||||
|
Assert.NotNull(lb);
|
||||||
|
var (ca, csa) = la!.Value;
|
||||||
|
var (cb, csb) = lb!.Value;
|
||||||
|
|
||||||
|
Matrix4x4.Invert(WorldTransform(ca), out var invA);
|
||||||
|
Matrix4x4.Invert(WorldTransform(cb), out var invB);
|
||||||
|
|
||||||
|
_out.WriteLine($"=== CellBSP containment across plane x={planeX:F2} " +
|
||||||
|
$"(A=0x{cellAId:X8}, B=0x{cellBId:X8}) ===");
|
||||||
|
for (float dx = -0.6f; dx <= 0.65f; dx += 0.05f)
|
||||||
|
{
|
||||||
|
var world = new Vector3(planeX + dx, y, z);
|
||||||
|
bool inA = csa.CellBSP?.Root is not null
|
||||||
|
&& Physics.PointInCellBspViaBspQuery(csa.CellBSP.Root, Vector3.Transform(world, invA));
|
||||||
|
bool inB = csb.CellBSP?.Root is not null
|
||||||
|
&& Physics.PointInCellBspViaBspQuery(csb.CellBSP.Root, Vector3.Transform(world, invB));
|
||||||
|
_out.WriteLine($" x=plane{(dx >= 0 ? "+" : "")}{dx:F2} inA={(inA ? "Y" : "-")} inB={(inB ? "Y" : "-")}" +
|
||||||
|
(inA && inB ? " <<< OVERLAP" : !inA && !inB ? " <<< NEITHER" : ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class Physics
|
||||||
|
{
|
||||||
|
// Thin forwarder so this Rendering-side test reads clearly; the walk
|
||||||
|
// is the production BSPQuery.PointInsideCellBsp.
|
||||||
|
public static bool PointInCellBspViaBspQuery(
|
||||||
|
DatReaderWriter.Types.CellBSPNode node, Vector3 localPoint)
|
||||||
|
=> AcDream.Core.Physics.BSPQuery.PointInsideCellBsp(node, localPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// (b) #177: which cell owns the stair-step geometry? Histogram of DRAWN
|
||||||
|
/// visual polys by normal class + the z-ladder of horizontal polys
|
||||||
|
/// (stair steps show as a ladder of small floor polys at stepped
|
||||||
|
/// z-levels). Also lists every portal with its plane orientation —
|
||||||
|
/// is the 0x0182↔0x0183 connection a floor-portal or a wall-portal?
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x8A020182u)]
|
||||||
|
[InlineData(0x8A020183u)]
|
||||||
|
public void StairTransit_GeometryOwnerAndPortalOrientation(uint cellId)
|
||||||
|
{
|
||||||
|
var datDir = ResolveDatDir();
|
||||||
|
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
|
||||||
|
var loaded = LoadCell(dats, cellId);
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
var (cell, cs) = loaded!.Value;
|
||||||
|
var world = WorldTransform(cell);
|
||||||
|
|
||||||
|
int floors = 0, ceilings = 0, walls = 0, inclined = 0;
|
||||||
|
var floorZLevels = new SortedDictionary<int, int>(); // z rounded to 0.1 m → count
|
||||||
|
|
||||||
|
foreach (var (id, poly) in cs.Polygons)
|
||||||
|
{
|
||||||
|
var w = WorldVerts(cs, poly, world);
|
||||||
|
if (w.Count < 3) continue;
|
||||||
|
var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0]));
|
||||||
|
float az = MathF.Abs(n.Z);
|
||||||
|
if (az > 0.85f)
|
||||||
|
{
|
||||||
|
// Horizontal poly — bucket by mean z.
|
||||||
|
float meanZ = 0; foreach (var v in w) meanZ += v.Z; meanZ /= w.Count;
|
||||||
|
int zKey = (int)MathF.Round(meanZ * 10f);
|
||||||
|
floorZLevels.TryGetValue(zKey, out var c);
|
||||||
|
floorZLevels[zKey] = c + 1;
|
||||||
|
if (n.Z > 0) floors++; else ceilings++;
|
||||||
|
}
|
||||||
|
else if (az < 0.25f) walls++;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
inclined++;
|
||||||
|
float minZ = float.MaxValue, maxZ = float.MinValue;
|
||||||
|
foreach (var v in w) { minZ = MathF.Min(minZ, v.Z); maxZ = MathF.Max(maxZ, v.Z); }
|
||||||
|
_out.WriteLine($" INCLINED poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) z=[{minZ:F2},{maxZ:F2}] " +
|
||||||
|
$"verts={poly.VertexIds.Count} stip={poly.Stippling} DRAWN={WouldDraw(poly, cell)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_out.WriteLine($"=== 0x{cellId:X8} poly histogram: floors={floors} ceilings={ceilings} walls={walls} inclined={inclined} ===");
|
||||||
|
_out.WriteLine(" horizontal-poly z-ladder (z → count): " +
|
||||||
|
string.Join(" ", System.Linq.Enumerable.Select(floorZLevels, kv => $"{kv.Key / 10f:F1}:{kv.Value}")));
|
||||||
|
|
||||||
|
foreach (var p in cell.CellPortals)
|
||||||
|
{
|
||||||
|
if (!cs.Polygons.TryGetValue((ushort)p.PolygonId, out var poly)) continue;
|
||||||
|
var w = WorldVerts(cs, poly, world);
|
||||||
|
if (w.Count < 3) continue;
|
||||||
|
var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0]));
|
||||||
|
string orient = MathF.Abs(n.Z) > 0.85f ? "HORIZONTAL (floor/ceiling portal)"
|
||||||
|
: MathF.Abs(n.Z) < 0.25f ? "vertical (wall portal)"
|
||||||
|
: "INCLINED portal";
|
||||||
|
var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue);
|
||||||
|
foreach (var v in w) { min = Vector3.Min(min, v); max = Vector3.Max(max, v); }
|
||||||
|
_out.WriteLine($" portal poly={p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] {orient} " +
|
||||||
|
$"n=({n.X:F2},{n.Y:F2},{n.Z:F2}) z=[{min.Z:F2},{max.Z:F2}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue