feat(physics): S2 chunk 1b - callers supply the visual part array; installed-DAT comparator

Every production registration now hands ShadowObjectRegistry the object's
whole visual part array beside its collision dispatch: static publication
(App LandblockPhysicsPublisher and the headless Content twin) through
ShadowShapeBuilder.FromStaticRenderParts, live entities (Runtime
LiveEntityCollisionBuilder) through the new FromSetupRenderParts, which walks
every Setup part with the same physics-sphere-else-drawing-sphere and
part-box rule from the PhysicsDataCache Runtime already reaches. Nothing
consumes the retail products yet; the App hermetic lane still passes
6,758/6,758.

The Lane=InstalledDat comparator registers five real fixtures through the
real publication inputs and prints retail CELLARRAY, collision cells, and
the old render cells side by side: Facility Hub stair Setup 0x02000623
(7 cells incl. 0x8A02015F/015E), cathedral ramp 0x020009A2 (3 cells, the
genuine multi-part case), the #334 Neftet formation (25 cells), and a
landblock-edge crosser (6 cells, 2 in the neighbor block). All three
answers agree for BSP-bearing objects, as the shared primitive predicts;
the divergence chunk 3 expects appears only for decorative non-BSP parts.

Core Physics 2,202/2,202; Runtime 1,884/1,884; App hermetic 6,758/6,758;
comparator + stair pin 5/5; solution Release build 0 warnings / 0 errors.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-02 21:52:35 +02:00
parent 059b8883ab
commit 5a2792d689
7 changed files with 908 additions and 11 deletions

View file

@ -429,6 +429,110 @@ public static class ShadowShapeBuilder
return parts;
}
/// <summary>
/// Resolves EVERY part of a live entity's <see cref="Setup"/> into the
/// paired sphere + vertex-box geometry used by retail's render-shadow
/// cell registration — the live-entity analog of
/// <see cref="FromStaticRenderParts"/>. Unlike <see cref="FromSetup"/>'s
/// step 3 (which emits a BSP shape only for a part whose GfxObj carries a
/// physics BSP), this walks every Setup part unconditionally:
/// <c>CEnvCell::init_static_objects</c>/<c>CPartArray::AddPartsShadow</c>
/// registers every visual part in each crossed cell even when that part
/// cannot collide, and a live Setup is no exception.
///
/// <para>The returned values are geometry carriers only. Callers must not
/// publish them to the collision registry.</para>
/// </summary>
/// <param name="setup">The live entity's Setup.</param>
/// <param name="entScale">The entity's spawn scale, applied uniformly —
/// same composition as <see cref="FromSetup"/>.</param>
/// <param name="effectivePartGfxObjIds">Current part identities after
/// retail <c>AnimPartChanged</c> processing (see <see cref="FromSetup"/>).
/// Null or short lists fall back to the Setup identity.</param>
/// <param name="partPoseOverride">Pose priority 1: the motion-table
/// default-state pose (#175). Falls back to the Setup's own placement
/// frame, then identity — the same priority <see cref="FromSetup"/>'s
/// step 3 uses.</param>
/// <param name="getGfxObj">Resolves a GfxObj id to its cached physics
/// (BSP + bounding sphere + visual bounds). Production:
/// <c>id =&gt; physicsData.GetGfxObj(id)</c> — the SAME resolver shape
/// <see cref="FromStaticRenderParts"/> takes, so a static and a live
/// Setup sharing a GfxObj id can never disagree about its render
/// geometry.</param>
/// <param name="getVisualBounds">Fallback drawing-sphere/AABB source for
/// a part whose GfxObj carries no physics BSP. Production:
/// <c>id =&gt; physicsData.GetVisualBounds(id)</c>.</param>
public static List<ShadowShape> FromSetupRenderParts(
Setup setup,
float entScale,
IReadOnlyList<uint>? effectivePartGfxObjIds,
IReadOnlyList<Frame>? partPoseOverride,
Func<uint, GfxObjPhysics?> getGfxObj,
Func<uint, GfxObjVisualBounds?> getVisualBounds)
{
ArgumentNullException.ThrowIfNull(setup);
ArgumentNullException.ThrowIfNull(getGfxObj);
ArgumentNullException.ThrowIfNull(getVisualBounds);
var parts = new List<ShadowShape>(setup.Parts.Count);
AnimationFrame? placementFrame = ResolvePlacementFrame(setup);
for (int i = 0; i < setup.Parts.Count; i++)
{
uint gfxId = EffectivePartGfxObjId(setup, effectivePartGfxObjIds, i);
GfxObjPhysics? physics = getGfxObj(gfxId);
bool partHasPhysicsBsp = physics?.FlatPhysicsBsp is { RootIndex: >= 0 }
|| physics?.BSP?.Root is not null;
FlatGfxObjVisualBounds? flatBounds = physics?.VisualBounds;
if (flatBounds is null && getVisualBounds(gfxId) is { } visual)
{
flatBounds = new FlatGfxObjVisualBounds(
visual.Min,
visual.Max,
visual.Center,
visual.Radius,
visual.HalfExtents);
}
if (flatBounds is not { } bounds)
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 };
FlatCollisionSphere sphere;
if (partHasPhysicsBsp)
{
FlatPhysicsBsp? flat = physics!.FlatPhysicsBsp;
sphere = flat is { RootIndex: >= 0 }
? flat.Nodes[flat.RootIndex].BoundingSphere
: new FlatCollisionSphere(
physics.BoundingSphere?.Origin ?? bounds.Center,
physics.BoundingSphere?.Radius ?? bounds.Radius);
}
else
{
// Retail falls back from physics_sphere to the GfxObj drawing
// sphere — same fallback as FromStaticRenderParts.
sphere = new FlatCollisionSphere(bounds.Center, bounds.Radius);
}
parts.Add(ShadowShape.Bsp(
gfxId,
new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale,
partFrame.Orientation,
entScale,
ShadowPartGeometry.Create(sphere, bounds)));
}
return parts;
}
/// <summary>
/// The collision identity of part <paramref name="index"/>: the installed
/// <c>AnimPartChanged</c> replacement when one was supplied, else the