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>
147 lines
7 KiB
C#
147 lines
7 KiB
C#
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><< 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
|
||
}
|
||
}
|