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:
parent
e5457f9552
commit
79fb6e7c23
3 changed files with 87 additions and 25 deletions
|
|
@ -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}"));
|
$"[cell-transit] 0x{CellId:X8} -> 0x{newCellId:X8} pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) reason={reason}"));
|
||||||
}
|
}
|
||||||
CellId = newCellId;
|
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)
|
public void SetPosition(Vector3 pos, uint cellId)
|
||||||
|
|
|
||||||
|
|
@ -257,16 +257,29 @@ public sealed class PhysicsEngine
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// UCG W2 Task 1: record the resolved cell as the single membership answer. Additive —
|
/// Set the render root cell — <see cref="World.Cells.CellGraph.CurrCell"/>, which IS
|
||||||
/// CurrCell is written here; it has no reader yet (render-read is a later task).
|
/// "the PLAYER's cell" (CellGraph.cs:19) and roots the indoor render
|
||||||
/// Leaves CurrCell unchanged when the id can't be resolved in the graph (stale beats null).
|
/// (GameWindow.OnRender). Call ONLY for the local player, from
|
||||||
/// Retail anchor: CPhysicsObj::set_cell_id (acclient_2013_pseudo_c.txt).
|
/// <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>
|
/// </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;
|
cg.CurrCell = cell;
|
||||||
return resolvedId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal uint ResolveCellId(Vector3 worldPos, float sphereRadius, uint fallbackCellId)
|
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.
|
// optionally re-enter an indoor cell via CheckBuildingTransit.
|
||||||
var indoorCell = DataCache.GetCellStruct(indoorResult);
|
var indoorCell = DataCache.GetCellStruct(indoorResult);
|
||||||
if (indoorCell?.CellBSP?.Root is null)
|
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
|
// Issue #90 fix (2026-05-20): use SPHERE-overlap instead of POINT-in
|
||||||
// for the indoor verification. The previous point-only check caused
|
// 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.
|
// BSPTREE::sphere_intersects_cell_bsp at :323267.
|
||||||
var localCenter = Vector3.Transform(worldPos, indoorCell.InverseWorldTransform);
|
var localCenter = Vector3.Transform(worldPos, indoorCell.InverseWorldTransform);
|
||||||
if (BSPQuery.SphereIntersectsCellBsp(indoorCell.CellBSP.Root, localCenter, sphereRadius))
|
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
|
// Fall through to outdoor resolution: player has FULLY left the
|
||||||
// indoor portal-connected graph (sphere no longer overlaps).
|
// indoor portal-connected graph (sphere no longer overlaps).
|
||||||
|
|
@ -372,12 +385,12 @@ public sealed class PhysicsEngine
|
||||||
{
|
{
|
||||||
// First candidate wins — building portal containment is
|
// First candidate wins — building portal containment is
|
||||||
// mutually exclusive in retail (one interior cell per portal).
|
// 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
|
// 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.
|
// reads sphere_path.curr_cell), not a static re-derive from the resting origin.
|
||||||
// ValidateTransition advances sp.CurCellId only on accepted moves / reverts on
|
// ValidateTransition advances sp.CurCellId only on accepted moves / reverts on
|
||||||
// blocks, so push-back or standing still cannot flip it. SetCurrAndReturn keeps the
|
// blocks, so push-back or standing still cannot flip it. The render root
|
||||||
// W2a CellGraph.CurrCell write the render root consumes.
|
// (CellGraph.CurrCell) is NOT written here — this runs for EVERY entity; it is set
|
||||||
SetCurrAndReturn(sp.CurCellId),
|
// from this id only by the player's UpdateCellId (see UpdatePlayerCurrCell).
|
||||||
|
sp.CurCellId,
|
||||||
onGround,
|
onGround,
|
||||||
collisionNormalValid,
|
collisionNormalValid,
|
||||||
collisionNormal);
|
collisionNormal);
|
||||||
|
|
@ -898,7 +912,8 @@ public sealed class PhysicsEngine
|
||||||
sp.CheckPos,
|
sp.CheckPos,
|
||||||
// Phase W Stage 1: prefer the swept cell; fall back to partialCellId only when
|
// 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).
|
// 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,
|
partialOnGround,
|
||||||
collisionNormalValid,
|
collisionNormalValid,
|
||||||
collisionNormal);
|
collisionNormal);
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,77 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.Core.Physics;
|
using AcDream.Core.Physics;
|
||||||
using AcDream.Core.World.Cells;
|
|
||||||
using DatReaderWriter.Types;
|
using DatReaderWriter.Types;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
|
using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
|
||||||
|
|
||||||
namespace AcDream.Core.Tests.Physics;
|
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
|
public class CellGraphMembershipTests
|
||||||
{
|
{
|
||||||
[Fact]
|
private static PhysicsEngine MakeEngineWithCell(uint cellId)
|
||||||
public void ResolveCellId_Resolved_WritesCurrCellTrackingTheResolvedId()
|
|
||||||
{
|
{
|
||||||
var engine = new PhysicsEngine();
|
var engine = new PhysicsEngine();
|
||||||
var cache = new PhysicsDataCache();
|
var cache = new PhysicsDataCache();
|
||||||
engine.DataCache = cache;
|
engine.DataCache = cache;
|
||||||
|
|
||||||
var cs = new CellStruct {
|
var cs = new CellStruct
|
||||||
|
{
|
||||||
VertexArray = new VertexArray { Vertices = new Dictionary<ushort, SWVertex>() },
|
VertexArray = new VertexArray { Vertices = new Dictionary<ushort, SWVertex>() },
|
||||||
Polygons = new Dictionary<ushort, Polygon>(),
|
Polygons = new Dictionary<ushort, Polygon>(),
|
||||||
PhysicsBSP = null,
|
PhysicsBSP = null,
|
||||||
};
|
};
|
||||||
var dat = new DatEnvCell {
|
var dat = new DatEnvCell
|
||||||
|
{
|
||||||
Flags = (DatReaderWriter.Enums.EnvCellFlags)0,
|
Flags = (DatReaderWriter.Enums.EnvCellFlags)0,
|
||||||
CellPortals = new List<DatReaderWriter.Types.CellPortal>(),
|
CellPortals = new List<DatReaderWriter.Types.CellPortal>(),
|
||||||
VisibleCells = new List<ushort>(),
|
VisibleCells = new List<ushort>(),
|
||||||
};
|
};
|
||||||
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);
|
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.Equal(0xA9B40174u, result); // still resolves the cell correctly
|
||||||
Assert.NotNull(cache.CellGraph.CurrCell);
|
Assert.Null(engine.DataCache!.CellGraph.CurrCell); // but does NOT write the render root
|
||||||
Assert.Equal(result, cache.CellGraph.CurrCell!.Id);
|
|
||||||
Assert.Equal(0xA9B40174u, cache.CellGraph.CurrCell!.Id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue