diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index faf1a558..359b2f8e 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -5967,7 +5967,12 @@ public sealed class GameWindow : IDisposable
{
bldPortals.Add(new AcDream.Core.Physics.BldPortalInfo(
otherCellId: lbPrefix | (uint)bp.OtherCellId,
- otherPortalId: bp.OtherPortalId,
+ // DatReaderWriter parses the dat's 16-bit field as
+ // ushort; retail sign-extends it to a SIGNED int
+ // (CBldPortal.other_portal_id, acclient.h:32098) and
+ // check_building_transit gates on `>= 0`
+ // (Ghidra 0x0052c5dc). 0xFFFF → -1 = no reciprocal.
+ otherPortalId: unchecked((short)bp.OtherPortalId),
flags: (ushort)bp.Flags));
}
diff --git a/src/AcDream.Core/Physics/BuildingPhysics.cs b/src/AcDream.Core/Physics/BuildingPhysics.cs
index f717a58c..30081f8a 100644
--- a/src/AcDream.Core/Physics/BuildingPhysics.cs
+++ b/src/AcDream.Core/Physics/BuildingPhysics.cs
@@ -24,7 +24,7 @@ public sealed class BuildingPhysics
///
public readonly struct BldPortalInfo
{
- public BldPortalInfo(uint otherCellId, ushort otherPortalId, ushort flags)
+ public BldPortalInfo(uint otherCellId, short otherPortalId, ushort flags)
{
OtherCellId = otherCellId;
OtherPortalId = otherPortalId;
@@ -33,8 +33,20 @@ public readonly struct BldPortalInfo
/// Full id of the interior EnvCell this portal connects to.
public uint OtherCellId { get; }
- /// The portal id within the destination EnvCell.
- public ushort OtherPortalId { get; }
+ ///
+ /// The portal id within the destination EnvCell — SIGNED, like retail's
+ /// CBldPortal.other_portal_id (int, acclient.h:32098,
+ /// sign-extended from the dat's 16-bit field). -1 (wire
+ /// 0xFFFF) means "no reciprocal portal"; retail's
+ /// CEnvCell::check_building_transit (Ghidra 0x0052c5d0) rejects
+ /// the whole transit when this is negative — the arg2 >= 0
+ /// gate is the first instruction. BN's pseudo-C renders the comparison
+ /// unsigned (wrong); the sign-extension is Ghidra-proven
+ /// (wf1-interior-collision.md, BR-7 verified corrections).
+ /// DatReaderWriter parses the field as ushort; construction
+ /// sites reinterpret via unchecked((short)value).
+ ///
+ public short OtherPortalId { get; }
public ushort Flags { get; }
///
diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs
index 60dac924..0b307182 100644
--- a/src/AcDream.Core/Physics/CellTransit.cs
+++ b/src/AcDream.Core/Physics/CellTransit.cs
@@ -356,9 +356,44 @@ public static class CellTransit
Vector3 worldSphereCenter,
float sphereRadius,
ICollection candidates)
+ => CheckBuildingTransit(
+ cache, building,
+ new[] { new Sphere { Origin = worldSphereCenter, Radius = sphereRadius } },
+ 1, candidates, out _);
+
+ ///
+ /// Multi-sphere form matching retail's call shape: every path/flood
+ /// sphere is tested and the FIRST one intersecting the interior cell's
+ /// BSP admits the cell (CEnvCell::check_building_transit,
+ /// Ghidra 0x0052c5d0 — per-sphere loop at 0052c5fe, break-on-hit).
+ ///
+ /// True when at least one interior cell
+ /// was admitted — retail writes SPHEREPATH.hits_interior_cell = 1
+ /// at 0052c650 the moment a sphere lands a building-transit cell. Feeds
+ /// the building-shell bldg_check weakening in
+ /// BSPTREE::find_collisions (0x0053a440).
+ public static void CheckBuildingTransit(
+ PhysicsDataCache cache,
+ BuildingPhysics building,
+ IReadOnlyList worldSpheres,
+ int numSpheres,
+ ICollection candidates,
+ out bool hitsInteriorCell)
{
+ hitsInteriorCell = false;
+ int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
+ if (sphereCount == 0) return;
+
foreach (var portal in building.Portals)
{
+ // BR-7 / A6.P4 (2026-06-11): retail's first gate — the whole
+ // transit is rejected when other_portal_id is negative
+ // (`if (arg2 >= 0)` at 0x0052c5dc; arg2 is the SIGNED
+ // sign-extended CBldPortal.other_portal_id, acclient.h:32098).
+ // Wire 0xFFFF = -1 = "no reciprocal portal".
+ if (portal.OtherPortalId < 0)
+ continue;
+
var otherCell = cache.GetCellStruct(portal.OtherCellId);
if (otherCell?.CellBSP?.Root is null)
{
@@ -381,17 +416,26 @@ public static class CellTransit
// it, login-inside-the-inn keeps the player classified outdoor
// until they walk further in (sphere center crosses), letting
// them run through exterior walls on the way out.
- var localCenter = Vector3.Transform(worldSphereCenter, otherCell.InverseWorldTransform);
- bool inside = BSPQuery.SphereIntersectsCellBsp(otherCell.CellBSP.Root, localCenter, sphereRadius);
-
- if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
+ bool inside = false;
+ for (int i = 0; i < sphereCount && !inside; i++)
{
- Console.WriteLine(System.FormattableString.Invariant(
- $"[check-bldg] portal->0x{portal.OtherCellId:X8} wpos=({worldSphereCenter.X:F3},{worldSphereCenter.Y:F3},{worldSphereCenter.Z:F3}) lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) r={sphereRadius:F3} inside={inside}"));
+ var sphere = worldSpheres[i];
+ var localCenter = Vector3.Transform(sphere.Origin, otherCell.InverseWorldTransform);
+ inside = BSPQuery.SphereIntersectsCellBsp(
+ otherCell.CellBSP.Root, localCenter, sphere.Radius);
+
+ if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
+ {
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[check-bldg] portal->0x{portal.OtherCellId:X8} sphere#{i} wpos=({sphere.Origin.X:F3},{sphere.Origin.Y:F3},{sphere.Origin.Z:F3}) lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) r={sphere.Radius:F3} inside={inside}"));
+ }
}
if (inside)
{
+ // Retail sets SPHEREPATH.hits_interior_cell the moment a
+ // building-transit sphere lands an interior cell (0052c650).
+ hitsInteriorCell = true;
candidates.Add(portal.OtherCellId);
}
}
diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs
index 6ea51dc9..722e94fa 100644
--- a/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs
@@ -58,8 +58,125 @@ public class CellTransitCheckBuildingTransitTests
Assert.Empty(candidates);
}
- // A second test that uses a synthetic CellBSP whose Root.Type == BSPNodeType.Leaf
- // (which PointInsideCellBsp short-circuits as "inside") would verify the
- // happy path. Constructing a CellBSPTree by hand from DatReaderWriter
- // types is awkward; deferred to integration testing at visual-verify time.
+ ///
+ /// BR-7 / A6.P4 C1 (2026-06-11): retail's first gate in
+ /// CEnvCell::check_building_transit (Ghidra 0x0052c5dc) is
+ /// if (other_portal_id >= 0) 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.
+ ///
+ [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(),
+ 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();
+ CellTransit.CheckBuildingTransit(
+ cache, building,
+ worldSphereCenter: new Vector3(0, 0, 0),
+ sphereRadius: 0.5f,
+ candidates);
+
+ Assert.DoesNotContain(0xA9B40101u, candidates);
+ Assert.Contains(0xA9B40102u, candidates);
+ }
+
+ ///
+ /// Multi-sphere form (retail per-sphere loop at 0052c5fe): the first
+ /// intersecting sphere admits the cell and sets
+ /// hitsInteriorCell (retail SPHEREPATH.hits_interior_cell write
+ /// at 0052c650). All-spheres-outside ⇒ no admit, flag false.
+ ///
+ [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(),
+ 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();
+ 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();
+ CellTransit.CheckBuildingTransit(
+ cache, building, spheres, 0, empty, out bool hitsNone);
+ Assert.Empty(empty);
+ Assert.False(hitsNone);
+ }
}