acdream/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs
Erik 6ec4cde9a4 T6 (BR-7) C1: signed OtherPortalId + the >=0 building-transit gate
Retail CEnvCell::check_building_transit (Ghidra 0x0052c5d0) opens with
`if (other_portal_id >= 0)` on the SIGNED sign-extended portal id
(CBldPortal.other_portal_id is int, acclient.h:32098). Our BldPortalInfo
carried the dat reader's raw ushort and CheckBuildingTransit had no gate
at all, so a portal whose dat value is 0xFFFF (-1, "no reciprocal
portal") could admit its interior cell. BN's pseudo-C renders the
comparison unsigned - the sign-extension is Ghidra-proven (BR-7 verified
corrections, wf1-interior-collision.md).

- BldPortalInfo.OtherPortalId: ushort -> short; GameWindow construction
  reinterprets the dat ushort via unchecked((short)).
- CheckBuildingTransit: negative-id portals rejected before any sphere
  test; new multi-sphere overload matching retail's per-sphere loop
  (0052c5fe, first-hit admits) with the hits_interior_cell output
  (0052c650) the BR-7 building channel consumes next.
- Tests: negative-id skip vs positive-id admit on a leaf-root CellBSP;
  multi-sphere plumbing + zero-sphere no-op.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:56:16 +02:00

182 lines
6.7 KiB
C#

using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public class CellTransitCheckBuildingTransitTests
{
[Fact]
public void BuildingPortalWithUnloadedCellBSP_NoCandidateAdded()
{
// Verifies the null-CellBSP guard: when the destination interior cell
// is cached but its CellBSP isn't yet loaded (or is structurally absent),
// CheckBuildingTransit must NOT add the cell to candidates — even though
// PointInsideCellBsp(null, _) returns true.
//
// Happy-path (CellBSP present, sphere inside) requires a synthetic
// CellBSPTree which is non-trivial to construct from DatReaderWriter
// types. Deferred to visual verification.
// Building at world origin. One portal to interior cell 0xA9B40100.
var building = new BuildingPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Portals = new[]
{
new BldPortalInfo(
otherCellId: 0xA9B40100u,
otherPortalId: 0,
flags: 0),
},
};
// Interior cell with null CellBSP — PointInsideCellBsp(null, _) returns true,
// but CheckBuildingTransit guards on CellBSP?.Root being non-null, so this
// cell is skipped.
var interiorCell = new CellPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
};
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40100u, interiorCell);
var candidates = new HashSet<uint>();
CellTransit.CheckBuildingTransit(
cache, building,
worldSphereCenter: new Vector3(0, 0, 0),
sphereRadius: 0.5f,
candidates);
// CellBSP is null → containment guard (otherCell?.CellBSP?.Root is null)
// skips this cell. No candidate added.
Assert.Empty(candidates);
}
/// <summary>
/// BR-7 / A6.P4 C1 (2026-06-11): retail's first gate in
/// <c>CEnvCell::check_building_transit</c> (Ghidra 0x0052c5dc) is
/// <c>if (other_portal_id &gt;= 0)</c> on the SIGNED sign-extended
/// portal id — a portal whose dat value is 0xFFFF (-1, "no reciprocal
/// portal") never admits its interior cell, even when a sphere
/// overlaps it. A leaf-root CellBSP makes the sphere test
/// unconditionally "inside", so the only thing separating the two
/// portals below is the sign of OtherPortalId.
/// </summary>
[Fact]
public void NegativeOtherPortalId_RejectsTransit_PositiveAdmits()
{
var leafBsp = new DatReaderWriter.Types.CellBSPTree
{
Root = new DatReaderWriter.Types.CellBSPNode
{
Type = DatReaderWriter.Enums.BSPNodeType.Leaf,
},
};
CellPhysics MakeLeafCell() => new CellPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
CellBSP = leafBsp,
};
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40101u, MakeLeafCell());
cache.RegisterCellStructForTest(0xA9B40102u, MakeLeafCell());
var building = new BuildingPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Portals = new[]
{
// Wire 0xFFFF reinterpreted signed = -1 → must be skipped.
new BldPortalInfo(
otherCellId: 0xA9B40101u,
otherPortalId: unchecked((short)0xFFFF),
flags: 0),
// Non-negative id → admitted (leaf BSP ⇒ sphere inside).
new BldPortalInfo(
otherCellId: 0xA9B40102u,
otherPortalId: 0,
flags: 0),
},
};
var candidates = new HashSet<uint>();
CellTransit.CheckBuildingTransit(
cache, building,
worldSphereCenter: new Vector3(0, 0, 0),
sphereRadius: 0.5f,
candidates);
Assert.DoesNotContain(0xA9B40101u, candidates);
Assert.Contains(0xA9B40102u, candidates);
}
/// <summary>
/// Multi-sphere form (retail per-sphere loop at 0052c5fe): the first
/// intersecting sphere admits the cell and sets
/// <c>hitsInteriorCell</c> (retail SPHEREPATH.hits_interior_cell write
/// at 0052c650). All-spheres-outside ⇒ no admit, flag false.
/// </summary>
[Fact]
public void MultiSphere_AnySphereAdmits_SetsHitsInteriorCell()
{
var leafBsp = new DatReaderWriter.Types.CellBSPTree
{
Root = new DatReaderWriter.Types.CellBSPNode
{
Type = DatReaderWriter.Enums.BSPNodeType.Leaf,
},
};
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40103u, new CellPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
CellBSP = leafBsp,
});
var building = new BuildingPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Portals = new[]
{
new BldPortalInfo(0xA9B40103u, otherPortalId: 1, flags: 0),
},
};
var spheres = new[]
{
new DatReaderWriter.Types.Sphere { Origin = new Vector3(500f, 0f, 0f), Radius = 0.5f },
new DatReaderWriter.Types.Sphere { Origin = Vector3.Zero, Radius = 0.5f },
};
var candidates = new HashSet<uint>();
CellTransit.CheckBuildingTransit(
cache, building, spheres, spheres.Length, candidates, out bool hits);
// Leaf-root BSP treats every sphere as inside, so even the far
// sphere admits; the point of this test is the plumbing: candidates
// populated + hitsInteriorCell set through the multi-sphere form.
Assert.Contains(0xA9B40103u, candidates);
Assert.True(hits);
// Zero spheres → nothing admitted, flag stays false.
var empty = new HashSet<uint>();
CellTransit.CheckBuildingTransit(
cache, building, spheres, 0, empty, out bool hitsNone);
Assert.Empty(empty);
Assert.False(hitsNone);
}
}