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>
220 lines
10 KiB
C#
220 lines
10 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using AcDream.Core.World;
|
||
using DatReaderWriter.DBObjs;
|
||
using DatReaderWriter.Enums;
|
||
using DatReaderWriter.Types;
|
||
|
||
namespace AcDream.Core.Physics;
|
||
|
||
/// <summary>
|
||
/// Pure-function builder that translates a <see cref="Setup"/> into a list of
|
||
/// <see cref="ShadowShape"/>s suitable for registration via
|
||
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/>.
|
||
///
|
||
/// <para>
|
||
/// Walks (1) every CylSphere → Cylinder shape, (2) every Sphere ONLY when no
|
||
/// CylSpheres are present (matches retail and the existing landblock-static
|
||
/// convention at GameWindow.cs:6034), and (3) every Part whose GfxObj has a
|
||
/// non-null PhysicsBSP → per-part BSP shape, with local transforms from
|
||
/// PlacementFrames[Resting | Default | first available].
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Retail anchor: <c>CPhysicsObj::FindObjCollisions</c> calls
|
||
/// <c>CPartArray::FindObjCollisions</c> which iterates parts; each part's
|
||
/// <c>find_obj_collisions</c> tests CylSpheres + GfxObj BSP. We emit one
|
||
/// ShadowShape per part contribution so the existing FindObjCollisions
|
||
/// iteration loop in <see cref="Transition"/> tests each part independently.
|
||
/// </para>
|
||
/// </summary>
|
||
public static class ShadowShapeBuilder
|
||
{
|
||
/// <summary>
|
||
/// Build the shape list for a Setup.
|
||
/// </summary>
|
||
/// <param name="setup">The Setup to walk.</param>
|
||
/// <param name="entScale">The entity's overall scale factor; multiplies
|
||
/// every radius, height, and local offset.</param>
|
||
/// <param name="hasPhysicsBsp">Predicate: does the GfxObj with this id
|
||
/// have a non-null PhysicsBSP? Production: <c>id => cache.GetGfxObj(id)?.BSP?.Root is not null</c>.</param>
|
||
/// <param name="partPoseOverride">#175: per-part pose override for the
|
||
/// BSP part shapes — the entity's motion-table DEFAULT-STATE pose (the
|
||
/// closed pose for doors). Retail collision tests each part's LIVE
|
||
/// <c>CPhysicsPart</c> pose, which for an idle entity is the motion
|
||
/// table's default state, NOT the Setup's placement frame — the two
|
||
/// differ on e.g. the Facility Hub double door (Setup 0x02000C9D:
|
||
/// placement poses the panels AJAR at yaw −150°/−30°, y −0.44 m; the
|
||
/// closed pose is straight). Null / short lists fall back to the
|
||
/// placement frame per part (entities with no motion table, and the
|
||
/// CylSphere/Sphere shapes, are unaffected — retail poses those from
|
||
/// the setup too).</param>
|
||
public static IReadOnlyList<ShadowShape> FromSetup(
|
||
Setup setup,
|
||
float entScale,
|
||
Func<uint, bool> hasPhysicsBsp,
|
||
IReadOnlyList<Frame>? partPoseOverride = null)
|
||
{
|
||
if (setup is null) throw new ArgumentNullException(nameof(setup));
|
||
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
|
||
|
||
var result = new List<ShadowShape>();
|
||
|
||
// 1. CylSpheres — each becomes a Cylinder shape.
|
||
foreach (var cyl in setup.CylSpheres)
|
||
{
|
||
if (cyl.Radius <= 0f) continue;
|
||
float baseHeight = cyl.Height > 0f ? cyl.Height : cyl.Radius * 4f;
|
||
result.Add(new ShadowShape(
|
||
GfxObjId: 0u,
|
||
LocalPosition: new Vector3(cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z) * entScale,
|
||
LocalRotation: Quaternion.Identity,
|
||
Scale: entScale,
|
||
CollisionType: ShadowCollisionType.Cylinder,
|
||
Radius: cyl.Radius * entScale,
|
||
CylHeight: baseHeight * entScale));
|
||
}
|
||
|
||
// 2. Spheres — only when no CylSpheres (matches landblock-static convention
|
||
// at GameWindow.cs:6034). Each becomes a true Sphere (no height clamping).
|
||
// Retail anchor: CSphere::intersects_sphere @ 0x00537A80 uses 3-D distance
|
||
// for the overlap check, unlike CCylSphere which clips to [low_pt, high_pt].
|
||
if (setup.CylSpheres.Count == 0)
|
||
{
|
||
foreach (var sph in setup.Spheres)
|
||
{
|
||
if (sph.Radius <= 0f) continue;
|
||
result.Add(new ShadowShape(
|
||
GfxObjId: 0u,
|
||
LocalPosition: new Vector3(sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale,
|
||
LocalRotation: Quaternion.Identity,
|
||
Scale: entScale,
|
||
CollisionType: ShadowCollisionType.Sphere,
|
||
Radius: sph.Radius * entScale,
|
||
CylHeight: 0f));
|
||
}
|
||
}
|
||
|
||
// 3. Parts — one BSP shape per part with a non-null PhysicsBSP.
|
||
// Pose priority per part: partPoseOverride (the motion-table
|
||
// default-state pose, #175) → placement frame → identity.
|
||
AnimationFrame? placementFrame = ResolvePlacementFrame(setup);
|
||
for (int i = 0; i < setup.Parts.Count; i++)
|
||
{
|
||
uint gfxId = (uint)setup.Parts[i];
|
||
if (!hasPhysicsBsp(gfxId)) continue;
|
||
|
||
Frame partFrame;
|
||
if (partPoseOverride is not null && i < partPoseOverride.Count)
|
||
partFrame = partPoseOverride[i];
|
||
else if (placementFrame is not null && i < placementFrame.Frames.Count)
|
||
partFrame = placementFrame.Frames[i];
|
||
else
|
||
partFrame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
|
||
|
||
// BSP radius default; caller substitutes the real BoundingSphere.Radius
|
||
// at registration time when available. Loose-but-safe broadphase value.
|
||
float bspRadius = 2f * entScale;
|
||
|
||
result.Add(new ShadowShape(
|
||
GfxObjId: gfxId,
|
||
LocalPosition: new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale,
|
||
LocalRotation: partFrame.Orientation,
|
||
Scale: entScale,
|
||
CollisionType: ShadowCollisionType.BSP,
|
||
Radius: bspRadius,
|
||
CylHeight: 0f));
|
||
}
|
||
|
||
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)
|
||
{
|
||
if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting)) return resting;
|
||
if (setup.PlacementFrames.TryGetValue(Placement.Default, out var def)) return def;
|
||
foreach (var kvp in setup.PlacementFrames) return kvp.Value;
|
||
return null;
|
||
}
|
||
}
|