The AP-152 retail review (docs/research/2026-08-06-ap152-review-retail.md) FAILED `4abd1b5e` and is right. `ShadowObjectRegistry.BuildFloodSpheres` took each physics-BSP part's ROOT BOUNDING SPHERE RADIUS (FlatCollisionAssetBuilder.cs:393 -> LiveEntityCollisionBuilder.cs:137) and centred it on the PART ORIGIN (ShadowShapeBuilder.cs:194), discarding the root sphere's own Origin. Re-measured independently against the installed client_portal.dat, reproducing the reviewer's numbers exactly: 376 of 973 physics-BSP parts have |origin| > radius/2, worst 20.762 m on a 27.708 m sphere (gfx 0x010036DD, Setup 0x0200129A). Over the 172 Setups AP-152 moved onto that path the emitted flood FAILED TO CONTAIN the object's own BSP sphere for 170 of them (73 CylSphere-bearing, 97 Sphere-bearing), worst shortfall 9.911 m on Setup 0x02000255 — whose one part's sphere sits 9.911 m above the part origin — and for 43 the post-AP-152 flood was strictly SMALLER than the pre-AP-152 one. Indoor flooding is 3-D (CellTransit.cs:601 routes every id & 0xFFFF >= 0x0100 candidate through FindTransitCellsSphere), so a tall prop or door slab was absent from EnvCells it physically occupies and therefore never a broadphase candidate there (TransitionTypes.cs:3763 iterates only entries already in the cell). That is the #98 / #168 class AP-152 exists to remove. Retail, re-disassembled from the PDB-paired binary (check_exe_pdb.py MATCH, CodeView GUID 9e847e2f-777c-4bd9-886c-22256bb87f32), every address resolved back through named-retail/symbols.json: CGfxObj::physics_sphere is [gfxobj+0x74] (physics_bsp is [+0x78], as CPartArray::CacheHasPhysicsBSP @0x00518110 reads at 0x00518127), and acclient pseudo-C 0x00534b5b assigns it BSPTREE::GetSphere(physics_bsp). BSPTREE::GetSphere @0x005397e0 8b01 mov eax,[ecx] ; BSPTREE::root_node 83c004 add eax,4 ; past BSPNODE::vfptr -> CSphere sphere So retail's per-part flood sphere IS the BSP root bounding sphere, ORIGIN INCLUDED (acclient.h: BSPNODE { vfptr; CSphere sphere; ... }, CSphere { Vector3 center; float radius; } -> radius at +0xc). CPhysicsObj::find_bbox_cell_list @0x00510fc0 adds the object's own cell and then walks the PART ARRAY: 0x00511012 call 0x518160 (CPartArray::calc_cross_cells_static), which dispatches [edx+0x7c] with (num_parts, parts, cellarray). Its EnvCell body, CEnvCell::find_transit_cells @0x0052cae0: 0x0052cb31 mov edx,[eax+0x20] ; CPhysicsPart::gfxobj (CGfxObj**) 0x0052cb36 mov esi,[ecx+0x74] ; physics_sphere (else +0x90 drawing) 0x0052cb4c add eax,0x30 ; CPhysicsPart::pos 0x0052cb5a call Position::localtolocal ; transform the sphere CENTRE 0x0052cb65 fadd [esi+0xc] ; only NOW the radius Retail transforms the centre through the part's own Position before it ever touches the radius. Carrying the radius alone is not an approximation of that; it is a different sphere. Changes: * `ShadowShape` gains `BoundsCenter` — the bounding sphere's centre in the shape's own local frame, scaled like LocalPosition and Radius. Zero for Cylinder/Sphere shapes, whose LocalPosition already IS their centre. * `ShadowShapeBuilder.FromSetup` gains a `physicsBspBounds` resolver that supplies radius AND centre from ONE call, replacing the placeholder radius plus a downstream substitution. `LiveEntityCollisionBuilder` now holds a single `Func<uint, FlatCollisionSphere?>` and derives its dispatch predicate from it, so the gate and the geometry cannot disagree and the radius cannot be taken while the origin is dropped. That split is what produced this bug; it no longer exists. * `FromLandblockBspParts` carries the centre too. A landblock-baked part array is the same CPartArray walk, so stair runs, fences and rock clusters had the identical defect. Both storage forms (flat BSP and the graph fallback) are covered. * `BuildFloodSpheres` places each sphere at partWorldPos + rotate(BoundsCenter, partWorldRot), composed exactly as the ShadowEntry rows are. * The 10-sphere clamp now applies to the CYLSPHERE branch only. Retail's clamp is inside CObjCell::find_cell_list @0x0052b9f0 (0x0052ba21 cmp eax,0xa / 0x0052ba28 mov ebp,0xa); the BSP walk has none and the sorting-sphere overload @0x0052b990 takes one sphere. 7 installed Setups carry more than 10 physics-BSP parts (max 49, Setup 0x02001A91) and their tail parts were dropped from the flood entirely. Without this the new containment assertion would have covered shapes production never floods from. Register. AP-155 was two divergences with different code paths, populations and gates under one id; it is NARROWED to its static-publication half and its flood half is split out as AP-156 WITH ITS DIRECTION CORRECTED. AP-155(b) recorded the approximation as over-inclusive — "floods MORE cells rather than fewer, the safe direction for membership" — and that false direction was the stated reason the residual was safe to defer. It was under-inclusive for 170 of 172. AP-156 records the correction, this fix, and the one genuine residual: acdream's sphere-vs-portal traversal where retail walks each part's sphere against the cell's own portal planes. AP-155(b)'s "acdream approximates retail's bounding BOX" was wrong too — find_bbox_cell_list forms no box. AP-157 filed for the review's F4: retail's third branch floods from ONE CPartArray::GetSortingSphere @0x00518b00 ([partArray+0x54]+0x70 = CSetup::sorting_sphere; 4,154 of 5,935 installed Setups carry a non-zero one) where acdream floods from every Sphere shape, and acdream's cylinder flood ignores CylHeight. Deliberately NOT bundled here: different branch, disjoint population, different live gate. Active AP rows 107 -> 109, literal count. Tests. Both flood tests the review named substituted a CONCENTRIC Radius = 14f at LocalPosition = Zero — the one configuration in which the defect cannot appear. Every fixture is now off-centre by default, and `FromSetup_CylSphereAndBspSetup_FloodsTheBspFootprint` drives the production `physicsBspBounds` seam instead of hand-substituting. Five new facts: the flood centres on BoundsCenter not the part origin; it rotates BoundsCenter by the part rotation; it caps cylspheres at ten but never the BSP parts; the landblock path carries the scaled centre in both storage forms; and an installed-DAT containment sweep asserting every emitted BSP flood sphere contains that part's real bounding sphere at entity scale 1.75, behind four external controls — 973 parts, 376 off-centre, 172 affected, and 170 would-fail-if-the-origin-were-discarded, the last of which fails if the population ever stops exercising the field. Nine sabotages, each reverted and re-verified: A drop BoundsCenter from the flood -> 3 Core B rotate by entity rot, not part rot -> 1 Core (the rotation fact only) C FromSetup discards the origin -> 1 Core + 2 App + 1 Content (the shipped defect, now caught in three projects) D drop entScale on BoundsCenter -> 2 App + 1 Content E landblock flat branch drops the centre -> 1 Core F landblock graph branch drops it -> 1 Core G drop partScale on the landblock centre -> 1 Core H re-apply the 10-cap to every branch -> 1 Core I remove the cylsphere cap -> 1 Core AP-152's own two sabotages re-run against this tree: the step-0 gate disabled still reddens exactly its five facts with Headless 89/89 green, and cylinder-first flooding still reddens exactly one. Clean Release build after deleting all 44 bin/obj: 0 errors, 21 pre-existing warnings. Complete suite 11,208 passed / 4 skipped / 0 failed, +5 on the 11,203 baseline at4abd1b5e— Core 4264 -> 4268, Content 126 -> 127, App unchanged (one rename, not an addition). No new skips. NOT yet gated live. This moves shadow-cell membership for real objects, in both directions, and the connected session must look for both: props and doors that START blocking from a neighbouring cell (the 73 CylSphere+BSP Setups), AND ones that STOP blocking (the 99 Sphere+BSP Setups can shrink; 43 shrink below their pre-4abd1b5e size, which is the regression this fixes). Tall indoor props and door slabs — the ones whose sphere sits metres above the part origin — are where the change is largest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
359 lines
18 KiB
C#
359 lines
18 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 — retail's <c>CGfxObj::physics_sphere</c>, which is
|
||
/// literally <c>BSPTREE::GetSphere(physics_bsp)</c> @0x005397e0. Supplies
|
||
/// BOTH the emitted <see cref="ShadowShape.Radius"/> and its
|
||
/// <see cref="ShadowShape.BoundsCenter"/>, from one call, so the sphere's
|
||
/// size can never be carried while its position is dropped. 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, FlatCollisionSphere?>? 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(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. 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(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++)
|
||
{
|
||
// 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.
|
||
FlatCollisionSphere? bounds = physicsBspBounds?.Invoke(gfxId);
|
||
float bspRadius = (bounds?.Radius ?? 2f) * entScale;
|
||
Vector3 boundsCenter = (bounds?.Origin ?? Vector3.Zero) * 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,
|
||
BoundsCenter: boundsCenter));
|
||
}
|
||
|
||
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.
|
||
float localRadius;
|
||
Vector3 localCenter;
|
||
if (hasFlat)
|
||
{
|
||
FlatCollisionSphere root = flat!.Nodes[flat.RootIndex].BoundingSphere;
|
||
localRadius = root.Radius;
|
||
localCenter = root.Origin;
|
||
}
|
||
else
|
||
{
|
||
localRadius = phys.BoundingSphere?.Radius ?? 1f;
|
||
localCenter = phys.BoundingSphere?.Origin ?? Vector3.Zero;
|
||
}
|
||
|
||
shapes.Add(new ShadowShape(
|
||
GfxObjId: meshRef.GfxObjId,
|
||
LocalPosition: pPos,
|
||
LocalRotation: pRot,
|
||
Scale: partScale,
|
||
CollisionType: ShadowCollisionType.BSP,
|
||
Radius: localRadius * partScale,
|
||
CylHeight: 0f,
|
||
BoundsCenter: localCenter * partScale));
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|