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>
This commit is contained in:
parent
85fe20f51d
commit
6ec4cde9a4
4 changed files with 192 additions and 14 deletions
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ public sealed class BuildingPhysics
|
|||
/// </summary>
|
||||
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
|
|||
|
||||
/// <summary>Full id of the interior EnvCell this portal connects to.</summary>
|
||||
public uint OtherCellId { get; }
|
||||
/// <summary>The portal id within the destination EnvCell.</summary>
|
||||
public ushort OtherPortalId { get; }
|
||||
/// <summary>
|
||||
/// The portal id within the destination EnvCell — SIGNED, like retail's
|
||||
/// <c>CBldPortal.other_portal_id</c> (<c>int</c>, acclient.h:32098,
|
||||
/// sign-extended from the dat's 16-bit field). <c>-1</c> (wire
|
||||
/// <c>0xFFFF</c>) means "no reciprocal portal"; retail's
|
||||
/// <c>CEnvCell::check_building_transit</c> (Ghidra 0x0052c5d0) rejects
|
||||
/// the whole transit when this is negative — the <c>arg2 >= 0</c>
|
||||
/// 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 <c>ushort</c>; construction
|
||||
/// sites reinterpret via <c>unchecked((short)value)</c>.
|
||||
/// </summary>
|
||||
public short OtherPortalId { get; }
|
||||
public ushort Flags { get; }
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -356,9 +356,44 @@ public static class CellTransit
|
|||
Vector3 worldSphereCenter,
|
||||
float sphereRadius,
|
||||
ICollection<uint> candidates)
|
||||
=> CheckBuildingTransit(
|
||||
cache, building,
|
||||
new[] { new Sphere { Origin = worldSphereCenter, Radius = sphereRadius } },
|
||||
1, candidates, out _);
|
||||
|
||||
/// <summary>
|
||||
/// 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 (<c>CEnvCell::check_building_transit</c>,
|
||||
/// Ghidra 0x0052c5d0 — per-sphere loop at 0052c5fe, break-on-hit).
|
||||
/// </summary>
|
||||
/// <param name="hitsInteriorCell">True when at least one interior cell
|
||||
/// was admitted — retail writes <c>SPHEREPATH.hits_interior_cell = 1</c>
|
||||
/// at 0052c650 the moment a sphere lands a building-transit cell. Feeds
|
||||
/// the building-shell <c>bldg_check</c> weakening in
|
||||
/// <c>BSPTREE::find_collisions</c> (0x0053a440).</param>
|
||||
public static void CheckBuildingTransit(
|
||||
PhysicsDataCache cache,
|
||||
BuildingPhysics building,
|
||||
IReadOnlyList<Sphere> worldSpheres,
|
||||
int numSpheres,
|
||||
ICollection<uint> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
/// <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 >= 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue