diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 18804ab9..4cd41649 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -7896,77 +7896,42 @@ public sealed class GameWindow : IDisposable
// docs/superpowers/specs/2026-05-21-cylinder-fallback-dedup-design.md.
bool _isLandblockStab = (entity.Id & 0xFF000000u) == 0xC0000000u;
if (_isScenery) scTried++;
- // Register EACH physics-enabled part so multi-part Setups
- // (buildings, trees) have all their collision geometry registered.
- // Each part gets its own ShadowEntry with its world-space transform.
- var entityRoot =
- System.Numerics.Matrix4x4.CreateFromQuaternion(entity.Rotation) *
- System.Numerics.Matrix4x4.CreateTranslation(entity.Position);
-
- uint partIndex = 0;
- foreach (var meshRef in entity.MeshRefs)
+ // #185 FIX (2026-07-08): register the entity's BSP parts as ONE
+ // multi-part shadow object keyed on the entity's UNIQUE 32-bit id,
+ // via RegisterMultiPart (retail CPhysicsObj::add_shadows_to_cells
+ // 0x00514ae0 → CPartArray::AddPartsShadow — one object, a part array).
+ //
+ // The former per-part Register(entity.Id * 256u + partIndex) OVERFLOWED
+ // uint32 for class-prefixed landblock ids (0x40/0x80/0xC0…): the << 8
+ // dropped the prefix byte, so different-class entities sharing the low
+ // 24 bits collided on ONE shadow part-id and Register's
+ // deregister-then-insert silently overwrote one entity's collision
+ // geometry — the #185 "invisible wall half-way up the stairs" (rendered
+ // steps with no collision; 23 such part-id collisions in landblock
+ // 0xF682 alone: 0xF6822100 ← {0x40F68221, 0xC0F68221}, …). entity.Id is
+ // unique, so RegisterMultiPart has no synthetic id and no collision.
+ // Building shells stay excluded (they collide via the per-LandCell
+ // building channel, CSortCell::find_collisions). The Setup CylSphere/
+ // Sphere path below is UNCHANGED and only runs when entityBsp == 0
+ // (retail binary dispatch: BSP xor cyl). Builder:
+ // ShadowShapeBuilder.FromLandblockBspParts.
+ var bspShapes = AcDream.Core.Physics.ShadowShapeBuilder.FromLandblockBspParts(
+ entity.MeshRefs, entity.IsBuildingShell,
+ id => _physicsDataCache.GetGfxObj(id));
+ entityBsp = bspShapes.Count;
+ if (entityBsp > 0)
{
- // BR-7 / A6.P4 (2026-06-11): building SHELLS are not shadow
- // objects in retail — their collision is the per-LandCell
- // building channel (CSortCell::find_collisions, Ghidra
- // 0x005340a0, dispatched off the CacheBuilding entry at the
- // building's origin landcell). Registering them here is what
- // put the cottage GfxObj into the radial sweep's reach from
- // the cellar (#98). Interior statics + outdoor stab objects
- // (LandBlockInfo.Objects) remain shadow objects.
- if (entity.IsBuildingShell) break;
-
- var partCached = _physicsDataCache.GetGfxObj(meshRef.GfxObjId);
- if (partCached?.BSP?.Root is null) { partIndex++; continue; }
-
- // Compute the part's world-space position from its transform.
- var partWorld = meshRef.PartTransform * entityRoot;
-
- // Decompose to extract scale (scenery objects have it baked
- // into PartTransform), rotation, and translation.
- System.Numerics.Vector3 partScale3;
- System.Numerics.Quaternion partRot;
- System.Numerics.Vector3 partPos;
- if (System.Numerics.Matrix4x4.Decompose(partWorld,
- out partScale3, out partRot, out partPos))
- { /* decompose succeeded */ }
- else
- {
- partScale3 = System.Numerics.Vector3.One;
- partRot = entity.Rotation;
- partPos = new System.Numerics.Vector3(partWorld.M41, partWorld.M42, partWorld.M43);
- }
-
- // Use uniform scale (X component) — AC objects are uniformly scaled.
- float partScale = partScale3.X;
- if (partScale <= 0f) partScale = 1f;
-
- // Local bounding sphere radius × world scale = world-space radius
- // for the broad phase. The BSPQuery will also use `partScale` to
- // transform player spheres into the unscaled BSP coordinate space.
- float localRadius = partCached.BoundingSphere?.Radius ?? 1f;
- float worldRadius = localRadius * partScale;
-
- // Use a unique sub-ID per part: entity.Id * 256 + partIndex.
- uint partId = entity.Id * 256u + partIndex;
- _physicsEngine.ShadowObjects.Register(
- partId, meshRef.GfxObjId,
- partPos, partRot, worldRadius,
+ _physicsEngine.ShadowObjects.RegisterMultiPart(
+ entity.Id, entity.Position, entity.Rotation, bspShapes,
+ 0u, AcDream.Core.Physics.EntityCollisionFlags.None,
origin.X, origin.Y, lb.LandblockId,
- AcDream.Core.Physics.ShadowCollisionType.BSP, 0f,
- partScale,
- seedCellId: entity.ParentCellId ?? 0u);
+ seedCellId: entity.ParentCellId ?? 0u, isStatic: true);
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
- // partCached?.BSP?.Root non-null was checked above (else `continue`),
- // so hasPhys=true on this path.
- // state/flags literals: landblock-baked scenery has no server PhysicsState
- // broadcast and no PWD bitfield; defaults match static-solid semantics.
+ // One line per BSP part, all under the entity's single id (no synthetic partId).
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
- Console.WriteLine(System.FormattableString.Invariant(
- $"[entity-source] id=0x{partId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{meshRef.GfxObjId:X8} lb=0x{lb.LandblockId:X8} type=BSP note=partIdx={partIndex} hasPhys=true state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}"));
-
- entityBsp++;
- partIndex++;
+ for (int _pi = 0; _pi < bspShapes.Count; _pi++)
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{bspShapes[_pi].GfxObjId:X8} lb=0x{lb.LandblockId:X8} type=BSP note=multipart-part{_pi} hasPhys=true state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}"));
}
// Register collision shapes from the Setup (if this entity has one).
diff --git a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs
index 8cb30123..a0e28ff3 100644
--- a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs
+++ b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Numerics;
+using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
@@ -129,6 +130,84 @@ public static class ShadowShapeBuilder
return result;
}
+ ///
+ /// #185: build BSP shapes for a landblock-baked multi-part entity (buildings,
+ /// stair runs, fences, rock clusters) from its per-part s,
+ /// for registration via
+ /// under the entity's SINGLE unique id.
+ ///
+ ///
+ /// Replaces the former per-part Register(entity.Id * 256u + partIndex)
+ /// (GameWindow.cs) whose * 256u OVERFLOWED uint32 for class-prefixed
+ /// landblock ids (0x40/0x80/0xC0…): the overflow dropped
+ /// the prefix byte, so different-class entities sharing the low 24 bits
+ /// collided on one shadow part-id and Register's deregister-then-insert
+ /// silently overwrote one entity's collision geometry — the #185 "invisible
+ /// wall half-way up the stairs" (rendered steps with no collision).
+ ///
+ ///
+ ///
+ /// Retail anchor: a multi-part object is one CPhysicsObj + CPartArray;
+ /// CPhysicsObj::add_shadows_to_cells (0x00514ae0) → CPartArray::AddPartsShadow
+ /// walks the part array under the single object — no synthetic per-part id.
+ ///
+ ///
+ ///
+ /// Each part's local transform comes from its
+ /// (root-relative), decomposed to LocalPosition/LocalRotation/Scale;
+ /// RegisterMultiPart reconstructs the world placement identically
+ /// (entityWorldPos + rotate(LocalPosition, entityWorldRot)). Building
+ /// shells are excluded — they collide via the per-LandCell building channel
+ /// (CSortCell::find_collisions), not as shadow objects.
+ ///
+ ///
+ /// The entity's per-part mesh references.
+ /// True for LandBlockInfo.Buildings[] shells.
+ /// Resolves a GfxObj id to its cached physics (BSP +
+ /// bounding sphere). Production: id => cache.GetGfxObj(id).
+ public static List FromLandblockBspParts(
+ IReadOnlyList meshRefs,
+ bool isBuildingShell,
+ Func getGfxObj)
+ {
+ if (getGfxObj is null) throw new ArgumentNullException(nameof(getGfxObj));
+
+ var shapes = new List();
+ // Building shells collide via the building channel (retail), not shadow objects.
+ if (isBuildingShell || meshRefs is null) return shapes;
+
+ foreach (var meshRef in meshRefs)
+ {
+ var phys = getGfxObj(meshRef.GfxObjId);
+ if (phys?.BSP?.Root is null) continue; // no physics BSP → nothing to collide
+
+ // PartTransform is root-relative; decompose to local pos/rot/scale.
+ if (!Matrix4x4.Decompose(meshRef.PartTransform,
+ out var pScale, out var pRot, out var pPos))
+ {
+ pScale = Vector3.One;
+ pRot = Quaternion.Identity;
+ pPos = new Vector3(meshRef.PartTransform.M41,
+ meshRef.PartTransform.M42,
+ meshRef.PartTransform.M43);
+ }
+
+ float partScale = pScale.X > 0f ? pScale.X : 1f; // AC objects are uniformly scaled
+ float localRadius = phys.BoundingSphere?.Radius ?? 1f;
+
+ shapes.Add(new ShadowShape(
+ GfxObjId: meshRef.GfxObjId,
+ LocalPosition: pPos,
+ LocalRotation: pRot,
+ Scale: partScale,
+ CollisionType: ShadowCollisionType.BSP,
+ Radius: localRadius * partScale,
+ CylHeight: 0f));
+ }
+
+ return shapes;
+ }
+
/// Resolve the placement frame in priority Resting → Default →
/// first available. Mirrors SetupMesh.Flatten's convention.
private static AnimationFrame? ResolvePlacementFrame(Setup setup)
diff --git a/tests/AcDream.Core.Tests/Fixtures/issue185/0x01000AC5.gfxobj.json b/tests/AcDream.Core.Tests/Fixtures/issue185/0x01000AC5.gfxobj.json
new file mode 100644
index 00000000..d6633e26
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Fixtures/issue185/0x01000AC5.gfxobj.json
@@ -0,0 +1,221 @@
+{
+ "GfxObjId": 16779973,
+ "BoundingSphereOrigin": {
+ "X": -0.0486506,
+ "Y": -0.0466059,
+ "Z": 0.244318
+ },
+ "BoundingSphereRadius": 1.05416,
+ "ResolvedPolygons": [
+ {
+ "Id": 0,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": -1,
+ "Z": 0
+ },
+ "D": -0.75
+ },
+ "Vertices": [
+ {
+ "X": 0.25,
+ "Y": -0.75,
+ "Z": 0.6
+ },
+ {
+ "X": -0.25,
+ "Y": -0.75,
+ "Z": 0.2
+ },
+ {
+ "X": -0.25,
+ "Y": -0.75,
+ "Z": -0.4
+ },
+ {
+ "X": 0.25,
+ "Y": -0.75,
+ "Z": 2.38419E-08
+ }
+ ]
+ },
+ {
+ "Id": 1,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": -1,
+ "Y": 0,
+ "Z": 0
+ },
+ "D": -0.25
+ },
+ "Vertices": [
+ {
+ "X": -0.25,
+ "Y": -0.75,
+ "Z": 0.2
+ },
+ {
+ "X": -0.25,
+ "Y": 0.75,
+ "Z": 0.2
+ },
+ {
+ "X": -0.25,
+ "Y": 0.75,
+ "Z": -0.4
+ },
+ {
+ "X": -0.25,
+ "Y": -0.75,
+ "Z": -0.4
+ }
+ ]
+ },
+ {
+ "Id": 2,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": 1,
+ "Z": 0
+ },
+ "D": -0.75
+ },
+ "Vertices": [
+ {
+ "X": -0.25,
+ "Y": 0.75,
+ "Z": 0.2
+ },
+ {
+ "X": 0.25,
+ "Y": 0.75,
+ "Z": 0.6
+ },
+ {
+ "X": 0.25,
+ "Y": 0.75,
+ "Z": 2.38419E-08
+ },
+ {
+ "X": -0.25,
+ "Y": 0.75,
+ "Z": -0.4
+ }
+ ]
+ },
+ {
+ "Id": 3,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 1,
+ "Y": 0,
+ "Z": 0
+ },
+ "D": -0.25
+ },
+ "Vertices": [
+ {
+ "X": 0.25,
+ "Y": 0.75,
+ "Z": 0.6
+ },
+ {
+ "X": 0.25,
+ "Y": -0.75,
+ "Z": 0.6
+ },
+ {
+ "X": 0.25,
+ "Y": -0.75,
+ "Z": 2.38419E-08
+ },
+ {
+ "X": 0.25,
+ "Y": 0.75,
+ "Z": 2.38419E-08
+ }
+ ]
+ },
+ {
+ "Id": 4,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": -0.62469506,
+ "Y": 0,
+ "Z": 0.78086877
+ },
+ "D": -0.31234753
+ },
+ "Vertices": [
+ {
+ "X": 0.25,
+ "Y": -0.75,
+ "Z": 0.6
+ },
+ {
+ "X": 0.25,
+ "Y": 0.75,
+ "Z": 0.6
+ },
+ {
+ "X": -0.25,
+ "Y": 0.75,
+ "Z": 0.2
+ },
+ {
+ "X": -0.25,
+ "Y": -0.75,
+ "Z": 0.2
+ }
+ ]
+ },
+ {
+ "Id": 5,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0.62469506,
+ "Y": 0,
+ "Z": -0.78086877
+ },
+ "D": -0.15617374
+ },
+ "Vertices": [
+ {
+ "X": -0.25,
+ "Y": 0.75,
+ "Z": -0.4
+ },
+ {
+ "X": 0.25,
+ "Y": 0.75,
+ "Z": 2.38419E-08
+ },
+ {
+ "X": 0.25,
+ "Y": -0.75,
+ "Z": 2.38419E-08
+ },
+ {
+ "X": -0.25,
+ "Y": -0.75,
+ "Z": -0.4
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/AcDream.Core.Tests/Fixtures/issue185/0x01000ACA.gfxobj.json b/tests/AcDream.Core.Tests/Fixtures/issue185/0x01000ACA.gfxobj.json
new file mode 100644
index 00000000..0667d129
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Fixtures/issue185/0x01000ACA.gfxobj.json
@@ -0,0 +1,226 @@
+{
+ "GfxObjId": 16779978,
+ "BoundingSphereOrigin": {
+ "X": -0.125,
+ "Y": 0,
+ "Z": 0.3
+ },
+ "BoundingSphereRadius": 0.900576,
+ "ResolvedPolygons": [
+ {
+ "Id": 0,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": -1,
+ "Z": 0
+ },
+ "D": -0.75
+ },
+ "Vertices": [
+ {
+ "X": 0.25,
+ "Y": -0.75,
+ "Z": 0.6
+ },
+ {
+ "X": -0.5,
+ "Y": -0.75,
+ "Z": 1.78814E-08
+ },
+ {
+ "X": 0.25,
+ "Y": -0.75,
+ "Z": 2.38419E-08
+ }
+ ]
+ },
+ {
+ "Id": 1,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": 1,
+ "Z": 0
+ },
+ "D": -0.75
+ },
+ "Vertices": [
+ {
+ "X": -0.5,
+ "Y": 0.75,
+ "Z": 1.78814E-08
+ },
+ {
+ "X": 0.25,
+ "Y": 0.75,
+ "Z": 0.6
+ },
+ {
+ "X": 0.25,
+ "Y": 0.75,
+ "Z": 2.38419E-08
+ }
+ ]
+ },
+ {
+ "Id": 2,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 1,
+ "Y": 0,
+ "Z": 0
+ },
+ "D": -0.25
+ },
+ "Vertices": [
+ {
+ "X": 0.25,
+ "Y": 0.75,
+ "Z": 0.6
+ },
+ {
+ "X": 0.25,
+ "Y": -0.75,
+ "Z": 0.6
+ },
+ {
+ "X": 0.25,
+ "Y": -0.75,
+ "Z": 2.38419E-08
+ }
+ ]
+ },
+ {
+ "Id": 3,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": -0.62469506,
+ "Y": 0,
+ "Z": 0.7808688
+ },
+ "D": -0.31234753
+ },
+ "Vertices": [
+ {
+ "X": 0.25,
+ "Y": -0.75,
+ "Z": 0.6
+ },
+ {
+ "X": 0.25,
+ "Y": 0.75,
+ "Z": 0.6
+ },
+ {
+ "X": -0.5,
+ "Y": 0.75,
+ "Z": 1.78814E-08
+ },
+ {
+ "X": -0.5,
+ "Y": -0.75,
+ "Z": 1.78814E-08
+ }
+ ]
+ },
+ {
+ "Id": 4,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 7.947333E-09,
+ "Y": 0,
+ "Z": -1
+ },
+ "D": 2.1855065E-08
+ },
+ "Vertices": [
+ {
+ "X": 0.25,
+ "Y": -0.75,
+ "Z": 2.38419E-08
+ },
+ {
+ "X": -0.5,
+ "Y": -0.75,
+ "Z": 1.78814E-08
+ },
+ {
+ "X": -0.5,
+ "Y": 0.75,
+ "Z": 1.78814E-08
+ }
+ ]
+ },
+ {
+ "Id": 5,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 1,
+ "Y": 0,
+ "Z": 0
+ },
+ "D": -0.25
+ },
+ "Vertices": [
+ {
+ "X": 0.25,
+ "Y": -0.75,
+ "Z": 2.38419E-08
+ },
+ {
+ "X": 0.25,
+ "Y": 0.75,
+ "Z": 2.38419E-08
+ },
+ {
+ "X": 0.25,
+ "Y": 0.75,
+ "Z": 0.6
+ }
+ ]
+ },
+ {
+ "Id": 6,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 7.947333E-09,
+ "Y": 0,
+ "Z": -1
+ },
+ "D": 2.1855065E-08
+ },
+ "Vertices": [
+ {
+ "X": -0.5,
+ "Y": 0.75,
+ "Z": 1.78814E-08
+ },
+ {
+ "X": 0.25,
+ "Y": 0.75,
+ "Z": 2.38419E-08
+ },
+ {
+ "X": 0.25,
+ "Y": -0.75,
+ "Z": 2.38419E-08
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs
new file mode 100644
index 00000000..ec210cbf
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs
@@ -0,0 +1,192 @@
+using System;
+using System.IO;
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+using Xunit.Abstractions;
+using Plane = System.Numerics.Plane;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// #185 outdoor-stairs phantom (2026-07-08) — dat-free reproduction of the
+/// house-on-stilts staircase jam. The stairs are a continuous, COPLANAR
+/// 38.7° ramp built from stacked step-box objects (gfxObj 0x01000AC5); the
+/// tread quads abut at 0.5 m seams that fall on object boundaries. Walking
+/// up, at one seam the grounded forward move loses its contact plane and the
+/// step-down recovery cannot reach the coplanar (at-level) continuation, so
+/// EdgeSlideAfterStepDownFailed → PrecipiceSlide fabricates the
+/// tread-edge normal (0,±0.78,±0.62), which SetSlidingNormal
+/// horizontalises to (0,±1,0) — absorbing the +Y up-stairs motion into
+/// a pure sideways slide (the #137 / TS-4 family). A jump's +Z clears it.
+///
+///
+/// Fixtures captured live this session: the player pins at world
+/// (131.71, 77.914, 61.485) with slidingNormal=(0,1,0) and a
+/// re-fabricated collisionNormal=(0,0.78,0.62); the tread the player is
+/// grounded on has world verts (132.75,77.495,61.015)…. The step-box
+/// geometry is hydrated from the captured gfxobj dump
+/// (Fixtures/issue185/0x01000AC5.gfxobj.json) so the test is
+/// self-contained (no dat) and CI-runnable.
+///
+///
+///
+/// The step origins march +0.5 world-Y / +0.4 world-Z (matching every observed
+/// [resolve-bldg] origin: 75.2/75.7/76.2/76.7/77.2). The player's tread
+/// (k=4) is pinned to origin (132.0, 77.245, 60.415) by the captured
+/// walkable verts. k=0..7 are registered to span the jam seam + the
+/// continuation the player must climb onto.
+///
+///
+public class Issue185OutdoorStairsSeamReplayTests
+{
+ private readonly ITestOutputHelper _out;
+ public Issue185OutdoorStairsSeamReplayTests(ITestOutputHelper output) => _out = output;
+
+ private const uint StairCellId = 0xF682002Cu; // outdoor landcell (low16 = 0x2C < 0x100)
+ private const uint StairLandblock = 0xF6820000u;
+ private const uint StepGfxObjId = 0x01000AC5u;
+ private const int StepCount = 8;
+
+ // A +90°-about-Z-rotated step-box maps its local tread normal
+ // (-0.625,0,0.781) → world (0,-0.625,0.781) (the captured tread plane).
+ private static readonly Quaternion StepRot =
+ Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f);
+
+ // k=4 tread lands at the captured walkable verts when the step origin is
+ // (132.0, 77.245, 60.415); step k origin = this + k·(0, 0.5, 0.4), k−4 offset.
+ private static readonly Vector3 Step0Origin = new(132.0f, 75.245f, 58.815f);
+
+ private static PhysicsEngine BuildStairEngine()
+ {
+ var cache = new PhysicsDataCache();
+ var engine = new PhysicsEngine { DataCache = cache };
+
+ // Hydrate the step-box collision geometry from the captured dump.
+ var dumpPath = Path.Combine(SolutionRoot(), "tests", "AcDream.Core.Tests",
+ "Fixtures", "issue185", "0x01000AC5.gfxobj.json");
+ Assert.True(File.Exists(dumpPath), $"Missing fixture: {dumpPath}");
+ var physics = GfxObjDumpSerializer.Hydrate(GfxObjDumpSerializer.Read(dumpPath));
+ cache.RegisterGfxObjForTest(StepGfxObjId, physics);
+ float bspR = physics.BoundingSphere?.Radius ?? 1.06f;
+
+ // Stub landblock (terrain far below Z=61) so the outdoor context resolves
+ // without the player's grounding ever seeing terrain — it stands on the treads.
+ var heights = new byte[81];
+ var heightTable = new float[256];
+ for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
+ engine.AddLandblock(
+ landblockId: StairLandblock,
+ terrain: new TerrainSurface(heights, heightTable),
+ cells: Array.Empty(),
+ portals: Array.Empty(),
+ worldOffsetX: 0f,
+ worldOffsetY: 0f);
+
+ for (int k = 0; k < StepCount; k++)
+ {
+ var origin = Step0Origin + new Vector3(0f, 0.5f * k, 0.4f * k);
+ engine.ShadowObjects.Register(
+ entityId: 0xF6820100u + (uint)k,
+ gfxObjId: StepGfxObjId,
+ worldPos: origin,
+ rotation: StepRot,
+ radius: bspR,
+ worldOffsetX: 0f,
+ worldOffsetY: 0f,
+ landblockId: StairLandblock,
+ collisionType: ShadowCollisionType.BSP,
+ scale: 1.0f,
+ seedCellId: StairCellId);
+ }
+
+ return engine;
+ }
+
+ private static PhysicsBody GroundedOnTread()
+ {
+ var tread = new Plane(new Vector3(0f, -0.62469506f, 0.78086877f), 0.765995f);
+ return new PhysicsBody
+ {
+ Position = new Vector3(131.72375f, 77.49132f, 61.146755f),
+ Orientation = Quaternion.Identity,
+ ContactPlaneValid = true,
+ ContactPlane = tread,
+ ContactPlaneCellId = StairCellId,
+ WalkablePolygonValid = true,
+ WalkablePlane = tread,
+ WalkableUp = Vector3.UnitZ,
+ WalkableVertices = new[]
+ {
+ new Vector3(132.75f, 77.495f, 61.015f),
+ new Vector3(131.25f, 77.495f, 61.015f),
+ new Vector3(131.25f, 76.995f, 60.615f),
+ new Vector3(132.75f, 76.995f, 60.615f),
+ },
+ TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
+ };
+ }
+
+ ///
+ /// Drive a held-forward run up the ramp (flat +Y target each tick, physics
+ /// climbs Z via contact projection — as the movement controller sends it).
+ /// The player must climb PAST the tread seam (Y > 78.1); pre-fix it pins
+ /// at ~77.9 and persists a horizontal sliding normal = the wedge.
+ ///
+ [Fact]
+ public void OutdoorStairs_ForwardRun_ClimbsPastSeam_NoWedge()
+ {
+ var engine = BuildStairEngine();
+ var body = GroundedOnTread();
+
+ var pos = body.Position;
+ uint cell = StairCellId;
+ ResolveResult r = default;
+
+ for (int i = 0; i < 12; i++)
+ {
+ var target = new Vector3(pos.X, pos.Y + 0.2f, pos.Z); // flat +Y; physics climbs
+ r = engine.ResolveWithTransition(
+ currentPos: pos,
+ targetPos: target,
+ cellId: cell,
+ sphereRadius: 0.48f,
+ sphereHeight: 1.835f,
+ stepUpHeight: 0.6f,
+ stepDownHeight: 1.5f,
+ isOnGround: true,
+ body: body,
+ moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
+ movingEntityId: 0x01000000u);
+
+ _out.WriteLine(
+ $"f{i}: out=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) cell=0x{r.CellId:X8} " +
+ $"cnV={r.CollisionNormalValid} cn=({r.CollisionNormal.X:F2},{r.CollisionNormal.Y:F2},{r.CollisionNormal.Z:F2}) " +
+ $"sliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)} " +
+ $"sN=({body.SlidingNormal.X:F2},{body.SlidingNormal.Y:F2},{body.SlidingNormal.Z:F2})");
+
+ pos = r.Position;
+ cell = r.CellId;
+ body.Position = pos;
+ }
+
+ Assert.True(pos.Y > 78.10f,
+ $"Player must climb past the tread seam (reached Y={pos.Y:F3}); pinned at ~77.9 = the " +
+ $"#185 fabricated-precipice wedge (PrecipiceSlide horizontal sliding normal absorbs +Y).");
+ Assert.False(body.TransientState.HasFlag(TransientStateFlags.Sliding),
+ "A continuous walkable ramp seam must not persist a horizontal sliding normal (#137 family).");
+ }
+
+ private static string SolutionRoot()
+ {
+ var dir = AppContext.BaseDirectory;
+ while (!string.IsNullOrEmpty(dir))
+ {
+ if (File.Exists(Path.Combine(dir, "AcDream.slnx")))
+ return dir;
+ dir = Path.GetDirectoryName(dir);
+ }
+ throw new InvalidOperationException(
+ "Could not locate AcDream.slnx from " + AppContext.BaseDirectory);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/ShadowRegistrationOverflowTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowRegistrationOverflowTests.cs
new file mode 100644
index 00000000..5ea131ff
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/ShadowRegistrationOverflowTests.cs
@@ -0,0 +1,147 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using AcDream.Core.Physics;
+using AcDream.Core.World;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// #185 (2026-07-08) — the outdoor-stairs "invisible wall" root cause: the
+/// former per-part landblock shadow registration used a synthetic part-id
+/// entity.Id * 256u + partIndex that OVERFLOWED uint32 for class-prefixed
+/// landblock ids (0x40/0x80/0xC0…). The << 8 dropped the
+/// prefix byte, so different-class entities sharing the low 24 bits collided on
+/// one shadow part-id and Register's deregister-then-insert silently
+/// overwrote one entity's collision geometry — rendered stair steps with NO
+/// collision. The fix registers each multi-part entity via
+/// under its UNIQUE 32-bit
+/// entity.Id (retail add_shadows_to_cells / CPartArray::AddPartsShadow).
+///
+public class ShadowRegistrationOverflowTests
+{
+ // Two real stair-entity ids from the #185 capture that share the low 24 bits
+ // (0xF68221) but differ in the class-prefix byte.
+ private const uint EntityA = 0x40F68221u;
+ private const uint EntityB = 0xC0F68221u;
+
+ private const uint LbId = 0xF6820000u;
+ private const float OffX = 0f, OffY = 0f;
+
+ // ── The root cause, as pure arithmetic ────────────────────────────────
+
+ [Fact]
+ public void OldPartIdScheme_OverflowsUint32_AndCollides()
+ {
+ // entity.Id * 256u == entity.Id << 8, truncated to 32 bits → the prefix
+ // byte falls off the top and the two distinct entities alias.
+ uint oldPartA = unchecked(EntityA * 256u); // 0x40F68221 << 8 → 0xF6822100
+ uint oldPartB = unchecked(EntityB * 256u); // 0xC0F68221 << 8 → 0xF6822100
+ Assert.Equal(0xF6822100u, oldPartA);
+ Assert.Equal(oldPartA, oldPartB); // COLLISION = the #185 bug
+ Assert.NotEqual(EntityA, EntityB); // …yet the entities ARE distinct
+ }
+
+ // ── The bug: old per-part Register loses one registration ─────────────
+
+ private static ShadowShape Cyl(Vector3 local) => new(
+ GfxObjId: 0u, LocalPosition: local, LocalRotation: Quaternion.Identity,
+ Scale: 1f, CollisionType: ShadowCollisionType.Cylinder, Radius: 1f, CylHeight: 2f);
+
+ [Fact]
+ public void OldPerPartRegister_CollidingIds_SecondSilentlyOverwritesFirst()
+ {
+ var reg = new ShadowObjectRegistry();
+
+ // Two DIFFERENT physical objects at DIFFERENT cells, registered the OLD way
+ // (Register with the synthetic overflowing part-id). EntityA at cell (0,0),
+ // EntityB at cell (1,0) — 30 m apart in X.
+ var posA = new Vector3(12f, 12f, 50f); // → cell LbId|1
+ var posB = new Vector3(42f, 12f, 50f); // → cell LbId|9
+
+ reg.Register(unchecked(EntityA * 256u), 0u, posA, Quaternion.Identity, 1f,
+ OffX, OffY, LbId, ShadowCollisionType.Cylinder, 2f);
+ reg.Register(unchecked(EntityB * 256u), 0u, posB, Quaternion.Identity, 1f,
+ OffX, OffY, LbId, ShadowCollisionType.Cylinder, 2f);
+
+ // EntityA's registration is GONE (its part-id was reused by EntityB): its
+ // cell is empty. This is exactly the missing stair-step collision.
+ Assert.Empty(reg.GetObjectsInCell(LbId | 1u));
+ Assert.NotEmpty(reg.GetObjectsInCell(LbId | 9u));
+ Assert.Equal(1, reg.TotalRegistered); // one silently lost
+ }
+
+ // ── The fix: RegisterMultiPart keys on the unique entity.Id ───────────
+
+ [Fact]
+ public void RegisterMultiPart_CollidingLowBitsIds_BothSurvive()
+ {
+ var reg = new ShadowObjectRegistry();
+
+ var posA = new Vector3(12f, 12f, 50f); // → cell LbId|1
+ var posB = new Vector3(42f, 12f, 50f); // → cell LbId|9
+
+ reg.RegisterMultiPart(EntityA, posA, Quaternion.Identity,
+ new[] { Cyl(Vector3.Zero) }, 0u, EntityCollisionFlags.None,
+ OffX, OffY, LbId, isStatic: true);
+ reg.RegisterMultiPart(EntityB, posB, Quaternion.Identity,
+ new[] { Cyl(Vector3.Zero) }, 0u, EntityCollisionFlags.None,
+ OffX, OffY, LbId, isStatic: true);
+
+ // Both distinct entities survive at their own cells — no overflow collision.
+ Assert.Contains(reg.GetObjectsInCell(LbId | 1u), e => e.EntityId == EntityA);
+ Assert.Contains(reg.GetObjectsInCell(LbId | 9u), e => e.EntityId == EntityB);
+ Assert.Equal(2, reg.TotalRegistered);
+ }
+
+ // ── The builder: one BSP shape per BSP part; shells + no-BSP excluded ──
+
+ private static GfxObjPhysics BspGfx(float radius)
+ {
+ var leaf = new PhysicsBSPNode { Type = BSPNodeType.Leaf };
+ return new GfxObjPhysics
+ {
+ BSP = new PhysicsBSPTree { Root = leaf },
+ BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = radius },
+ Resolved = new Dictionary(),
+ PhysicsPolygons = new Dictionary(),
+ Vertices = new VertexArray(),
+ };
+ }
+
+ [Fact]
+ public void FromLandblockBspParts_OneShapePerBspPart_LocalTransformPreserved()
+ {
+ var meshRefs = new[]
+ {
+ new MeshRef(0x01000AC5u, Matrix4x4.CreateTranslation(0f, 0.5f, 0.4f)),
+ new MeshRef(0x01000AC5u, Matrix4x4.CreateTranslation(0f, 1.0f, 0.8f)),
+ new MeshRef(0x0BADBADu, Matrix4x4.Identity), // no physics BSP → skipped
+ };
+
+ var shapes = ShadowShapeBuilder.FromLandblockBspParts(
+ meshRefs, isBuildingShell: false,
+ getGfxObj: id => id == 0x01000AC5u ? BspGfx(1.05f) : null);
+
+ Assert.Equal(2, shapes.Count); // only the two BSP-bearing parts
+ Assert.All(shapes, s => Assert.Equal(ShadowCollisionType.BSP, s.CollisionType));
+ Assert.All(shapes, s => Assert.Equal(0x01000AC5u, s.GfxObjId));
+ // Local part offsets survive (decomposed from PartTransform).
+ Assert.Contains(shapes, s => Vector3.Distance(s.LocalPosition, new Vector3(0f, 0.5f, 0.4f)) < 1e-4f);
+ Assert.Contains(shapes, s => Vector3.Distance(s.LocalPosition, new Vector3(0f, 1.0f, 0.8f)) < 1e-4f);
+ // Radius = local BoundingSphere radius × part scale (1.0).
+ Assert.All(shapes, s => Assert.Equal(1.05f, s.Radius, 3));
+ }
+
+ [Fact]
+ public void FromLandblockBspParts_BuildingShell_ReturnsEmpty()
+ {
+ var meshRefs = new[] { new MeshRef(0x01000AC5u, Matrix4x4.Identity) };
+ var shapes = ShadowShapeBuilder.FromLandblockBspParts(
+ meshRefs, isBuildingShell: true, getGfxObj: _ => BspGfx(1f));
+ Assert.Empty(shapes); // building shells collide via the building channel
+ }
+}