fix(render): doorway blue-hole — render root clobbered by NPCs (CurrCell per-entity write)

THE doorway flap root cause, found via [flap-cam]/[shell]/[cell-transit] (2026-06-03):
the player spawned + stood still in the room (cell 0171, NO [cell-transit] after teleport),
yet the render rooted at the vestibule (0170) for all 77,951 frames — drawing only 0170's
~8-triangle shell, the rest = GL clear color = the bluish void.

CellGraph.CurrCell IS "the player's cell" (the render root), but it was written by
SetCurrAndReturn inside the PER-ENTITY ResolveWithTransition + ResolveCellId — so EVERY NPC
wrote it. A Holtburg NPC (0x000F4240) jump-looping near the doorway clobbered the player's
render root every tick. Standing still (player makes no resolve calls) the NPC's write wins
→ stuck blue void; moving, player/NPC writes fight → the flap. This is why the membership
pick fix (correct, kept) didn't change the visual — the render root was clobbered regardless.

Fix: CurrCell is now written ONLY by the player. New PhysicsEngine.UpdatePlayerCurrCell is
called from PlayerMovementController.UpdateCellId — the single player-only chokepoint for
CellId (teleport / server snap @ SetPosition + per-frame resolver). Removed the CurrCell
write from SetCurrAndReturn (inlined the 2 resolve call sites to sp.CurCellId) and the 4
ResolveCellId sites. NPCs no longer touch the render root. Teleport→UpdateCellId also covers
spawn/standing-still (CurrCell = the player's spawn cell immediately).

CellGraphMembershipTests rewritten to the new contract (3 tests): UpdatePlayerCurrCell writes
the render root; ResolveCellId does NOT (the blue-hole guard); stale-beats-null preserved.
Full Core suite: 1295 pass / 5 fail = the documented §10 baseline, zero new breakage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-03 10:12:38 +02:00
parent e5457f9552
commit 79fb6e7c23
3 changed files with 87 additions and 25 deletions

View file

@ -257,16 +257,29 @@ public sealed class PhysicsEngine
/// </para>
/// </summary>
/// <summary>
/// UCG W2 Task 1: record the resolved cell as the single membership answer. Additive —
/// CurrCell is written here; it has no reader yet (render-read is a later task).
/// Leaves CurrCell unchanged when the id can't be resolved in the graph (stale beats null).
/// Retail anchor: CPhysicsObj::set_cell_id (acclient_2013_pseudo_c.txt).
/// Set the render root cell — <see cref="World.Cells.CellGraph.CurrCell"/>, which IS
/// "the PLAYER's cell" (CellGraph.cs:19) and roots the indoor render
/// (GameWindow.OnRender). Call ONLY for the local player, from
/// <c>PlayerMovementController.UpdateCellId</c> — the single player chokepoint for CellId
/// (teleport / server snap / per-frame resolver).
///
/// <para>
/// 2026-06-03: this write was previously inside the per-entity <see cref="ResolveWithTransition"/>
/// (every NPC / remote calls that). A Holtburg NPC jump-looping near the cottage doorway
/// clobbered the player's render root every tick → the render rooted at the NPC's tiny
/// connector cell (0170) instead of the player's room (0171) → only that cell's ~8-triangle
/// shell drew, the rest showing the GL clear color = the cottage doorway "blue-hole" flap.
/// Moving the write to the player-only chokepoint fixes it: NPCs no longer touch CurrCell.
/// </para>
///
/// <para>Leaves CurrCell unchanged when the id isn't resolvable in the graph yet
/// (stale beats null), matching the prior behavior. Retail anchor:
/// CObjCell::change_cell sets the object's curr_cell; only the player's drives the viewer.</para>
/// </summary>
private uint SetCurrAndReturn(uint resolvedId)
public void UpdatePlayerCurrCell(uint cellId)
{
if (DataCache?.CellGraph is { } cg && cg.GetVisible(resolvedId) is { } cell)
if (DataCache?.CellGraph is { } cg && cg.GetVisible(cellId) is { } cell)
cg.CurrCell = cell;
return resolvedId;
}
internal uint ResolveCellId(Vector3 worldPos, float sphereRadius, uint fallbackCellId)
@ -320,7 +333,7 @@ public sealed class PhysicsEngine
// optionally re-enter an indoor cell via CheckBuildingTransit.
var indoorCell = DataCache.GetCellStruct(indoorResult);
if (indoorCell?.CellBSP?.Root is null)
return SetCurrAndReturn(indoorResult); // Can't verify (no CellBSP); trust FindCellList.
return indoorResult; // render root (CurrCell) set by the player's UpdateCellId // Can't verify (no CellBSP); trust FindCellList.
// Issue #90 fix (2026-05-20): use SPHERE-overlap instead of POINT-in
// for the indoor verification. The previous point-only check caused
@ -338,7 +351,7 @@ public sealed class PhysicsEngine
// BSPTREE::sphere_intersects_cell_bsp at :323267.
var localCenter = Vector3.Transform(worldPos, indoorCell.InverseWorldTransform);
if (BSPQuery.SphereIntersectsCellBsp(indoorCell.CellBSP.Root, localCenter, sphereRadius))
return SetCurrAndReturn(indoorResult);
return indoorResult; // render root (CurrCell) set by the player's UpdateCellId
// Fall through to outdoor resolution: player has FULLY left the
// indoor portal-connected graph (sphere no longer overlaps).
@ -372,12 +385,12 @@ public sealed class PhysicsEngine
{
// First candidate wins — building portal containment is
// mutually exclusive in retail (one interior cell per portal).
foreach (var c in candidates) return SetCurrAndReturn(c);
foreach (var c in candidates) return c;
}
}
}
return SetCurrAndReturn(outdoorCellId);
return outdoorCellId; // render root (CurrCell) set by the player's UpdateCellId
}
}
@ -875,9 +888,10 @@ public sealed class PhysicsEngine
// Phase W Stage 1: return the transition's SWEPT cell (retail SetPositionInternal
// reads sphere_path.curr_cell), not a static re-derive from the resting origin.
// ValidateTransition advances sp.CurCellId only on accepted moves / reverts on
// blocks, so push-back or standing still cannot flip it. SetCurrAndReturn keeps the
// W2a CellGraph.CurrCell write the render root consumes.
SetCurrAndReturn(sp.CurCellId),
// blocks, so push-back or standing still cannot flip it. The render root
// (CellGraph.CurrCell) is NOT written here — this runs for EVERY entity; it is set
// from this id only by the player's UpdateCellId (see UpdatePlayerCurrCell).
sp.CurCellId,
onGround,
collisionNormalValid,
collisionNormal);
@ -898,7 +912,8 @@ public sealed class PhysicsEngine
sp.CheckPos,
// Phase W Stage 1: prefer the swept cell; fall back to partialCellId only when
// sp.CurCellId is zero (transition never advanced — teleport or physics reset).
SetCurrAndReturn(sp.CurCellId != 0 ? sp.CurCellId : partialCellId),
// (Render root set by the player's UpdateCellId, not here — see UpdatePlayerCurrCell.)
sp.CurCellId != 0 ? sp.CurCellId : partialCellId,
partialOnGround,
collisionNormalValid,
collisionNormal);