fix(#185): landblock collision part-id uint32 overflow dropped stair steps

Root cause (live capture #3 + code): GameWindow's per-part landblock shadow
registration used a synthetic part-id `entity.Id * 256u + partIndex` that
OVERFLOWS uint32 for class-prefixed landblock ids (0x40/0x80/0xC0...). The << 8
drops the prefix byte, so different-class entities sharing the low 24 bits
collide on ONE shadow part-id and Register's deregister-then-insert silently
overwrites one entity's collision geometry. Landblock 0xF682 had 23 such
collisions incl. the stair runs (0xF6822100 <- {0x40F68221, 0xC0F68221}, ...),
so 3 mid-staircase steps rendered but had no collision -> the player floats into
the hole and the (faithful) PrecipiceSlide wedge fires = the 'invisible wall
half-way up the stairs'.

Fix (Option A, retail-faithful): register each multi-part landblock entity via
ShadowObjectRegistry.RegisterMultiPart under its UNIQUE 32-bit entity.Id
(retail CPhysicsObj::add_shadows_to_cells 0x00514ae0 -> CPartArray::AddPartsShadow
- one object, a part array; no synthetic per-part id). New testable builder
ShadowShapeBuilder.FromLandblockBspParts decomposes each MeshRef.PartTransform to
local pos/rot/scale; RegisterMultiPart reconstructs the identical world placement.
Building shells stay excluded (building channel); the Setup cyl/sphere path is
unchanged (runs only when entityBsp==0, retail BSP-xor-cyl dispatch). Despawn is
landblock-scoped (RemoveLandblock by cell prefix), so the id change is safe.

Tests: ShadowRegistrationOverflowTests (overflow arithmetic; old scheme drops one;
RegisterMultiPart keeps both; builder). Issue185OutdoorStairsSeamReplayTests
(dat-free clean-climb pin). Core 2629 / App 741 green, 0 warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-08 10:07:50 +02:00
parent b62b13ce21
commit 07c5b832cf
6 changed files with 897 additions and 67 deletions

View file

@ -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;
/// <summary>
/// #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
/// <c>EdgeSlideAfterStepDownFailed</c> → <c>PrecipiceSlide</c> fabricates the
/// tread-edge normal <c>(0,±0.78,±0.62)</c>, which <c>SetSlidingNormal</c>
/// horizontalises to <c>(0,±1,0)</c> — absorbing the +Y up-stairs motion into
/// a pure sideways slide (the #137 / TS-4 family). A jump's +Z clears it.
///
/// <para>
/// Fixtures captured live this session: the player pins at world
/// <c>(131.71, 77.914, 61.485)</c> with <c>slidingNormal=(0,1,0)</c> and a
/// re-fabricated <c>collisionNormal=(0,0.78,0.62)</c>; the tread the player is
/// grounded on has world verts <c>(132.75,77.495,61.015)…</c>. The step-box
/// geometry is hydrated from the captured gfxobj dump
/// (<c>Fixtures/issue185/0x01000AC5.gfxobj.json</c>) so the test is
/// self-contained (no dat) and CI-runnable.
/// </para>
///
/// <para>
/// The step origins march +0.5 world-Y / +0.4 world-Z (matching every observed
/// <c>[resolve-bldg]</c> origin: 75.2/75.7/76.2/76.7/77.2). The player's tread
/// (k=4) is pinned to origin <c>(132.0, 77.245, 60.415)</c> by the captured
/// walkable verts. k=0..7 are registered to span the jam seam + the
/// continuation the player must climb onto.
/// </para>
/// </summary>
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), k4 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<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
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,
};
}
/// <summary>
/// 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 &gt; 78.1); pre-fix it pins
/// at ~77.9 and persists a horizontal sliding normal = the wedge.
/// </summary>
[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);
}
}

View file

@ -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;
/// <summary>
/// #185 (2026-07-08) — the outdoor-stairs "invisible wall" root cause: the
/// former per-part landblock shadow registration used a synthetic part-id
/// <c>entity.Id * 256u + partIndex</c> that OVERFLOWED uint32 for class-prefixed
/// landblock ids (<c>0x40</c>/<c>0x80</c>/<c>0xC0</c>…). The <c>&lt;&lt; 8</c> dropped the
/// prefix byte, so different-class entities sharing the low 24 bits collided on
/// one shadow part-id and <c>Register</c>'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
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> under its UNIQUE 32-bit
/// <c>entity.Id</c> (retail <c>add_shadows_to_cells</c> / <c>CPartArray::AddPartsShadow</c>).
/// </summary>
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<ushort, ResolvedPolygon>(),
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
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
}
}