From 07c5b832cf00c0b06899ba48a6f383e9fb2127ef Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 8 Jul 2026 10:07:50 +0200 Subject: [PATCH] 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 --- src/AcDream.App/Rendering/GameWindow.cs | 99 +++----- .../Physics/ShadowShapeBuilder.cs | 79 ++++++ .../Fixtures/issue185/0x01000AC5.gfxobj.json | 221 +++++++++++++++++ .../Fixtures/issue185/0x01000ACA.gfxobj.json | 226 ++++++++++++++++++ .../Issue185OutdoorStairsSeamReplayTests.cs | 192 +++++++++++++++ .../ShadowRegistrationOverflowTests.cs | 147 ++++++++++++ 6 files changed, 897 insertions(+), 67 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Fixtures/issue185/0x01000AC5.gfxobj.json create mode 100644 tests/AcDream.Core.Tests/Fixtures/issue185/0x01000ACA.gfxobj.json create mode 100644 tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs create mode 100644 tests/AcDream.Core.Tests/Physics/ShadowRegistrationOverflowTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 18804ab9..4cd41649 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7896,77 +7896,42 @@ public sealed class GameWindow : IDisposable // docs/superpowers/specs/2026-05-21-cylinder-fallback-dedup-design.md. bool _isLandblockStab = (entity.Id & 0xFF000000u) == 0xC0000000u; if (_isScenery) scTried++; - // Register EACH physics-enabled part so multi-part Setups - // (buildings, trees) have all their collision geometry registered. - // Each part gets its own ShadowEntry with its world-space transform. - var entityRoot = - System.Numerics.Matrix4x4.CreateFromQuaternion(entity.Rotation) * - System.Numerics.Matrix4x4.CreateTranslation(entity.Position); - - uint partIndex = 0; - foreach (var meshRef in entity.MeshRefs) + // #185 FIX (2026-07-08): register the entity's BSP parts as ONE + // multi-part shadow object keyed on the entity's UNIQUE 32-bit id, + // via RegisterMultiPart (retail CPhysicsObj::add_shadows_to_cells + // 0x00514ae0 → CPartArray::AddPartsShadow — one object, a part array). + // + // The former per-part Register(entity.Id * 256u + partIndex) OVERFLOWED + // uint32 for class-prefixed landblock ids (0x40/0x80/0xC0…): the << 8 + // dropped the prefix byte, so different-class entities sharing the low + // 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 - // objects in retail — their collision is the per-LandCell - // building channel (CSortCell::find_collisions, Ghidra - // 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, + _physicsEngine.ShadowObjects.RegisterMultiPart( + entity.Id, entity.Position, entity.Rotation, bspShapes, + 0u, AcDream.Core.Physics.EntityCollisionFlags.None, origin.X, origin.Y, lb.LandblockId, - AcDream.Core.Physics.ShadowCollisionType.BSP, 0f, - partScale, - seedCellId: entity.ParentCellId ?? 0u); + seedCellId: entity.ParentCellId ?? 0u, isStatic: true); // L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg]. - // partCached?.BSP?.Root non-null was checked above (else `continue`), - // 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. + // One line per BSP part, all under the entity's single id (no synthetic partId). if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) - Console.WriteLine(System.FormattableString.Invariant( - $"[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}")); - - entityBsp++; - partIndex++; + for (int _pi = 0; _pi < bspShapes.Count; _pi++) + 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}")); } // Register collision shapes from the Setup (if this entity has one). diff --git a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs index 8cb30123..a0e28ff3 100644 --- a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs +++ b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Numerics; +using AcDream.Core.World; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; @@ -129,6 +130,84 @@ public static class ShadowShapeBuilder return result; } + /// + /// #185: build BSP shapes for a landblock-baked multi-part entity (buildings, + /// stair runs, fences, rock clusters) from its per-part s, + /// for registration via + /// under the entity's SINGLE unique id. + /// + /// + /// Replaces the former per-part Register(entity.Id * 256u + partIndex) + /// (GameWindow.cs) whose * 256u OVERFLOWED uint32 for class-prefixed + /// landblock ids (0x40/0x80/0xC0…): the overflow dropped + /// the prefix byte, so different-class entities sharing the low 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). + /// + /// + /// + /// Retail anchor: a multi-part object is one CPhysicsObj + CPartArray; + /// CPhysicsObj::add_shadows_to_cells (0x00514ae0) → CPartArray::AddPartsShadow + /// walks the part array under the single object — no synthetic per-part id. + /// + /// + /// + /// Each part's local transform comes from its + /// (root-relative), decomposed to LocalPosition/LocalRotation/Scale; + /// RegisterMultiPart reconstructs the world placement identically + /// (entityWorldPos + rotate(LocalPosition, entityWorldRot)). Building + /// shells are excluded — they collide via the per-LandCell building channel + /// (CSortCell::find_collisions), not as shadow objects. + /// + /// + /// The entity's per-part mesh references. + /// True for LandBlockInfo.Buildings[] shells. + /// Resolves a GfxObj id to its cached physics (BSP + + /// bounding sphere). Production: id => cache.GetGfxObj(id). + public static List FromLandblockBspParts( + IReadOnlyList meshRefs, + bool isBuildingShell, + Func getGfxObj) + { + if (getGfxObj is null) throw new ArgumentNullException(nameof(getGfxObj)); + + var shapes = new List(); + // 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; + } + /// Resolve the placement frame in priority Resting → Default → /// first available. Mirrors SetupMesh.Flatten's convention. private static AnimationFrame? ResolvePlacementFrame(Setup setup) diff --git a/tests/AcDream.Core.Tests/Fixtures/issue185/0x01000AC5.gfxobj.json b/tests/AcDream.Core.Tests/Fixtures/issue185/0x01000AC5.gfxobj.json new file mode 100644 index 00000000..d6633e26 --- /dev/null +++ b/tests/AcDream.Core.Tests/Fixtures/issue185/0x01000AC5.gfxobj.json @@ -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 + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/AcDream.Core.Tests/Fixtures/issue185/0x01000ACA.gfxobj.json b/tests/AcDream.Core.Tests/Fixtures/issue185/0x01000ACA.gfxobj.json new file mode 100644 index 00000000..0667d129 --- /dev/null +++ b/tests/AcDream.Core.Tests/Fixtures/issue185/0x01000ACA.gfxobj.json @@ -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 + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs new file mode 100644 index 00000000..ec210cbf --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs @@ -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; + +/// +/// #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 +/// EdgeSlideAfterStepDownFailedPrecipiceSlide fabricates the +/// tread-edge normal (0,±0.78,±0.62), which SetSlidingNormal +/// horizontalises to (0,±1,0) — absorbing the +Y up-stairs motion into +/// a pure sideways slide (the #137 / TS-4 family). A jump's +Z clears it. +/// +/// +/// Fixtures captured live this session: the player pins at world +/// (131.71, 77.914, 61.485) with slidingNormal=(0,1,0) and a +/// re-fabricated collisionNormal=(0,0.78,0.62); the tread the player is +/// grounded on has world verts (132.75,77.495,61.015)…. The step-box +/// geometry is hydrated from the captured gfxobj dump +/// (Fixtures/issue185/0x01000AC5.gfxobj.json) so the test is +/// self-contained (no dat) and CI-runnable. +/// +/// +/// +/// The step origins march +0.5 world-Y / +0.4 world-Z (matching every observed +/// [resolve-bldg] origin: 75.2/75.7/76.2/76.7/77.2). The player's tread +/// (k=4) is pinned to origin (132.0, 77.245, 60.415) by the captured +/// walkable verts. k=0..7 are registered to span the jam seam + the +/// continuation the player must climb onto. +/// +/// +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(), + portals: Array.Empty(), + 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, + }; + } + + /// + /// 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. + /// + [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); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/ShadowRegistrationOverflowTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowRegistrationOverflowTests.cs new file mode 100644 index 00000000..5ea131ff --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/ShadowRegistrationOverflowTests.cs @@ -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; + +/// +/// #185 (2026-07-08) — the outdoor-stairs "invisible wall" root cause: the +/// former per-part landblock shadow registration used a synthetic part-id +/// entity.Id * 256u + partIndex that OVERFLOWED uint32 for class-prefixed +/// landblock ids (0x40/0x80/0xC0…). The << 8 dropped the +/// prefix byte, so different-class entities sharing the low 24 bits collided on +/// one shadow part-id and Register'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 +/// under its UNIQUE 32-bit +/// entity.Id (retail add_shadows_to_cells / CPartArray::AddPartsShadow). +/// +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(), + PhysicsPolygons = new Dictionary(), + 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 + } +}