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
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)
|
||||
|
||||
**Status:** DONE (2026-07-04) — **user visual gate PASSED** ("Looks good, ship it")
|
||||
|
|
@ -929,15 +1334,165 @@ walls** in particular. (Symptoms not fully characterized yet: likely walking thr
|
|||
openings that should block / blocking at openings that should pass, and door collision not
|
||||
matching the door's open/closed state.)
|
||||
|
||||
**Root cause / status (to investigate):** dungeon collision is EnvCell-based — the cell's
|
||||
collision BSP + portal openings + per-cell static objects (doors). Candidates: door
|
||||
apparatus collision in EnvCells (open/closed BSP swap) not fully ported; portal-opening
|
||||
(wall gap) collision geometry handled differently from buildings; the per-cell
|
||||
shadow-object registration (A6.P4, see the physics digest) for dungeon EnvCell statics.
|
||||
Related families: #32 (edge-slide), #116 (slide-response), the door-collision saga
|
||||
(see `feedback_dedup_keys_after_cardinality_change`, `feedback_retail_per_cell_shadow_list`).
|
||||
Needs a targeted repro (which door / which opening, expected vs actual) before fixing —
|
||||
oracle-first per the physics digest.
|
||||
**✅ CORRIDOR GATE PASSED 2026-07-06 evening (user: "not collision anymore.
|
||||
Good.")** — the corridor phantom arc (mechanisms 1–3) is user-verified
|
||||
FIXED. REMAINING #137 scope from the same gate session:
|
||||
- **Window/opening climb FIXED + GATE PASSED 2026-07-06 (user: "Looks
|
||||
good", incl. the taller-capsule regression sweep — doorways/seams/stairs
|
||||
clean): the player's collision capsule TOPPED OUT AT 1.2 m.** The callers passed
|
||||
`sphereHeight: 1.2f` and `InitPath` places the head sphere center at
|
||||
`height − radius` = 0.72 — the top 0.63 m of a 1.83 m character had NO
|
||||
collision. The dat human Setup 0x02000001 (dumped in
|
||||
`HumanSetup_CollisionSpheres_DatTruth`): spheres `(0,0,0.475) r=0.48` +
|
||||
`(0,0,1.350) r=0.48` (top 1.83 = Setup.Height 1.835); retail collides
|
||||
with that list verbatim (`CPhysicsObj::transition` 0x00512dc0 →
|
||||
`init_sphere(GetNumSphere, GetSphere, scale)`). At the corridor-end
|
||||
window alcove (0x8A020179 → 0x8A02017E: sill face 0.70 m, opening 1.3 m
|
||||
tall, sloped funnel behind — full-vertex dump in
|
||||
`WindowShaft_FullPolyDump`), the missing head let the step-up's
|
||||
placement pass and the player climbed in head-through-lintel. Fix: both
|
||||
live callers now pass 1.835 (capsule top; head center 1.355 ≈ dat
|
||||
1.350); register TS-46 documents the residual 5 mm scalar
|
||||
approximation. Pins: `WindowOpening_HeadCannotFit_EntryBlocked` (walked
|
||||
approach wall-slides and never enters 0x8A02017E) +
|
||||
`WindowAlcove_RaisedPlacement_HeadInLintelSolid_Collides` (the raised
|
||||
placement rejects on the head-vs-lintel). Captured-input replay
|
||||
fixtures keep their recorded 1.2 inputs — InitPath unchanged.
|
||||
- Doors half (block/pass per open state) — unchanged.
|
||||
Two RENDER issues also observed at the gate (filed separately below as
|
||||
#176/#177): the purple floor flashing at seams is angle/camera-dependent
|
||||
(the floor IS a portal polygon to the under-room — likely the portal
|
||||
surface drawn under some culling state), and a stairs pop-in/out between
|
||||
levels (the #119 visibility class, dungeon edition).
|
||||
|
||||
**SEAM SHAKE FIXED (same day): the stale `footCenter` in `CheckOtherCells`'
|
||||
per-cell loop** — the P2 cellar-lip lesson one loop deeper. A mid-loop
|
||||
other-cell query can MOVE the sphere (the boundary full-hit dispatches
|
||||
step_sphere_up; the successful climb lifts the foot +0.6 mm and returns
|
||||
OK), and the remaining cells were then queried with the by-value
|
||||
pre-climb center — 0.4 mm inside the floor slab, grazing the under-room's
|
||||
ceiling and firing the chain below. Retail's `check_other_cells` reads the
|
||||
LIVE `sphere_path.global_sphere` per cell (pc:272717+). Fix: re-read
|
||||
`footCenter = sp.GlobalSphere[0].Origin` per iteration. All three
|
||||
`Issue137CorridorSeamReplayTests` repros un-skipped and GREEN; full suites
|
||||
green. Visual gate pending. (The step 3 "restore clobbers CheckPos" wording
|
||||
below was the right CLASS but the wrong site — CheckPos was fine; the
|
||||
stale copy was the loop's captured parameter.)
|
||||
|
||||
**GATE 2026-07-06 FAILED — THIRD MECHANISM CHARACTERIZED (the seam shake),
|
||||
deterministic offline repro secured:** with mechanisms 1+2 fixed the dead
|
||||
stop became a SHAKE at cell seams (+ purple floor flashing there — almost
|
||||
certainly the render exposing the same per-frame position/OnWalkable
|
||||
oscillation; re-check after the physics fix). Full chain, every link
|
||||
probe-traced (`launch-137-seam-probes.log`, capture
|
||||
`resolve-137-seam-capture.jsonl` tick 4101 ×46):
|
||||
1. Corridor cells sit above under-rooms; the shared floor slab is
|
||||
double-faced (up-face + underside as separate physics polys) and IS a
|
||||
portal plane (e.g. 0x8A020165's ramp over 0x8A020166). The resting foot
|
||||
sphere is permanently within ±0.5 mm of THREE thresholds there (poly-hit
|
||||
r−ε, walkable r−ε, portal-straddle r+ε).
|
||||
2. Walking across the boundary at the flat-floor height penetrates the
|
||||
ramp slab by ~0.4 mm → foot full-hit on the up-face → StepSphereUp →
|
||||
step-down accepts the ramp (+0.6 mm lift, CheckPos −5.999,
|
||||
`[stepsphereup] stepped=True`).
|
||||
3. **THE BUG: the lifted position is then LOST** — the next pass runs at
|
||||
the UNLIFTED height (GlobalSphere center −5.520 vs the lifted −5.519;
|
||||
the P2 stale-snapshot class, single-slot Save/RestoreCheckPos clobber
|
||||
suspected — retail `CTransition::step_up` 0x0050b6cc restores ONLY on
|
||||
failure) → the re-test at 0.4 mm inside the slab grazes the NEIGHBOR
|
||||
under-room's CEILING (the slab underside, n≈(−0.03,0,−1)) within the
|
||||
near-miss window → recorded (retail records it too — pos_hits_sphere
|
||||
registers geometric hits pre-cull) → neg-poly step-up dispatch with the
|
||||
DOWNWARD normal → the nested step-down finds no walkable at exact
|
||||
tangency → StepUpSlide → slide_sphere(down normal vs up contact plane)
|
||||
→ the opposing branch → reversed-movement collision normal → Collided →
|
||||
validate revert (Contact/OnWalkable stripped) → next step's AdjustOffset
|
||||
zeroes → out==in every frame = the shake. Retail never enters at step 3:
|
||||
its kept step-up lift leaves the sphere ON the surface, no graze.
|
||||
4. Offline repro: `Issue137CorridorSeamReplayTests` (3 tests, currently
|
||||
`Skip="#137 seam shake"`) reproduce the block deterministically — the
|
||||
key was hydrating THREE portal rings (the under-room 0x8A020166 is
|
||||
ring-3; with fewer rings the flood can't add it and everything passes).
|
||||
NEXT: read our `TransitionalInsert` attempt loop against retail
|
||||
0x0050b6f0 to find the restore that clobbers the successful step-up's
|
||||
position; fix; un-skip the three tests.
|
||||
|
||||
**CORRIDOR PHANTOM mechanisms 1+2 FIXED 2026-07-06 (see
|
||||
`docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md`
|
||||
for the full audit):** mechanism 2 = BSPQuery Contact-branch stub slide
|
||||
responses leaked sliding normals retail's BSP layer never writes (fixed:
|
||||
real `slide_sphere` routing + success-gated body writeback). Mechanism 1 as
|
||||
theorized is REFUTED: the recorded wall normal `(−1.00,0.03,−0.03)` matches
|
||||
NO dat polygon (world-space sweep of both seam cells + all portal-adjacent
|
||||
neighbors) — it is the SYNTHETIC negated movement direction from
|
||||
`slide_sphere`'s opposing-normals branch, which our port let survive by
|
||||
returning OK where retail returns COLLIDED_TS (0x0053762c; second fix). The
|
||||
PortalSide polys to 0x011E were a red herring: cell 0x8A02016E has IDENTITY
|
||||
rotation, the polys are ±Y planes perpendicular to the run (directionally
|
||||
culled), retail's physics-BSP leaves reference them too, and the dat's
|
||||
keep-PortalSide/strip-ExactMatch asymmetry reads as intentional (solid
|
||||
window/grate-class portals) — NO portal-poly filter needed, no cdb session
|
||||
needed for this repro. Dat-backed replay
|
||||
(`Issue137CorridorSeamReplayTests`) reproduces the live frame exactly and
|
||||
runs the corridor clean. The issue's DOOR half remains open.
|
||||
|
||||
**CHARACTERIZED 2026-07-05 (Facility Hub corridor repro, probe + dat evidence)
|
||||
— two stacked mechanisms (historical; see the 2026-07-06 resolution above):**
|
||||
1. **PortalSide portal polygons are IN the physics polygon set and we treat
|
||||
them as solid.** Live: running the corridor, the seam crossing
|
||||
`0x8A02016E → 0x8A02017A` (x≈85.25) records a wall hit with normal
|
||||
(−1,0,0) — straight against the movement (`launch-175-verify2.log:42858`).
|
||||
Dat (`Issue137CorridorSeamInspectionTests`): cell 0x8A02016E's portals to
|
||||
0x011E (polys 1/3/5, flags=**PortalSide**, no ExactMatch) are PRESENT in
|
||||
`CellStruct.PhysicsPolygons` — every ExactMatch portal in the same cell is
|
||||
absent from the physics set. The cell's rotation maps those local ±Y portal
|
||||
planes to world ±X — the phantom mid-corridor wall. Retail must honor the
|
||||
portal's SIDE (pass from one side / solid from the other, or pass when the
|
||||
neighbor is loaded); we collide with the raw polygon unconditionally.
|
||||
**Oracle findings so far (2026-07-05 evening — greps done, question
|
||||
OPEN):** `CCellStruct::UnPack` (0x00533d00) loads physics_polygons +
|
||||
physics_bsp verbatim — NO portal-poly stripping at load;
|
||||
`CPolygon::pos_hits_sphere`/`hits_sphere`/`polygon_hits_sphere_slow_but_sure`
|
||||
(0x005394f0/0x00539540/0x00538a10) are pure geometry — no portal check;
|
||||
`CCellPortal` (0x0053bab0) carries portal→CPolygon ptr + portal_side +
|
||||
exact_match but nothing in the BSP test chain consults it. So retail's
|
||||
passability for a PortalSide physics poly is NOT a load filter and NOT a
|
||||
poly-level flag — remaining candidates: the transit/membership order
|
||||
makes the sphere test the NEIGHBOR cell first (never hitting the portal
|
||||
poly from the passable side), or a sidedness interaction
|
||||
(stippling=NoPos + approach direction). NEXT: cdb-attach retail at this
|
||||
exact corridor (0x8A02016E→011E portals) per the CLAUDE.md step −1
|
||||
protocol — the decomp alone hasn't settled it.
|
||||
2. **The stale sliding normal then wedges all forward motion** (the #116
|
||||
slide-response family): after the single seam hit, EVERY subsequent
|
||||
forward resolve returns `ok=False hit=no` with zero advance — the
|
||||
body-persisted SlidingNormal (−1,0,0) projects the +X offset to exactly
|
||||
zero in AdjustOffset, aborting at step 0 BEFORE any collision test could
|
||||
update the state — an ABSORBING wedge escaped only by strafing ("push
|
||||
through on the side"). Retail re-derives slide state per frame
|
||||
(get_object_info pc:279992 governs only the NEXT frame — #116 notes);
|
||||
audit who clears the body's sliding normal when no contact recurs.
|
||||
|
||||
**MECHANISM 2 FIXED 2026-07-06 (audit complete — full lifecycle in
|
||||
`docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md`):**
|
||||
retail's ONLY in-transition sliding-normal writer is
|
||||
`validate_transition` (0x0050ac21); the BSP/sphere layer never writes it,
|
||||
and the body persistence (`SetPositionInternal` 0x005154c2/0x005154e1)
|
||||
is success-only. Our BSPQuery Contact-branch full-hit responses were
|
||||
STUBS (`SetSlidingNormal + return Slid`) where retail dispatches the
|
||||
real `slide_sphere` — the seam hit (a SUCCESSFUL full-advance resolve,
|
||||
`ok=True` in the log, not a failed one) leaked the phantom wall's normal
|
||||
into the body, and the seed absorbed every later forward push. Fix:
|
||||
both stub sites now route through the real
|
||||
`Transition.SlideSphereInternal` (`CSphere::slide_sphere` 0x00537440,
|
||||
in-frame, no sliding write) and the body writeback is gated on
|
||||
transition success. Pins: `Issue137SlidingNormalLifecycleTests` (2 site
|
||||
pins + the persist/absorb/clear wall lifecycle). Register: TS-4 amended
|
||||
(steep-tangent sites still write the normal — documented), TS-45 added
|
||||
(`SphereCollision`'s write — same class, out of blast radius). The
|
||||
absorbed exactly-anti-parallel frame at a REAL wall is retail-faithful
|
||||
(the persisted normal is a "still pressed" cache); only the phantom
|
||||
PROVENANCE was the bug. Corridor re-test rides the mechanism-1 session.
|
||||
|
||||
**Files:** `src/AcDream.Core/Physics/` (EnvCell collision, CellTransit, the door apparatus),
|
||||
`src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (per-cell registration). See
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| AD-22 | Async streamed mesh loading with point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's per-frame meshMissing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:211` | Documented convergence argument: the self-heal makes absence transient, converging the async pipeline to retail's never-absent guarantee | A missing mesh referenced OUTSIDE the dispatcher's walk (a future consumer not touching meshMissing) stays permanently invisible — the #119/#128 broken-stairs class; best case, late pop-in | retail synchronous content load (note at WbMeshAdapter.cs:211) |
|
||||
| AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal |
|
||||
| AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) |
|
||||
| AD-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding | `src/AcDream.App/Input/PlayerMovementController.cs:874` | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral); at elasticity 0.05 retail's landing bounce is imperceptible; sledding reverts to retail rule | Landing-reflection-dependent behavior (slope-landing momentum, high-elasticity surfaces) won't reproduce; the suppression masks the landing-snap gate fragility and could outlive its reason | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |
|
||||
| AD-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding. **2026-07-05 (#173): the same reflection + suppression now also runs in the remote DR sweep** (remote jumps hitting ceilings reflect like the local player; both sites share the rule and this row) | `src/AcDream.App/Input/PlayerMovementController.cs:874`; `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep post-resolve, #173 block) | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral — both the local and remote landing snaps use that gate); at elasticity 0.05 retail's landing bounce is imperceptible; sledding reverts to retail rule | Landing-reflection-dependent behavior (slope-landing momentum, high-elasticity surfaces) won't reproduce; the suppression masks the landing-snap gate fragility and could outlive its reason | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |
|
||||
| AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Rendering/GameWindow.cs:12939` (MoveToComplete subscription) + `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete` seam doc) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract (never on cancel) | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) |
|
||||
| AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 |
|
||||
| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` |
|
||||
|
|
@ -99,7 +99,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
|
||||
---
|
||||
|
||||
## 3. Documented approximation (AP) — 73 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin)
|
||||
## 3. Documented approximation (AP) — 73 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin; AP-6 retired 2026-07-05 — full CCylSphere family ported verbatim, residual AP-83 added same commit)
|
||||
|
||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||
|---|---|---|---|---|---|
|
||||
|
|
@ -108,7 +108,6 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| AP-3 | Step-down chain triggered only when contact is invalid OR steeper than walkable; retail's `transitional_insert` OK-path ALWAYS runs it | `src/AcDream.Core/Physics/TransitionTypes.cs:1197` | Conditional preserves the observed-to-matter cases (edge departure, steep cliff-slide) without running the chain every step (per pc:273191 agent reports) | Steps where retail runs step-down despite a valid walkable contact (bump maintenance, edge-slide arming) are skipped — float-off or missed edge slides in untested geometry | `transitional_insert` OK-path pc:273191 |
|
||||
| AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) |
|
||||
| AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 |
|
||||
| AP-6 | Analytic swept-sphere cylinder collision (XY overlap + step-over + wall-slide) instead of retail CylSphere functions via the 6-path dispatcher; A6.P6 step-over branch ports `step_sphere_up`'s clearance check | `src/AcDream.Core/Physics/TransitionTypes.cs:2601` | Claimed to match retail for the exercised cases (trunks, NPC bodies, door foot-colliders); step-over and step_up_slide fallback retro-fitted from retail when the door phantom surfaced | Unported branches (push direction, interpenetration resolution) differ from retail against cylinder entities — the phantom-collision / sticky-NPC family | `CCylSphere::step_sphere_up` pc:324516-324538 |
|
||||
| AP-7 | `calc_friction` threshold 0.0 with retail's state gate missing; retail uses 0.25 gated by an undecoded state check | `src/AcDream.Core/Physics/PhysicsBody.cs:307` | Bumping the threshold without the gate hammered normal walking (3 → 0.16 m/s); as-read 0.0 kept; locomotion probably state-exempted in retail. Filed L.3c-followup | Friction engages under different conditions — post-landing slides, knockback decay, sledding speeds mismatch retail's deceleration | pc:276702-276705 (state gate + 0.25) |
|
||||
| AP-10 | Dry-corner water depth: retail's 0.1 m allowed sink-in collapsed to 0 | `src/AcDream.Core/Physics/TerrainSurface.cs:481` | The 0.1 offset destabilizes the feet-exactly-on-plane contact-touch check (dist > EPSILON → SetContactPlane never fires → float/fall); retail's ~10 cm sink-in is visually indistinguishable | Masks a contact-touch epsilon fragility — other water-depth values exercising the same instability could oscillate shoreline walkable validation; retail's wet/dry corner sink-in visual absent | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port) |
|
||||
| AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80–350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 |
|
||||
|
|
@ -180,6 +179,9 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
|
||||
| AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) |
|
||||
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
|
||||
| AP-85 | **⚠️ CORRECTED 2026-07-06: visible-cell scoping SHIPPED (`LightSource.CellId` from `entity.ParentCellId` + `BuildPointLightSnapshot(camPos, visibleCells)` over the portal-flood set; probe-proven to drop ~285 through-floor lights/frame in the Hub; end-state pin un-skipped). The visual gate REFUTED the light-cap #176/#177 attribution stated below — BOTH symptoms were UNCHANGED, so #176 = an intensity-100 purple point light `(0.784,0,0.784)` and #177 = a portal-visibility miss (see digest banner). Residual deviation now: 128 backstop vs retail 40+7 (0x0081ec94/8), no dynamic-priority split — benign (visible-scoped pool is 1–9). HISTORICAL claim below.** **Per-frame flat point-light snapshot capped at the 128 lights nearest THE CAMERA** (`BuildPointLightSnapshot`); retail registers lights per-CELL (`insert_light` 0x0054d1b0) and `minimize_object_lighting` (0x0054d480) consults the reaching set with NO global pool cap. The cap BITES in the Facility Hub (366 registered fixtures → 238 evictions/frame) and the eviction is the CONFIRMED mechanism of #176 (purple seam flash — an in-range torch of a visible cell ranks past the cap and drops from that cell's 8-set; per-cell Gouraud pops as the camera moves) + #177 (a stair room's fixtures all past the cap render it 0.2-ambient-dark until approach). ⚠️ Raising to 1024 was live-tested 2026-07-06 and REVERTED: the uncapped pool exposes (a) light-through-solid-floors (no per-cell reach/occlusion — the under-room portal light washes the corridor above), (b) stationary weenie fixtures on the DYNAMIC 1/d falloff (~9× retail's static 1/d³ at 3 m; #143 misassignment for ACE-served fixtures), (c) an unexplained striped floor artifact. Fix = the A7 arc: per-cell light registration + static curve for fixtures + the stripe hunt, THEN uncap | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights` — the load-bearing-stopgap comment); desired-end-state pin (Skip) `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant` | The 128 cap keeps the light pool local to the camera, which accidentally APPROXIMATES per-cell reach (far lights can't leak through floors into view) — the least-wrong state until A7 ports real per-cell registration | The #176/#177 pop class stays live until A7 (purple flashes at seams; unlit rooms popping lit on approach); any dungeon with >128 fixtures has camera-dependent per-cell lighting | `minimize_object_lighting` 0x0054d480 (no global pool cap); `insert_light` 0x0054d1b0 (per-cell registration); `calc_point_light` 0x0059c8b0 (static 1/d³ bake curve) |
|
||||
| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
|
||||
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified). Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping missiles | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |
|
||||
|
||||
## 4. Temporary stopgap (TS) — 39 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded)
|
||||
|
||||
|
|
@ -188,7 +190,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| TS-1 | PrecipiceSlide context missing — conservative stop-at-edge instead of retail's EdgeSlide → PrecipiceSlide / CliffSlide | `src/AcDream.Core/Physics/TransitionTypes.cs:1254` | Awaiting the next L.2c slice; a diagnostic records which ingredient (precipice context / steep plane / EdgeSlide flag) is missing | Player stops dead at precipice edges where retail slides along/over — visible mismatch at cliff and roof edges | retail EdgeSlide → PrecipiceSlide chain |
|
||||
| TS-2 | `BspOnlyDispatch` reduces retail's `(HAS_PHYSICS_BSP_PS && !pvpTargetPlayer && !missileIgnore)` to the flag test alone (M1.5 scope: no PK, no missiles) | `src/AcDream.Core/Physics/TransitionTypes.cs:660` | Both omitted terms are genuinely false pre-M2; comment directs wiring them with PK (M2+) and missiles (F.3) | If PK or missiles land without the terms, flagged entities get BSP-only where retail tests cyl+sphere — pass-through / wrong blocking in PvP/missile interactions | `FindObjCollisions` pc:276861; HAS_PHYSICS_BSP_PS acclient.h:2833 |
|
||||
| TS-3 | `FramesStationaryFall` accounting absent (`moved = true` unconditionally in the accepted-move branch) | `src/AcDream.Core/Physics/TransitionTypes.cs:3691` | Explicitly deferred to the full physics port | A body wedged falling-in-place never triggers retail's stuck-fall escalation — indefinite falling-animation wedges | CPhysicsObj frames_stationary_fall |
|
||||
| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place | `src/AcDream.Core/Physics/BSPQuery.cs:2001` | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge | `BSPTREE::find_collisions` SetCollide pc:323783-323821 |
|
||||
| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place. **Includes a `SetSlidingNormal` write at both sites** — retail's BSP layer never writes `collision_info.sliding_normal` (only `validate_transition` 0x0050ac21 does; the #137 mechanism-2 class), so on transition success the steep-face normal persists to the body and seeds the next frame | `src/AcDream.Core/Physics/BSPQuery.cs` (Path-6 steep branches, `worldNormal.Z < FloorZ`) | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge; a persisted steep-face normal can absorb an exactly-anti-parallel next-frame push (#137 wedge class) until an oblique input clears it | `BSPTREE::find_collisions` SetCollide pc:323783-323821 |
|
||||
| TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2). R3-W3 extends this row: `IWeenieObject.JumpStaminaCost`/`PlayerWeenie.JumpStaminaCost` are new (feeding `jump_is_allowed`'s verbatim stamina-refusal branch) and are ALSO always-affordable/cost-0 stubs for the same reason | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` (`CanJump`), `:52` (`JumpStaminaCost`, R3-W3) | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry; `jump_is_allowed` 0x005282b0 `JumpStaminaCost` vtable +0x44 |
|
||||
| TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) |
|
||||
| TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 |
|
||||
|
|
@ -224,6 +226,8 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| TS-41 | Grounded remote NPCs WITHOUT an armed moveto are body-driven by UP-synthesized server velocity (`HasServerVelocity` → the SERVERVEL per-tick leg: `Body.Velocity = ServerVelocity`, `MovementManager.UseTime` (the R5-V5 facade relay, ex-loose `MoveToManager.UseTime`) SKIPPED, stale-decay stop via `ApplyServerControlledVelocityCycle(Zero)`); retail has no wire-velocity leg-driver anywhere — `MovementManager::UseTime` runs UNCONDITIONALLY per tick and between-UP translation comes from the motion state (`get_state_velocity`), UPs only hard-snap. **#170 residual fix narrowed this branch: an ARMED moveto (`MovementTypeState != Invalid`) now always takes the MOVETO leg** — the old arbitration starved the verbatim MoveToManager for exactly the duration of a server-side chase (UPs flowing → UseTime never ran → legs stayed Ready while the body glided = the #170 slide; live funnel 16 arms → 1 run install). **R5-V3 (#171) narrowed it again: a STUCK entity (`PositionManager.GetStickyObjectId() != 0`) also takes the MOVETO leg** — after the sticky arrival the moveto is cleaned (Invalid) but `StickyManager::adjust_offset` owns the between-snap translation; SERVERVEL would glide the body against the sticky steer (same starvation class) | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch, `moveToArmed`+`stickyArmed` gate) | ACE moves some entities by position updates alone (scripted paths, missiles) with no UM/moveto stream — without a velocity fallback they freeze between UPs; entities WITH a moveto now get the retail drive | An entity class that carries BOTH wire velocity and an armed moveto with conflicting truths follows the moveto; UP hard-snaps bound the drift. Non-moveto entities keep the non-retail stale-stop heuristics (AP-80 thresholds) | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` call @0x00515998, unconditional); retire in R6 (full per-tick order) |
|
||||
| TS-42 | Per-tick DRAIN ORDER inverted vs retail: acdream's `TickAnimations` runs `HandleTargetting` → `Movement.UseTime` (the R5-V5 MovementManager relay, ex-loose `MoveTo.UseTime`) FIRST and the animation-completion drain (Sequencer.Advance → AnimDone hooks → `MotionTableManager.AnimationDone`/`UseTime` → `CMotionInterp.MotionDone` pops) LAST, so every motion-completion-gated decision (`BeginTurnToHeading`'s `motions_pending` wait) sees a queue that is one frame STALE — the unblock after a stop/swing lands one frame later than retail. Retail order (pinned from the named decomp this session): `UpdatePositionInternal` (CPartArray::Update + `process_hooks` @0x00512d3d — the drain) runs BEFORE `TargetManager::HandleTargetting` @0x00515989 → `MovementManager::UseTime` @0x00515998 → `CPartArray::HandleMovement` @0x005159a4 (zero-tick sweep) in `UpdateObjectInternal` | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` per-entity phase order; the R2-Q4 comment already marks the placement "provisional until R6") | Bounded to exactly ONE frame (~16 ms) of extra latency per completion-gated event; every queue eventually drains identically (RemoteChaseEndToEndHarnessTests conformance) | Motion-completion-gated transitions (chase turn start, post-swing re-arm) systematically lag retail by one frame; under compound churn the lag can cost an extra retry cycle | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 + `UpdatePositionInternal` 0x00512c30 (`process_hooks` @0x00512d3d); retire in R6 (retail UpdateObjectInternal order) |
|
||||
| TS-44 | NPC UpdatePosition hard-snaps (position @`OnLivePositionUpdated` + orientation + velocity/cycle adoption) are SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`) — an adaptation of retail's chain semantics to the legacy snap path: retail routes UP corrections through the InterpolationManager into the SAME per-tick `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES them while armed (0x00555190 order, 0x00555430 assigns m_fOrigin), so a server correction can never fight an armed stick; the legacy NPC path snaps OUTSIDE the chain, producing snap-out/steer-back position flapping + stale-facing stomps (the 2026-07-04 #171 gate residuals). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick, bounded by the 1 s sticky lease | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's mechanism (sticky-overwrites-interp) is unreachable until the NPC path gets the interp-queue architecture (the player-remote branch already has it — the R5-V3 combiner→sticky chain); the gate reproduces the retail-observable behavior on the snap architecture | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target by construction) would drift until unstick+next UP; worst case bounded by the 1 s lease + the next UM re-arm | `PositionManager::adjust_offset` 0x00555190; `InterpolationManager` UP routing (`CPhysicsObj::MoveOrTeleport`); retire when the NPC path unifies onto the interp queue (S6/R6) |
|
||||
| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001 spheres `(0,0,0.475) r=0.48`, `(0,0,1.350) r=0.48`). Remotes also use the hardcoded HUMAN dims regardless of creature Setup/scale. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — 0.63 m of headless character; the #137 window climb. Fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`), `src/AcDream.App/Input/PlayerMovementController.cs` + `src/AcDream.App/Rendering/GameWindow.cs` (the two `sphereHeight:` call sites) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold for the human capsule and keeps every captured-input replay fixture byte-identical | 5 mm offsets can flip marginal grazes near the r−ε/r+ε knife edges (today's seam class); creature-scale remotes collide with human-sized capsules until setup-derived dims are plumbed | `CPhysicsObj::transition` 0x00512dc0; dat Setup 0x02000001; retire by plumbing the Setup sphere list into `InitPath` |
|
||||
| TS-45 | `SphereCollision` (the shadow-object Sphere response) is a hand-rolled 3-D wall-slide that ALSO calls `SetSlidingNormal` — retail's `CSphere::intersects_sphere` (0x00537A80) dispatches `CSphere::slide_sphere` (0x00537440), which slides in-frame and never writes `collision_info.sliding_normal` (the only in-transition writer is `validate_transition` 0x0050ac21). Same leak class as the #137 mechanism-2 stubs fixed 2026-07-06 (BSPQuery Contact branch); left in place because the response's blocking semantics for sphere-shaped server objects are untested against the real slide and #171 sticky-melee behavior is freshly gated | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollision`, the `ci.SetSlidingNormal` tail) | The in-frame push-out already moves the check position; the extra sliding normal only persists on transition success, and pure-Sphere shadow shapes are rare (most creatures/statics are CylSphere, which routes through the real `SlideSphere` since #172) | A sphere-shaped object touch persists a normal retail would discard — an exactly-anti-parallel follow-up push absorbs to a zero offset (#137 wedge class) at that object until an oblique input clears it | `CSphere::intersects_sphere` 0x00537A80 → `slide_sphere` 0x00537440 (pc:321678+); fix = route the tail through `Transition.SlideSphere` like `CylSlideSphere` does |
|
||||
| TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition` → `PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -293,14 +293,32 @@ successfully 2026-04-30 for the steep-roof case. Matching binaries
|
|||
|
||||
#### Phase A7 — Indoor lighting fidelity (RenderDoc + retail-decomp driven)
|
||||
|
||||
**Now also owns #176/#177 (2026-07-06):** the Facility Hub purple seam
|
||||
flash + stair-room light pop-in are ROOT-CAUSED to this phase's "light
|
||||
visibility culling" layer — a camera-nearest `MaxGlobalLights=128`
|
||||
snapshot cap evicts in-range lights of visible cells (Hub has 366
|
||||
fixtures), so per-cell 8-light sets churn as the camera moves.
|
||||
Uncapping was live-tested and reverted because the full pool exposes the
|
||||
per-cell-reach + fixture-curve defects below (through-floor light,
|
||||
1/d-vs-1/d³). Analysis PRE-PAID — see
|
||||
`docs/research/2026-07-06-176-177-handoff-A7-lighting.md` (the fix order:
|
||||
per-cell `insert_light` registration → static fixture curve → stripe
|
||||
hunt → uncap) + register AP-85.
|
||||
|
||||
**Hypothesis layers (less mapped than physics):**
|
||||
- Per-cell environment-light tag association — indoor cells should
|
||||
inherit only their own env lights, not outdoor day-cycle.
|
||||
- Light visibility culling — what lights actually contribute to each
|
||||
cell's render.
|
||||
cell's render. **CONFIRMED bug here (#176/#177): no per-cell light
|
||||
registration — lights are a flat world-space sphere-overlap pool that
|
||||
reaches through solid floors; retail's `insert_light` 0x0054d1b0
|
||||
scopes each light to its cell.**
|
||||
- Per-entity light direction transform — held-item-spotlight bug
|
||||
(#L-spotlight) is per-entity attribution gone wrong.
|
||||
- Static-stab atmospheric inheritance (#81).
|
||||
- **Fixture falloff curve (#176/#177): stationary server-spawned
|
||||
fixtures ride the DYNAMIC 1/d path (#143 `isDynamic`); should be the
|
||||
static 1/d³ bake (`calc_point_light` 0x0059c8b0).**
|
||||
|
||||
**Investigation methodology:** less existing infrastructure than
|
||||
physics. Requires:
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
Loading…
Add table
Add a link
Reference in a new issue