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
|
|
@ -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