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:
parent
b62b13ce21
commit
07c5b832cf
6 changed files with 897 additions and 67 deletions
|
|
@ -7896,77 +7896,42 @@ public sealed class GameWindow : IDisposable
|
||||||
// docs/superpowers/specs/2026-05-21-cylinder-fallback-dedup-design.md.
|
// docs/superpowers/specs/2026-05-21-cylinder-fallback-dedup-design.md.
|
||||||
bool _isLandblockStab = (entity.Id & 0xFF000000u) == 0xC0000000u;
|
bool _isLandblockStab = (entity.Id & 0xFF000000u) == 0xC0000000u;
|
||||||
if (_isScenery) scTried++;
|
if (_isScenery) scTried++;
|
||||||
// Register EACH physics-enabled part so multi-part Setups
|
// #185 FIX (2026-07-08): register the entity's BSP parts as ONE
|
||||||
// (buildings, trees) have all their collision geometry registered.
|
// multi-part shadow object keyed on the entity's UNIQUE 32-bit id,
|
||||||
// Each part gets its own ShadowEntry with its world-space transform.
|
// via RegisterMultiPart (retail CPhysicsObj::add_shadows_to_cells
|
||||||
var entityRoot =
|
// 0x00514ae0 → CPartArray::AddPartsShadow — one object, a part array).
|
||||||
System.Numerics.Matrix4x4.CreateFromQuaternion(entity.Rotation) *
|
//
|
||||||
System.Numerics.Matrix4x4.CreateTranslation(entity.Position);
|
// The former per-part Register(entity.Id * 256u + partIndex) OVERFLOWED
|
||||||
|
// uint32 for class-prefixed landblock ids (0x40/0x80/0xC0…): the << 8
|
||||||
uint partIndex = 0;
|
// dropped the prefix byte, so different-class entities sharing the low
|
||||||
foreach (var meshRef in entity.MeshRefs)
|
// 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
|
_physicsEngine.ShadowObjects.RegisterMultiPart(
|
||||||
// objects in retail — their collision is the per-LandCell
|
entity.Id, entity.Position, entity.Rotation, bspShapes,
|
||||||
// building channel (CSortCell::find_collisions, Ghidra
|
0u, AcDream.Core.Physics.EntityCollisionFlags.None,
|
||||||
// 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,
|
|
||||||
origin.X, origin.Y, lb.LandblockId,
|
origin.X, origin.Y, lb.LandblockId,
|
||||||
AcDream.Core.Physics.ShadowCollisionType.BSP, 0f,
|
seedCellId: entity.ParentCellId ?? 0u, isStatic: true);
|
||||||
partScale,
|
|
||||||
seedCellId: entity.ParentCellId ?? 0u);
|
|
||||||
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
|
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
|
||||||
// partCached?.BSP?.Root non-null was checked above (else `continue`),
|
// One line per BSP part, all under the entity's single id (no synthetic partId).
|
||||||
// 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.
|
|
||||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||||
Console.WriteLine(System.FormattableString.Invariant(
|
for (int _pi = 0; _pi < bspShapes.Count; _pi++)
|
||||||
$"[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}"));
|
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}"));
|
||||||
entityBsp++;
|
|
||||||
partIndex++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register collision shapes from the Setup (if this entity has one).
|
// Register collision shapes from the Setup (if this entity has one).
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
using AcDream.Core.World;
|
||||||
using DatReaderWriter.DBObjs;
|
using DatReaderWriter.DBObjs;
|
||||||
using DatReaderWriter.Enums;
|
using DatReaderWriter.Enums;
|
||||||
using DatReaderWriter.Types;
|
using DatReaderWriter.Types;
|
||||||
|
|
@ -129,6 +130,84 @@ public static class ShadowShapeBuilder
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #185: build BSP shapes for a landblock-baked multi-part entity (buildings,
|
||||||
|
/// stair runs, fences, rock clusters) from its per-part <see cref="MeshRef"/>s,
|
||||||
|
/// for registration via <see cref="ShadowObjectRegistry.RegisterMultiPart"/>
|
||||||
|
/// under the entity's SINGLE unique id.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Replaces the former per-part <c>Register(entity.Id * 256u + partIndex)</c>
|
||||||
|
/// (GameWindow.cs) whose <c>* 256u</c> OVERFLOWED uint32 for class-prefixed
|
||||||
|
/// landblock ids (<c>0x40</c>/<c>0x80</c>/<c>0xC0</c>…): the overflow 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 — the #185 "invisible
|
||||||
|
/// wall half-way up the stairs" (rendered steps with no collision).
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Retail anchor: a multi-part object is one <c>CPhysicsObj</c> + <c>CPartArray</c>;
|
||||||
|
/// <c>CPhysicsObj::add_shadows_to_cells</c> (0x00514ae0) → <c>CPartArray::AddPartsShadow</c>
|
||||||
|
/// walks the part array under the single object — no synthetic per-part id.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Each part's local transform comes from its <see cref="MeshRef.PartTransform"/>
|
||||||
|
/// (root-relative), decomposed to LocalPosition/LocalRotation/Scale;
|
||||||
|
/// <c>RegisterMultiPart</c> reconstructs the world placement identically
|
||||||
|
/// (<c>entityWorldPos + rotate(LocalPosition, entityWorldRot)</c>). Building
|
||||||
|
/// shells are excluded — they collide via the per-LandCell building channel
|
||||||
|
/// (<c>CSortCell::find_collisions</c>), not as shadow objects.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="meshRefs">The entity's per-part mesh references.</param>
|
||||||
|
/// <param name="isBuildingShell">True for <c>LandBlockInfo.Buildings[]</c> shells.</param>
|
||||||
|
/// <param name="getGfxObj">Resolves a GfxObj id to its cached physics (BSP +
|
||||||
|
/// bounding sphere). Production: <c>id => cache.GetGfxObj(id)</c>.</param>
|
||||||
|
public static List<ShadowShape> FromLandblockBspParts(
|
||||||
|
IReadOnlyList<MeshRef> meshRefs,
|
||||||
|
bool isBuildingShell,
|
||||||
|
Func<uint, GfxObjPhysics?> getGfxObj)
|
||||||
|
{
|
||||||
|
if (getGfxObj is null) throw new ArgumentNullException(nameof(getGfxObj));
|
||||||
|
|
||||||
|
var shapes = new List<ShadowShape>();
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Resolve the placement frame in priority Resting → Default →
|
/// <summary>Resolve the placement frame in priority Resting → Default →
|
||||||
/// first available. Mirrors <c>SetupMesh.Flatten</c>'s convention.</summary>
|
/// first available. Mirrors <c>SetupMesh.Flatten</c>'s convention.</summary>
|
||||||
private static AnimationFrame? ResolvePlacementFrame(Setup setup)
|
private static AnimationFrame? ResolvePlacementFrame(Setup setup)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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), 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<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 > 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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><< 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue