fix(portal): synchronize destination presentation state

This commit is contained in:
Erik 2026-07-16 21:17:13 +02:00
parent 4b1bceefbb
commit e95f55f25b
42 changed files with 2815 additions and 288 deletions

View file

@ -66,15 +66,56 @@ public class PhysicsBodyCellSyncTests
Assert.Equal(0u, body.CellPosition.ObjCellId);
}
[Fact]
public void PositionDelta_InsideEnvCell_AdvancesCanonicalLocalFrame()
{
var body = new PhysicsBody();
body.SnapToCell(
0x8C0401ADu,
worldPos: new Vector3(12f, -30f, 4f),
cellLocal: new Vector3(80f, 40f, 4f));
body.Position += new Vector3(3f, -2f, 0.5f);
Assert.Equal(0x8C0401ADu, body.CellPosition.ObjCellId);
Assert.Equal(new Vector3(83f, 38f, 4.5f), body.CellPosition.Frame.Origin);
}
[Fact]
public void CommitTransitionPosition_InsideDungeon_AdoptsResolvedEnvCellAndFrame()
{
var body = new PhysicsBody();
body.SnapToCell(
0x8C0401ADu,
worldPos: new Vector3(12f, -30f, 4f),
cellLocal: new Vector3(80f, 40f, 4f));
body.CommitTransitionPosition(
0x8C0401AEu,
new Vector3(17f, -27f, 4f));
Assert.Equal(0x8C0401AEu, body.CellPosition.ObjCellId);
Assert.Equal(new Vector3(85f, 43f, 4f), body.CellPosition.Frame.Origin);
Assert.Equal(new Vector3(17f, -27f, 4f), body.Position);
}
[Fact]
public void CommitTransitionPosition_WithoutSeed_DoesNotInventCanonicalCell()
{
var body = new PhysicsBody();
body.CommitTransitionPosition(0x8C0401ADu, new Vector3(1f, 2f, 3f));
Assert.Equal(0u, body.CellPosition.ObjCellId);
Assert.Equal(new Vector3(1f, 2f, 3f), body.Position);
}
[Fact]
public void ContinuousTracking_LongWalkAcrossCellsAndBlock_NeverGoesStale()
{
// Refutes the "CellPosition goes stale" concern (incl. the indoor-transit case):
// because the Position setter re-derives the cell via AdjustToOutside on EVERY
// write, CellPosition continuously tracks the outdoor cell under the body's WORLD
// position. The body is always world-space, so the world delta is frame-invariant —
// there is no separate "interior" frame to desync. On any exit it is the correct
// cell, never stale.
// Refutes outdoor CellPosition drift: the Position setter re-derives the
// cell via AdjustToOutside on every write, so the carried (cell, local)
// pair continuously tracks the body across cells and landblocks.
var body = new PhysicsBody();
body.SnapToCell(0xC95B0001u, new Vector3(1000f, 2000f, 12f), new Vector3(10f, 10f, 12f));

View file

@ -1,6 +1,7 @@
using System;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.World.Cells;
using Xunit;
namespace AcDream.Core.Tests.Physics;
@ -31,4 +32,123 @@ public class PhysicsEngineResidencyTests
Assert.True(eng.IsLandblockTerrainResident(0xA9B40019u)); // cell-resolved id, same LB
Assert.False(eng.IsLandblockTerrainResident(0xC6A90019u)); // different LB
}
[Fact]
public void DemoteLandblockToTerrain_RemovesEnvCellsButPreservesTerrainResidency()
{
const uint landblockId = 0xA9B4FFFFu;
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
engine.AddLandblock(
landblockId,
new TerrainSurface(new byte[81], new float[256]),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
0f,
0f);
cache.CellGraph.Add(new EnvCell(
0xA9B40174u,
Matrix4x4.Identity,
Matrix4x4.Identity,
Vector3.Zero,
Vector3.One,
Array.Empty<CellPortal>(),
Array.Empty<uint>(),
false,
null));
engine.DemoteLandblockToTerrain(landblockId);
Assert.True(engine.IsLandblockTerrainResident(landblockId));
Assert.Null(cache.CellGraph.GetVisible(0xA9B40174u));
Assert.IsType<LandCell>(cache.CellGraph.GetVisible(0xA9B40001u));
}
[Fact]
public void DemoteLandblockToTerrain_DeregistersCrossBoundaryStaticButRetainsDynamic()
{
const uint landblockId = 0xA9B4FFFFu;
const uint adjacentCell = 0xAAB40001u;
const uint staticId = 100u;
const uint dynamicId = 200u;
var engine = new PhysicsEngine();
engine.AddLandblock(
landblockId,
new TerrainSurface(new byte[81], new float[256]),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
0f,
0f);
var seamPosition = new Vector3(191.5f, 12f, 1f);
engine.ShadowObjects.Register(
staticId,
0x01000001u,
seamPosition,
Quaternion.Identity,
2f,
0f,
0f,
landblockId,
isStatic: true);
engine.ShadowObjects.Register(
dynamicId,
0x01000002u,
seamPosition,
Quaternion.Identity,
2f,
0f,
0f,
landblockId,
isStatic: false);
Assert.Contains(
engine.ShadowObjects.GetObjectsInCell(adjacentCell),
entry => entry.EntityId == staticId);
Assert.Contains(
engine.ShadowObjects.GetObjectsInCell(adjacentCell),
entry => entry.EntityId == dynamicId);
engine.DemoteLandblockToTerrain(landblockId);
Assert.DoesNotContain(
engine.ShadowObjects.AllEntriesForDebug(),
entry => entry.EntityId == staticId);
Assert.Contains(
engine.ShadowObjects.GetObjectsInCell(adjacentCell),
entry => entry.EntityId == dynamicId);
Assert.Equal(1, engine.ShadowObjects.RetainedRegistrationCount);
}
[Fact]
public void RemoveLandblock_DeregistersCrossBoundaryStaticButRetainsDynamic()
{
const uint landblockId = 0xA9B4FFFFu;
const uint adjacentCell = 0xAAB40001u;
const uint staticId = 300u;
const uint dynamicId = 400u;
var engine = new PhysicsEngine();
engine.AddLandblock(
landblockId,
new TerrainSurface(new byte[81], new float[256]),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
0f,
0f);
var seamPosition = new Vector3(191.5f, 12f, 1f);
engine.ShadowObjects.Register(
staticId, 0x01000003u, seamPosition, Quaternion.Identity, 2f,
0f, 0f, landblockId, isStatic: true);
engine.ShadowObjects.Register(
dynamicId, 0x01000004u, seamPosition, Quaternion.Identity, 2f,
0f, 0f, landblockId, isStatic: false);
engine.RemoveLandblock(landblockId);
Assert.DoesNotContain(
engine.ShadowObjects.AllEntriesForDebug(),
entry => entry.EntityId == staticId);
Assert.Contains(
engine.ShadowObjects.GetObjectsInCell(adjacentCell),
entry => entry.EntityId == dynamicId);
Assert.Equal(1, engine.ShadowObjects.RetainedRegistrationCount);
}
}

View file

@ -207,6 +207,132 @@ public class ShadowObjectRegistryTests
entry => entry.EntityId == entityId);
}
[Fact]
public void RefloodLandblock_RestoresAdjacentOwnedFootprintWithdrawnByUnload()
{
const uint adjacentOwnerLb = 0xAAB40000u;
const uint ownerCell = adjacentOwnerLb | 1u;
const uint touchedCell = LbId | 57u; // west block cell (7,0)
const uint entityId = 32u;
var reg = new ShadowObjectRegistry();
var cache = new PhysicsDataCache();
cache.CellGraph.RegisterTerrain(
adjacentOwnerLb,
new TerrainSurface(new byte[81], new float[256]),
new Vector3(192f, 0f, 0f));
reg.DataCache = cache;
reg.Register(
entityId,
0x01000006u,
new Vector3(192.5f, 12f, 50f),
Quaternion.Identity,
2f,
192f,
0f,
adjacentOwnerLb,
seedCellId: ownerCell,
isStatic: false);
Assert.Contains(reg.GetObjectsInCell(touchedCell), e => e.EntityId == entityId);
Assert.Contains(reg.GetObjectsInCell(ownerCell), e => e.EntityId == entityId);
reg.RemoveLandblock(LbId);
Assert.DoesNotContain(reg.GetObjectsInCell(touchedCell), e => e.EntityId == entityId);
Assert.Contains(reg.GetObjectsInCell(ownerCell), e => e.EntityId == entityId);
Assert.Equal(1, reg.WithdrawnPrefixMarkerCount);
reg.RefloodLandblock(LbId);
Assert.Contains(reg.GetObjectsInCell(touchedCell), e => e.EntityId == entityId);
Assert.Contains(reg.GetObjectsInCell(ownerCell), e => e.EntityId == entityId);
Assert.Equal(0, reg.WithdrawnPrefixMarkerCount);
}
[Fact]
public void AuthoritativeMove_ClearsObsoleteWithdrawnPrefix()
{
const uint adjacentOwnerLb = 0xAAB40000u;
const uint ownerCell = adjacentOwnerLb | 1u;
const uint touchedCell = LbId | 57u;
const uint entityId = 33u;
var cache = new PhysicsDataCache();
cache.CellGraph.RegisterTerrain(
adjacentOwnerLb,
new TerrainSurface(new byte[81], new float[256]),
new Vector3(192f, 0f, 0f));
var reg = new ShadowObjectRegistry { DataCache = cache };
reg.Register(
entityId, 0x01000007u, new Vector3(192.5f, 12f, 50f),
Quaternion.Identity, 2f, 192f, 0f, adjacentOwnerLb,
seedCellId: ownerCell, isStatic: false);
reg.RemoveLandblock(LbId);
Assert.Equal(1, reg.WithdrawnPrefixMarkerCount);
reg.UpdatePosition(
entityId,
new Vector3(204f, 12f, 50f),
Quaternion.Identity,
192f,
0f,
adjacentOwnerLb,
seedCellId: ownerCell);
Assert.Equal(0, reg.WithdrawnPrefixMarkerCount);
reg.RefloodLandblock(LbId);
Assert.DoesNotContain(reg.GetObjectsInCell(touchedCell), e => e.EntityId == entityId);
}
[Fact]
public void RefloodLandblock_RetiresOnlyCurrentMarker_WhenTwoPrefixesWereWithdrawn()
{
const uint ownerLb = 0xAAB50000u;
const uint ownerCell = ownerLb | 1u;
const uint westLb = 0xA9B50000u;
const uint southLb = 0xAAB40000u;
const uint westCell = westLb | 57u;
const uint southCell = southLb | 8u;
const uint entityId = 35u;
var cache = new PhysicsDataCache();
cache.CellGraph.RegisterTerrain(
ownerLb,
new TerrainSurface(new byte[81], new float[256]),
new Vector3(192f, 192f, 0f));
var reg = new ShadowObjectRegistry { DataCache = cache };
reg.Register(
entityId, 0x01000009u, new Vector3(192.5f, 192.5f, 50f),
Quaternion.Identity, 2f, 192f, 192f, ownerLb,
seedCellId: ownerCell, isStatic: false);
Assert.Contains(reg.GetObjectsInCell(westCell), e => e.EntityId == entityId);
Assert.Contains(reg.GetObjectsInCell(southCell), e => e.EntityId == entityId);
reg.RemoveLandblock(westLb);
reg.RemoveLandblock(southLb);
Assert.Equal(2, reg.WithdrawnPrefixMarkerCount);
reg.RefloodLandblock(westLb);
Assert.Equal(1, reg.WithdrawnPrefixMarkerCount);
reg.RefloodLandblock(southLb);
Assert.Equal(0, reg.WithdrawnPrefixMarkerCount);
Assert.Contains(reg.GetObjectsInCell(westCell), e => e.EntityId == entityId);
Assert.Contains(reg.GetObjectsInCell(southCell), e => e.EntityId == entityId);
}
[Fact]
public void RemoveLandblock_DirectStaticRetirement_ClearsWithdrawnMarker()
{
const uint entityId = 34u;
var reg = new ShadowObjectRegistry();
reg.Register(
entityId, 0x01000008u, new Vector3(12f, 12f, 50f),
Quaternion.Identity, 1f, OffX, OffY, LbId,
isStatic: true);
reg.RemoveLandblock(LbId);
Assert.Equal(0, reg.RetainedRegistrationCount);
Assert.Equal(0, reg.WithdrawnPrefixMarkerCount);
}
// -----------------------------------------------------------------------
// Per-cell query surface (BR-7 / A6.P4 2026-06-11): GetObjectsInCell IS
// the query — retail CObjCell::find_obj_collisions iterates only the