From 79fb6e7c23c9d50b44f12447e60e31aa0c0c42b9 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 3 Jun 2026 10:12:38 +0200 Subject: [PATCH] =?UTF-8?q?fix(render):=20doorway=20blue-hole=20=E2=80=94?= =?UTF-8?q?=20render=20root=20clobbered=20by=20NPCs=20(CurrCell=20per-enti?= =?UTF-8?q?ty=20write)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Input/PlayerMovementController.cs | 9 +++ src/AcDream.Core/Physics/PhysicsEngine.cs | 45 +++++++++----- .../Physics/CellGraphMembershipTests.cs | 58 +++++++++++++++---- 3 files changed, 87 insertions(+), 25 deletions(-) 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 } }