acdream/src/AcDream.Core/Physics/ShadowShapeBuilder.cs
Erik 13fcf38138 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>
2026-08-06 19:07:06 +02:00

368 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics;
/// <summary>
/// Pure-function builder that translates a <see cref="Setup"/> into a list of
/// <see cref="ShadowShape"/>s suitable for registration via
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/>.
///
/// <para>
/// This is a DISPATCH, not a union. In priority order: (3) when ANY Part's
/// effective GfxObj has a non-null PhysicsBSP, emit one BSP shape per such
/// Part — and nothing else; otherwise (1) every CylSphere → Cylinder shape;
/// otherwise (2) every Sphere → Sphere shape; otherwise nothing at all. Local
/// transforms come from PlacementFrames[Resting | Default | first available].
/// </para>
///
/// <para>
/// Retail anchor: <c>CPhysicsObj::FindObjCollisions</c> (0x0050f050)
/// dispatches EXCLUSIVELY on <c>HAS_PHYSICS_BSP_PS</c> (0x10000)
/// (<c>0x0050f165 test dword [esi+0xa8],0x10000</c> /
/// <c>0x0050f16f je 0x50f1a2</c>): it calls
/// <c>CPartArray::FindObjCollisions</c> (the per-part BSP walk) and returns
/// (<c>0x0050f19d jmp 0x50f2b0</c>, an UNCONDITIONAL jump past both primitive
/// branches — the CylSphere loop starts at 0x50f1a2 and the Sphere loop at
/// 0x50f21d), OR walks the Setup's CylSpheres and returns
/// (<c>0x0050f1d6 jae 0x50f317</c>), OR walks the Setup's Spheres, OR — with
/// none of the three — returns the seeded <c>OK_TS</c> without synthesizing
/// any shape (<c>0x0050f22f je 0x50f31b</c>). BSP wins.
/// <c>CPhysicsPart::find_obj_collisions</c> (0x0050d8d0) tests ONLY the
/// GfxObj physics BSP; CylSpheres and Spheres are <c>Setup</c>-level arrays
/// reached through <c>CPartArray::GetCylsphere</c> (0x00518090) and
/// <c>GetSphere</c> (0x00518070), so a part has no primitive of its own.
/// </para>
///
/// <para>
/// Cell membership dispatches on the SAME flag and in the same priority:
/// <c>CPhysicsObj::calc_cross_cells</c> (0x00515230) tests
/// <c>0x10000</c> at <c>0x00515285</c> and routes a BSP-bearing object to
/// <c>CPhysicsObj::find_bbox_cell_list</c> (0x00510fc0) at
/// <c>0x0051528f jne 0x515305</c>, never reaching its cylsphere
/// (<c>0x005152d1</c>) or sorting-sphere (<c>0x005152fb</c>) branches. That
/// is why the exclusivity is enforced HERE, at emission, rather than only at
/// the query-time guard <c>Transition.BspOnlyDispatch</c>: the shape list is
/// also the input to <c>ShadowObjectRegistry.BuildFloodSpheres</c>.
/// </para>
///
/// <para>
/// AP-152 (filed and retired 2026-08-06): these three steps used to be
/// emitted ADDITIVELY — 172 of 5,935 installed Setups carry both a primitive
/// and a physics-BSP part. The collision half of that divergence was already
/// inert, because <c>Transition.BspOnlyDispatch</c> skips both primitive
/// branches whenever the wire <c>PhysicsState</c> carries 0x10000 and ACE
/// derives that bit from the same DAT flag; the live half was CELL
/// MEMBERSHIP, which had no such guard. Gating here also removes acdream's
/// undeclared dependency on the server sending the bit: the gate is derived
/// from the parts, exactly as retail's <c>CPartArray::CacheHasPhysicsBSP</c>
/// (0x00518110) derives it.
/// </para>
/// </summary>
public static class ShadowShapeBuilder
{
/// <summary>
/// Build the shape list for a Setup.
/// </summary>
/// <param name="setup">The Setup to walk.</param>
/// <param name="entScale">The entity's overall scale factor; multiplies
/// every radius, height, and local offset.</param>
/// <param name="hasPhysicsBsp">Predicate: does the GfxObj with this id
/// have a physics BSP? Production derives it from the SAME resolver that
/// supplies <paramref name="physicsBspBounds"/>
/// (<c>id => _physicsBspBounds(id) is not null</c>, over
/// <c>PhysicsDataCache.GetFlatGfxObj(id)?.PhysicsBsp</c>), so the dispatch
/// gate and the emitted geometry cannot disagree.</param>
/// <param name="partPoseOverride">#175: per-part pose override for the
/// BSP part shapes — the entity's motion-table DEFAULT-STATE pose (the
/// closed pose for doors). Retail collision tests each part's LIVE
/// <c>CPhysicsPart</c> pose, which for an idle entity is the motion
/// table's default state, NOT the Setup's placement frame — the two
/// differ on e.g. the Facility Hub double door (Setup 0x02000C9D:
/// placement poses the panels AJAR at yaw 150°/30°, y 0.44 m; the
/// closed pose is straight). Null / short lists fall back to the
/// placement frame per part (entities with no motion table, and the
/// CylSphere/Sphere shapes, are unaffected — retail poses those from
/// the setup too).</param>
/// <param name="effectivePartGfxObjIds">Current part identities after
/// retail <c>AnimPartChanged</c> processing. Collision keeps each Setup
/// index and pose, but reads PhysicsBSP from the installed replacement.
/// Null or short lists fall back to the Setup identity.</param>
/// <param name="physicsBspBounds">The part GfxObj's physics-BSP ROOT
/// bounding sphere 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, ShadowPartGeometry?>? physicsBspBounds = null)
{
if (setup is null) throw new ArgumentNullException(nameof(setup));
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
var result = new List<ShadowShape>();
// 0. Retail dispatch gate. CPhysicsObj::FindObjCollisions tests
// HAS_PHYSICS_BSP_PS FIRST (0x0050f165) and leaves the BSP branch
// through an unconditional jmp past both primitive loops
// (0x0050f19d); CPhysicsObj::calc_cross_cells tests the same flag
// at 0x00515285 and routes to find_bbox_cell_list. Retail derives
// the flag from the part array itself
// (CPartArray::CacheHasPhysicsBSP 0x00518110 ORs 0x10000 on the
// first part whose gfxobj->physics_bsp is non-null), so the gate
// below reads the SAME effective part identities step 3 reads —
// never setup.Parts directly. A gate keyed on a different identity
// could suppress the primitives while step 3 emitted nothing,
// silently deleting the entity's collision.
bool anyPhysicsBspPart = false;
for (int i = 0; i < setup.Parts.Count; i++)
{
if (hasPhysicsBsp(EffectivePartGfxObjId(setup, effectivePartGfxObjIds, i)))
{
anyPhysicsBspPart = true;
break;
}
}
// Steps 1 and 2 run ONLY for an object with no physics-BSP part.
if (!anyPhysicsBspPart)
{
// 1. CylSpheres — each becomes a Cylinder shape.
foreach (var cyl in setup.CylSpheres)
{
if (cyl.Radius <= 0f) continue;
float baseHeight = cyl.Height > 0f ? cyl.Height : cyl.Radius * 4f;
result.Add(ShadowShape.Cylinder(
gfxObjId: 0u,
localPosition: new Vector3(cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z) * entScale,
localRotation: Quaternion.Identity,
scale: entScale,
radius: cyl.Radius * entScale,
cylHeight: baseHeight * entScale));
}
// 2. Spheres — only when no CylSpheres. Retail's CylSphere loop
// returns rather than falling into the Sphere loop
// (0x0050f1d6 jae 0x50f317). Each becomes a true Sphere (no
// height clamping): CSphere::intersects_sphere @ 0x00537A80
// uses 3-D distance for the overlap check, unlike CCylSphere
// which clips to [low_pt, high_pt].
if (setup.CylSpheres.Count == 0)
{
foreach (var sph in setup.Spheres)
{
if (sph.Radius <= 0f) continue;
result.Add(ShadowShape.Sphere(
gfxObjId: 0u,
localPosition: new Vector3(sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale,
localRotation: Quaternion.Identity,
scale: entScale,
radius: sph.Radius * entScale));
}
}
}
// 3. Parts — one BSP shape per part with a non-null PhysicsBSP.
// Pose priority per part: partPoseOverride (the motion-table
// default-state pose, #175) → placement frame → identity.
AnimationFrame? placementFrame = ResolvePlacementFrame(setup);
for (int i = 0; i < setup.Parts.Count; i++)
{
// Retail CPhysicsPart::SetPart installs AnimPartChanged's current
// degrade array before CPartArray::FindObjCollisions reads it.
// Keep the stable Setup part index/pose, but source collision
// identity from that effective part when one was supplied.
uint gfxId = EffectivePartGfxObjId(setup, effectivePartGfxObjIds, i);
if (!hasPhysicsBsp(gfxId)) continue;
Frame partFrame;
if (partPoseOverride is not null && i < partPoseOverride.Count)
partFrame = partPoseOverride[i];
else if (placementFrame is not null && i < placementFrame.Frames.Count)
partFrame = placementFrame.Frames[i];
else
partFrame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
// The part's physics-BSP root bounding sphere — retail's
// CGfxObj::physics_sphere, assigned BSPTREE::GetSphere(physics_bsp)
// @0x005397e0. RADIUS AND CENTER TOGETHER: retail's per-part
// cross-cell walk (CEnvCell::find_transit_cells @0x0052cae0,
// reached from find_bbox_cell_list @0x00510fc0 through
// CPartArray::calc_cross_cells_static @0x00518160) transforms the
// sphere's CENTER through the part's own Position
// (0x0052cb4c add eax,0x30 → Position::localtolocal) BEFORE it
// reads the radius at 0x0052cb65 fadd [esi+0xc]. The center is not
// the part origin: 376 of the 973 installed physics-BSP parts sit
// further from it than half their own radius. A single resolver
// supplies both so one cannot be taken without the other.
// Absent bounds keep the loose-but-safe 2 m placeholder, centred
// on the part origin because nothing better is known.
// 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)
?? 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,
localGeometry: geometry));
}
return result;
}
/// <summary>
/// #185: build BSP shapes for a landblock-baked multi-part entity (buildings,
/// stair runs, fences, rock clusters) from its per-part <see cref="MeshRef"/>s,
/// for registration via <see cref="ShadowObjectRegistry.RegisterMultiPart"/>
/// under the entity's SINGLE unique id.
///
/// <para>
/// Replaces the former per-part <c>Register(entity.Id * 256u + partIndex)</c>
/// (GameWindow.cs) whose <c>* 256u</c> OVERFLOWED uint32 for class-prefixed
/// landblock ids (<c>0x40</c>/<c>0x80</c>/<c>0xC0</c>…): the overflow dropped
/// the prefix byte, so different-class entities sharing the low 24 bits
/// collided on one shadow part-id and <c>Register</c>'s deregister-then-insert
/// silently overwrote one entity's collision geometry — the #185 "invisible
/// wall half-way up the stairs" (rendered steps with no collision).
/// </para>
///
/// <para>
/// Retail anchor: a multi-part object is one <c>CPhysicsObj</c> + <c>CPartArray</c>;
/// <c>CPhysicsObj::add_shadows_to_cells</c> (0x00514ae0) → <c>CPartArray::AddPartsShadow</c>
/// walks the part array under the single object — no synthetic per-part id.
/// </para>
///
/// <para>
/// Each part's local transform comes from its <see cref="MeshRef.PartTransform"/>
/// (root-relative), decomposed to LocalPosition/LocalRotation/Scale;
/// <c>RegisterMultiPart</c> reconstructs the world placement identically
/// (<c>entityWorldPos + rotate(LocalPosition, entityWorldRot)</c>). Building
/// shells are excluded — they collide via the per-LandCell building channel
/// (<c>CSortCell::find_collisions</c>), not as shadow objects.
/// </para>
/// </summary>
/// <param name="meshRefs">The entity's per-part mesh references.</param>
/// <param name="isBuildingShell">True for <c>LandBlockInfo.Buildings[]</c> shells.</param>
/// <param name="getGfxObj">Resolves a GfxObj id to its cached physics (BSP +
/// bounding sphere). Production: <c>id => cache.GetGfxObj(id)</c>.</param>
public static List<ShadowShape> FromLandblockBspParts(
IReadOnlyList<MeshRef> meshRefs,
bool isBuildingShell,
Func<uint, GfxObjPhysics?> getGfxObj)
{
if (getGfxObj is null) throw new ArgumentNullException(nameof(getGfxObj));
var shapes = new List<ShadowShape>();
// Building shells collide via the building channel (retail), not shadow objects.
if (isBuildingShell || meshRefs is null) return shapes;
foreach (var meshRef in meshRefs)
{
var phys = getGfxObj(meshRef.GfxObjId);
if (phys is null) continue;
FlatPhysicsBsp? flat = phys.FlatPhysicsBsp;
bool hasFlat = flat is { RootIndex: >= 0 };
if (!hasFlat && phys.BSP?.Root is null)
continue; // graph-only fixture seam until I6 referee removal
// PartTransform is root-relative; decompose to local pos/rot/scale.
if (!Matrix4x4.Decompose(meshRef.PartTransform,
out var pScale, out var pRot, out var pPos))
{
pScale = Vector3.One;
pRot = Quaternion.Identity;
pPos = new Vector3(meshRef.PartTransform.M41,
meshRef.PartTransform.M42,
meshRef.PartTransform.M43);
}
float partScale = pScale.X > 0f ? pScale.X : 1f; // AC objects are uniformly scaled
// Root bounding sphere, CENTER AND RADIUS TOGETHER — see
// ShadowShape.BoundsCenter. Retail's per-part cross-cell walk
// (CEnvCell::find_transit_cells @0x0052cae0) transforms
// CGfxObj::physics_sphere's center through the part's Position
// before using its radius; a landblock-baked part array is the
// same CPartArray walk (CPartArray::calc_cross_cells_static
// @0x00518160), so dropping the center here mis-places the flood
// exactly as it did for live Setups.
FlatCollisionSphere localBounds =
hasFlat
? flat!.Nodes[flat.RootIndex].BoundingSphere
: new FlatCollisionSphere(
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,
localGeometry: geometry));
}
return shapes;
}
/// <summary>
/// The collision identity of part <paramref name="index"/>: the installed
/// <c>AnimPartChanged</c> replacement when one was supplied, else the
/// Setup's own part. Shared by the step-0 dispatch gate and the step-3
/// emission so the two can never read different identities.
/// </summary>
private static uint EffectivePartGfxObjId(
Setup setup,
IReadOnlyList<uint>? effectivePartGfxObjIds,
int index)
=> effectivePartGfxObjIds is not null && index < effectivePartGfxObjIds.Count
? effectivePartGfxObjIds[index]
: (uint)setup.Parts[index];
/// <summary>Resolve the placement frame in priority Resting → Default →
/// first available. Mirrors <c>SetupMesh.Flatten</c>'s convention.</summary>
private static AnimationFrame? ResolvePlacementFrame(Setup setup)
{
if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting)) return resting;
if (setup.PlacementFrames.TryGetValue(Placement.Default, out var def)) return def;
foreach (var kvp in setup.PlacementFrames) return kvp.Value;
return null;
}
}