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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue