acdream had never implemented retail's SECOND cell-membership algorithm.
CPhysicsObj::calc_cross_cells @0x00515230 tests HAS_PHYSICS_BSP_PS at
0x00515285 and jumps (0x0051528f jne 0x515305) to find_bbox_cell_list
@0x00510fc0 for a BSP-bearing object; everything below that jump is the
OTHER algorithm, CObjCell::find_cell_list, and that is all we had. Every
object, BSP-bearing or not, was routed through it.
That path's outdoor expansion is a HARD CAP of one cell in each direction.
CellTransit.AddAllOutsideCells computes minRad = radius, maxRad = 24 - radius
and adds at most the eight neighbours of the sphere's own cell, so for any
radius >= 12 m both boundary tests are unconditionally true and the result is
exactly 3x3. Widening the radius or adding a second sphere is mechanically
incapable of adding a tenth cell. The user's live probe measured the
consequence directly: standing inside a Neftet formation, inCell=2 exempt=2
reached=0 -- the geometry was not a candidate at all.
The port. AddAllOutsideCellsFromParts is CLandCell::add_all_outside_cells
@0x00533360 plus add_cell_block @0x005331d0: base landcell from the FIRST
part's own adjust_to_outside, baseX/baseY within-block, each part's authored
CGfxObj::gfx_bound_box re-fit through all eight corners
(BBox::LocalToGlobal @0x005b2120), floor(v / square_length) where
square_length = 0x7c920c = 24.0f, four accumulators seeded to zero, ONE
rectangle unioned across all parts, FILLED, in GLOBAL lcoords so it crosses
landblocks freely, clamped only to [0, 0x7f8).
BuildShadowCellSetFromParts is find_bbox_cell_list's worklist.
RegisterMultiPart dispatches on the same flag retail does, and
BuildFloodSpheres' BSP arm is deleted rather than left unreachable.
Disassembled from the PDB-paired 2013-09-06 binary, not read from Binary
Ninja: BN mis-renders four separate constructs inside add_all_outside_cells
alone -- a dropped `and eax,0xffff` on baseX, a neg/sbb/and select shown as
identically zero, a wrong get_landcell argument, and both x87 flag tests as
`unimplemented {test ah}`.
ShadowPartGeometry pairs the BSP root sphere with the authored box so no
resolver can answer one and leave the other call site to synthesize a
substitute -- the AP-156 invariant applied a second time, since that split is
what produced AP-156 and then this. The box comes from
FlatGfxObjVisualBounds, already computed by exactly CGfxObj::init_end's
algorithm and already in the prepared package: no bake change, no DAT re-read.
Cost, measured over the installed DATs before any code was written: 1,258
physics-BSP GfxObjs, cells/object p50 4, p90 4, p99 12, max 49. The port is
CHEAPER than the old 3x3 = 9 for 98.97% of them. Row totals (shapes x cells)
over all 1,031 landblocks with BSP owners fall 97,173 -> 15,607 (0.161x);
dense Arwic 0xC6A9 falls 342 -> 43. One landblock more than doubles.
Precondition confirmed before pinning any expected cell set: 0x010046D8's box
is 96 m x 96 m about cell (2,2) = 0x87640013, which independently corroborates
the 3x3-centred-there diagnosis, and its rectangle does contain 0x87640011 and
0x87640019 -- the two cells the probe measured empty.
Register: AP-156's outdoor half CLOSED and its risk column CORRECTED (it read
"extra broadphase candidates, never a missed one", which generalised the indoor
direction to the whole row and is why #334 sat inside it unnoticed). AP-159 +
issue #335 file the unported indoor arm; AD-49 records the seed-time rectangle.
Issue #336 files a fourth load-sensitive test flake seen once during the gate.
Ten tests, every one sabotage-verified in both directions across eight
mutations (dispatch, 8-corner refit, floor-vs-truncation, union-vs-per-part,
map clamp, adjust guard, landblock clamp, box-path-for-everything). The
strongest is an installed-DAT replay of the user's own probe evidence.
Suite 11,208 -> 11,218 passed / 4 skipped / 0 failed; the +10 is exactly the
new tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
368 lines
19 KiB
C#
368 lines
19 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>
|
||
/// This is a DISPATCH, not a union. In priority order: (3) when ANY Part's
|
||
/// effective GfxObj has a non-null PhysicsBSP, emit one BSP shape per such
|
||
/// Part — and nothing else; otherwise (1) every CylSphere → Cylinder shape;
|
||
/// otherwise (2) every Sphere → Sphere shape; otherwise nothing at all. Local
|
||
/// transforms come from PlacementFrames[Resting | Default | first available].
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Retail anchor: <c>CPhysicsObj::FindObjCollisions</c> (0x0050f050)
|
||
/// dispatches EXCLUSIVELY on <c>HAS_PHYSICS_BSP_PS</c> (0x10000)
|
||
/// (<c>0x0050f165 test dword [esi+0xa8],0x10000</c> /
|
||
/// <c>0x0050f16f je 0x50f1a2</c>): it calls
|
||
/// <c>CPartArray::FindObjCollisions</c> (the per-part BSP walk) and returns
|
||
/// (<c>0x0050f19d jmp 0x50f2b0</c>, an UNCONDITIONAL jump past both primitive
|
||
/// branches — the CylSphere loop starts at 0x50f1a2 and the Sphere loop at
|
||
/// 0x50f21d), OR walks the Setup's CylSpheres and returns
|
||
/// (<c>0x0050f1d6 jae 0x50f317</c>), OR walks the Setup's Spheres, OR — with
|
||
/// none of the three — returns the seeded <c>OK_TS</c> without synthesizing
|
||
/// any shape (<c>0x0050f22f je 0x50f31b</c>). BSP wins.
|
||
/// <c>CPhysicsPart::find_obj_collisions</c> (0x0050d8d0) tests ONLY the
|
||
/// GfxObj physics BSP; CylSpheres and Spheres are <c>Setup</c>-level arrays
|
||
/// reached through <c>CPartArray::GetCylsphere</c> (0x00518090) and
|
||
/// <c>GetSphere</c> (0x00518070), so a part has no primitive of its own.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Cell membership dispatches on the SAME flag and in the same priority:
|
||
/// <c>CPhysicsObj::calc_cross_cells</c> (0x00515230) tests
|
||
/// <c>0x10000</c> at <c>0x00515285</c> and routes a BSP-bearing object to
|
||
/// <c>CPhysicsObj::find_bbox_cell_list</c> (0x00510fc0) at
|
||
/// <c>0x0051528f jne 0x515305</c>, never reaching its cylsphere
|
||
/// (<c>0x005152d1</c>) or sorting-sphere (<c>0x005152fb</c>) branches. That
|
||
/// is why the exclusivity is enforced HERE, at emission, rather than only at
|
||
/// the query-time guard <c>Transition.BspOnlyDispatch</c>: the shape list is
|
||
/// also the input to <c>ShadowObjectRegistry.BuildFloodSpheres</c>.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// AP-152 (filed and retired 2026-08-06): these three steps used to be
|
||
/// emitted ADDITIVELY — 172 of 5,935 installed Setups carry both a primitive
|
||
/// and a physics-BSP part. The collision half of that divergence was already
|
||
/// inert, because <c>Transition.BspOnlyDispatch</c> skips both primitive
|
||
/// branches whenever the wire <c>PhysicsState</c> carries 0x10000 and ACE
|
||
/// derives that bit from the same DAT flag; the live half was CELL
|
||
/// MEMBERSHIP, which had no such guard. Gating here also removes acdream's
|
||
/// undeclared dependency on the server sending the bit: the gate is derived
|
||
/// from the parts, exactly as retail's <c>CPartArray::CacheHasPhysicsBSP</c>
|
||
/// (0x00518110) derives it.
|
||
/// </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 physics BSP? Production derives it from the SAME resolver that
|
||
/// supplies <paramref name="physicsBspBounds"/>
|
||
/// (<c>id => _physicsBspBounds(id) is not null</c>, over
|
||
/// <c>PhysicsDataCache.GetFlatGfxObj(id)?.PhysicsBsp</c>), so the dispatch
|
||
/// gate and the emitted geometry cannot disagree.</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>
|
||
/// <param name="effectivePartGfxObjIds">Current part identities after
|
||
/// retail <c>AnimPartChanged</c> processing. Collision keeps each Setup
|
||
/// index and pose, but reads PhysicsBSP from the installed replacement.
|
||
/// Null or short lists fall back to the Setup identity.</param>
|
||
/// <param name="physicsBspBounds">The part GfxObj's physics-BSP ROOT
|
||
/// bounding sphere AND its authored vertex-array box — retail's
|
||
/// <c>CGfxObj::physics_sphere</c> (<c>BSPTREE::GetSphere(physics_bsp)</c>
|
||
/// @0x005397e0) and <c>CGfxObj::gfx_bound_box</c>
|
||
/// (<c>CPhysicsPart::GetBoundingBox</c> @0x0050d600), as ONE
|
||
/// <see cref="ShadowPartGeometry"/>. Supplies the emitted
|
||
/// <see cref="ShadowShape.Radius"/>, <see cref="ShadowShape.BoundsCenter"/>
|
||
/// and <see cref="ShadowShape.LocalBoundsMin"/>/<c>Max</c> from one call,
|
||
/// so no part of the flood geometry can be carried while another is
|
||
/// dropped (AP-156, then #334). Null (or a null result) falls back to the
|
||
/// loose-but-safe 2 m placeholder at the part origin — a fixture-only
|
||
/// configuration; production always supplies it
|
||
/// (<c>LiveEntityCollisionBuilder</c>).</param>
|
||
public static IReadOnlyList<ShadowShape> FromSetup(
|
||
Setup setup,
|
||
float entScale,
|
||
Func<uint, bool> hasPhysicsBsp,
|
||
IReadOnlyList<Frame>? partPoseOverride = null,
|
||
IReadOnlyList<uint>? effectivePartGfxObjIds = null,
|
||
Func<uint, ShadowPartGeometry?>? physicsBspBounds = null)
|
||
{
|
||
if (setup is null) throw new ArgumentNullException(nameof(setup));
|
||
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
|
||
|
||
var result = new List<ShadowShape>();
|
||
|
||
// 0. Retail dispatch gate. CPhysicsObj::FindObjCollisions tests
|
||
// HAS_PHYSICS_BSP_PS FIRST (0x0050f165) and leaves the BSP branch
|
||
// through an unconditional jmp past both primitive loops
|
||
// (0x0050f19d); CPhysicsObj::calc_cross_cells tests the same flag
|
||
// at 0x00515285 and routes to find_bbox_cell_list. Retail derives
|
||
// the flag from the part array itself
|
||
// (CPartArray::CacheHasPhysicsBSP 0x00518110 ORs 0x10000 on the
|
||
// first part whose gfxobj->physics_bsp is non-null), so the gate
|
||
// below reads the SAME effective part identities step 3 reads —
|
||
// never setup.Parts directly. A gate keyed on a different identity
|
||
// could suppress the primitives while step 3 emitted nothing,
|
||
// silently deleting the entity's collision.
|
||
bool anyPhysicsBspPart = false;
|
||
for (int i = 0; i < setup.Parts.Count; i++)
|
||
{
|
||
if (hasPhysicsBsp(EffectivePartGfxObjId(setup, effectivePartGfxObjIds, i)))
|
||
{
|
||
anyPhysicsBspPart = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Steps 1 and 2 run ONLY for an object with no physics-BSP part.
|
||
if (!anyPhysicsBspPart)
|
||
{
|
||
// 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(ShadowShape.Cylinder(
|
||
gfxObjId: 0u,
|
||
localPosition: new Vector3(cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z) * entScale,
|
||
localRotation: Quaternion.Identity,
|
||
scale: entScale,
|
||
radius: cyl.Radius * entScale,
|
||
cylHeight: baseHeight * entScale));
|
||
}
|
||
|
||
// 2. Spheres — only when no CylSpheres. Retail's CylSphere loop
|
||
// returns rather than falling into the Sphere loop
|
||
// (0x0050f1d6 jae 0x50f317). Each becomes a true Sphere (no
|
||
// height clamping): 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(ShadowShape.Sphere(
|
||
gfxObjId: 0u,
|
||
localPosition: new Vector3(sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale,
|
||
localRotation: Quaternion.Identity,
|
||
scale: entScale,
|
||
radius: sph.Radius * entScale));
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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++)
|
||
{
|
||
// Retail CPhysicsPart::SetPart installs AnimPartChanged's current
|
||
// degrade array before CPartArray::FindObjCollisions reads it.
|
||
// Keep the stable Setup part index/pose, but source collision
|
||
// identity from that effective part when one was supplied.
|
||
uint gfxId = EffectivePartGfxObjId(setup, effectivePartGfxObjIds, 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 };
|
||
|
||
// The part's physics-BSP root bounding sphere — retail's
|
||
// CGfxObj::physics_sphere, assigned BSPTREE::GetSphere(physics_bsp)
|
||
// @0x005397e0. RADIUS AND CENTER TOGETHER: retail's per-part
|
||
// cross-cell walk (CEnvCell::find_transit_cells @0x0052cae0,
|
||
// reached from find_bbox_cell_list @0x00510fc0 through
|
||
// CPartArray::calc_cross_cells_static @0x00518160) transforms the
|
||
// sphere's CENTER through the part's own Position
|
||
// (0x0052cb4c add eax,0x30 → Position::localtolocal) BEFORE it
|
||
// reads the radius at 0x0052cb65 fadd [esi+0xc]. The center is not
|
||
// the part origin: 376 of the 973 installed physics-BSP parts sit
|
||
// further from it than half their own radius. A single resolver
|
||
// supplies both so one cannot be taken without the other.
|
||
// Absent bounds keep the loose-but-safe 2 m placeholder, centred
|
||
// on the part origin because nothing better is known.
|
||
// ShadowShape.Bsp scales radius, centre and box together.
|
||
//
|
||
// #334: the SAME resolver also supplies the authored vertex-array
|
||
// box. Retail's outdoor cell membership
|
||
// (CLandCell::add_all_outside_cells @0x00533360, reached from
|
||
// find_bbox_cell_list @0x00510fc0) divides that box — never the
|
||
// sphere — by square_length to build its cell rectangle, so a
|
||
// resolver that answered only the sphere would leave that walk
|
||
// with nothing to walk.
|
||
ShadowPartGeometry geometry =
|
||
physicsBspBounds?.Invoke(gfxId)
|
||
?? ShadowPartGeometry.Create(
|
||
new FlatCollisionSphere(Vector3.Zero, 2f),
|
||
null);
|
||
|
||
result.Add(ShadowShape.Bsp(
|
||
gfxObjId: gfxId,
|
||
localPosition: new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale,
|
||
localRotation: partFrame.Orientation,
|
||
scale: entScale,
|
||
localGeometry: geometry));
|
||
}
|
||
|
||
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 is null) continue;
|
||
FlatPhysicsBsp? flat = phys.FlatPhysicsBsp;
|
||
bool hasFlat = flat is { RootIndex: >= 0 };
|
||
if (!hasFlat && phys.BSP?.Root is null)
|
||
continue; // graph-only fixture seam until I6 referee removal
|
||
|
||
// 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
|
||
// Root bounding sphere, CENTER AND RADIUS TOGETHER — see
|
||
// ShadowShape.BoundsCenter. Retail's per-part cross-cell walk
|
||
// (CEnvCell::find_transit_cells @0x0052cae0) transforms
|
||
// CGfxObj::physics_sphere's center through the part's Position
|
||
// before using its radius; a landblock-baked part array is the
|
||
// same CPartArray walk (CPartArray::calc_cross_cells_static
|
||
// @0x00518160), so dropping the center here mis-places the flood
|
||
// exactly as it did for live Setups.
|
||
FlatCollisionSphere localBounds =
|
||
hasFlat
|
||
? flat!.Nodes[flat.RootIndex].BoundingSphere
|
||
: new FlatCollisionSphere(
|
||
phys.BoundingSphere?.Origin ?? Vector3.Zero,
|
||
phys.BoundingSphere?.Radius ?? 1f);
|
||
|
||
// #334: the same cached record carries the authored vertex-array
|
||
// box (CGfxObj::gfx_bound_box), which retail's outdoor extent walk
|
||
// — CLandCell::add_all_outside_cells @0x00533360 — divides by
|
||
// square_length. Landblock-baked part arrays are exactly the
|
||
// population whose extent exceeds one 24 m land cell, so the
|
||
// sphere alone cannot describe their membership.
|
||
ShadowPartGeometry geometry =
|
||
ShadowPartGeometry.Create(localBounds, phys.VisualBounds);
|
||
|
||
shapes.Add(ShadowShape.Bsp(
|
||
gfxObjId: meshRef.GfxObjId,
|
||
localPosition: pPos,
|
||
localRotation: pRot,
|
||
scale: partScale,
|
||
localGeometry: geometry));
|
||
}
|
||
|
||
return shapes;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The collision identity of part <paramref name="index"/>: the installed
|
||
/// <c>AnimPartChanged</c> replacement when one was supplied, else the
|
||
/// Setup's own part. Shared by the step-0 dispatch gate and the step-3
|
||
/// emission so the two can never read different identities.
|
||
/// </summary>
|
||
private static uint EffectivePartGfxObjId(
|
||
Setup setup,
|
||
IReadOnlyList<uint>? effectivePartGfxObjIds,
|
||
int index)
|
||
=> effectivePartGfxObjIds is not null && index < effectivePartGfxObjIds.Count
|
||
? effectivePartGfxObjIds[index]
|
||
: (uint)setup.Parts[index];
|
||
|
||
/// <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;
|
||
}
|
||
}
|