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:
Erik 2026-07-08 10:07:50 +02:00
parent b62b13ce21
commit 07c5b832cf
6 changed files with 897 additions and 67 deletions

View file

@ -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)