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

@ -953,6 +953,20 @@ public sealed class LandblockPhysicsPublisher
entity.MeshRefs,
entity.IsBuildingShell,
publication.StagingCache.GetGfxObj);
// Campaign OVERHAUL S2 chunk 1b: retail's WHOLE visual part array —
// every visual part of this static, colliding or not — beside the
// BSP-only/primitive-only COLLISION dispatch above and below. The
// SAME resolver pair WalkProductionWorldData.ResolveStaticRenderCells
// already reads off this landblock's PhysicsDataCache. A side
// product only: no consumer reads it yet.
IReadOnlyList<ShadowShape> partArray =
ShadowShapeBuilder.FromStaticRenderParts(
entity.MeshRefs,
publication.StagingCache.GetGfxObj,
publication.StagingCache.GetVisualBounds,
out _);
entityBspCount = bspShapes.Count;
if (entityBspCount > 0)
{
@ -967,7 +981,8 @@ public sealed class LandblockPhysicsPublisher
publication.Origin.Y,
landblock.LandblockId,
seedCellId: entity.ParentCellId ?? 0u,
isStatic: true);
isStatic: true,
partArray: partArray);
LogMultipartRegistration(landblock, entity, bspShapes);
}
@ -1060,7 +1075,8 @@ public sealed class LandblockPhysicsPublisher
publication.Origin.Y,
landblock.LandblockId,
seedCellId: entity.ParentCellId ?? 0u,
isStatic: true);
isStatic: true,
partArray: partArray);
LogSetupRegistration(landblock, entity, setupShapes);
entityCylinderCount = setupShapes.Count;
}

View file

@ -614,6 +614,18 @@ public static class LandblockPhysicsContentBuilder
entity.MeshRefs,
entity.IsBuildingShell,
cache.GetGfxObj);
// Campaign OVERHAUL S2 chunk 1b: retail's WHOLE visual part
// array, mirroring LandblockPhysicsPublisher.PublishStaticEntity
// exactly — same PhysicsDataCache resolver pair, same
// FromStaticRenderParts primitive. A side product only.
IReadOnlyList<ShadowShape> partArray =
ShadowShapeBuilder.FromStaticRenderParts(
entity.MeshRefs,
cache.GetGfxObj,
cache.GetVisualBounds,
out _);
if (bspShapes.Count > 0)
{
engine.ShadowObjects.RegisterMultiPart(
@ -627,7 +639,8 @@ public static class LandblockPhysicsContentBuilder
worldOffsetY: origin.Y,
landblockId: landblock.LandblockId,
seedCellId: entity.ParentCellId ?? 0u,
isStatic: true);
isStatic: true,
partArray: partArray);
bspOwners++;
continue;
}
@ -711,7 +724,8 @@ public static class LandblockPhysicsContentBuilder
worldOffsetY: origin.Y,
landblockId: landblock.LandblockId,
seedCellId: entity.ParentCellId ?? 0u,
isStatic: true);
isStatic: true,
partArray: partArray);
setupOwners++;
}

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

View file

@ -24,7 +24,14 @@ internal sealed record LiveEntityCollisionRegistration(
float WorldOffsetX,
float WorldOffsetY,
uint LandblockId,
uint SeedCellId);
uint SeedCellId,
// Campaign OVERHAUL S2 chunk 1b: retail's WHOLE visual part array for
// this live entity's Setup (ShadowShapeBuilder.FromSetupRenderParts) —
// every part, colliding or not, physics sphere else drawing sphere,
// exact part box. Distinct from Shapes, which stays the BSP-exclusive
// COLLISION dispatch (AP-152). A side product only: no consumer reads
// it yet.
IReadOnlyList<ShadowShape> RenderParts);
/// <summary>
/// Ports the live-object collision-shape policy used by
@ -72,6 +79,21 @@ internal sealed class LiveEntityCollisionBuilder
private readonly Func<uint, bool> _hasPhysicsBsp;
private readonly LiveEntityDefaultPoseResolver _defaultPose;
/// <summary>
/// Campaign OVERHAUL S2 chunk 1b: the SAME resolver shape
/// <c>WalkProductionWorldData.ResolveStaticRenderCells</c> passes to
/// <see cref="ShadowShapeBuilder.FromStaticRenderParts"/> — a static and a
/// live Setup sharing a GfxObj id can never disagree about its render
/// geometry. Production: <c>PhysicsDataCache.GetGfxObj</c>.
/// </summary>
private readonly Func<uint, GfxObjPhysics?> _getGfxObj;
/// <summary>Fallback drawing-sphere/AABB source, paired with
/// <see cref="_getGfxObj"/> exactly as <see cref="ShadowShapeBuilder.FromStaticRenderParts"/>
/// pairs its own two resolvers. Production:
/// <c>PhysicsDataCache.GetVisualBounds</c>.</summary>
private readonly Func<uint, GfxObjVisualBounds?> _getVisualBounds;
public LiveEntityCollisionBuilder(
PhysicsDataCache physicsData,
LiveEntityDefaultPoseResolver defaultPose)
@ -86,7 +108,9 @@ internal sealed class LiveEntityCollisionBuilder
asset!.VisualBounds)
: (ShadowPartGeometry?)null;
},
defaultPose)
defaultPose,
id => physicsData.GetGfxObj(id),
id => physicsData.GetVisualBounds(id))
{
ArgumentNullException.ThrowIfNull(physicsData);
}
@ -101,13 +125,21 @@ internal sealed class LiveEntityCollisionBuilder
/// walk cannot be left without a box (#334).</param>
internal LiveEntityCollisionBuilder(
Func<uint, ShadowPartGeometry?> physicsBspBounds,
LiveEntityDefaultPoseResolver defaultPose)
LiveEntityDefaultPoseResolver defaultPose,
Func<uint, GfxObjPhysics?>? getGfxObj = null,
Func<uint, GfxObjVisualBounds?>? getVisualBounds = null)
{
_physicsBspBounds = physicsBspBounds
?? throw new ArgumentNullException(nameof(physicsBspBounds));
_hasPhysicsBsp = id => _physicsBspBounds(id) is not null;
_defaultPose = defaultPose
?? throw new ArgumentNullException(nameof(defaultPose));
// Campaign OVERHAUL S2 chunk 1b: absent in every existing fixture
// call site (both positional-arg tests and production before this
// chunk) — resolves to "no render geometry for any part", so
// Build() below retains its exact prior Shapes/registration output.
_getGfxObj = getGfxObj ?? (_ => null);
_getVisualBounds = getVisualBounds ?? (_ => null);
}
/// <summary>
@ -197,6 +229,18 @@ internal sealed class LiveEntityCollisionBuilder
if (spawn.ItemType == (uint)ItemType.Creature)
flags |= EntityCollisionFlags.IsCreature;
// Campaign OVERHAUL S2 chunk 1b: retail's WHOLE visual part array —
// every Setup part, colliding or not — beside the BSP-exclusive
// `shapes` collision dispatch above. A side product only; nothing
// consumes it yet.
IReadOnlyList<ShadowShape> renderParts = ShadowShapeBuilder.FromSetupRenderParts(
setup,
scale,
effectivePartGfxObjIds,
defaultPose,
_getGfxObj,
_getVisualBounds);
return new LiveEntityCollisionRegistration(
entity.Id,
entity.SourceGfxObjOrSetupId,
@ -208,7 +252,8 @@ internal sealed class LiveEntityCollisionBuilder
worldOrigin.X,
worldOrigin.Y,
position.LandblockId,
position.LandblockId);
position.LandblockId,
renderParts);
}
public static void Register(
@ -228,7 +273,8 @@ internal sealed class LiveEntityCollisionBuilder
registration.WorldOffsetY,
registration.LandblockId,
registration.SeedCellId,
isStatic: false);
isStatic: false,
partArray: registration.RenderParts);
if (!PhysicsDiagnostics.ProbeBuildingEnabled)
return;
@ -276,6 +322,7 @@ internal sealed class LiveEntityCollisionBuilder
registration.LandblockId,
registration.SeedCellId,
isStatic: false,
suspendIfNew);
suspendIfNew,
partArray: registration.RenderParts);
}
}