From 6ec4cde9a421ac87fb71119b5ded89b8a8ad8ebe Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 11 Jun 2026 13:56:16 +0200 Subject: [PATCH] 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 --- src/AcDream.App/Rendering/GameWindow.cs | 7 +- src/AcDream.Core/Physics/BuildingPhysics.cs | 18 ++- src/AcDream.Core/Physics/CellTransit.cs | 56 +++++++- .../CellTransitCheckBuildingTransitTests.cs | 125 +++++++++++++++++- 4 files changed, 192 insertions(+), 14 deletions(-) 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); + } }