diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index 8d4ab44..5332fe3 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -780,6 +780,15 @@ public sealed class PlayerMovementController
$"[cell-transit] 0x{CellId:X8} -> 0x{newCellId:X8} pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) reason={reason}"));
}
CellId = newCellId;
+
+ // Render root: CellGraph.CurrCell IS "the player's cell" — it roots the indoor render
+ // (GameWindow.OnRender). Set it HERE, the single PLAYER-only chokepoint for CellId
+ // (teleport / server snap @ SetPosition + per-frame resolver), NOT in the per-entity
+ // PhysicsEngine.ResolveWithTransition. That ran for EVERY entity, so a Holtburg NPC
+ // jump-looping near the cottage doorway clobbered the render root every tick → the render
+ // rooted at the NPC's tiny connector cell → only its ~8-tri shell drew, rest = GL clear
+ // color = the cottage doorway "blue-hole" flap (diagnosed 2026-06-03 via [flap-cam]/[shell]).
+ _physics.UpdatePlayerCurrCell(newCellId);
}
public void SetPosition(Vector3 pos, uint cellId)
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index 5d68e23..dba7fc0 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -257,16 +257,29 @@ public sealed class PhysicsEngine
///
///
///
- /// 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 — , which IS
+ /// "the PLAYER's cell" (CellGraph.cs:19) and roots the indoor render
+ /// (GameWindow.OnRender). Call ONLY for the local player, from
+ /// PlayerMovementController.UpdateCellId — the single player chokepoint for CellId
+ /// (teleport / server snap / per-frame resolver).
+ ///
+ ///
+ /// 2026-06-03: this write was previously inside the per-entity
+ /// (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.
+ ///
+ ///
+ /// 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.
///
- 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);
diff --git a/tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs b/tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs
index 15faa9d..8968091 100644
--- a/tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs
@@ -1,39 +1,77 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
-using AcDream.Core.World.Cells;
using DatReaderWriter.Types;
using Xunit;
using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
namespace AcDream.Core.Tests.Physics;
+// 2026-06-03 blue-hole fix: CellGraph.CurrCell IS "the player's cell" (CellGraph.cs:19) — it roots
+// the indoor render (GameWindow.OnRender). It is written ONLY by the player-only
+// PhysicsEngine.UpdatePlayerCurrCell (called from PlayerMovementController.UpdateCellId, the single
+// player chokepoint for CellId), NOT by the per-entity ResolveCellId / ResolveWithTransition. Before
+// the fix, those per-entity paths wrote CurrCell, so 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 → the cottage doorway "blue-hole" flap.
public class CellGraphMembershipTests
{
- [Fact]
- public void ResolveCellId_Resolved_WritesCurrCellTrackingTheResolvedId()
+ private static PhysicsEngine MakeEngineWithCell(uint cellId)
{
var engine = new PhysicsEngine();
var cache = new PhysicsDataCache();
engine.DataCache = cache;
- var cs = new CellStruct {
+ var cs = new CellStruct
+ {
VertexArray = new VertexArray { Vertices = new Dictionary() },
Polygons = new Dictionary(),
PhysicsBSP = null,
};
- var dat = new DatEnvCell {
+ var dat = new DatEnvCell
+ {
Flags = (DatReaderWriter.Enums.EnvCellFlags)0,
CellPortals = new List(),
VisibleCells = new List(),
};
- cache.CacheCellStruct(0xA9B40174u, dat, cs, Matrix4x4.Identity); // registers in the graph (W1)
+ cache.CacheCellStruct(cellId, dat, cs, Matrix4x4.Identity); // registers in the graph (W1)
+ return engine;
+ }
+
+ [Fact]
+ public void UpdatePlayerCurrCell_Resolved_WritesCurrCellTrackingTheId()
+ {
+ var engine = MakeEngineWithCell(0xA9B40174u);
+
+ engine.UpdatePlayerCurrCell(0xA9B40174u);
+
+ Assert.NotNull(engine.DataCache!.CellGraph.CurrCell);
+ Assert.Equal(0xA9B40174u, engine.DataCache.CellGraph.CurrCell!.Id);
+ }
+
+ [Fact]
+ public void UpdatePlayerCurrCell_UnresolvableId_LeavesCurrCellUnchanged()
+ {
+ var engine = MakeEngineWithCell(0xA9B40174u);
+ engine.UpdatePlayerCurrCell(0xA9B40174u); // CurrCell = 0174
+
+ engine.UpdatePlayerCurrCell(0xDEADBEEFu); // not in the graph → stale beats null
+
+ Assert.NotNull(engine.DataCache!.CellGraph.CurrCell);
+ Assert.Equal(0xA9B40174u, engine.DataCache.CellGraph.CurrCell!.Id);
+ }
+
+ // Guards the blue-hole fix: the per-entity ResolveCellId must NOT touch the render root.
+ // (Only the player's UpdatePlayerCurrCell does.) Before the fix this wrote CurrCell, so a
+ // nearby NPC's ResolveCellId/ResolveWithTransition clobbered the player's render root.
+ [Fact]
+ public void ResolveCellId_DoesNotWriteTheRenderRoot()
+ {
+ var engine = MakeEngineWithCell(0xA9B40174u);
uint result = engine.ResolveCellId(new Vector3(0, 0, 0), 0.5f, 0xA9B40174u);
- // CurrCell tracks whatever id ResolveCellId returned (when that id is in the graph).
- Assert.NotNull(cache.CellGraph.CurrCell);
- Assert.Equal(result, cache.CellGraph.CurrCell!.Id);
- Assert.Equal(0xA9B40174u, cache.CellGraph.CurrCell!.Id);
+ Assert.Equal(0xA9B40174u, result); // still resolves the cell correctly
+ Assert.Null(engine.DataCache!.CellGraph.CurrCell); // but does NOT write the render root
}
}