fix(physics): port retail's find_bbox_cell_list outdoor extent walk (#334)

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>
This commit is contained in:
Erik 2026-08-06 19:07:06 +02:00
parent f0588725cf
commit 13fcf38138
19 changed files with 2186 additions and 137 deletions

View file

@ -47,7 +47,7 @@ internal sealed record LiveEntityCollisionRegistration(
/// </remarks>
internal sealed class LiveEntityCollisionBuilder
{
private readonly Func<uint, FlatCollisionSphere?> _physicsBspBounds;
private readonly Func<uint, ShadowPartGeometry?> _physicsBspBounds;
/// <summary>
/// The dispatch gate, derived from <see cref="_physicsBspBounds"/> so the
/// two can never disagree (AP-156), and cached once so <see cref="Build"/>
@ -62,11 +62,13 @@ internal sealed class LiveEntityCollisionBuilder
: this(
id =>
{
FlatPhysicsBsp? flat =
physicsData.GetFlatGfxObj(id)?.PhysicsBsp;
FlatGfxObjCollisionAsset? asset = physicsData.GetFlatGfxObj(id);
FlatPhysicsBsp? flat = asset?.PhysicsBsp;
return flat is { RootIndex: >= 0 }
? flat.Nodes[flat.RootIndex].BoundingSphere
: (FlatCollisionSphere?)null;
? ShadowPartGeometry.Create(
flat.Nodes[flat.RootIndex].BoundingSphere,
asset!.VisualBounds)
: (ShadowPartGeometry?)null;
},
defaultPose)
{
@ -74,14 +76,15 @@ internal sealed class LiveEntityCollisionBuilder
}
/// <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>
/// bounding sphere AND its authored vertex-array box, or null when it has
/// no physics BSP. ONE resolver answers every question the builder asks —
/// "does this part dispatch as BSP?", "where and how big is its flood
/// sphere?", and "what is its outdoor extent?" — so the dispatch gate and
/// the emitted geometry cannot disagree, the sphere's radius cannot be
/// carried while its origin is dropped (AP-156), and the outdoor extent
/// walk cannot be left without a box (#334).</param>
internal LiveEntityCollisionBuilder(
Func<uint, FlatCollisionSphere?> physicsBspBounds,
Func<uint, ShadowPartGeometry?> physicsBspBounds,
LiveEntityDefaultPoseResolver defaultPose)
{
_physicsBspBounds = physicsBspBounds

View file

@ -372,6 +372,146 @@ public static class CellTransit
}
}
/// <summary>
/// Outdoor extent walk for a physics-BSP part array — the OTHER outdoor
/// expansion retail has, and the one #334 was missing entirely. Verbatim
/// port of <c>CLandCell::add_all_outside_cells</c> @0x00533360 (pc:317289)
/// plus <c>CLandCell::add_cell_block</c> @0x005331d0 (pc:317202),
/// disassembled from the PDB-paired 2013-09-06 binary rather than read
/// from Binary Ninja's pseudo-C, which mis-renders four separate
/// constructs inside this one function
/// (<c>docs/research/2026-08-06-334-contract.md</c> §9.4).
///
/// <para>
/// Shape, byte-verified:
/// </para>
/// <list type="number">
/// <item>The base landcell comes from the FIRST part's own
/// <c>adjust_to_outside</c> (<c>0x005333a2</c>-<c>0x005333dd</c>),
/// NOT from the object's position; a failed adjust selects gid 0
/// through the <c>neg/sbb/and</c> conditional select at
/// <c>0x005333eb</c>, whose <c>get_landcell</c> then returns null
/// and the walk returns (<c>0x00533417</c>).</item>
/// <item><c>baseX = ((gid &amp; 0xFFFF) - 1) >> 3</c>
/// (<c>0x0053343a and eax,0xffff</c> — the mask BN drops) and
/// <c>baseY = (gid - 1) &amp; 7</c> (<c>0x00533443</c>): WITHIN-BLOCK
/// 0..7, bridged to the GLOBAL lcoords from
/// <c>gid_to_lcoord</c> (<c>0x00533428</c>) by the four deltas.</item>
/// <item>Per part: <c>GetBoundingBox</c> @0x0050d600 →
/// <c>BBox::LocalToGlobal</c> @0x005b2120 (<c>0x00533527</c>), then
/// <c>floor(v / square_length)</c> on min.x, min.y, max.x, max.y
/// (stack slots <c>+0x48/+0x4c/+0x54/+0x58</c> in the entry frame —
/// the raw displacements differ only because <c>sub esp,8</c> at
/// <c>0x00533536</c> brackets the middle three). Z is never read:
/// land cells are a 2-D grid. <c>square_length</c> is
/// <c>0x7c920c</c> = <c>00 00 c0 41</c> = 24.0f, read from the
/// binary.</item>
/// <item>The four accumulators are seeded to ZERO
/// (<c>0x00533390</c>-<c>0x0053339c</c>), so the rectangle always
/// contains the base cell, and are combined with plain integer
/// <c>jge</c>/<c>jle</c> (<c>0x005335a6</c>, <c>0x005335b8</c>,
/// <c>0x005335c8</c>, <c>0x005335d9</c>) — BN renders these as
/// <c>unimplemented {test ah}</c> / <c>FCMP_UO</c>.</item>
/// <item>ONE rectangle over ALL parts, filled — not outlined, not a
/// per-part union (<c>0x00533614</c>
/// <c>add_cell_block(gx+minDX, gy+minDY, gx+maxDX, gy+maxDY,
/// cellarray)</c>, argument order recovered from the five pushes
/// at <c>0x005335f2</c>-<c>0x00533613</c>). An L-shaped object
/// claims the notch; retail's coverage is deliberately
/// conservative and lets the narrow phase reject.</item>
/// </list>
///
/// <para>
/// Retail also has an <c>Always2D()</c> arm (<c>0x0053346b</c>) that falls
/// back to the part's sphere. It is unreachable here: this overload is
/// only ever handed physics-BSP parts, and a 2-D sprite part carries no
/// physics BSP.
/// </para>
/// </summary>
/// <param name="worldParts">The object's physics-BSP parts, world-placed.</param>
/// <param name="currentCellId">The flood seed cell — supplies the landblock
/// base <c>adjust_to_outside</c> measures part 0's position against.</param>
/// <param name="currentBlockOrigin">World origin of the seed cell's
/// landblock (#106 frame convention); <see cref="Vector3.Zero"/> when the
/// seed block IS the anchor.</param>
/// <returns>False when <c>adjust_to_outside</c> or <c>gid_to_lcoord</c>
/// rejects the base position (map edge / invalid id) — retail returns
/// without adding anything.</returns>
public static bool AddAllOutsideCellsFromParts(
IReadOnlyList<ShadowPartBox> worldParts,
uint currentCellId,
Vector3 currentBlockOrigin,
ICollection<uint> candidates)
{
if (worldParts is null || worldParts.Count == 0)
return false;
// 0x005333a2-0x005333dd: the base gid is the FIRST part's landcell.
// DeriveOutdoorSeed clamps its own result to the seed block; this
// deliberately does not inherit that clamp — a part array whose first
// part sits over the neighbour block anchors there, as retail does.
Vector3 seedFramePos = worldParts[0].WorldPosition - currentBlockOrigin;
Vector3 baseFramePos = seedFramePos;
uint baseCellId = currentCellId;
if (!LandDefs.AdjustToOutside(ref baseCellId, ref baseFramePos))
return false; // gid 0 → get_landcell null → return
if (!LandDefs.GidToLcoord(baseCellId, out int gx, out int gy))
return false; // 0x00533432 je 0x53361c
int baseX = (int)(((baseCellId & 0xFFFFu) - 1u) >> 3); // 0x0053343a
int baseY = (int)((baseCellId - 1u) & 7u); // 0x00533443
// adjust_to_outside re-based part 0's position into the ADJUSTED
// block's local frame; retail's BBox::LocalToGlobal writes every part
// box into that same frame (cell0->pos). The re-basing is a pure
// translation, so applying it to the world origin is exact.
Vector3 frameOrigin =
currentBlockOrigin - (baseFramePos - seedFramePos);
int minDX = 0, minDY = 0, maxDX = 0, maxDY = 0; // 0x00533390
for (int i = 0; i < worldParts.Count; i++)
{
worldParts[i].RefitTo(frameOrigin, out Vector3 boxMin, out Vector3 boxMax);
// floor, then _ftol2 — NOT truncation. C#'s (int)(v / 24f)
// truncates toward zero and is wrong for every negative
// block-local coordinate, which is precisely the case a part
// hanging off the block's SW corner produces.
int a = (int)MathF.Floor(boxMin.X / LandDefs.CellLength);
int b = (int)MathF.Floor(boxMin.Y / LandDefs.CellLength);
int c = (int)MathF.Floor(boxMax.X / LandDefs.CellLength);
int d = (int)MathF.Floor(boxMax.Y / LandDefs.CellLength);
if (a - baseX < minDX) minDX = a - baseX; // 0x005335a2
if (b - baseY < minDY) minDY = b - baseY; // 0x005335b4
if (c - baseX > maxDX) maxDX = c - baseX; // 0x005335c2
if (d - baseY > maxDY) maxDY = d - baseY; // 0x005335d5
}
AddCellBlock(gx + minDX, gy + minDY, gx + maxDX, gy + maxDY, candidates);
return true;
}
/// <summary>
/// <c>CLandCell::add_cell_block</c> @0x005331d0 (pc:317202): both loops are
/// INCLUSIVE (<c>0x0053324d</c> / <c>0x00533246 jle</c>) and the rectangle
/// is FILLED. Coordinates are GLOBAL lcoords, so the landblock prefix is
/// re-derived per cell and the rectangle crosses landblock boundaries
/// freely — that re-derivation is <see cref="AddOutsideCell"/>'s
/// <see cref="LandDefs.LcoordToGid"/>, whose
/// <see cref="LandDefs.InBounds"/> rejection IS retail's
/// <c>0 &lt;= v &lt; 0x7f8</c> clamp at <c>0x005331f0</c>-<c>0x00533206</c>.
/// </summary>
private static void AddCellBlock(
int x0, int y0, int x1, int y1,
ICollection<uint> candidates)
{
for (int x = x0; x <= x1; x++)
for (int y = y0; y <= y1; y++)
AddOutsideCell(candidates, x, y);
}
private static void AddOutsideCell(ICollection<uint> candidates, int lx, int ly)
{
// CLandCell::add_outside_cell (pc:317056 @0x00532ec0): map-bounds check,
@ -500,8 +640,13 @@ public static class CellTransit
}
/// <summary>
/// BR-7 / A6.P4 (2026-06-11). Registration-side cell-set builder — the
/// sphere-overlap portal flood retail runs at SHADOW REGISTRATION time.
/// BR-7 / A6.P4 (2026-06-11). Registration-side cell-set builder for an
/// object with NO physics BSP — the sphere-overlap portal flood retail
/// runs at SHADOW REGISTRATION time for the cylsphere and sorting-sphere
/// branches. It is ONE of TWO registration floods: a BSP-bearing object
/// takes <see cref="BuildShadowCellSetFromParts"/> instead
/// (<c>CPhysicsObj::calc_cross_cells</c> @0x00515230 dispatches on
/// <c>HAS_PHYSICS_BSP_PS</c> at <c>0x00515285</c>).
/// Verbatim port of <c>CObjCell::find_cell_list</c> (Ghidra 0x0052b4e0,
/// pc:308742) as invoked by <c>CPhysicsObj::calc_cross_cells</c> /
/// <c>calc_cross_cells_static</c> (Ghidra 0x00515230 / 0x00515160):
@ -657,6 +802,184 @@ public static class CellTransit
return candidates.OrderedIds;
}
/// <summary>
/// #334 (2026-08-06). Registration-side cell-set builder for a
/// PHYSICS-BSP-BEARING object — retail's OTHER cross-cell algorithm, which
/// acdream had never implemented. Port of
/// <c>CPhysicsObj::find_bbox_cell_list</c> @0x00510fc0 (pc:279006), the
/// branch <c>CPhysicsObj::calc_cross_cells</c> @0x00515230 takes at
/// <c>0x00515285 test dword [esi+0xa8],0x10000</c> /
/// <c>0x0051528f jne 0x515305</c> — <c>HAS_PHYSICS_BSP_PS</c>
/// (<c>acclient.h:2833</c>). <see cref="BuildShadowCellSet"/> ports the
/// branches BELOW that jump (cylspheres, then the sorting sphere) and
/// remains correct for them.
///
/// <para>
/// <c>find_bbox_cell_list</c> forms no bounding box itself — it is a
/// worklist. It seeds the array with the object's OWN cell
/// (<c>0x00510fe2 CELLARRAY::add_cell</c>) and walks it while it grows,
/// re-reading <c>num_cells</c> each iteration
/// (<c>0x00510ff8</c> / <c>0x00511017</c> / <c>0x0051101d jb</c>),
/// dispatching each array cell through
/// <c>CPartArray::calc_cross_cells_static</c> @0x00518160 — a forwarding
/// thunk to <c>cell-&gt;vtable[0x7c]</c> (<c>0x00518176</c>;
/// <c>CObjCell</c>'s vftable base <c>0x007c8b20</c> + <c>0x7c</c> =
/// <c>0x007c8b9c</c>, holding <c>0x0052b080</c>, the four-argument
/// part-array <c>find_transit_cells</c>). The boxes are formed one and two
/// levels down: outdoors in
/// <see cref="AddAllOutsideCellsFromParts"/>
/// (<c>CLandCell::find_transit_cells</c> @0x00533840 =
/// <c>add_all_outside_cells</c> @0x00533360 + the <c>CSortCell</c>
/// @0x00534080 building bridge), indoors in
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0.
/// </para>
///
/// <para>
/// Note the difference from <see cref="BuildShadowCellSet"/>'s seed: the
/// sphere overload calls <c>add_all_outside_cells</c> AT SEED TIME for an
/// outdoor id (<c>CObjCell::find_cell_list</c> <c>0x0052b53f</c>);
/// <c>find_bbox_cell_list</c> does not — the outdoor expansion happens
/// only when the walk reaches a landcell, under the same once-per-flood
/// <c>CELLARRAY::added_outside</c> latch (<c>0x0053336c</c>). And it is
/// ONE rectangle over all parts, run ONCE, not a per-part loop.
/// </para>
///
/// <para>
/// DIVERGENCE (registered, AP-159): the INDOOR half of retail's part-array
/// overload — box-vs-portal-plane
/// (<c>BBox::LocalToLocal</c> @0x005b1e60 + <c>Plane::intersect_box</c>
/// @0x005aa170 at <c>0x0052cbf9</c>/<c>0x0052cc05</c>) and
/// <c>CCellStruct::box_intersects_cell</c> @0x00533910 — is NOT ported
/// here. Indoor candidates keep the sphere-vs-portal traversal
/// <see cref="FindTransitCellsSphere"/> already runs, from the same
/// per-part BSP root spheres, which is byte-for-byte the behaviour every
/// BSP object had before #334. AP-156's row already names that port as its
/// open residual; #334 is the OUTDOOR half of it.
/// </para>
/// </summary>
/// <param name="worldParts">Per-part world-placed authored boxes — the
/// outdoor extent walk's input.</param>
/// <param name="worldPartSpheres">Per-part world-placed BSP root spheres —
/// the indoor residual's and the building bridge's input. Same parts, same
/// order, from the same <see cref="ShadowPartGeometry"/> values.</param>
public static IReadOnlyList<uint> BuildShadowCellSetFromParts(
PhysicsDataCache cache,
uint seedCellId,
IReadOnlyList<ShadowPartBox> worldParts,
IReadOnlyList<Sphere> worldPartSpheres,
bool isStatic)
{
var candidates = new CellArray();
if (seedCellId == 0u || worldParts is null || worldParts.Count == 0)
return candidates.OrderedIds;
int sphereCount =
EffectiveSphereCount(worldPartSpheres, worldPartSpheres?.Count ?? 0);
uint seedLow = seedCellId & 0xFFFFu;
cache.CellGraph.TryGetTerrainOrigin(seedCellId, out var blockOrigin);
// SEED. 0x00510fd5-0x00510fe2 adds the object's own cell by id;
// 0x00510fed / 0x00510ff6 then skip the walk when the cell or the part
// array is null.
//
// DEVIATION (registered, AD-40): the outdoor rectangle runs at seed
// time here, not only from the walk. Retail can gate everything on
// obj->cell because a placed CPhysicsObj always has a resident
// CObjCell; acdream's CellGraph residency is transiently false during
// streaming (#168 / #169), and deferring the rectangle to the walk
// would drop a static or a live entity to a single cell for the window
// before its landblock publishes. This is the SAME residency policy
// BuildShadowCellSet already applies to its outdoor seed
// (CObjCell::find_cell_list 0x0052b53f, ahead of the arg4 walk gate),
// so the two registration floods differ only in sphere-vs-box — which
// is the whole of #334 — and the direction is over-inclusive.
bool outdoorAdded = false; // CELLARRAY::added_outside
bool seedLoaded;
if (seedLow >= 0x0100u)
{
candidates.Add(seedCellId);
seedLoaded = cache.GetCellStruct(seedCellId) is not null;
}
else
{
candidates.Add(seedCellId);
outdoorAdded = AddAllOutsideCellsFromParts(
worldParts, seedCellId, blockOrigin, candidates);
seedLoaded = cache.CellGraph.GetVisible(seedCellId) is not null;
}
if (!seedLoaded)
return candidates.OrderedIds;
for (int i = 0; i < candidates.Count; i++)
{
uint cellId = candidates.OrderedIds[i];
if ((cellId & 0xFFFFu) >= 0x0100u)
{
var cell = cache.GetCellStruct(cellId);
if (cell is null) continue; // 0x00511009 null cell pointer
if (sphereCount == 0) continue;
FindTransitCellsSphere(
cache, cell, cellId, worldPartSpheres!, sphereCount,
candidates, out bool exitStraddle);
if (exitStraddle && !outdoorAdded)
{
outdoorAdded = AddAllOutsideCellsFromParts(
worldParts, seedCellId, blockOrigin, candidates);
}
}
else
{
if (cache.CellGraph.GetVisible(cellId) is null)
continue;
// CLandCell::find_transit_cells @0x00533840:
// add_all_outside_cells (added_outside-guarded) then the
// CSortCell building bridge for this landcell's building.
if (!outdoorAdded)
{
outdoorAdded = AddAllOutsideCellsFromParts(
worldParts, seedCellId, blockOrigin, candidates);
}
var building = cache.GetBuilding(cellId);
if (building is not null && sphereCount > 0)
{
CheckBuildingTransit(
cache, building, worldPartSpheres!, sphereCount,
candidates, out _);
}
}
}
// Static prune (do_not_load_cells, 0x0052b66e) — indoor-seeded ONLY.
// The outdoor rectangle is deliberately unpruned: pruning it would
// re-create #334 in a new form.
if (isStatic && seedLow >= 0x0100u)
{
var seedCell = cache.GetCellStruct(seedCellId);
if (seedCell is not null)
{
var keep = new List<uint>(candidates.Count);
foreach (uint id in candidates.OrderedIds)
{
if (id == seedCellId || seedCell.VisibleCellIds.Contains(id))
keep.Add(id);
}
if (keep.Count != candidates.Count)
{
candidates.Clear();
foreach (uint id in keep) candidates.Add(id);
}
}
}
return candidates.OrderedIds;
}
/// <summary>
/// Verbatim port of <c>CEnvCell::find_visible_child_cell</c>
/// (<c>acclient_2013_pseudo_c.txt:311397</c>). Returns the cell whose cell-BSP

View file

@ -197,6 +197,10 @@ public sealed class PhysicsDataCache
{
_visualBounds[gfxObjId] = ComputeVisualBounds(gfxObj.VertexArray);
}
GfxObjVisualBounds? parsedBounds =
_visualBounds.TryGetValue(gfxObjId, out var cachedBounds)
? cachedBounds
: null;
if (_gfxObj.TryGetValue(gfxObjId, out GfxObjPhysics? existing))
{
@ -217,6 +221,10 @@ public sealed class PhysicsDataCache
Vertices = gfxObj.VertexArray,
Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray),
FlatPhysicsBsp = prepared?.PhysicsBsp,
VisualBounds = prepared?.VisualBounds ?? (parsedBounds is { } pb
? new FlatGfxObjVisualBounds(
pb.Min, pb.Max, pb.Center, pb.Radius, pb.HalfExtents)
: null),
};
_gfxObj[gfxObjId] = physics;
@ -285,6 +293,7 @@ public sealed class PhysicsDataCache
Radius = root.Radius,
},
FlatPhysicsBsp = prepared.PhysicsBsp,
VisualBounds = prepared.VisualBounds,
});
}
@ -1426,6 +1435,16 @@ public sealed class GfxObjPhysics
/// omit it.
/// </summary>
public FlatPhysicsBsp? FlatPhysicsBsp { get; internal set; }
/// <summary>
/// Retail <c>CGfxObj::gfx_bound_box</c> — the AABB of this GfxObj's vertex
/// array, filled by <c>CGfxObj::init_end</c> @0x00534200 and returned by
/// <c>CPhysicsPart::GetBoundingBox</c> @0x0050d600. Cached beside
/// <see cref="BoundingSphere"/> so the single
/// <c>ShadowShapeBuilder.FromLandblockBspParts</c> resolver answers both of
/// retail's cell-membership questions from one lookup (#334).
/// </summary>
public FlatGfxObjVisualBounds? VisualBounds { get; init; }
}
/// <summary>Cached collision shape data for a Setup (character/creature capsule).</summary>

View file

@ -430,12 +430,29 @@ 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). 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.
/// per OBJECT, not per part). WHICH flood is retail's own exclusive
/// dispatch on <c>HAS_PHYSICS_BSP_PS</c>
/// (<c>CPhysicsObj::calc_cross_cells</c> @0x00515230,
/// <c>0x00515285 test dword [esi+0xa8],0x10000</c> /
/// <c>0x0051528f jne 0x515305</c>):
/// </para>
/// <list type="bullet">
/// <item>BSP-bearing → <c>find_bbox_cell_list</c> @0x00510fc0, ported as
/// <see cref="CellTransit.BuildShadowCellSetFromParts"/>. Each part
/// contributes its authored BOUNDING BOX
/// (<see cref="ShadowShape.LocalBoundsMin"/>/<c>Max</c>), and the
/// outdoor expansion is the FILLED CELL RECTANGLE that box spans —
/// crossing landblock boundaries freely. Before #334 these objects
/// were routed through the sphere flood below, whose outdoor reach
/// is a fixed 3×3 (±24 m) regardless of radius, so any formation
/// wider than one land cell simply was not registered in its outer
/// cells.</item>
/// <item>otherwise → <see cref="BuildFloodSpheres"/> +
/// <see cref="CellTransit.BuildShadowCellSet"/>, retail's
/// cylsphere and sorting-sphere branches, byte-identical to before
/// #334 for every object that legitimately is spherical.</item>
/// </list>
/// <para>
/// Every shape row is then written into every flooded cell, mirroring
/// add_shadows_to_cells (0x00514ae0) + CPartArray::AddPartsShadow.
/// </para>
@ -460,9 +477,35 @@ public sealed class ShadowObjectRegistry
: DeriveOutdoorSeed(entityWorldPos, worldOffsetX, worldOffsetY, landblockId);
if (seed == 0u) return;
var floodSpheres = BuildFloodSpheres(entityWorldPos, entityWorldRot, shapes);
var cellSet = CellTransit.BuildShadowCellSet(
FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic);
// Retail's exclusive dispatch, mirrored: CPartArray::CacheHasPhysicsBSP
// (0x00518110) ORs 0x10000 on the first part whose gfxobj carries a
// physics BSP, and calc_cross_cells (0x00515285) branches on that bit.
// AP-152 made shape emission BSP-exclusive, so "has a BSP shape" and
// "is a BSP object" coincide exactly as the cached retail flag does.
bool hasBsp = false;
for (int i = 0; i < shapes.Count; i++)
{
if (shapes[i].CollisionType == ShadowCollisionType.BSP)
{
hasBsp = true;
break;
}
}
IReadOnlyList<uint> cellSet;
if (hasBsp)
{
var partBoxes = BuildFloodPartBoxes(entityWorldPos, entityWorldRot, shapes);
var partSpheres = BuildBspPartSpheres(entityWorldPos, entityWorldRot, shapes);
cellSet = CellTransit.BuildShadowCellSetFromParts(
FloodCache, seed, partBoxes, partSpheres, isStatic);
}
else
{
var floodSpheres = BuildFloodSpheres(entityWorldPos, entityWorldRot, shapes);
cellSet = CellTransit.BuildShadowCellSet(
FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic);
}
if (cellSet.Count == 0) return;
DeregisterCore(entityId, publishMutation: false);
@ -598,25 +641,13 @@ public sealed class ShadowObjectRegistry
}
/// <summary>
/// Retail cross-cell dispatch, <c>CPhysicsObj::calc_cross_cells</c>
/// @0x00515230, in retail's own priority order:
/// Flood spheres for an object with NO physics BSP — retail's cylsphere
/// and sorting-sphere branches of <c>CPhysicsObj::calc_cross_cells</c>
/// @0x00515230, both of which sit BELOW the <c>HAS_PHYSICS_BSP_PS</c> jump
/// at <c>0x0051528f jne 0x515305</c> and are unreachable from it:
///
/// <list type="number">
/// <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. <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) →
/// <item>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
/// radius, capped at 10.</item>
@ -626,15 +657,17 @@ public sealed class ShadowObjectRegistry
/// </list>
///
/// <para>
/// The BSP-first rule is redundant for every shape list acdream produces
/// today — <see cref="ShadowShapeBuilder.FromSetup"/> dispatches at
/// emission (AP-152) and both landblock-static publishers emit
/// homogeneous lists — exactly as
/// <c>Transition.BspOnlyDispatch</c> is redundant at the query site. It is
/// kept because retail genuinely dispatches here, and because a producer
/// that handed this method a mixed list would otherwise flood a
/// BSP-bearing object from its primitive and silently place it in the
/// wrong shadow cells (the #98 / #168 symptom class).
/// #334: there is no BSP arm here any more, and there must not be one.
/// The BSP branch is a structurally different algorithm over BOXES
/// (<see cref="CellTransit.BuildShadowCellSetFromParts"/>), and
/// <see cref="RegisterMultiPart"/> routes to it before this method is
/// reached. The arm this method used to carry — "a BSP part contributes
/// its ROOT BOUNDING SPHERE placed at its real center" — described
/// retail's INDOOR portal reject, not its outdoor expansion, and using it
/// for both is what capped every BSP object's outdoor reach at a 3×3
/// neighbourhood. A BSP shape reaching this method would be a dispatch
/// bug; it is skipped rather than flooded from, so it cannot silently
/// produce the wrong cells (the #98 / #168 symptom class).
/// </para>
/// </summary>
private static List<DatReaderWriter.Types.Sphere> BuildFloodSpheres(
@ -645,21 +678,16 @@ public sealed class ShadowObjectRegistry
const int RetailSphereCap = 10;
var spheres = new List<DatReaderWriter.Types.Sphere>();
bool anyBsp = false;
bool anyCyl = false;
foreach (var s in shapes)
{
if (s.CollisionType == ShadowCollisionType.BSP) anyBsp = true;
else if (s.CollisionType == ShadowCollisionType.Cylinder) anyCyl = true;
if (s.CollisionType == ShadowCollisionType.Cylinder) anyCyl = true;
}
// Retail's branch, chosen once: BSP-bbox, else cylspheres, else the
// sorting sphere (which acdream approximates with the remaining
// shapes' bounding spheres — AP-157).
ShadowCollisionType? only =
anyBsp ? ShadowCollisionType.BSP
: anyCyl ? ShadowCollisionType.Cylinder
: null;
// Retail's branch, chosen once: cylspheres, else the sorting sphere
// (which acdream approximates with the Sphere shapes — AP-157).
ShadowCollisionType only =
anyCyl ? ShadowCollisionType.Cylinder : ShadowCollisionType.Sphere;
// The 10-sphere clamp belongs to the CYLSPHERE branch alone.
// CObjCell::find_cell_list @0x0052b9f0 clamps the cylsphere count at
@ -667,41 +695,29 @@ public sealed class ShadowObjectRegistry
// fixed static-buffer capacity (the destination array at
// 0x844838..0x8448d8 is exactly ten 16-byte entries), not a policy.
//
// BSP branch: NO CAP, and this is a retail port. find_bbox_cell_list
// @0x00510fc0 -> CPartArray::calc_cross_cells_static @0x00518160 ->
// CEnvCell::find_transit_cells @0x0052cae0 walks every part, bounded
// only by num_parts. Clamping it 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.
//
// only == null (the sorting-sphere branch): int.MaxValue is NOT a
// retail port and the addresses above do not justify it. Retail's
// overload @0x0052b990 pushes a literal 1 (0x0052b9d6 push 1) and
// floods from ONE authored CSetup::sorting_sphere. acdream floods from
// every Sphere shape instead — a different DAT field with a different
// cardinality, which is AP-157, filed and open. Capping at 1 HERE would
// not move toward retail: it would take Spheres[0], which is not the
// sorting sphere. int.MaxValue keeps the substitution in its safe
// (over-inclusive) direction until AP-157 ports the real field. Inert
// over installed data — max 5 Spheres on any Setup (0x020016F7).
// Sorting-sphere branch: int.MaxValue is NOT a retail port and the
// addresses above do not justify it. Retail's overload @0x0052b990
// pushes a literal 1 (0x0052b9d6 push 1) and floods from ONE authored
// CSetup::sorting_sphere. acdream floods from every Sphere shape
// instead — a different DAT field with a different cardinality, which
// is AP-157, filed and open. Capping at 1 HERE would not move toward
// retail: it would take Spheres[0], which is not the sorting sphere.
// int.MaxValue keeps the substitution in its safe (over-inclusive)
// direction until AP-157 ports the real field. Inert over installed
// data — max 5 Spheres on any Setup (0x020016F7).
int cap = only == ShadowCollisionType.Cylinder ? RetailSphereCap : int.MaxValue;
foreach (var s in shapes)
{
if (only is { } required && s.CollisionType != required)
if (s.CollisionType != only)
continue;
if (spheres.Count >= cap)
break;
// 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.
// A primitive's LocalPosition already IS its centre, so
// BoundsCenter is Zero; the composition is kept identical to the
// emitted ShadowEntry rows so the flood and the geometry can never
// disagree about where the shape is.
var partWorldPos = entityWorldPos + Vector3.Transform(s.LocalPosition, entityWorldRot);
var partWorldRot = entityWorldRot * s.LocalRotation;
var world = partWorldPos + Vector3.Transform(s.BoundsCenter, partWorldRot);
@ -715,6 +731,66 @@ public sealed class ShadowObjectRegistry
return spheres;
}
/// <summary>
/// #334: the per-part world-placed authored boxes retail's
/// <c>CLandCell::add_all_outside_cells</c> @0x00533360 divides by
/// <c>square_length</c>. Composed exactly as the emitted
/// <see cref="ShadowEntry"/> rows are, so the flood rectangle and the
/// collision geometry describe the same placement.
/// </summary>
private static List<ShadowPartBox> BuildFloodPartBoxes(
Vector3 entityWorldPos,
Quaternion entityWorldRot,
System.Collections.Generic.IReadOnlyList<ShadowShape> shapes)
{
var boxes = new List<ShadowPartBox>(shapes.Count);
foreach (var s in shapes)
{
if (s.CollisionType != ShadowCollisionType.BSP)
continue;
boxes.Add(ShadowPartBox.FromShape(s, entityWorldPos, entityWorldRot));
}
return boxes;
}
/// <summary>
/// The per-part BSP ROOT bounding spheres retail's part-array
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0 loads at
/// <c>0x0052cb36 mov esi,[ecx+0x74]</c>, transforms through the part's own
/// Position (<c>0x0052cb4c</c> / <c>Position::localtolocal</c>) and reads
/// the radius from at <c>0x0052cb65 fadd [esi+0xc]</c>.
///
/// <para>
/// These drive ONLY the indoor half of the BSP flood and the outdoor
/// building bridge (<c>CEnvCell::check_building_transit</c> @0x0052c5d0),
/// which still use the sphere traversal — the AP-159 residual. The
/// outdoor expansion uses <see cref="BuildFloodPartBoxes"/> and never
/// these. No cap: <c>find_bbox_cell_list</c> walks every part, bounded
/// only by <c>num_parts</c> (7 installed Setups carry more than 10
/// physics-BSP parts, max 49 on Setup 0x02001A91).
/// </para>
/// </summary>
private static List<DatReaderWriter.Types.Sphere> BuildBspPartSpheres(
Vector3 entityWorldPos,
Quaternion entityWorldRot,
System.Collections.Generic.IReadOnlyList<ShadowShape> shapes)
{
var spheres = new List<DatReaderWriter.Types.Sphere>(shapes.Count);
foreach (var s in shapes)
{
if (s.CollisionType != ShadowCollisionType.BSP)
continue;
var partWorldPos = entityWorldPos + Vector3.Transform(s.LocalPosition, entityWorldRot);
var partWorldRot = entityWorldRot * s.LocalRotation;
spheres.Add(new DatReaderWriter.Types.Sphere
{
Origin = partWorldPos + Vector3.Transform(s.BoundsCenter, partWorldRot),
Radius = s.Radius,
});
}
return spheres;
}
/// <summary>
/// Derive the outdoor landcell id under a world position — the implicit
/// seed for landblock-baked statics registered without a cell id

View file

@ -0,0 +1,171 @@
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// One physics-BSP part's flood geometry, resolved as ONE value: the part
/// GfxObj's physics-BSP root bounding sphere AND the axis-aligned box of its
/// vertex array, both in the GfxObj's own unscaled frame.
///
/// <para>
/// The pairing is the AP-156 invariant applied a second time. Retail's cell
/// membership reads BOTH — <c>CEnvCell::find_transit_cells</c> @0x0052cae0
/// takes <c>CGfxObj::physics_sphere</c> (<c>[gfxobj+0x74]</c>) for its cheap
/// portal-plane reject, and <c>CLandCell::add_all_outside_cells</c> @0x00533360
/// takes <c>CPhysicsPart::GetBoundingBox</c> @0x0050d600
/// (<c>&amp;gfxobj-&gt;gfx_bound_box</c>) for the outdoor extent walk. A resolver
/// that answered only one of the two would leave the other call site to
/// synthesize a substitute, which is exactly how the sphere came to be placed
/// at the part origin (AP-156) and how #334's outdoor rectangle came to be a
/// fixed 3×3.
/// </para>
///
/// <para>
/// <see cref="BoxMin"/>/<see cref="BoxMax"/> come from
/// <c>FlatGfxObjVisualBounds</c>, which
/// <c>FlatCollisionAssetBuilder.FlattenGfxObj</c> computes with
/// <c>PhysicsDataCache.ComputeVisualBounds(source.VertexArray)</c> — the exact
/// <c>CGfxObj::init_end</c> @0x00534200 computation (seed min=max=vertices[0],
/// then <c>BBox::AdjustBBox</c> over every vertex of the render vertex array,
/// which is also the array the physics polygons index into).
/// </para>
/// </summary>
public readonly record struct ShadowPartGeometry
{
private ShadowPartGeometry(
FlatCollisionSphere sphere,
Vector3 boxMin,
Vector3 boxMax)
{
Sphere = sphere;
BoxMin = boxMin;
BoxMax = boxMax;
}
/// <summary>Retail <c>CGfxObj::physics_sphere</c> — the physics BSP root
/// bounding sphere, origin included, unscaled.</summary>
public FlatCollisionSphere Sphere { get; }
/// <summary>Retail <c>CGfxObj::gfx_bound_box.m_vMin</c>, unscaled.</summary>
public Vector3 BoxMin { get; }
/// <summary>Retail <c>CGfxObj::gfx_bound_box.m_vMax</c>, unscaled.</summary>
public Vector3 BoxMax { get; }
/// <summary>
/// Pairs the two. <paramref name="visualBounds"/> is the prepared
/// package's <c>FlatGfxObjVisualBounds</c>; when it is absent (graph-only
/// fixtures, and prepared assets baked without the field) the box falls
/// back to the sphere's own axis-aligned bound, which contains every
/// physics polygon vertex the sphere contains and keeps the substitution
/// in the over-inclusive direction retail itself uses (§1.8 of
/// <c>docs/research/2026-08-06-334-contract.md</c>). The fallback lives
/// HERE so no call site can observe a half-populated value.
/// </summary>
public static ShadowPartGeometry Create(
FlatCollisionSphere sphere,
FlatGfxObjVisualBounds? visualBounds)
{
if (visualBounds is { } bounds)
return new ShadowPartGeometry(sphere, bounds.Min, bounds.Max);
var extent = new Vector3(sphere.Radius);
return new ShadowPartGeometry(
sphere,
sphere.Origin - extent,
sphere.Origin + extent);
}
}
/// <summary>
/// One physics-BSP part's world-placed bounding box — the per-part input to
/// retail's outdoor extent walk.
///
/// <para>
/// Retail's <c>CLandCell::add_all_outside_cells</c> @0x00533360 calls
/// <c>BBox::LocalToGlobal(part-&gt;gfxobj-&gt;gfx_bound_box, part-&gt;pos,
/// cell0-&gt;pos)</c> (@0x00533527) per part, so the box it divides by
/// <c>square_length</c> is the part's authored box re-fit through the part's
/// own placement. <see cref="LocalMin"/>/<see cref="LocalMax"/> are that
/// authored box (entity-scaled); <see cref="WorldPosition"/>/
/// <see cref="WorldRotation"/> are the part placement
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> composes for the
/// <c>ShadowEntry</c> rows, so the flood and the geometry can never disagree
/// about where the part is.
/// </para>
///
/// <para>
/// CONSTRUCTION IS BY FACTORY ONLY: min and max arrive together, from one
/// <see cref="ShadowShape"/>, so no future producer can carry one and drop the
/// other.
/// </para>
/// </summary>
public readonly record struct ShadowPartBox
{
private ShadowPartBox(
Vector3 localMin,
Vector3 localMax,
Vector3 worldPosition,
Quaternion worldRotation)
{
LocalMin = localMin;
LocalMax = localMax;
WorldPosition = worldPosition;
WorldRotation = worldRotation;
}
/// <summary>Authored box minimum in the part's own frame, entity-scaled.</summary>
public Vector3 LocalMin { get; }
/// <summary>Authored box maximum in the part's own frame, entity-scaled.</summary>
public Vector3 LocalMax { get; }
/// <summary>The part's world placement — retail <c>CPhysicsPart::pos</c>.</summary>
public Vector3 WorldPosition { get; }
/// <summary>The part's world orientation — retail <c>CPhysicsPart::pos</c>.</summary>
public Quaternion WorldRotation { get; }
/// <summary>
/// Composes the part's world placement exactly as
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> composes the
/// emitted <c>ShadowEntry</c>'s.
/// </summary>
public static ShadowPartBox FromShape(
in ShadowShape shape,
Vector3 entityWorldPosition,
Quaternion entityWorldRotation)
=> new(
shape.LocalBoundsMin,
shape.LocalBoundsMax,
entityWorldPosition
+ Vector3.Transform(shape.LocalPosition, entityWorldRotation),
entityWorldRotation * shape.LocalRotation);
/// <summary>
/// Retail <c>BBox::LocalToGlobal</c> @0x005b2120 — a proper EIGHT-CORNER
/// re-fit, not a min/max transform: transform <c>min</c>, seed both
/// corners from it, transform the other seven and <c>AdjustBBox</c> each.
/// A rotated box therefore GROWS, conservatively, which is the direction
/// retail deliberately errs in.
/// </summary>
/// <param name="frameOrigin">World origin of the destination frame —
/// retail's <c>cell0-&gt;pos</c>, i.e. the landblock the extent walk
/// anchors on.</param>
public void RefitTo(Vector3 frameOrigin, out Vector3 min, out Vector3 max)
{
Vector3 offset = WorldPosition - frameOrigin;
min = new Vector3(float.MaxValue);
max = new Vector3(float.MinValue);
for (int corner = 0; corner < 8; corner++)
{
var local = new Vector3(
(corner & 1) == 0 ? LocalMin.X : LocalMax.X,
(corner & 2) == 0 ? LocalMin.Y : LocalMax.Y,
(corner & 4) == 0 ? LocalMin.Z : LocalMax.Z);
Vector3 world = Vector3.Transform(local, WorldRotation) + offset;
min = Vector3.Min(min, world);
max = Vector3.Max(max, world);
}
}
}

View file

@ -20,12 +20,13 @@ namespace AcDream.Core.Physics;
/// CONSTRUCTION IS BY FACTORY ONLY (<see cref="Bsp"/>, <see cref="Cylinder"/>,
/// <see cref="Sphere"/>) and the constructor is private. That is the AP-156
/// invariant expressed at the type rather than only at the producer: a BSP
/// shape's radius and its bounding-sphere CENTRE arrive as one
/// <see cref="FlatCollisionSphere"/> value and are scaled together inside
/// <see cref="Bsp"/>, so no call site — present or future — can take the
/// radius while dropping the origin. That split is exactly what produced
/// AP-156, and with the old public 7-argument constructor a new BSP producer
/// could have reintroduced it silently and green.
/// shape's radius, its bounding-sphere CENTRE, and its authored bounding BOX
/// arrive as one <see cref="ShadowPartGeometry"/> value and are scaled together
/// inside <see cref="Bsp"/>, so no call site — present or future — can take one
/// while dropping another. Those splits are exactly what produced AP-156 (the
/// centre dropped) and #334 (the box never resolved at all); with a public
/// positional constructor a new BSP producer could reintroduce either silently
/// and green.
/// </para>
/// </summary>
public readonly record struct ShadowShape
@ -38,7 +39,9 @@ public readonly record struct ShadowShape
ShadowCollisionType collisionType,
float radius,
float cylHeight,
Vector3 boundsCenter)
Vector3 boundsCenter,
Vector3 localBoundsMin,
Vector3 localBoundsMax)
{
GfxObjId = gfxObjId;
LocalPosition = localPosition;
@ -48,6 +51,8 @@ public readonly record struct ShadowShape
Radius = radius;
CylHeight = cylHeight;
BoundsCenter = boundsCenter;
LocalBoundsMin = localBoundsMin;
LocalBoundsMax = localBoundsMax;
}
/// <summary>Source GfxObj id, for the BSP walk and for diagnostics.</summary>
@ -107,27 +112,51 @@ public readonly record struct ShadowShape
public Vector3 BoundsCenter { get; }
/// <summary>
/// One physics-BSP part. <paramref name="localBounds"/> is the part
/// GfxObj's physics-BSP ROOT bounding sphere in the GfxObj's OWN frame,
/// unscaled — retail's <c>CGfxObj::physics_sphere</c>. Radius and centre
/// are scaled together here, which is the whole point of taking them as
/// one value.
/// The shape's AXIS-ALIGNED BOX in the same local frame as
/// <see cref="BoundsCenter"/>, already entity-scaled. For a BSP shape this
/// is retail's <c>CGfxObj::gfx_bound_box</c> — the AABB of the GfxObj's
/// vertex array, which <c>CPhysicsPart::GetBoundingBox</c> @0x0050d600
/// returns and which <c>CLandCell::add_all_outside_cells</c> @0x00533360
/// divides by <c>square_length</c> to build the outdoor cell rectangle
/// (#334). Primitive shapes carry their own radius/height box; they never
/// reach that path, because <c>CPhysicsObj::calc_cross_cells</c>
/// @0x00515230 routes only <c>HAS_PHYSICS_BSP_PS</c> objects to
/// <c>find_bbox_cell_list</c>.
/// </summary>
public Vector3 LocalBoundsMin { get; }
/// <inheritdoc cref="LocalBoundsMin"/>
public Vector3 LocalBoundsMax { get; }
/// <summary>
/// One physics-BSP part. <paramref name="localGeometry"/> carries the part
/// GfxObj's physics-BSP ROOT bounding sphere AND its vertex-array box in
/// the GfxObj's OWN frame, unscaled — retail's
/// <c>CGfxObj::physics_sphere</c> and <c>CGfxObj::gfx_bound_box</c>.
/// Sphere radius, sphere centre, and both box corners are scaled together
/// here, which is the whole point of taking them as one value: retail
/// reads the sphere for the indoor portal reject and the box for the
/// outdoor extent walk, and a producer that supplied one without the
/// other would silently force a substitute at the other call site
/// (AP-156, then #334).
/// </summary>
public static ShadowShape Bsp(
uint gfxObjId,
Vector3 localPosition,
Quaternion localRotation,
float scale,
FlatCollisionSphere localBounds)
ShadowPartGeometry localGeometry)
=> new(
gfxObjId,
localPosition,
localRotation,
scale,
ShadowCollisionType.BSP,
localBounds.Radius * scale,
localGeometry.Sphere.Radius * scale,
0f,
localBounds.Origin * scale);
localGeometry.Sphere.Origin * scale,
localGeometry.BoxMin * scale,
localGeometry.BoxMax * scale);
/// <summary>
/// One Setup CylSphere. <paramref name="localPosition"/> already IS the
@ -148,7 +177,9 @@ public readonly record struct ShadowShape
ShadowCollisionType.Cylinder,
radius,
cylHeight,
Vector3.Zero);
Vector3.Zero,
new Vector3(-radius, -radius, 0f),
new Vector3(radius, radius, cylHeight));
/// <summary>
/// One Setup Sphere. <paramref name="localPosition"/> already IS the
@ -168,5 +199,7 @@ public readonly record struct ShadowShape
ShadowCollisionType.Sphere,
radius,
0f,
Vector3.Zero);
Vector3.Zero,
new Vector3(-radius),
new Vector3(radius));
}

View file

@ -94,21 +94,25 @@ public static class ShadowShapeBuilder
/// 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>
/// 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, FlatCollisionSphere?>? physicsBspBounds = null)
Func<uint, ShadowPartGeometry?>? physicsBspBounds = null)
{
if (setup is null) throw new ArgumentNullException(nameof(setup));
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
@ -210,17 +214,27 @@ public static class ShadowShapeBuilder
// 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 and centre together.
FlatCollisionSphere bounds =
// 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)
?? new FlatCollisionSphere(Vector3.Zero, 2f);
?? 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,
localBounds: bounds));
localGeometry: geometry));
}
return result;
@ -308,12 +322,21 @@ public static class ShadowShapeBuilder
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,
localBounds: localBounds));
localGeometry: geometry));
}
return shapes;