acdream/src/AcDream.Core/Physics/ShadowPartBox.cs
Erik b3e43d22c9
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
fix(physics): S1B — indoor cell membership admits on the part BOX, as retail does (#335, AP-159 narrowed)
CellTransit.FindTransitCellsBox ports CEnvCell::find_transit_cells'
part-array overload @0x0052cae0 line-for-line: per-portal x per-part
order, the sphere cheap-reject at F_EPSILON+radius, the box admit whose
"Straddle or crossing-side" rule is exactly retail's `eax != side` under
the PDB Sidedness enum, leads-outside placed AFTER the admit, the
unconditional unloaded-neighbour hint without the sphere overload's
re-test, the destination box_intersects_cell gate with its deliberate
no-break, and add_all_outside_cells after the loop. The box-vs-cell BSP
traversal lands in BOTH representations behind the flat-authoritative
dispatcher with a graph referee whose 20,000 installed comparisons are
pinned by assertion (review F5), zero mismatch.

Dual Opus review: PASS on both lenses. The mandatory D0 pseudocode pass
caught that the contract's own supplementary note misattributed the box
block to the sphere overload — it belongs to a SECOND
check_building_transit overload @0x0052c680, whose portal-side
convention is INVERTED and whose admit differs; the pseudocode doc now
records that trap plus two byte confirmations made at review:
which_side @0x00444720 is strictly > eps for POSITIVE, and
intersect_box's in-plane early exit returns CROSSING(3)
(jp @0x005aa1bc -> mov eax,3), settling review items b1/b2 for the
future bridge porter. The bridge itself stays unported as AP-159's
explicit remainder.

The review also retired #335's severity premise honestly: "over-
inclusive only, never a missed one" is wrong at production shape ratios,
where the box (whole-vertex AABB) legitimately exceeds the sphere
(physics-polygon root sphere). Measured, both populations: rigged
(box << sphere) — 1,520 placements, 978 cells removed, 0 added;
production-ratio (box >= sphere) — 950 placements, 20 removed, 1 ADDED
through the loaded-neighbour gate, which is retail's direction, not a
defect. The no-op guard (review F4) asserts removal is nonzero so an
unwired admit cannot pass silently.

Process note: the implementer authored against this session's worktree
at bec5c69d, 25 commits stale — the recorded worktree-base class. All
six files were byte-identical between bases, the diff transplanted
losslessly, and every verdict-bearing run (referee, direction sweeps,
this clean-room) was re-executed on current main. S2's uncommitted
phase-1 edits were stashed for this landing so the suite verdicts
exactly one changeset.

Also untracks 341-slope-capture.jsonl (an accidental add) and
gitignores it.

Clean-room suite: 11,248 passed / 6 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 07:46:57 +02:00

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