acdream/tests/AcDream.Core.Tests/Physics/ShadowRegistrationOverflowTests.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

147 lines
7 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.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
}
}