fix(physics): Cluster A #84 + #85 — indoor cell tracking

ResolveOutdoorCellId only resolved outdoor terrain landcells. A player
geometrically inside an EnvCell stayed in outdoor-landcell range, so
FindEnvCollisions' indoor cell-BSP branch (gated on cellLow >= 0x0100)
never fired. Both #84 (blocked by air indoors) and #85 (pass through
walls outside→in) are downstream of this — without indoor cell-BSP
collision the player gets stuck against outdoor-stab back-faces of the
building shell, and walls only block from one side.

Adds an indoor-cell-containment check via PhysicsDataCache: at
CacheCellStruct time, compute each cell's local AABB from its resolved
polygon vertices; at ResolveOutdoorCellId time, transform the world
position into each cached cell's local space and return the matched
cell's full id when contained. Falls through to the existing outdoor
terrain logic when no EnvCell contains the position.

Also fixes a pre-existing prefix-preservation bug in the outdoor branch:
the function now always applies the matched landblock's high-16 prefix
even when the input fallbackCellId arrived bare-low-byte (the L.2e
finding from CLAUDE.md). Updated two existing PhysicsEngineTests that
encoded the old bare-low-byte output.

Evidence: launch-cluster-a-capture.log @ 2026-05-19 — player at
worldPos (155.376, 14.010, 94.000) geometrically inside cottage cell
0xA9B40172, but sp.CheckCellId stuck at 0x00000031 (outdoor landcell)
across 454 [resolve] lines; zero [indoor-bsp] lines because the gate
never opened.

Closes #84.
Closes #85.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-19 15:20:36 +02:00
parent 4e308d567a
commit c19d6fb321
4 changed files with 292 additions and 10 deletions

View file

@ -0,0 +1,154 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Indoor walking Phase D (2026-05-19): tests for the indoor-cell-containment
/// check added to <see cref="PhysicsEngine.ResolveOutdoorCellId"/>.
/// Covers the four scenarios described in the Phase D implementation plan.
/// </summary>
public class ResolveOutdoorCellIdIndoorContainmentTests
{
/// <summary>
/// Build a <see cref="CellPhysics"/> whose local AABB spans ±<paramref name="halfExtent"/>
/// around the origin, placed at <paramref name="worldOrigin"/> via the
/// WorldTransform / InverseWorldTransform pair.
/// </summary>
private static CellPhysics MakeIndoorCellAt(Vector3 worldOrigin, Vector3 halfExtent)
{
// Four vertices defining a floor quad — enough for AABB computation at
// cache time (in production this is done by CacheCellStruct, in tests
// we pre-supply LocalAabbMin / LocalAabbMax directly).
var min = -halfExtent;
var max = halfExtent;
var verts = new[]
{
new Vector3(min.X, min.Y, min.Z),
new Vector3(max.X, min.Y, min.Z),
new Vector3(max.X, max.Y, max.Z),
new Vector3(min.X, max.Y, max.Z),
};
var poly = new ResolvedPolygon
{
Vertices = verts,
Plane = new Plane(Vector3.UnitZ, 0f),
NumPoints = 4,
SidesType = DatReaderWriter.Enums.CullMode.None,
};
var world = Matrix4x4.CreateTranslation(worldOrigin);
Matrix4x4.Invert(world, out var inv);
return new CellPhysics
{
Resolved = new Dictionary<ushort, ResolvedPolygon> { [0] = poly },
WorldTransform = world,
InverseWorldTransform = inv,
LocalAabbMin = min,
LocalAabbMax = max,
};
}
// -----------------------------------------------------------------------
// Test 1: player inside a cached EnvCell → returns that cell's full id.
// -----------------------------------------------------------------------
[Fact]
public void ResolveOutdoorCellId_PlayerInsideCachedEnvCell_ReturnsEnvCellId()
{
var engine = new PhysicsEngine();
engine.DataCache = new PhysicsDataCache();
// Cache an EnvCell at world origin spanning ±5 m on each axis.
var cell = MakeIndoorCellAt(Vector3.Zero, new Vector3(5f, 5f, 5f));
engine.DataCache.RegisterCellStructForTest(0xA9B40172u, cell);
// Player at world origin → inside the EnvCell's AABB.
uint result = engine.ResolveOutdoorCellId(Vector3.Zero, fallbackCellId: 0x00000031u);
Assert.Equal(0xA9B40172u, result);
}
// -----------------------------------------------------------------------
// Test 2: player outside all cached EnvCells → falls through to outdoor
// (and since no landblocks are registered, returns the fallback unchanged).
// -----------------------------------------------------------------------
[Fact]
public void ResolveOutdoorCellId_PlayerOutsideAllCachedEnvCells_FallsThroughToOutdoor()
{
var engine = new PhysicsEngine();
engine.DataCache = new PhysicsDataCache();
var cell = MakeIndoorCellAt(Vector3.Zero, new Vector3(5f, 5f, 5f));
engine.DataCache.RegisterCellStructForTest(0xA9B40172u, cell);
// Player at (100, 100, 0) — far outside the cached EnvCell.
// No landblocks registered → outdoor branch can't match either.
uint result = engine.ResolveOutdoorCellId(new Vector3(100f, 100f, 0f), fallbackCellId: 0x00000031u);
Assert.Equal(0x00000031u, result);
}
// -----------------------------------------------------------------------
// Test 3: EnvCell with a non-identity WorldTransform (rotation around Z).
// Player at world (3, 0, 0) is still inside the rotated local AABB.
// -----------------------------------------------------------------------
[Fact]
public void ResolveOutdoorCellId_PlayerInsideEnvCellWithRotatedTransform_StillDetectsContainment()
{
var halfExtent = new Vector3(5f, 5f, 5f);
var verts = new[]
{
new Vector3(-5f, -5f, -5f),
new Vector3( 5f, -5f, -5f),
new Vector3( 5f, 5f, 5f),
new Vector3(-5f, 5f, 5f),
};
var poly = new ResolvedPolygon
{
Vertices = verts,
Plane = new Plane(Vector3.UnitZ, 0f),
NumPoints = 4,
SidesType = DatReaderWriter.Enums.CullMode.None,
};
// 90° rotation around Z. A point at world (3, 0, 0) transforms to
// local (0, -3, 0) — still within ±5 on every axis.
var rotation = Matrix4x4.CreateRotationZ(MathF.PI / 2f);
Matrix4x4.Invert(rotation, out var inv);
var cell = new CellPhysics
{
Resolved = new Dictionary<ushort, ResolvedPolygon> { [0] = poly },
WorldTransform = rotation,
InverseWorldTransform = inv,
LocalAabbMin = -halfExtent,
LocalAabbMax = halfExtent,
};
var engine = new PhysicsEngine();
engine.DataCache = new PhysicsDataCache();
engine.DataCache.RegisterCellStructForTest(0xA9B40172u, cell);
uint result = engine.ResolveOutdoorCellId(new Vector3(3f, 0f, 0f), fallbackCellId: 0x00000031u);
Assert.Equal(0xA9B40172u, result);
}
// -----------------------------------------------------------------------
// Test 4: fallbackCellId == 0 → always returns 0 (existing early-return).
// -----------------------------------------------------------------------
[Fact]
public void ResolveOutdoorCellId_FallbackZero_ReturnsZero()
{
var engine = new PhysicsEngine();
engine.DataCache = new PhysicsDataCache();
// Even if the player is inside a cell, fallback=0 should still return 0.
var cell = MakeIndoorCellAt(Vector3.Zero, new Vector3(5f, 5f, 5f));
engine.DataCache.RegisterCellStructForTest(0xA9B40172u, cell);
uint result = engine.ResolveOutdoorCellId(Vector3.Zero, fallbackCellId: 0u);
Assert.Equal(0u, result);
}
}