acdream/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs
Erik 07c5b832cf 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>
2026-07-08 10:07:50 +02:00

192 lines
8.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}