fix(physics): AP-156 — flood the BSP sphere where the geometry is, not at the part origin

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 at 4abd1b5e — 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>
This commit is contained in:
Erik 2026-08-06 15:52:04 +02:00
parent 4abd1b5eb7
commit b52967def3
13 changed files with 1424 additions and 90 deletions

View file

@ -29,8 +29,10 @@ internal sealed record LiveEntityCollisionRegistration(
/// <summary>
/// Ports the live-object collision-shape policy used by
/// <c>CPartArray::FindObjCollisions</c>: CylSpheres before Spheres, and every
/// physics-BSP part.
/// <c>CPhysicsObj::FindObjCollisions</c> (0x0050f050), which DISPATCHES
/// rather than unions: every physics-BSP part, ELSE the Setup's CylSpheres,
/// ELSE its Spheres, else nothing (AP-152). The gate itself lives in
/// <see cref="ShadowShapeBuilder.FromSetup"/>.
/// </summary>
/// <remarks>
/// A Setup that yields no shape produces no registration. Retail synthesizes
@ -45,37 +47,39 @@ internal sealed record LiveEntityCollisionRegistration(
/// </remarks>
internal sealed class LiveEntityCollisionBuilder
{
private readonly Func<uint, bool> _hasPhysicsBsp;
private readonly Func<uint, float?> _physicsBspRadius;
private readonly Func<uint, FlatCollisionSphere?> _physicsBspBounds;
private readonly LiveEntityDefaultPoseResolver _defaultPose;
public LiveEntityCollisionBuilder(
PhysicsDataCache physicsData,
LiveEntityDefaultPoseResolver defaultPose)
: this(
id => physicsData.GetFlatGfxObj(id)?.PhysicsBsp.RootIndex >= 0,
id =>
{
FlatPhysicsBsp? flat =
physicsData.GetFlatGfxObj(id)?.PhysicsBsp;
return flat is { RootIndex: >= 0 }
? flat.Nodes[flat.RootIndex].BoundingSphere.Radius
: null;
? flat.Nodes[flat.RootIndex].BoundingSphere
: (FlatCollisionSphere?)null;
},
defaultPose)
{
ArgumentNullException.ThrowIfNull(physicsData);
}
/// <param name="physicsBspBounds">The part GfxObj's physics-BSP root
/// bounding sphere, or null when it has none. ONE resolver answers both
/// questions the builder asks — "does this part dispatch as BSP?" and
/// "where and how big is its flood sphere?" — so the dispatch gate and
/// the emitted geometry cannot disagree, and the sphere's radius cannot
/// be carried while its origin is dropped. That split is what produced
/// the AP-156 mis-placed flood.</param>
internal LiveEntityCollisionBuilder(
Func<uint, bool> hasPhysicsBsp,
Func<uint, float?> physicsBspRadius,
Func<uint, FlatCollisionSphere?> physicsBspBounds,
LiveEntityDefaultPoseResolver defaultPose)
{
_hasPhysicsBsp = hasPhysicsBsp
?? throw new ArgumentNullException(nameof(hasPhysicsBsp));
_physicsBspRadius = physicsBspRadius
?? throw new ArgumentNullException(nameof(physicsBspRadius));
_physicsBspBounds = physicsBspBounds
?? throw new ArgumentNullException(nameof(physicsBspBounds));
_defaultPose = defaultPose
?? throw new ArgumentNullException(nameof(defaultPose));
}
@ -123,25 +127,17 @@ internal sealed class LiveEntityCollisionBuilder
IReadOnlyList<Frame>? defaultPose = _defaultPose.Resolve(
spawn.MotionTableId ?? 0u,
setup.Parts.Count);
IReadOnlyList<ShadowShape> raw = ShadowShapeBuilder.FromSetup(
// One resolver drives both the dispatch gate and the emitted BSP
// geometry, and FromSetup applies the entity scale to radius and
// center alike — there is no downstream substitution that could take
// one and drop the other.
IReadOnlyList<ShadowShape> shapes = ShadowShapeBuilder.FromSetup(
setup,
scale,
_hasPhysicsBsp,
id => _physicsBspBounds(id) is not null,
partPoseOverride: defaultPose,
effectivePartGfxObjIds: effectivePartGfxObjIds);
var shapes = new List<ShadowShape>(raw.Count);
foreach (ShadowShape shape in raw)
{
if (shape.CollisionType == ShadowCollisionType.BSP)
{
float radius = (_physicsBspRadius(shape.GfxObjId) ?? 2f) * scale;
shapes.Add(shape with { Radius = radius });
}
else
{
shapes.Add(shape);
}
}
effectivePartGfxObjIds: effectivePartGfxObjIds,
physicsBspBounds: _physicsBspBounds);
if (shapes.Count == 0 && !retainEmptyPayload)
return null;

View file

@ -430,13 +430,14 @@ public sealed class ShadowObjectRegistry
///
/// <para>
/// BR-7: the cell set is ONE flood for the whole entity (retail floods
/// per OBJECT with its full sphere set, not per part). Flood spheres
/// follow retail's rule (Ghidra 0x0052b9f0): when the object has
/// CylSpheres, they alone drive the flood (base point + cyl radius,
/// capped at 10); otherwise the BSP parts' bounding spheres stand in
/// for the sorting sphere. Every shape row is then written into every
/// flooded cell, mirroring add_shadows_to_cells (0x00514ae0) +
/// CPartArray::AddPartsShadow.
/// per OBJECT with its full sphere set, not per part). The flood spheres
/// follow <c>CPhysicsObj::calc_cross_cells</c>' own EXCLUSIVE priority —
/// physics-BSP parts, else CylSpheres, else the remaining shapes — see
/// <see cref="BuildFloodSpheres"/> for the disassembly. A BSP part
/// contributes its ROOT BOUNDING SPHERE placed at its real center
/// (<see cref="ShadowShape.BoundsCenter"/>), not at the part origin.
/// Every shape row is then written into every flooded cell, mirroring
/// add_shadows_to_cells (0x00514ae0) + CPartArray::AddPartsShadow.
/// </para>
/// </summary>
public void RegisterMultiPart(
@ -604,8 +605,17 @@ public sealed class ShadowObjectRegistry
/// <item>BSP-bearing (<c>0x00515285 test dword [esi+0xa8],0x10000</c> /
/// <c>0x0051528f jne 0x515305</c>) → <c>CPhysicsObj::find_bbox_cell_list</c>
/// @0x00510fc0. The cylsphere and sorting-sphere branches are BOTH below
/// that jump and unreachable from it. acdream approximates the bbox with
/// the BSP parts' bounding spheres.</item>
/// that jump and unreachable from it. <c>find_bbox_cell_list</c> adds the
/// object's own cell and then walks the PART ARRAY through
/// <c>CPartArray::calc_cross_cells_static</c> @0x00518160's
/// <c>[vtbl+0x7c]</c> dispatch, whose EnvCell body
/// (<c>CEnvCell::find_transit_cells</c> @0x0052cae0) tests each part's
/// <c>CGfxObj::physics_sphere</c> — the BSP root bounding sphere, center
/// transformed through the part's own Position — against the cell's
/// portal planes. acdream floods from those same per-part spheres
/// (<see cref="ShadowShape.BoundsCenter"/> + <see cref="ShadowShape.Radius"/>)
/// rather than walking portal planes per part; the sphere set is exact,
/// the traversal is the sphere-vs-portal one (AP-156).</item>
/// <item>else cylspheres (<c>0x00515298 GetNumCylsphere</c> non-zero) →
/// <c>CObjCell::find_cell_list</c> @0x0052b9f0 over the cylsphere array;
/// each contributes one sphere at its world BASE point with the cylinder
@ -645,20 +655,42 @@ public sealed class ShadowObjectRegistry
// Retail's branch, chosen once: BSP-bbox, else cylspheres, else the
// sorting sphere (which acdream approximates with the remaining
// shapes' bounding spheres).
// shapes' bounding spheres — AP-157).
ShadowCollisionType? only =
anyBsp ? ShadowCollisionType.BSP
: anyCyl ? ShadowCollisionType.Cylinder
: null;
// The 10-sphere clamp belongs to the CYLSPHERE branch alone.
// CObjCell::find_cell_list @0x0052b9f0 clamps the cylsphere count at
// 0x0052ba21 cmp eax,0xa / 0x0052ba28 mov ebp,0xa. The BSP branch —
// find_bbox_cell_list @0x00510fc0 -> CPartArray::calc_cross_cells_static
// @0x00518160 -> CEnvCell::find_transit_cells @0x0052cae0 — walks EVERY
// part with no cap, and the sorting-sphere overload @0x0052b990 takes a
// single sphere. Applying the clamp to the BSP branch dropped parts
// 11..N out of the flood entirely: 7 installed Setups carry more than
// 10 physics-BSP parts (max 49, Setup 0x02001A91), and landblock-baked
// part arrays — stair runs, fences, rock clusters — routinely do.
int cap = only == ShadowCollisionType.Cylinder ? RetailSphereCap : int.MaxValue;
foreach (var s in shapes)
{
if (only is { } required && s.CollisionType != required)
continue;
if (spheres.Count >= RetailSphereCap)
if (spheres.Count >= cap)
break;
var world = entityWorldPos + Vector3.Transform(s.LocalPosition, entityWorldRot);
// Place the sphere where the GEOMETRY is, not where the part
// origin is. Composed exactly as the ShadowEntry rows below are
// (partWorldPos / partWorldRot), then offset by the shape's own
// BoundsCenter — retail's CEnvCell::find_transit_cells @0x0052cae0
// transforms CGfxObj::physics_sphere's center through the part's
// Position at [part+0x30] before reading its radius at
// 0x0052cb65. Primitives carry BoundsCenter == Zero because their
// LocalPosition already is their center.
var partWorldPos = entityWorldPos + Vector3.Transform(s.LocalPosition, entityWorldRot);
var partWorldRot = entityWorldRot * s.LocalRotation;
var world = partWorldPos + Vector3.Transform(s.BoundsCenter, partWorldRot);
spheres.Add(new DatReaderWriter.Types.Sphere
{
Origin = world,

View file

@ -4,8 +4,9 @@ namespace AcDream.Core.Physics;
/// <summary>
/// One collision-bearing shape attached to a logical PhysicsObj.
/// A door emits 3-4 (CylSphere(s) + Sphere(s) + Part-BSPs); a creature
/// may emit a few; a simple item zero. Positions and rotations are
/// The set is a DISPATCH, not a union (AP-152): a BSP door emits one shape
/// per physics-BSP part and no primitive; a primitive-only object emits its
/// CylSpheres, else its Spheres; a simple item zero. Positions and rotations are
/// LOCAL to the entity's origin so <see cref="ShadowObjectRegistry.UpdatePosition"/>
/// can re-transform them when the entity moves.
///
@ -15,6 +16,40 @@ namespace AcDream.Core.Physics;
/// own transform and dispatches per-part collision to its GfxObj.
/// </para>
/// </summary>
/// <param name="BoundsCenter">
/// Center of the shape's bounding sphere IN THE SHAPE'S OWN LOCAL FRAME —
/// the frame <see cref="LocalRotation"/> rotates out of and
/// <see cref="Radius"/> is measured in — already multiplied by the entity
/// scale, like <see cref="LocalPosition"/> and <see cref="Radius"/>.
/// <c>Zero</c> for Cylinder/Sphere shapes, whose
/// <see cref="LocalPosition"/> already IS their center.
///
/// <para>
/// BSP shapes need it because a GfxObj's physics BSP is authored in the
/// GfxObj's own coordinates and its root bounding sphere is frequently NOT
/// centered on that origin (376 of the 973 physics-BSP parts in the
/// installed <c>client_portal.dat</c> sit further from it than half their
/// own radius; worst 20.762 m on a 27.708 m sphere, gfx 0x010036DD).
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/>'s flood needs the
/// sphere's real position, not the part origin's.
/// </para>
///
/// <para>
/// Retail anchor: <c>CGfxObj::physics_sphere</c> (<c>[gfxobj+0x74]</c>) is
/// assigned <c>BSPTREE::GetSphere(physics_bsp)</c> @0x005397e0
/// (<c>mov eax,[ecx]; add eax,4</c> — the root <c>BSPNODE</c>'s
/// <c>CSphere sphere</c>, past its 4-byte vftable), so retail's per-part
/// flood sphere IS the BSP root bounding sphere, origin included.
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0 — the part-array overload
/// reached from <c>CPhysicsObj::find_bbox_cell_list</c> @0x00510fc0 via
/// <c>CPartArray::calc_cross_cells_static</c> @0x00518160's
/// <c>[vtbl+0x7c]</c> dispatch — loads that sphere at
/// <c>0x0052cb36 mov esi,[ecx+0x74]</c>, transforms its CENTER through the
/// part's own <c>Position</c> at <c>[part+0x30]</c>
/// (<c>0x0052cb4c add eax,0x30</c> / <c>0x0052cb5a call Position::localtolocal</c>),
/// and only then reads the radius at <c>0x0052cb65 fadd [esi+0xc]</c>.
/// </para>
/// </param>
public readonly record struct ShadowShape(
uint GfxObjId,
Vector3 LocalPosition,
@ -22,4 +57,5 @@ public readonly record struct ShadowShape(
float Scale,
ShadowCollisionType CollisionType,
float Radius,
float CylHeight);
float CylHeight,
Vector3 BoundsCenter = default);

View file

@ -73,7 +73,11 @@ public static class ShadowShapeBuilder
/// <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>
/// 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
@ -89,12 +93,22 @@ public static class ShadowShapeBuilder
/// 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)
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));
@ -185,9 +199,22 @@ public static class ShadowShapeBuilder
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;
// 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,
@ -196,7 +223,8 @@ public static class ShadowShapeBuilder
Scale: entScale,
CollisionType: ShadowCollisionType.BSP,
Radius: bspRadius,
CylHeight: 0f));
CylHeight: 0f,
BoundsCenter: boundsCenter));
}
return result;
@ -269,9 +297,27 @@ public static class ShadowShapeBuilder
}
float partScale = pScale.X > 0f ? pScale.X : 1f; // AC objects are uniformly scaled
float localRadius = hasFlat
? flat!.Nodes[flat.RootIndex].BoundingSphere.Radius
: phys.BoundingSphere?.Radius ?? 1f;
// 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,
@ -280,7 +326,8 @@ public static class ShadowShapeBuilder
Scale: partScale,
CollisionType: ShadowCollisionType.BSP,
Radius: localRadius * partScale,
CylHeight: 0f));
CylHeight: 0f,
BoundsCenter: localCenter * partScale));
}
return shapes;