fix(physics): AP-152 — dispatch collision shapes BSP-first, at emission and at the cell flood

The register row predicted "catching or stopping on a doorway sill". That
symptom could not have been occurring. `Transition.BspOnlyDispatch`
(TransitionTypes.cs:1348, landed 2026-05-25 as A6.P7) already skipped both
primitive branches (:3911, :3954) whenever the target's wire PhysicsState
carries HAS_PHYSICS_BSP_PS, and ACE sets that bit from CSetup.HasPhysicsBSP
for every affected Setup. The extra primitive was never tested for collision.

The live defect was CELL MEMBERSHIP. The same shape list feeds
`ShadowObjectRegistry.BuildFloodSpheres`, which had no such guard and
preferred Cylinders over everything whenever any Cylinder existed — retail's
SECOND priority applied ahead of its first. For the 73 CylSphere+BSP Setups
acdream therefore flooded shadow cells from the cylinder and never from the
slab: an object absent from cells it physically occupies, which is the
#98 / #168 symptom class, not the door-collision class the row named.

Retail, re-disassembled from the PDB-paired binary (v11.4186, CodeView GUID
9e847e2f-777c-4bd9-886c-22256bb87f32, check_exe_pdb.py MATCH) rather than
taken from Binary Ninja, which drops flag tests:

  CPhysicsObj::FindObjCollisions @0x0050f050
    0x0050f165  test dword [esi+0xa8], 0x10000
    0x0050f16f  je   0x50f1a2        ; clear -> primitive dispatch
    0x0050f18d  call 0x518180        ; CPartArray::FindObjCollisions
    0x0050f19d  jmp  0x50f2b0        ; UNCONDITIONAL, past BOTH primitive loops
                                     ; (CylSphere 0x50f1a2, Sphere 0x50f21d)
    0x0050f1d6  jae  0x50f317        ; CylSphere loop exhausted -> RETURN
    0x0050f22f  je   0x50f31b        ; zero Spheres -> RETURN seeded OK_TS

  CPhysicsObj::calc_cross_cells @0x00515230
    0x00515285  test dword [esi+0xa8], 0x10000
    0x0051528f  jne  0x515305 -> CPhysicsObj::find_bbox_cell_list @0x00510fc0
    0x005152d1  call 0x52b9f0        ; cylsphere branch, below the jump
    0x005152fb  call 0x52b990        ; sorting-sphere branch, below the jump

Priority at both consumers: BSP -> CylSphere -> Sphere -> nothing. BSP wins.
Every address above was resolved back to its symbol by exact lookup in
named-retail/symbols.json.

Changes:

* `ShadowShapeBuilder.FromSetup` gains a step-0 dispatch gate. Steps 1 and 2
  are skipped entirely when any part's EFFECTIVE GfxObj carries a physics
  BSP. The gate and step 3 now share one `EffectivePartGfxObjId` helper, so
  they cannot read different identities — a gate on `setup.Parts` would,
  after an ObjDesc swap, suppress the primitives while step 3 emitted
  nothing and `Build` returned null, deleting the entity's collision.
  Emission order is unchanged. This also removes acdream's undeclared
  reliance on the server sending the flag: the gate is derived from the
  parts, exactly as CPartArray::CacheHasPhysicsBSP @0x00518110 derives it.

* `ShadowObjectRegistry.BuildFloodSpheres` now applies calc_cross_cells'
  own order: BSP, else Cylinder, else everything. Given the gate above this
  is a no-op for every shape list acdream produces (FromSetup is now
  exclusive; both landblock-static publishers already emit homogeneous
  lists), so the measured membership delta remains attributable to the
  gate alone. It is kept for the same reason BspOnlyDispatch is kept: retail
  genuinely dispatches here, and it guards a future additive producer.

`Transition.BspOnlyDispatch` is deliberately untouched.

Register: AP-152 RETIRED with its four false statements corrected — the risk
statement (the symptom was already inert); "small and centred at the part
origin" (max primitive is 6.714 m, and 0x0200086E's sphere origin is
(0.759, 0.165, 5.842)); the cottage door's "~14 cm base Sphere" (it is
0.100 m; 0.141 is Setup.Radius, which AP-22 proved is never collision
geometry); and naming one pinning test where two existed. AP-153/154/155
filed: retail's dispatch flag is cached once at InitPartArrayObject+0x7e
where acdream's gate is live; the query-time guard takes a client-derived
flag off the wire; and the static publishers emit Setup Spheres as
height-capped Cylinders while BuildFloodSpheres approximates retail's
bounding box with bounding spheres.

Tests. Both pinning tests corrected, neither deleted:
`FromSetup_DoorSetup_ProducesFourShapes` -> `..._EmitsBspPartsOnly`;
`FromSetup_DoorSetup_SphereAtExpectedLocalOffset` re-hosted on
`_ => false`, the DAT-real configuration for the 3,605 Sphere-only Setups.
`FromSetup_ScaleFactor_MultipliesAllRadiiAndOffsets` was the campaign's
eighth green test covering nothing — its assertions sat inside
`if (CollisionType == Cylinder)` on a fixture with zero CylSpheres, so only
`Scale == 2.0f` ever ran. Proved empirically: with the sphere radius scale
deleted, the old body passes and the corrected body fails. Three new facts:
the effective-identity gate, the App-layer CylSphere+BSP registration (no
App fixture combined the two before), and the flood-set dispatch. One new
installed-DAT sweep pins 172 affected Setups (73 CylSphere+BSP, 99
Sphere+BSP) behind external bucket controls, re-measured independently and
agreeing exactly with the filing commit's separate sweep.

All eight sabotages run and reported; every discriminating fact reddens in
the intended direction and only there. Clean Release build after deleting
every bin/obj: 0 errors. Complete suite 11,203 passed / 4 skipped / 0
failed, +5 on the 11,198 baseline at ec29a732 — exactly the five added
facts, no new skips.

Blast radius, corrected: the FromSetup half is graphical-only (its sole
production caller is LiveEntityCollisionBuilder in AcDream.App, which
AcDream.Headless cannot reference — Headless -> Runtime -> Core/Content).
The BuildFloodSpheres half lives in AcDream.Core and DOES execute in
Headless via LandblockPhysicsContentBuilder, but is behaviour-neutral there
because both of that builder's registrations pass homogeneous lists.
Headless suite green at 89/89.

NOT yet gated live: this changes shadow-cell membership for 22 Setups used
by 151 Door weenies and 38 stationary props. Needs a connected session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-06 14:51:36 +02:00
parent ec29a732f5
commit 4abd1b5eb7
9 changed files with 1635 additions and 85 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -597,11 +597,35 @@ public sealed class ShadowObjectRegistry
}
/// <summary>
/// Retail flood-sphere rule (CylSphere overload, Ghidra 0x0052b9f0):
/// when the object has cylinder shapes, each contributes one sphere at
/// its world BASE point (low_pt) with the cylinder radius, capped at 10;
/// otherwise the BSP parts' bounding spheres are the footprint (the
/// sorting-sphere fallback, calc_cross_cells 0x00515230 tail).
/// Retail cross-cell dispatch, <c>CPhysicsObj::calc_cross_cells</c>
/// @0x00515230, in retail's own priority order:
///
/// <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. acdream approximates the bbox with
/// the BSP parts' bounding spheres.</item>
/// <item>else 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>
/// <item>else the sorting sphere (<c>0x005152dc</c> →
/// <c>CPartArray::GetSortingSphere</c> @0x00518b00 →
/// <c>CObjCell::find_cell_list</c> @0x0052b990).</item>
/// </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).
/// </para>
/// </summary>
private static List<DatReaderWriter.Types.Sphere> BuildFloodSpheres(
Vector3 entityWorldPos,
@ -611,15 +635,25 @@ 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.Cylinder) { anyCyl = true; break; }
if (s.CollisionType == ShadowCollisionType.BSP) anyBsp = true;
else 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).
ShadowCollisionType? only =
anyBsp ? ShadowCollisionType.BSP
: anyCyl ? ShadowCollisionType.Cylinder
: null;
foreach (var s in shapes)
{
if (anyCyl && s.CollisionType != ShadowCollisionType.Cylinder)
if (only is { } required && s.CollisionType != required)
continue;
if (spheres.Count >= RetailSphereCap)
break;

View file

@ -14,21 +14,25 @@ namespace AcDream.Core.Physics;
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/>.
///
/// <para>
/// Walks (1) every CylSphere → Cylinder shape, (2) every Sphere ONLY when no
/// CylSpheres are present (matches retail and the existing landblock-static
/// convention at GameWindow.cs:6034), and (3) every Part whose GfxObj has a
/// non-null PhysicsBSP → per-part BSP shape, with local transforms from
/// PlacementFrames[Resting | Default | first available].
/// 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): it calls
/// 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</c> past the primitive branches), OR walks the Setup's
/// CylSpheres, 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>). It is never a union.
/// (<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
@ -36,12 +40,28 @@ namespace AcDream.Core.Physics;
/// </para>
///
/// <para>
/// KNOWN DIVERGENCE (AP-152): steps 1/2 and step 3 below are emitted
/// ADDITIVELY here, where retail is exclusive — 172 of 5,935 installed
/// Setups carry a primitive and a physics-BSP part. Do not cite the anchor
/// above as justification for the additive design; it is the evidence
/// against it. acdream's two static publication paths already implement the
/// exclusive rule.
/// 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
@ -81,6 +101,31 @@ public static class ShadowShapeBuilder
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)
{
@ -96,10 +141,12 @@ public static class ShadowShapeBuilder
CylHeight: baseHeight * entScale));
}
// 2. Spheres — only when no CylSpheres (matches landblock-static convention
// at GameWindow.cs:6034). Each becomes a true Sphere (no height clamping).
// Retail anchor: CSphere::intersects_sphere @ 0x00537A80 uses 3-D distance
// for the overlap check, unlike CCylSphere which clips to [low_pt, high_pt].
// 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)
@ -115,6 +162,7 @@ public static class ShadowShapeBuilder
CylHeight: 0f));
}
}
}
// 3. Parts — one BSP shape per part with a non-null PhysicsBSP.
// Pose priority per part: partPoseOverride (the motion-table
@ -126,10 +174,7 @@ public static class ShadowShapeBuilder
// 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 = effectivePartGfxObjIds is not null
&& i < effectivePartGfxObjIds.Count
? effectivePartGfxObjIds[i]
: (uint)setup.Parts[i];
uint gfxId = EffectivePartGfxObjId(setup, effectivePartGfxObjIds, i);
if (!hasPhysicsBsp(gfxId)) continue;
Frame partFrame;
@ -241,6 +286,20 @@ public static class ShadowShapeBuilder
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)

View file

@ -122,6 +122,49 @@ public sealed class LiveEntityCollisionBuilderTests
Assert.Equal(part, shape.GfxObjId);
}
/// <summary>
/// AP-152. Every other fixture in this file is primitive-only or BSP-only,
/// so nothing at the App layer used to exercise the CylSphere+BSP
/// combination — 73 of the 172 affected installed Setups.
/// Retail's <c>CPhysicsObj::FindObjCollisions</c> @0x0050f050 tests
/// <c>HAS_PHYSICS_BSP_PS</c> at 0x0050f165 and leaves the BSP branch
/// through the unconditional <c>0x0050f19d jmp 0x50f2b0</c>, past both the
/// CylSphere loop (0x50f1a2) and the Sphere loop (0x50f21d);
/// <c>calc_cross_cells</c> @0x00515230 dispatches identically at
/// 0x00515285. The CylSphere must not survive, and the surviving BSP shape
/// must still receive the real scaled bounding radius.
/// </summary>
[Fact]
public void CylSphereAndPhysicsBspPart_EmitsOnlyTheScaledBspShape()
{
const uint part = 0x0100AC01u;
var setup = new Setup();
setup.Parts.Add(part);
setup.CylSpheres.Add(new CylSphere
{
Origin = Vector3.Zero,
Radius = 0.4f,
Height = 1.2f,
});
WorldSession.EntitySpawn spawn = Spawn(scale: 1.5f);
var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn);
WorldEntity entity = Entity();
record.WorldEntity = entity;
var builder = new LiveEntityCollisionBuilder(
id => id == part,
id => id == part ? 3f : null,
PoseResolver());
LiveEntityCollisionRegistration registration =
Assert.IsType<LiveEntityCollisionRegistration>(builder.Build(
entity, setup, [part], spawn, record, Vector3.Zero));
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(part, shape.GfxObjId);
}
[Fact]
public void EffectiveReplacementWithoutPhysicsBsp_RemovesBasePartCollision()
{

View file

@ -0,0 +1,159 @@
using AcDream.Core.Physics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Options;
namespace AcDream.Content.Tests;
/// <summary>
/// AP-152 population + behaviour proof over the installed client_portal.dat.
///
/// <para>
/// Retail dispatches a Setup's collision geometry EXCLUSIVELY, BSP first, at
/// both consumers: <c>CPhysicsObj::FindObjCollisions</c> @0x0050f050 tests
/// <c>HAS_PHYSICS_BSP_PS</c> at <c>0x0050f165</c> and leaves the BSP branch
/// through the unconditional <c>0x0050f19d jmp 0x50f2b0</c>, past both the
/// CylSphere loop (0x50f1a2) and the Sphere loop (0x50f21d); and
/// <c>CPhysicsObj::calc_cross_cells</c> @0x00515230 tests the same flag at
/// <c>0x00515285</c> and routes to <c>CPhysicsObj::find_bbox_cell_list</c>
/// @0x00510fc0 at <c>0x0051528f jne 0x515305</c>, never reaching its
/// cylsphere (0x005152d1) or sorting-sphere (0x005152fb) branches.
/// </para>
///
/// <para>
/// This sweep pins the affected population and asserts that
/// <see cref="ShadowShapeBuilder.FromSetup"/> emits NO primitive for any of
/// it. Retail derives the dispatch flag from the parts themselves
/// (<c>CPartArray::CacheHasPhysicsBSP</c> @0x00518110 ORs 0x10000 on the first
/// part whose <c>gfxobj-&gt;physics_bsp</c> is non-null), which is exactly the
/// predicate used here.
/// </para>
/// </summary>
public sealed class InstalledSetupBspPrimitiveDispatchTests
{
// EXTERNAL constants. The four bucket controls are the ones already
// committed by the AP-22 reachability sweep (measured by an independent
// raw client_portal.dat B-tree parse that validated itself by byte
// accounting); the affected counts were measured on 2026-08-06 by a
// separate DatReaderWriter sweep that reproduced FromSetup's steps rather
// than calling it.
//
// They are deliberately NOT derived from the predicates below. A broken
// enumeration, a wrong dat path, or a silently-empty decode all satisfy
// the affected-count claim vacuously and are caught only by the controls.
private const int ExpectedSetups = 5935;
private const int ExpectedWithCylinder = 678;
private const int ExpectedSphereOnlyNoCylinder = 3605;
private const int ExpectedWithoutAnyPrimitive = 1652;
private const int ExpectedAffected = 172;
private const int ExpectedAffectedCylinderBearing = 73;
private const int ExpectedAffectedSphereBearing = 99;
private const int ExpectedWithPhysicsBspPart = 530;
[Fact]
public void InstalledSetups_WithBothAPrimitiveAndAPhysicsBspPart_EmitOnlyBspShapes()
{
string? datDir = ContentConformanceDats.ResolveDatDir();
if (datDir is null)
return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
// Production physics-BSP predicate, FlatCollisionAssetBuilder.cs:377-380.
var physicsBspCache = new Dictionary<uint, bool>();
bool HasPhysicsBsp(uint gfxObjId)
{
if (physicsBspCache.TryGetValue(gfxObjId, out bool cached))
return cached;
bool result =
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;
physicsBspCache[gfxObjId] = result;
return result;
}
int total = 0;
int withCylinder = 0;
int sphereOnly = 0;
int withoutPrimitive = 0;
int withPhysicsBspPart = 0;
int affected = 0;
int affectedCylinderBearing = 0;
int affectedSphereBearing = 0;
var affectedThatStillEmitAPrimitive = new List<uint>();
foreach (uint id in dats.GetAllIdsOfType<Setup>())
{
if (!dats.Portal.TryGet<Setup>(id, out Setup? setup) || setup is null)
continue;
total++;
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; }
}
// FromSetup step 2 is gated on CylSpheres.Count == 0, so a Setup
// with both only ever emitted Cylinders.
bool emitsSphere = setup.CylSpheres.Count == 0 && hasSphere;
if (hasCylinder) withCylinder++;
else if (emitsSphere) sphereOnly++;
else withoutPrimitive++;
bool hasBspPart = false;
foreach (uint partId in setup.Parts)
{
if (HasPhysicsBsp(partId)) { hasBspPart = true; break; }
}
if (hasBspPart) withPhysicsBspPart++;
if (!hasBspPart || !(hasCylinder || emitsSphere))
continue;
affected++;
if (hasCylinder) affectedCylinderBearing++;
else affectedSphereBearing++;
// The behaviour: for every affected Setup the production builder
// must emit BSP shapes only.
IReadOnlyList<ShadowShape> shapes =
ShadowShapeBuilder.FromSetup(setup, 1f, HasPhysicsBsp);
bool clean = shapes.Count > 0;
foreach (ShadowShape shape in shapes)
{
if (shape.CollisionType != ShadowCollisionType.BSP)
{
clean = false;
break;
}
}
if (!clean)
affectedThatStillEmitAPrimitive.Add(id);
}
// Positive controls first — without these the claim below is
// satisfiable by an empty enumeration.
Assert.Equal(ExpectedSetups, total);
Assert.Equal(ExpectedWithCylinder, withCylinder);
Assert.Equal(ExpectedSphereOnlyNoCylinder, sphereOnly);
Assert.Equal(ExpectedWithoutAnyPrimitive, withoutPrimitive);
Assert.Equal(ExpectedWithPhysicsBspPart, withPhysicsBspPart);
Assert.Equal(ExpectedAffected, affected);
Assert.Equal(ExpectedAffectedCylinderBearing, affectedCylinderBearing);
Assert.Equal(ExpectedAffectedSphereBearing, affectedSphereBearing);
Assert.Empty(affectedThatStillEmitAPrimitive);
}
}

View file

@ -217,4 +217,113 @@ public class ShadowObjectRegistryMultiPartTests
Assert.Equal(0, reg.TotalRegistered);
}
// ---------------------------------------------------------------------
// AP-152 — cross-cell dispatch. CPhysicsObj::calc_cross_cells @0x00515230
// tests HAS_PHYSICS_BSP_PS at 0x00515285 and routes a BSP-bearing object
// to CPhysicsObj::find_bbox_cell_list @0x00510fc0 through
// 0x0051528f jne 0x515305. The cylsphere branch (0x005152d1
// CObjCell::find_cell_list @0x0052b9f0) and the sorting-sphere branch
// (0x005152fb ... @0x0052b990) are BOTH below that jump and unreachable
// from it. BuildFloodSpheres used to prefer Cylinders over everything
// whenever any Cylinder was present, which is retail's SECOND priority
// applied ahead of its first.
// ---------------------------------------------------------------------
/// <summary>Cells of the landblock that hold at least one row for owner.</summary>
private static List<uint> OutdoorCellsHolding(ShadowObjectRegistry reg, uint ownerId)
{
var cells = new List<uint>();
for (uint index = 1u; index <= 64u; index++)
{
uint cellId = LbId | index;
if (reg.GetObjectsInCell(cellId).Any(e => e.EntityId == ownerId))
cells.Add(cellId);
}
return cells;
}
private static ShadowShape Cyl(float radius) => new(
GfxObjId: 0u, LocalPosition: Vector3.Zero, LocalRotation: Quaternion.Identity,
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,
Scale: 1f, CollisionType: ShadowCollisionType.BSP,
Radius: radius, CylHeight: 0f);
private static List<uint> FloodCellsFor(params ShadowShape[] shapes)
{
var reg = new ShadowObjectRegistry();
const uint ownerId = 0xBEEF01u;
// Centre of the landblock's cell (1,1) so a 14 m footprint stays
// inside the block's own 8x8 outdoor grid on every side.
reg.RegisterMultiPart(
ownerId, new Vector3(36f, 36f, 50f), Quaternion.Identity,
shapes, 0x10008u, EntityCollisionFlags.None, OffX, OffY, LbId);
return OutdoorCellsHolding(reg, ownerId);
}
[Fact]
public void BuildFloodSpheres_BspBearingOwner_FloodsFromBspNotFromCylinder()
{
List<uint> cylinderOnly = FloodCellsFor(Cyl(0.5f));
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);
Assert.True(bspOnly.Count > 1,
$"BSP footprint control failed: expected >1 cell, got {bspOnly.Count}");
// The fact: a mixed list floods from the BSP shapes, exactly as if the
// cylinder were not there. Retail 0x0051528f.
Assert.Equal(bspOnly, mixed);
Assert.NotEqual(cylinderOnly, mixed);
}
/// <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
/// production builder. Before the fix the emitted list carried both, and
/// 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.
/// </summary>
[Fact]
public void FromSetup_CylSphereAndBspSetup_FloodsTheBspFootprint()
{
const uint part = 0x010044B5u;
var setup = new DatReaderWriter.DBObjs.Setup
{
Parts = { part },
CylSpheres = { new DatReaderWriter.Types.CylSphere
{ 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();
ShadowShape only = Assert.Single(shapes);
Assert.Equal(ShadowCollisionType.BSP, only.CollisionType);
var reg = new ShadowObjectRegistry();
const uint ownerId = 0xBEEF02u;
reg.RegisterMultiPart(
ownerId, new Vector3(36f, 36f, 50f), Quaternion.Identity,
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}");
}
}

View file

@ -49,10 +49,20 @@ public class ShadowShapeBuilderShapeSourceTests
// BEFORE ShadowShapeBuilder ran and discarded these entities entirely.
//
// Retail oracle: CPhysicsPart::find_obj_collisions@0x0050D8D0 -- when
// physics_bsp is non-null the part IS tested; the outer loop in
// CPartArray::FindObjCollisions iterates all parts regardless of
// CylSpheres/Spheres. ShadowShapeBuilder.FromSetup mirrors this by emitting
// one BSP shape per part that `hasPhysicsBsp` returns true for.
// physics_bsp is non-null the part IS tested, and the outer loop in
// CPartArray::FindObjCollisions@0x00518180 iterates ALL parts.
// ShadowShapeBuilder.FromSetup mirrors this by emitting one BSP shape per
// part that `hasPhysicsBsp` returns true for.
//
// AP-152 correction (2026-08-06): that "regardless of CylSpheres/Spheres"
// used to be stated here and was then used to justify emitting the BSP
// shapes IN ADDITION TO the primitives. It does not support that. The
// per-part loop is reached only from the BSP branch of
// CPhysicsObj::FindObjCollisions@0x0050f050, which is entered on
// HAS_PHYSICS_BSP_PS (0x0050f165 test / 0x0050f16f je) and left by the
// UNCONDITIONAL 0x0050f19d jmp 0x50f2b0 — past both primitive loops. The
// dispatch is exclusive and BSP wins; this fixture's Setup simply has no
// primitive to lose.
[Fact]
public void Setup_WithBspPart_NoCylSpheres_EmitsBspShape()
{

View file

@ -47,40 +47,57 @@ public class ShadowShapeBuilderTests
return setup;
}
/// <summary>
/// AP-152, corrected 2026-08-06 (was <c>FromSetup_DoorSetup_ProducesFourShapes</c>,
/// which pinned the additive emission as intended).
///
/// <para>
/// Retail dispatches EXCLUSIVELY and BSP wins.
/// <c>CPhysicsObj::FindObjCollisions</c> @0x0050f050 tests
/// <c>HAS_PHYSICS_BSP_PS</c> first (<c>0x0050f165
/// test dword [esi+0xa8],0x10000</c> / <c>0x0050f16f je 0x50f1a2</c>) and
/// leaves the BSP branch through the UNCONDITIONAL
/// <c>0x0050f19d jmp 0x50f2b0</c>, which is past both the CylSphere loop
/// (0x50f1a2) and the Sphere loop (0x50f21d).
/// <c>CPhysicsObj::calc_cross_cells</c> @0x00515230 tests the same flag at
/// 0x00515285 and routes to <c>find_bbox_cell_list</c> @0x00510fc0.
/// So the cottage door's 0.100 m base Sphere is neither tested for
/// collision nor used for cell membership — only the three slab BSP parts.
/// </para>
/// </summary>
[Fact]
public void FromSetup_DoorSetup_ProducesFourShapes()
public void FromSetup_DoorSetup_EmitsBspPartsOnly()
{
var setup = CreateDoorSetup();
Func<uint, bool> hasBsp = id => id == 0x010044B5u || id == 0x010044B6u;
var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1.0f, hasBsp);
Assert.Equal(4, shapes.Count);
// Task 2 (2026-06-24): Setup.Spheres now emit ShadowCollisionType.Sphere,
// not Cylinder. A door's Sphere entry contributes the Sphere-typed shape;
// the 3 parts (all with physics BSP) contribute the 3 BSP shapes.
int sphereCount = 0;
int bspCount = 0;
foreach (var s in shapes)
{
if (s.CollisionType == ShadowCollisionType.Sphere) sphereCount++;
else if (s.CollisionType == ShadowCollisionType.BSP) bspCount++;
}
Assert.Equal(1, sphereCount);
Assert.Equal(3, bspCount);
Assert.Equal(3, shapes.Count);
Assert.All(shapes, s => Assert.Equal(ShadowCollisionType.BSP, s.CollisionType));
Assert.DoesNotContain(shapes, s => s.CollisionType == ShadowCollisionType.Sphere);
Assert.DoesNotContain(shapes, s => s.CollisionType == ShadowCollisionType.Cylinder);
}
/// <summary>
/// AP-152, re-hosted 2026-08-06 on <c>hasPhysicsBsp: _ => false</c> — the
/// DAT-real configuration for the 3,605 Sphere-only Setups. The fact
/// itself is unchanged and still live: a Setup Sphere emits a TRUE
/// <see cref="ShadowCollisionType.Sphere"/> (not a height-capped
/// Cylinder), passing its local offset and radius through.
/// <c>ShadowCollisionType.Sphere</c> is produced at exactly one site in
/// <c>src/</c>, and it is the premise of the whole CSphere family port.
/// Retail: <c>CSphere::intersects_sphere</c> @0x00537A80 uses 3-D
/// distance, so there is no height cap.
/// </summary>
[Fact]
public void FromSetup_DoorSetup_SphereAtExpectedLocalOffset()
{
var setup = CreateDoorSetup();
var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, _ => true);
var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, _ => false);
// Task 2 (2026-06-24): Spheres emit ShadowCollisionType.Sphere (not Cylinder).
// Retail: CSphere::intersects_sphere @ 0x00537A80 uses 3-D distance; no height cap.
var sphereShape = shapes.FirstOrDefault(s => s.CollisionType == ShadowCollisionType.Sphere);
Assert.NotEqual(default, sphereShape);
var sphereShape = Assert.Single(shapes);
Assert.Equal(ShadowCollisionType.Sphere, sphereShape.CollisionType);
Assert.Equal(0f, sphereShape.LocalPosition.X, 4);
Assert.Equal(0f, sphereShape.LocalPosition.Y, 4);
Assert.Equal(0.018f, sphereShape.LocalPosition.Z, 4);
@ -89,6 +106,43 @@ public class ShadowShapeBuilderTests
Assert.Equal(0f, sphereShape.CylHeight, 4);
}
/// <summary>
/// AP-152 trap 1. The step-0 dispatch gate and the step-3 emission must
/// read the SAME part identities. If the gate read <c>setup.Parts</c>
/// while step 3 read the installed <c>AnimPartChanged</c> replacements,
/// a swap could suppress the primitives while step 3 emitted nothing —
/// <c>LiveEntityCollisionBuilder.Build</c> would then return null and the
/// entity's collision would disappear entirely.
/// </summary>
[Fact]
public void FromSetup_DispatchGateReadsTheEffectivePartIdentities()
{
const uint basePart = 0x010044B5u;
const uint replacementWithBsp = 0x0100AA01u;
var setup = new Setup
{
Parts = { basePart },
CylSpheres = { new CylSphere { Radius = 0.4f, Height = 1.2f, Origin = Vector3.Zero } },
};
Func<uint, bool> hasBsp = id => id == replacementWithBsp;
var swapped = ShadowShapeBuilder.FromSetup(
setup, 1.0f, hasBsp, effectivePartGfxObjIds: [replacementWithBsp]);
var unswapped = ShadowShapeBuilder.FromSetup(setup, 1.0f, hasBsp);
// Replacement carries the BSP -> BSP wins, the CylSphere is suppressed.
ShadowShape swappedShape = Assert.Single(swapped);
Assert.Equal(ShadowCollisionType.BSP, swappedShape.CollisionType);
Assert.Equal(replacementWithBsp, swappedShape.GfxObjId);
// Base identity has no BSP -> no BSP shape exists, so the CylSphere
// must survive. A gate reading setup.Parts would agree here and
// disagree above; a gate reading nothing at all would disagree here.
ShadowShape unswappedShape = Assert.Single(unswapped);
Assert.Equal(ShadowCollisionType.Cylinder, unswappedShape.CollisionType);
Assert.Equal(0.4f, unswappedShape.Radius, 4);
}
[Fact]
public void FromSetup_PartWithoutBsp_SkipsBspShape()
{
@ -147,22 +201,44 @@ public class ShadowShapeBuilderTests
Assert.Equal(1.20f, shapes[0].CylHeight, 3);
}
/// <summary>
/// Corrected 2026-08-06 (AP-152 §11.5). This test used to run
/// <c>CreateDoorSetup()</c> — which has ZERO CylSpheres — and then assert
/// radius/offset scaling inside
/// <c>if (s.CollisionType == ShadowCollisionType.Cylinder)</c>. That
/// branch had been unreachable since Setup Spheres started emitting
/// <see cref="ShadowCollisionType.Sphere"/> (2026-06-24), so the only
/// assertion that ever executed was <c>Scale == 2.0f</c>: the name
/// promised radius and offset scaling and pinned neither. Both primitive
/// kinds are now asserted unconditionally, on fixtures that actually
/// emit them.
/// </summary>
[Fact]
public void FromSetup_ScaleFactor_MultipliesAllRadiiAndOffsets()
{
var setup = CreateDoorSetup();
var sphereShape = Assert.Single(
ShadowShapeBuilder.FromSetup(CreateDoorSetup(), entScale: 2.0f, _ => false));
Assert.Equal(ShadowCollisionType.Sphere, sphereShape.CollisionType);
Assert.Equal(2.0f, sphereShape.Scale, 3);
Assert.Equal(0.200f, sphereShape.Radius, 3); // 0.100 * 2
Assert.Equal(0.036f, sphereShape.LocalPosition.Z, 3); // 0.018 * 2
var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 2.0f, _ => true);
foreach (var s in shapes)
var cylSetup = new Setup
{
Assert.Equal(2.0f, s.Scale, 3);
if (s.CollisionType == ShadowCollisionType.Cylinder)
CylSpheres =
{
Assert.Equal(0.200f, s.Radius, 3);
Assert.Equal(0.036f, s.LocalPosition.Z, 3);
}
new CylSphere { Radius = 0.40f, Height = 1.20f, Origin = new Vector3(0.1f, 0.2f, 0.6f) }
}
};
var cylShape = Assert.Single(
ShadowShapeBuilder.FromSetup(cylSetup, entScale: 2.0f, _ => false));
Assert.Equal(ShadowCollisionType.Cylinder, cylShape.CollisionType);
Assert.Equal(2.0f, cylShape.Scale, 3);
Assert.Equal(0.800f, cylShape.Radius, 3); // 0.40 * 2
Assert.Equal(2.400f, cylShape.CylHeight, 3); // 1.20 * 2
Assert.Equal(0.200f, cylShape.LocalPosition.X, 3);
Assert.Equal(0.400f, cylShape.LocalPosition.Y, 3);
Assert.Equal(1.200f, cylShape.LocalPosition.Z, 3);
}
[Fact]