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
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>
200 lines
8.3 KiB
C#
200 lines
8.3 KiB
C#
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);
|
||
}
|
||
}
|
||
|
||
/// <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);
|
||
}
|
||
}
|
||
}
|