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:
parent
f0588725cf
commit
13fcf38138
19 changed files with 2186 additions and 137 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 & 0xFFFF) - 1) >> 3</c>
|
||||
/// (<c>0x0053343a and eax,0xffff</c> — the mask BN drops) and
|
||||
/// <c>baseY = (gid - 1) & 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 <= v < 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->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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
171
src/AcDream.Core/Physics/ShadowPartBox.cs
Normal file
171
src/AcDream.Core/Physics/ShadowPartBox.cs
Normal 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>&gfxobj->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->gfxobj->gfx_bound_box, part->pos,
|
||||
/// cell0->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->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue