fix(physics): AP-156 — flood the BSP sphere where the geometry is, not at the part origin
The AP-152 retail review (docs/research/2026-08-06-ap152-review-retail.md) FAILED `4abd1b5e` and is right. `ShadowObjectRegistry.BuildFloodSpheres` took each physics-BSP part's ROOT BOUNDING SPHERE RADIUS (FlatCollisionAssetBuilder.cs:393 -> LiveEntityCollisionBuilder.cs:137) and centred it on the PART ORIGIN (ShadowShapeBuilder.cs:194), discarding the root sphere's own Origin. Re-measured independently against the installed client_portal.dat, reproducing the reviewer's numbers exactly: 376 of 973 physics-BSP parts have |origin| > radius/2, worst 20.762 m on a 27.708 m sphere (gfx 0x010036DD, Setup 0x0200129A). Over the 172 Setups AP-152 moved onto that path the emitted flood FAILED TO CONTAIN the object's own BSP sphere for 170 of them (73 CylSphere-bearing, 97 Sphere-bearing), worst shortfall 9.911 m on Setup 0x02000255 — whose one part's sphere sits 9.911 m above the part origin — and for 43 the post-AP-152 flood was strictly SMALLER than the pre-AP-152 one. Indoor flooding is 3-D (CellTransit.cs:601 routes every id & 0xFFFF >= 0x0100 candidate through FindTransitCellsSphere), so a tall prop or door slab was absent from EnvCells it physically occupies and therefore never a broadphase candidate there (TransitionTypes.cs:3763 iterates only entries already in the cell). That is the #98 / #168 class AP-152 exists to remove. Retail, re-disassembled from the PDB-paired binary (check_exe_pdb.py MATCH, CodeView GUID 9e847e2f-777c-4bd9-886c-22256bb87f32), every address resolved back through named-retail/symbols.json: CGfxObj::physics_sphere is [gfxobj+0x74] (physics_bsp is [+0x78], as CPartArray::CacheHasPhysicsBSP @0x00518110 reads at 0x00518127), and acclient pseudo-C 0x00534b5b assigns it BSPTREE::GetSphere(physics_bsp). BSPTREE::GetSphere @0x005397e0 8b01 mov eax,[ecx] ; BSPTREE::root_node 83c004 add eax,4 ; past BSPNODE::vfptr -> CSphere sphere So retail's per-part flood sphere IS the BSP root bounding sphere, ORIGIN INCLUDED (acclient.h: BSPNODE { vfptr; CSphere sphere; ... }, CSphere { Vector3 center; float radius; } -> radius at +0xc). CPhysicsObj::find_bbox_cell_list @0x00510fc0 adds the object's own cell and then walks the PART ARRAY: 0x00511012 call 0x518160 (CPartArray::calc_cross_cells_static), which dispatches [edx+0x7c] with (num_parts, parts, cellarray). Its EnvCell body, CEnvCell::find_transit_cells @0x0052cae0: 0x0052cb31 mov edx,[eax+0x20] ; CPhysicsPart::gfxobj (CGfxObj**) 0x0052cb36 mov esi,[ecx+0x74] ; physics_sphere (else +0x90 drawing) 0x0052cb4c add eax,0x30 ; CPhysicsPart::pos 0x0052cb5a call Position::localtolocal ; transform the sphere CENTRE 0x0052cb65 fadd [esi+0xc] ; only NOW the radius Retail transforms the centre through the part's own Position before it ever touches the radius. Carrying the radius alone is not an approximation of that; it is a different sphere. Changes: * `ShadowShape` gains `BoundsCenter` — the bounding sphere's centre in the shape's own local frame, scaled like LocalPosition and Radius. Zero for Cylinder/Sphere shapes, whose LocalPosition already IS their centre. * `ShadowShapeBuilder.FromSetup` gains a `physicsBspBounds` resolver that supplies radius AND centre from ONE call, replacing the placeholder radius plus a downstream substitution. `LiveEntityCollisionBuilder` now holds a single `Func<uint, FlatCollisionSphere?>` and derives its dispatch predicate from it, so the gate and the geometry cannot disagree and the radius cannot be taken while the origin is dropped. That split is what produced this bug; it no longer exists. * `FromLandblockBspParts` carries the centre too. A landblock-baked part array is the same CPartArray walk, so stair runs, fences and rock clusters had the identical defect. Both storage forms (flat BSP and the graph fallback) are covered. * `BuildFloodSpheres` places each sphere at partWorldPos + rotate(BoundsCenter, partWorldRot), composed exactly as the ShadowEntry rows are. * The 10-sphere clamp now applies to the CYLSPHERE branch only. Retail's clamp is inside CObjCell::find_cell_list @0x0052b9f0 (0x0052ba21 cmp eax,0xa / 0x0052ba28 mov ebp,0xa); the BSP walk has none and the sorting-sphere overload @0x0052b990 takes one sphere. 7 installed Setups carry more than 10 physics-BSP parts (max 49, Setup 0x02001A91) and their tail parts were dropped from the flood entirely. Without this the new containment assertion would have covered shapes production never floods from. Register. AP-155 was two divergences with different code paths, populations and gates under one id; it is NARROWED to its static-publication half and its flood half is split out as AP-156 WITH ITS DIRECTION CORRECTED. AP-155(b) recorded the approximation as over-inclusive — "floods MORE cells rather than fewer, the safe direction for membership" — and that false direction was the stated reason the residual was safe to defer. It was under-inclusive for 170 of 172. AP-156 records the correction, this fix, and the one genuine residual: acdream's sphere-vs-portal traversal where retail walks each part's sphere against the cell's own portal planes. AP-155(b)'s "acdream approximates retail's bounding BOX" was wrong too — find_bbox_cell_list forms no box. AP-157 filed for the review's F4: retail's third branch floods from ONE CPartArray::GetSortingSphere @0x00518b00 ([partArray+0x54]+0x70 = CSetup::sorting_sphere; 4,154 of 5,935 installed Setups carry a non-zero one) where acdream floods from every Sphere shape, and acdream's cylinder flood ignores CylHeight. Deliberately NOT bundled here: different branch, disjoint population, different live gate. Active AP rows 107 -> 109, literal count. Tests. Both flood tests the review named substituted a CONCENTRIC Radius = 14f at LocalPosition = Zero — the one configuration in which the defect cannot appear. Every fixture is now off-centre by default, and `FromSetup_CylSphereAndBspSetup_FloodsTheBspFootprint` drives the production `physicsBspBounds` seam instead of hand-substituting. Five new facts: the flood centres on BoundsCenter not the part origin; it rotates BoundsCenter by the part rotation; it caps cylspheres at ten but never the BSP parts; the landblock path carries the scaled centre in both storage forms; and an installed-DAT containment sweep asserting every emitted BSP flood sphere contains that part's real bounding sphere at entity scale 1.75, behind four external controls — 973 parts, 376 off-centre, 172 affected, and 170 would-fail-if-the-origin-were-discarded, the last of which fails if the population ever stops exercising the field. Nine sabotages, each reverted and re-verified: A drop BoundsCenter from the flood -> 3 Core B rotate by entity rot, not part rot -> 1 Core (the rotation fact only) C FromSetup discards the origin -> 1 Core + 2 App + 1 Content (the shipped defect, now caught in three projects) D drop entScale on BoundsCenter -> 2 App + 1 Content E landblock flat branch drops the centre -> 1 Core F landblock graph branch drops it -> 1 Core G drop partScale on the landblock centre -> 1 Core H re-apply the 10-cap to every branch -> 1 Core I remove the cylsphere cap -> 1 Core AP-152's own two sabotages re-run against this tree: the step-0 gate disabled still reddens exactly its five facts with Headless 89/89 green, and cylinder-first flooding still reddens exactly one. Clean Release build after deleting all 44 bin/obj: 0 errors, 21 pre-existing warnings. Complete suite 11,208 passed / 4 skipped / 0 failed, +5 on the 11,203 baseline at4abd1b5e— Core 4264 -> 4268, Content 126 -> 127, App unchanged (one rename, not an addition). No new skips. NOT yet gated live. This moves shadow-cell membership for real objects, in both directions, and the connected session must look for both: props and doors that START blocking from a neighbouring cell (the 73 CylSphere+BSP Setups), AND ones that STOP blocking (the 99 Sphere+BSP Setups can shrink; 43 shrink below their pre-4abd1b5e size, which is the regression this fixes). Tall indoor props and door slabs — the ones whose sphere sits metres above the part origin — are where the change is largest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4abd1b5eb7
commit
b52967def3
13 changed files with 1424 additions and 90 deletions
|
|
@ -98,8 +98,18 @@ public sealed class LiveEntityCollisionBuilderTests
|
|||
new Vector3(192f, -192f, 0f)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The BSP root bounding sphere reaches the shape WHOLE — radius and
|
||||
/// centre, both scaled. Retail's <c>CGfxObj::physics_sphere</c> is
|
||||
/// <c>BSPTREE::GetSphere(physics_bsp)</c> @0x005397e0, and
|
||||
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0 transforms its centre
|
||||
/// through the part's Position (<c>0x0052cb4c add eax,0x30</c>) before
|
||||
/// reading the radius at <c>0x0052cb65</c>. Carrying only the radius is
|
||||
/// AP-156: 170 of the 172 affected installed Setups then flood from a
|
||||
/// sphere that does not contain their own collision geometry.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BspOnlyPart_UsesRealScaledPhysicsBoundingRadius()
|
||||
public void BspOnlyPart_UsesRealScaledPhysicsBoundingSphere()
|
||||
{
|
||||
const uint part = 0x0100ABCDu;
|
||||
var setup = new Setup();
|
||||
|
|
@ -109,8 +119,7 @@ public sealed class LiveEntityCollisionBuilderTests
|
|||
WorldEntity entity = Entity();
|
||||
record.WorldEntity = entity;
|
||||
var builder = new LiveEntityCollisionBuilder(
|
||||
id => id == part,
|
||||
id => id == part ? 3f : null,
|
||||
id => id == part ? Bsp(3f, centerZ: 2f) : null,
|
||||
PoseResolver());
|
||||
|
||||
LiveEntityCollisionRegistration registration = Assert.IsType<LiveEntityCollisionRegistration>(
|
||||
|
|
@ -118,7 +127,8 @@ public sealed class LiveEntityCollisionBuilderTests
|
|||
|
||||
ShadowShape shape = Assert.Single(registration.Shapes);
|
||||
Assert.Equal(ShadowCollisionType.BSP, shape.CollisionType);
|
||||
Assert.Equal(4.5f, shape.Radius);
|
||||
Assert.Equal(4.5f, shape.Radius); // 3 m * 1.5
|
||||
Assert.Equal(new Vector3(0f, 0f, 3f), shape.BoundsCenter); // 2 m * 1.5
|
||||
Assert.Equal(part, shape.GfxObjId);
|
||||
}
|
||||
|
||||
|
|
@ -151,8 +161,7 @@ public sealed class LiveEntityCollisionBuilderTests
|
|||
WorldEntity entity = Entity();
|
||||
record.WorldEntity = entity;
|
||||
var builder = new LiveEntityCollisionBuilder(
|
||||
id => id == part,
|
||||
id => id == part ? 3f : null,
|
||||
id => id == part ? Bsp(3f, centerZ: 2f) : null,
|
||||
PoseResolver());
|
||||
|
||||
LiveEntityCollisionRegistration registration =
|
||||
|
|
@ -162,6 +171,7 @@ public sealed class LiveEntityCollisionBuilderTests
|
|||
ShadowShape shape = Assert.Single(registration.Shapes);
|
||||
Assert.Equal(ShadowCollisionType.BSP, shape.CollisionType);
|
||||
Assert.Equal(4.5f, shape.Radius); // 3 m physics-BSP radius * 1.5 scale
|
||||
Assert.Equal(new Vector3(0f, 0f, 3f), shape.BoundsCenter);
|
||||
Assert.Equal(part, shape.GfxObjId);
|
||||
}
|
||||
|
||||
|
|
@ -177,8 +187,7 @@ public sealed class LiveEntityCollisionBuilderTests
|
|||
WorldEntity entity = Entity();
|
||||
record.WorldEntity = entity;
|
||||
var builder = new LiveEntityCollisionBuilder(
|
||||
id => id == basePart,
|
||||
_ => 1f,
|
||||
id => id == basePart ? Bsp(1f) : null,
|
||||
PoseResolver());
|
||||
|
||||
Assert.Null(builder.Build(
|
||||
|
|
@ -202,8 +211,7 @@ public sealed class LiveEntityCollisionBuilderTests
|
|||
WorldEntity entity = Entity();
|
||||
record.WorldEntity = entity;
|
||||
var builder = new LiveEntityCollisionBuilder(
|
||||
id => id == replacement,
|
||||
id => id == replacement ? 2.25f : null,
|
||||
id => id == replacement ? Bsp(2.25f) : null,
|
||||
PoseResolver());
|
||||
|
||||
LiveEntityCollisionRegistration registration =
|
||||
|
|
@ -232,8 +240,7 @@ public sealed class LiveEntityCollisionBuilderTests
|
|||
WorldEntity entity = Entity();
|
||||
record.WorldEntity = entity;
|
||||
var builder = new LiveEntityCollisionBuilder(
|
||||
id => id == basePart,
|
||||
_ => 1f,
|
||||
id => id == basePart ? Bsp(1f) : null,
|
||||
PoseResolver());
|
||||
LiveEntityCollisionRegistration initial =
|
||||
Assert.IsType<LiveEntityCollisionRegistration>(builder.Build(
|
||||
|
|
@ -265,8 +272,7 @@ public sealed class LiveEntityCollisionBuilderTests
|
|||
WorldEntity entity = Entity();
|
||||
record.WorldEntity = entity;
|
||||
var builder = new LiveEntityCollisionBuilder(
|
||||
id => id == basePart || id == replacement,
|
||||
_ => 1f,
|
||||
id => id == basePart || id == replacement ? Bsp(1f) : null,
|
||||
PoseResolver());
|
||||
LiveEntityCollisionRegistration initial =
|
||||
Assert.IsType<LiveEntityCollisionRegistration>(builder.Build(
|
||||
|
|
@ -330,8 +336,7 @@ public sealed class LiveEntityCollisionBuilderTests
|
|||
WorldEntity entity = Entity();
|
||||
record.WorldEntity = entity;
|
||||
var builder = new LiveEntityCollisionBuilder(
|
||||
id => id == basePart || id == replacement,
|
||||
_ => 1f,
|
||||
id => id == basePart || id == replacement ? Bsp(1f) : null,
|
||||
PoseResolver());
|
||||
LiveEntityCollisionRegistration initial =
|
||||
Assert.IsType<LiveEntityCollisionRegistration>(builder.Build(
|
||||
|
|
@ -394,10 +399,20 @@ public sealed class LiveEntityCollisionBuilderTests
|
|||
}
|
||||
|
||||
private static LiveEntityCollisionBuilder Builder() => new(
|
||||
_ => false,
|
||||
_ => null,
|
||||
PoseResolver());
|
||||
|
||||
/// <summary>
|
||||
/// A physics-BSP root bounding sphere. DELIBERATELY OFF-CENTRE by
|
||||
/// default: a GfxObj's BSP is authored in the GfxObj's own coordinates
|
||||
/// and its root sphere is usually NOT centred on that origin (376 of the
|
||||
/// 973 installed physics-BSP parts sit further from it than half their
|
||||
/// radius). A fixture pinned at <c>Vector3.Zero</c> cannot observe the
|
||||
/// centre at all — which is how AP-156's discarded origin stayed green.
|
||||
/// </summary>
|
||||
private static FlatCollisionSphere? Bsp(float radius, float centerZ = 1.25f)
|
||||
=> new FlatCollisionSphere(new Vector3(0f, 0f, centerZ), radius);
|
||||
|
||||
private static LiveEntityDefaultPoseResolver PoseResolver() => new(
|
||||
_ => null,
|
||||
new NullAnimationLoader(),
|
||||
|
|
|
|||
|
|
@ -142,8 +142,9 @@ public sealed class PvpBitfieldSurvivesAppearanceRebuildTests
|
|||
var setup = new Setup();
|
||||
setup.Parts.Add(0x0100AB01u);
|
||||
var builder = new LiveEntityCollisionBuilder(
|
||||
id => id == 0x0100AB01u,
|
||||
id => id == 0x0100AB01u ? 1f : null,
|
||||
id => id == 0x0100AB01u
|
||||
? new FlatCollisionSphere(new Vector3(0f, 0f, 0.5f), 1f)
|
||||
: null,
|
||||
new LiveEntityDefaultPoseResolver(
|
||||
_ => null,
|
||||
new NullAnimationLoader(),
|
||||
|
|
|
|||
|
|
@ -149,7 +149,6 @@ public sealed class LiveAppearanceAnimationTests
|
|||
seedCellId: cell,
|
||||
isStatic: false);
|
||||
var builder = new LiveEntityCollisionBuilder(
|
||||
_ => false,
|
||||
_ => null,
|
||||
new LiveEntityDefaultPoseResolver(
|
||||
_ => null,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Options;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Content.Tests;
|
||||
|
||||
|
|
@ -156,4 +159,217 @@ public sealed class InstalledSetupBspPrimitiveDispatchTests
|
|||
|
||||
Assert.Empty(affectedThatStillEmitAPrimitive);
|
||||
}
|
||||
|
||||
// EXTERNAL constants for the containment sweep, measured 2026-08-06 by a
|
||||
// scratch DatReaderWriter sweep that reproduced the geometry by hand
|
||||
// rather than calling ShadowShapeBuilder, and independently reproduced by
|
||||
// the AP-152 retail reviewer's own sweep. NOT derived from the code under
|
||||
// test.
|
||||
//
|
||||
// OffCentreParts is the population control: without it, a build in which
|
||||
// every BSP root sphere happened to sit at its part origin would satisfy
|
||||
// the containment claim vacuously.
|
||||
// WouldFailIfOriginDiscarded is the DEFECT control: it re-runs the
|
||||
// pre-fix composition (radius carried, root-sphere origin dropped) and
|
||||
// pins how many of the affected Setups it breaks. If that number ever
|
||||
// goes to zero the fixture population has stopped exercising the field
|
||||
// and the containment assertion below has stopped meaning anything.
|
||||
private const int ExpectedPhysicsBspParts = 973;
|
||||
private const int ExpectedOffCentreParts = 376; // |origin| > radius/2
|
||||
private const int ExpectedWouldFailIfOriginDiscarded = 170; // of 172
|
||||
private const int ExpectedDeepestBspPartArray = 49; // Setup 0x02001A91
|
||||
|
||||
/// <summary>
|
||||
/// AP-156. Every flood sphere acdream emits for a physics-BSP part must
|
||||
/// CONTAIN that part's real collision geometry.
|
||||
///
|
||||
/// <para>
|
||||
/// A GfxObj's physics BSP is authored in the GfxObj's own coordinates and
|
||||
/// its root bounding sphere is usually not centred on that origin — 376
|
||||
/// of the 973 installed physics-BSP parts sit further from it than half
|
||||
/// their own radius, worst 20.762 m on a 27.708 m sphere (gfx 0x010036DD,
|
||||
/// Setup 0x0200129A). acdream used to take the sphere's radius and drop
|
||||
/// its origin, flooding from the part origin instead: over the 172
|
||||
/// AP-152 Setups that failed to contain the object's own BSP sphere for
|
||||
/// 170 of them, worst shortfall 9.911 m (Setup 0x02000255, whose single
|
||||
/// part's root sphere sits 9.911 m above the part origin). Indoor floods
|
||||
/// are 3-D (<c>CellTransit.BuildShadowCellSet</c> routes every candidate
|
||||
/// with <c>id & 0xFFFF >= 0x0100</c> through
|
||||
/// <c>FindTransitCellsSphere</c>), so a tall prop or door slab simply was
|
||||
/// not registered in the EnvCells it occupies — never a broadphase
|
||||
/// candidate there, the #98 / #168 class.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail: <c>CGfxObj::physics_sphere</c> (<c>[gfxobj+0x74]</c>) is
|
||||
/// assigned <c>BSPTREE::GetSphere(physics_bsp)</c> @0x005397e0 — the root
|
||||
/// <c>BSPNODE</c>'s <c>CSphere</c>, past its 4-byte vftable — and
|
||||
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0, the part-array
|
||||
/// overload reached from <c>CPhysicsObj::find_bbox_cell_list</c>
|
||||
/// @0x00510fc0 via <c>CPartArray::calc_cross_cells_static</c> @0x00518160,
|
||||
/// transforms that sphere's CENTRE through the part's own Position at
|
||||
/// <c>[part+0x30]</c> before reading its radius at <c>[esi+0xc]</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstalledSetups_BspFloodSpheres_ContainTheirOwnBoundingSpheres()
|
||||
{
|
||||
string? datDir = ContentConformanceDats.ResolveDatDir();
|
||||
if (datDir is null)
|
||||
return;
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
var boundsCache = new Dictionary<uint, FlatCollisionSphere?>();
|
||||
FlatCollisionSphere? Bounds(uint gfxObjId)
|
||||
{
|
||||
if (boundsCache.TryGetValue(gfxObjId, out FlatCollisionSphere? cached))
|
||||
return cached;
|
||||
FlatCollisionSphere? result = null;
|
||||
if (dats.Portal.TryGet<GfxObj>(gfxObjId, out GfxObj? gfx)
|
||||
&& gfx is not null
|
||||
&& gfx.Flags.HasFlag(GfxObjFlags.HasPhysics)
|
||||
&& gfx.PhysicsBSP?.Root is not null
|
||||
&& gfx.VertexArray is not null
|
||||
&& gfx.PhysicsBSP.Root.BoundingSphere is { } bs)
|
||||
{
|
||||
result = new FlatCollisionSphere(bs.Origin, bs.Radius);
|
||||
}
|
||||
boundsCache[gfxObjId] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
const float EntScale = 1.75f; // not 1: a dropped scale must show up
|
||||
int bspParts = 0;
|
||||
int offCentreParts = 0;
|
||||
int affected = 0;
|
||||
int wouldFailIfOriginDiscarded = 0;
|
||||
float worstShortfall = 0f;
|
||||
uint worstShortfallSetup = 0u;
|
||||
int mostBspShapesOnOneSetup = 0;
|
||||
var uncontained = new List<uint>();
|
||||
|
||||
foreach (uint id in dats.GetAllIdsOfType<Setup>())
|
||||
{
|
||||
if (!dats.Portal.TryGet<Setup>(id, out Setup? setup) || setup is null)
|
||||
continue;
|
||||
|
||||
// Independent oracle: resolve the placement frame from the raw
|
||||
// Setup and place each part's TRUE root sphere by hand.
|
||||
AnimationFrame? placement = null;
|
||||
if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting))
|
||||
placement = resting;
|
||||
else if (setup.PlacementFrames.TryGetValue(Placement.Default, out var def))
|
||||
placement = def;
|
||||
else foreach (var kvp in setup.PlacementFrames) { placement = kvp.Value; break; }
|
||||
|
||||
var truth = new List<(Vector3 Centre, float Radius)>();
|
||||
for (int i = 0; i < setup.Parts.Count; i++)
|
||||
{
|
||||
FlatCollisionSphere? b = Bounds((uint)setup.Parts[i]);
|
||||
if (b is null) continue;
|
||||
bspParts++;
|
||||
if (b.Value.Origin.Length() > b.Value.Radius / 2f)
|
||||
offCentreParts++;
|
||||
|
||||
Vector3 partOrigin = Vector3.Zero;
|
||||
Quaternion partRot = Quaternion.Identity;
|
||||
if (placement is not null && i < placement.Frames.Count)
|
||||
{
|
||||
partOrigin = placement.Frames[i].Origin;
|
||||
partRot = placement.Frames[i].Orientation;
|
||||
}
|
||||
truth.Add((
|
||||
(partOrigin + Vector3.Transform(b.Value.Origin, partRot)) * EntScale,
|
||||
b.Value.Radius * EntScale));
|
||||
}
|
||||
if (truth.Count == 0) continue;
|
||||
|
||||
bool hasCylinder = false;
|
||||
foreach (var cyl in setup.CylSpheres) if (cyl.Radius > 0f) { hasCylinder = true; break; }
|
||||
bool hasSphere = false;
|
||||
foreach (var sph in setup.Spheres) if (sph.Radius > 0f) { hasSphere = true; break; }
|
||||
if (hasCylinder || (setup.CylSpheres.Count == 0 && hasSphere))
|
||||
affected++;
|
||||
|
||||
// Production emission, through the production bounds seam.
|
||||
IReadOnlyList<ShadowShape> shapes = ShadowShapeBuilder.FromSetup(
|
||||
setup,
|
||||
EntScale,
|
||||
id => Bounds(id) is not null,
|
||||
physicsBspBounds: Bounds);
|
||||
|
||||
// ShadowObjectRegistry.BuildFloodSpheres' composition, at an
|
||||
// entity placed at the world origin with identity rotation.
|
||||
// EVERY BSP shape contributes: retail's BSP branch has no
|
||||
// sphere cap (the 10-clamp at 0x0052ba21 is inside the cylsphere
|
||||
// overload only), and 7 installed Setups carry more than 10
|
||||
// physics-BSP parts — capping here would silently exclude their
|
||||
// tail from the containment claim below.
|
||||
var flood = new List<(Vector3 Centre, float Radius)>();
|
||||
var floodIfOriginDiscarded = new List<(Vector3 Centre, float Radius)>();
|
||||
foreach (ShadowShape shape in shapes)
|
||||
{
|
||||
if (shape.CollisionType != ShadowCollisionType.BSP) continue;
|
||||
flood.Add((
|
||||
shape.LocalPosition
|
||||
+ Vector3.Transform(shape.BoundsCenter, shape.LocalRotation),
|
||||
shape.Radius));
|
||||
floodIfOriginDiscarded.Add((shape.LocalPosition, shape.Radius));
|
||||
}
|
||||
if (flood.Count > mostBspShapesOnOneSetup)
|
||||
mostBspShapesOnOneSetup = flood.Count;
|
||||
|
||||
float Shortfall(List<(Vector3 Centre, float Radius)> spheres)
|
||||
{
|
||||
float worst = 0f;
|
||||
foreach ((Vector3 tc, float tr) in truth)
|
||||
{
|
||||
float best = float.MaxValue;
|
||||
foreach ((Vector3 fc, float fr) in spheres)
|
||||
{
|
||||
float need = (tc - fc).Length() + tr - fr;
|
||||
if (need < best) best = need;
|
||||
}
|
||||
if (best > worst) worst = best;
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
const float Tolerance = 1e-3f;
|
||||
float shortfall = Shortfall(flood);
|
||||
if (shortfall > Tolerance)
|
||||
{
|
||||
uncontained.Add(id);
|
||||
if (shortfall > worstShortfall)
|
||||
{
|
||||
worstShortfall = shortfall;
|
||||
worstShortfallSetup = id;
|
||||
}
|
||||
}
|
||||
if ((hasCylinder || (setup.CylSpheres.Count == 0 && hasSphere))
|
||||
&& Shortfall(floodIfOriginDiscarded) > Tolerance)
|
||||
{
|
||||
wouldFailIfOriginDiscarded++;
|
||||
}
|
||||
}
|
||||
|
||||
// Population + defect controls first.
|
||||
Assert.Equal(ExpectedPhysicsBspParts, bspParts);
|
||||
Assert.Equal(ExpectedOffCentreParts, offCentreParts);
|
||||
Assert.Equal(ExpectedAffected, affected);
|
||||
Assert.Equal(ExpectedWouldFailIfOriginDiscarded, wouldFailIfOriginDiscarded);
|
||||
// Cap control: the deepest installed part array is 49 BSP shapes, so
|
||||
// the containment claim genuinely covers Setups past the retired
|
||||
// 10-sphere clamp rather than stopping short of them.
|
||||
Assert.Equal(ExpectedDeepestBspPartArray, mostBspShapesOnOneSetup);
|
||||
|
||||
// The fact.
|
||||
Assert.True(
|
||||
uncontained.Count == 0,
|
||||
$"{uncontained.Count} Setups flood from a sphere that does not contain "
|
||||
+ $"their own physics-BSP bounding sphere; worst shortfall "
|
||||
+ $"{worstShortfall.ToString("F3", CultureInfo.InvariantCulture)} m on "
|
||||
+ $"Setup 0x{worstShortfallSetup:X8}.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -248,10 +248,26 @@ public class ShadowObjectRegistryMultiPartTests
|
|||
Scale: 1f, CollisionType: ShadowCollisionType.Cylinder,
|
||||
Radius: radius, CylHeight: radius * 2f);
|
||||
|
||||
private static ShadowShape Bsp(float radius) => new(
|
||||
GfxObjId: 0x010044B5u, LocalPosition: Vector3.Zero, LocalRotation: Quaternion.Identity,
|
||||
/// <summary>
|
||||
/// A physics-BSP part shape. <paramref name="boundsCenter"/> defaults
|
||||
/// OFF-CENTRE because that is the DAT-real configuration: a GfxObj's
|
||||
/// physics BSP is authored in the GfxObj's own coordinates and 376 of the
|
||||
/// 973 installed physics-BSP parts have a root bounding sphere further
|
||||
/// from the part origin than half their own radius. A fixture pinned at
|
||||
/// <c>Vector3.Zero</c> is the one configuration in which discarding the
|
||||
/// centre is invisible.
|
||||
/// </summary>
|
||||
private static ShadowShape Bsp(
|
||||
float radius,
|
||||
Vector3 boundsCenter = default,
|
||||
Vector3 localPosition = default,
|
||||
Quaternion localRotation = default) => new(
|
||||
GfxObjId: 0x010044B5u,
|
||||
LocalPosition: localPosition,
|
||||
LocalRotation: localRotation == default ? Quaternion.Identity : localRotation,
|
||||
Scale: 1f, CollisionType: ShadowCollisionType.BSP,
|
||||
Radius: radius, CylHeight: 0f);
|
||||
Radius: radius, CylHeight: 0f,
|
||||
BoundsCenter: boundsCenter == default ? new Vector3(0f, 6f, 0f) : boundsCenter);
|
||||
|
||||
private static List<uint> FloodCellsFor(params ShadowShape[] shapes)
|
||||
{
|
||||
|
|
@ -272,6 +288,7 @@ public class ShadowObjectRegistryMultiPartTests
|
|||
List<uint> bspOnly = FloodCellsFor(Bsp(14f));
|
||||
List<uint> mixed = FloodCellsFor(Cyl(0.5f), Bsp(14f));
|
||||
|
||||
|
||||
// Controls: the two footprints must actually differ, or the fact below
|
||||
// is satisfiable by any dispatch rule at all.
|
||||
Assert.Equal([LbId | 10u], cylinderOnly);
|
||||
|
|
@ -284,6 +301,116 @@ public class ShadowObjectRegistryMultiPartTests
|
|||
Assert.NotEqual(cylinderOnly, mixed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AP-156. Retail's per-part cross-cell walk transforms the BSP root
|
||||
/// bounding sphere's CENTRE through the part's own frame before it uses
|
||||
/// the radius: <c>CGfxObj::physics_sphere</c> is
|
||||
/// <c>BSPTREE::GetSphere(physics_bsp)</c> @0x005397e0 (root
|
||||
/// <c>BSPNODE</c> + 4, past its vftable), and
|
||||
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0 — the part-array
|
||||
/// overload reached from <c>find_bbox_cell_list</c> @0x00510fc0 through
|
||||
/// <c>CPartArray::calc_cross_cells_static</c> @0x00518160's
|
||||
/// <c>[vtbl+0x7c]</c> — loads it at <c>0x0052cb36 mov esi,[ecx+0x74]</c>,
|
||||
/// runs <c>Position::localtolocal</c> on it against
|
||||
/// <c>[part+0x30]</c> (<c>0x0052cb4c add eax,0x30</c>), and only then
|
||||
/// reads <c>[esi+0xc]</c> for the radius.
|
||||
///
|
||||
/// <para>
|
||||
/// acdream used to flood from the part ORIGIN with the sphere's radius.
|
||||
/// Over the installed DAT that failed to contain the object's own BSP
|
||||
/// sphere for 170 of the 172 AP-152 Setups, shortfall up to 9.911 m
|
||||
/// (0x02000255) — under-inclusive membership, the #98 / #168 class.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildFloodSpheres_BspShape_CentresOnTheBoundsCentreNotThePartOrigin()
|
||||
{
|
||||
// Same sphere, three placements of the SAME 6 m radius:
|
||||
// a) part origin at (0,0,0), bounds centre at the origin too;
|
||||
// b) part origin at (0,0,0), bounds centre 30 m along +Y;
|
||||
// c) part origin 30 m along +Y, bounds centre at the origin.
|
||||
// (b) and (c) describe the identical world sphere, so they must flood
|
||||
// the identical cells — and neither may equal (a).
|
||||
List<uint> concentric = FloodCellsFor(
|
||||
Bsp(6f, boundsCenter: new Vector3(0.001f, 0f, 0f)));
|
||||
List<uint> viaBoundsCentre = FloodCellsFor(
|
||||
Bsp(6f, boundsCenter: new Vector3(0f, 30f, 0f)));
|
||||
List<uint> viaPartOrigin = FloodCellsFor(
|
||||
Bsp(6f,
|
||||
boundsCenter: new Vector3(0.001f, 0f, 0f),
|
||||
localPosition: new Vector3(0f, 30f, 0f)));
|
||||
|
||||
// Control: the offset must actually move the footprint, or the
|
||||
// equality below is satisfiable by ignoring BoundsCenter entirely.
|
||||
Assert.NotEqual(concentric, viaBoundsCentre);
|
||||
|
||||
Assert.Equal(viaPartOrigin, viaBoundsCentre);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's 10-sphere clamp is on the CYLSPHERE branch and nowhere else.
|
||||
/// <c>CObjCell::find_cell_list</c> @0x0052b9f0 clamps the cylsphere count
|
||||
/// (<c>0x0052ba21 cmp eax,0xa</c> / <c>0x0052ba28 mov ebp,0xa</c>); the
|
||||
/// BSP branch — <c>find_bbox_cell_list</c> @0x00510fc0 →
|
||||
/// <c>CPartArray::calc_cross_cells_static</c> @0x00518160 →
|
||||
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0 — walks every part with
|
||||
/// no cap, and the sorting-sphere overload @0x0052b990 takes one sphere.
|
||||
/// 7 installed Setups carry more than 10 physics-BSP parts (max 49,
|
||||
/// Setup 0x02001A91) and landblock-baked stair runs and fences routinely
|
||||
/// do, so clamping the BSP branch dropped their tail parts out of the
|
||||
/// flood entirely.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildFloodSpheres_CapsCylSpheresAtTenButNeverTheBspParts()
|
||||
{
|
||||
// Ten shapes on the owner's own cell, then an eleventh 72 m north.
|
||||
var near = new Vector3(0f, 0f, 0f);
|
||||
var far = new Vector3(0f, 72f, 0f);
|
||||
uint ownCell = LbId | (uint)(1 * 8 + 1 + 1); // (x=1, y=1)
|
||||
uint farCell = LbId | (uint)(1 * 8 + 4 + 1); // (x=1, y=4)
|
||||
|
||||
var bsp = new ShadowShape[11];
|
||||
for (int i = 0; i < 10; i++)
|
||||
bsp[i] = Bsp(1f, boundsCenter: new Vector3(0.001f, 0f, 0f));
|
||||
bsp[10] = Bsp(1f, boundsCenter: far);
|
||||
List<uint> bspCells = FloodCellsFor(bsp);
|
||||
|
||||
var cyls = new ShadowShape[11];
|
||||
for (int i = 0; i < 10; i++)
|
||||
cyls[i] = Cyl(1f) with { LocalPosition = near };
|
||||
cyls[10] = Cyl(1f) with { LocalPosition = far };
|
||||
List<uint> cylCells = FloodCellsFor(cyls);
|
||||
|
||||
Assert.Contains(ownCell, bspCells);
|
||||
Assert.Contains(ownCell, cylCells);
|
||||
|
||||
// The eleventh BSP part still floods; the eleventh cylsphere does not.
|
||||
Assert.Contains(farCell, bspCells);
|
||||
Assert.DoesNotContain(farCell, cylCells);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The BoundsCentre is expressed in the SHAPE's own frame, so the part's
|
||||
/// LocalRotation must carry it — exactly as retail transforms the sphere
|
||||
/// centre through <c>[part+0x30]</c>, the part's full Position, not just
|
||||
/// its origin. A +Y offset on a part yawed 180 degrees must land at -Y.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildFloodSpheres_BspShape_RotatesTheBoundsCentreByThePartRotation()
|
||||
{
|
||||
Quaternion yaw180 = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI);
|
||||
|
||||
List<uint> yawed = FloodCellsFor(
|
||||
Bsp(6f, boundsCenter: new Vector3(0f, 20f, 0f), localRotation: yaw180));
|
||||
List<uint> negatedUnrotated = FloodCellsFor(
|
||||
Bsp(6f, boundsCenter: new Vector3(0f, -20f, 0f)));
|
||||
List<uint> unrotated = FloodCellsFor(
|
||||
Bsp(6f, boundsCenter: new Vector3(0f, 20f, 0f)));
|
||||
|
||||
Assert.NotEqual(unrotated, negatedUnrotated); // control
|
||||
Assert.Equal(negatedUnrotated, yawed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The AP-152 delta end-to-end: a CylSphere+BSP Setup (73 of the 172
|
||||
/// affected installed Setups are this shape) registered through the
|
||||
|
|
@ -291,6 +418,14 @@ public class ShadowObjectRegistryMultiPartTests
|
|||
/// BuildFloodSpheres' cylinder preference confined the owner to the
|
||||
/// cylinder's cell while its slab BSP reached further — an object absent
|
||||
/// from shadow cells it physically occupies, the #98 / #168 symptom class.
|
||||
///
|
||||
/// <para>
|
||||
/// The BSP bounds are supplied through the production seam
|
||||
/// (<c>physicsBspBounds</c>, the same resolver
|
||||
/// <c>LiveEntityCollisionBuilder</c> passes) and are OFF-CENTRE, so the
|
||||
/// flood is asserted where the geometry is rather than where the part
|
||||
/// origin is.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromSetup_CylSphereAndBspSetup_FloodsTheBspFootprint()
|
||||
|
|
@ -303,17 +438,20 @@ public class ShadowObjectRegistryMultiPartTests
|
|||
{ Radius = 0.5f, Height = 1f, Origin = Vector3.Zero } },
|
||||
};
|
||||
|
||||
IReadOnlyList<ShadowShape> raw =
|
||||
ShadowShapeBuilder.FromSetup(setup, entScale: 1f, hasPhysicsBsp: id => id == part);
|
||||
// Production substitutes the real BSP bounding radius at registration
|
||||
// time (LiveEntityCollisionBuilder.Build); 14 m stands in for a slab
|
||||
// wide enough to leave its own landcell.
|
||||
var shapes = raw.Select(s => s.CollisionType == ShadowCollisionType.BSP
|
||||
? s with { Radius = 14f }
|
||||
: s).ToList();
|
||||
// 14 m stands in for a slab wide enough to leave its own landcell;
|
||||
// the +18 m Y offset stands in for the 376-of-973 installed parts
|
||||
// whose root sphere is nowhere near the part origin.
|
||||
var bounds = new FlatCollisionSphere(new Vector3(0f, 18f, 0f), 14f);
|
||||
IReadOnlyList<ShadowShape> shapes = ShadowShapeBuilder.FromSetup(
|
||||
setup,
|
||||
entScale: 1f,
|
||||
hasPhysicsBsp: id => id == part,
|
||||
physicsBspBounds: id => id == part ? bounds : null);
|
||||
|
||||
ShadowShape only = Assert.Single(shapes);
|
||||
Assert.Equal(ShadowCollisionType.BSP, only.CollisionType);
|
||||
Assert.Equal(14f, only.Radius);
|
||||
Assert.Equal(new Vector3(0f, 18f, 0f), only.BoundsCenter);
|
||||
|
||||
var reg = new ShadowObjectRegistry();
|
||||
const uint ownerId = 0xBEEF02u;
|
||||
|
|
@ -322,8 +460,13 @@ public class ShadowObjectRegistryMultiPartTests
|
|||
shapes, 0x10008u, EntityCollisionFlags.None, OffX, OffY, LbId);
|
||||
|
||||
List<uint> cells = OutdoorCellsHolding(reg, ownerId);
|
||||
Assert.Contains(LbId | 10u, cells);
|
||||
Assert.True(cells.Count > 1,
|
||||
$"Expected the slab footprint to span more than its own landcell; got {cells.Count}");
|
||||
// Landcells are 24 m. The slab's sphere is centred at y = 54 m with
|
||||
// r = 14 m, so it spans y in [40, 68] — rows 1 and 2, and it comes no
|
||||
// closer than 16 m to row 0 (y < 24). Flooding it around the PART
|
||||
// ORIGIN instead (y = 36 m, span [22, 50]) would drag row 0 in.
|
||||
Assert.Contains(LbId | (uint)(1 * 8 + 2 + 1), cells); // (x=1, y=2)
|
||||
Assert.DoesNotContain(LbId | (uint)(1 * 8 + 0 + 1), cells); // (x=1, y=0)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
|
|
@ -99,19 +100,43 @@ public class ShadowRegistrationOverflowTests
|
|||
|
||||
// ── The builder: one BSP shape per BSP part; shells + no-BSP excluded ──
|
||||
|
||||
private static GfxObjPhysics BspGfx(float radius)
|
||||
/// <summary>
|
||||
/// Graph-form fixture. The bounding sphere is OFF-CENTRE by default
|
||||
/// because that is the DAT-real case — a GfxObj's physics BSP is authored
|
||||
/// in the GfxObj's own coordinates and 376 of the 973 installed
|
||||
/// physics-BSP parts have a root sphere further from the part origin than
|
||||
/// half their radius.
|
||||
/// </summary>
|
||||
private static GfxObjPhysics BspGfx(float radius, float centerZ = 0.75f)
|
||||
{
|
||||
var leaf = new PhysicsBSPNode { Type = BSPNodeType.Leaf };
|
||||
return new GfxObjPhysics
|
||||
{
|
||||
BSP = new PhysicsBSPTree { Root = leaf },
|
||||
BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = radius },
|
||||
BoundingSphere = new Sphere
|
||||
{ Origin = new Vector3(0f, 0f, centerZ), Radius = radius },
|
||||
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
|
||||
Vertices = new VertexArray(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Flat-form fixture — the production storage since I6/I7.</summary>
|
||||
private static GfxObjPhysics FlatBspGfx(float radius, float centerZ)
|
||||
{
|
||||
var node = new FlatPhysicsBspNode(
|
||||
BSPNodeType.Leaf, default, -1, -1, 0, 0,
|
||||
new FlatCollisionSphere(new Vector3(0f, 0f, centerZ), radius),
|
||||
new FlatIndexRange(0, 0));
|
||||
GfxObjPhysics phys = BspGfx(radius, centerZ);
|
||||
phys.FlatPhysicsBsp = new FlatPhysicsBsp(
|
||||
0,
|
||||
ImmutableArray.Create(node),
|
||||
ImmutableArray<int>.Empty,
|
||||
FlatPolygonTable.Empty);
|
||||
return phys;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromLandblockBspParts_OneShapePerBspPart_LocalTransformPreserved()
|
||||
{
|
||||
|
|
@ -136,6 +161,42 @@ public class ShadowRegistrationOverflowTests
|
|||
Assert.All(shapes, s => Assert.Equal(1.05f, s.Radius, 3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AP-156, landblock half. A landblock-baked multi-part entity is the
|
||||
/// same <c>CPartArray</c> walk as a live Setup
|
||||
/// (<c>CPhysicsObj::find_bbox_cell_list</c> @0x00510fc0 →
|
||||
/// <c>CPartArray::calc_cross_cells_static</c> @0x00518160 →
|
||||
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0), which transforms each
|
||||
/// part's <c>CGfxObj::physics_sphere</c> CENTRE through the part's own
|
||||
/// Position before reading its radius. Carrying the radius alone puts a
|
||||
/// stair run's or fence's flood sphere at the part origin instead of on
|
||||
/// its geometry. Both storage forms — the flat BSP that production uses
|
||||
/// and the graph fallback — must supply the centre, SCALED with the part.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromLandblockBspParts_CarriesTheScaledRootSphereCentre()
|
||||
{
|
||||
Matrix4x4 halfScale = Matrix4x4.CreateScale(0.5f)
|
||||
* Matrix4x4.CreateTranslation(0f, 2f, 0f);
|
||||
|
||||
var flat = ShadowShapeBuilder.FromLandblockBspParts(
|
||||
[new MeshRef(0x01000AC5u, halfScale)],
|
||||
isBuildingShell: false,
|
||||
getGfxObj: _ => FlatBspGfx(4f, centerZ: 3f));
|
||||
ShadowShape flatShape = Assert.Single(flat);
|
||||
Assert.Equal(0.5f, flatShape.Scale, 3);
|
||||
Assert.Equal(2f, flatShape.Radius, 3); // 4 * 0.5
|
||||
Assert.Equal(new Vector3(0f, 0f, 1.5f), flatShape.BoundsCenter); // 3 * 0.5
|
||||
|
||||
// Graph fallback (fixtures without a flat BSP) takes the same path.
|
||||
var graph = ShadowShapeBuilder.FromLandblockBspParts(
|
||||
[new MeshRef(0x01000AC5u, halfScale)],
|
||||
isBuildingShell: false,
|
||||
getGfxObj: _ => BspGfx(4f, centerZ: 3f));
|
||||
ShadowShape graphShape = Assert.Single(graph);
|
||||
Assert.Equal(new Vector3(0f, 0f, 1.5f), graphShape.BoundsCenter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromLandblockBspParts_BuildingShell_ReturnsEmpty()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue