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.
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <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 →
|
||||
/// first available. Mirrors <c>SetupMesh.Flatten</c>'s convention.</summary>
|
||||
private static AnimationFrame? ResolvePlacementFrame(Setup setup)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue