fix(physics): Slice 1 — delete render-mesh-AABB synthetic collision; DAT-only shape authority

Retail oracle: CPartArray::InitParts@0x00517F40, CGfxObj::Serialize@0x00534970 (physics_bsp
gated on serialized-flags bit-0), CPhysicsPart::find_obj_collisions@0x0050D8D0 (returns OK /
passable when physics_bsp==null). Render-mesh bounds never enter collision.

Changes:
- GameWindow.cs: delete the ~200-line VISUAL mesh-bounds collision block (the
  isPhantomSetup / isPhantomGfxObj locals + the if-block computing worldMin/worldMax
  AABB + the ShadowObjects.Register call that capped and registered the synthetic
  cylinder). Also removes dead counter variables scHaveBounds/scRegistered/scNoBounds/
  scTooThin; trims the ProbeBuildingEnabled summary line accordingly.
- PhysicsDataCache.cs: delete IsPhantomGfxObjSource (the predicate that only existed
  to fence the mesh-AABB synthesis; the "phantom" concept is now the default — no DAT
  shape means no registration, verbatim with retail).
- PhysicsDataCachePhantomSourceTests.cs: deleted (tested the removed method).
- ShadowShapeBuilderShapeSourceTests.cs: new guard test — a Setup with parts but
  hasPhysicsBsp=false and no CylSpheres/Spheres yields an empty shape list, locking
  the DAT-only rule in the builder.
- retail-divergence-register.md: AP-2 row deleted (divergence retired).

Objects with no DAT physics shape (no CylSpheres, no Spheres, no part with a
PhysicsBSP) now register no collision shape and are passable, verbatim with retail.
Objects with real DAT shapes (BSP parts, CylSpheres) are unaffected.

dotnet build green, 22/22 tests passing (ShadowShapeBuilderShapeSourceTests +
CellarUpTrajectoryReplay + CornerFlood + Issue147ArwicBuildings replay harnesses).

Visual gate pending: walk Holtburg + open world; objects that become passable must
match retail (DAT has no physics shape — trees with real CylSpheres still solid).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-24 18:57:46 +02:00
parent 4f26067755
commit 79dee342f2
5 changed files with 44 additions and 297 deletions

View file

@ -7112,7 +7112,7 @@ public sealed class GameWindow : IDisposable
// 2. Setup: use Setup.Radius (the capsule radius) if available.
// 3. Fallback: 1.0m (conservative default for trees / small objects).
int lbBspCount = 0, lbCylCount = 0, lbNoneCount = 0;
int scTried = 0, scHaveBounds = 0, scRegistered = 0, scTooThin = 0, scNoBounds = 0;
int scTried = 0;
foreach (var entity in lb.Entities)
{
// Phase G.2: if the entity's Setup has baked-in LightInfos,
@ -7364,207 +7364,6 @@ public sealed class GameWindow : IDisposable
}
}
// L-fix3 (2026-04-28): retail "decorative / phantom" detection.
// A Setup is phantom (no collision) when it has NO CylSpheres,
// NO Spheres, AND zero overall Radius. Small plants, grass
// tufts, flowers, ground-cover bushes all match — retail
// ships them with empty collision arrays so the player walks
// through them. Without this gate the mesh-bounds fallback
// below assigns every plant a 0.3 m+ collision cylinder
// (line ~4409 clamp) and they block the player.
//
// The gate is layered AFTER the Setup CylSphere / BSP
// registrations above (which are no-ops for phantom Setups
// anyway), so non-phantom scenery (trees with real
// CylSpheres or canopy-only BSPs) still gets the
// mesh-bounds fallback. The check is on entity.SourceGfxObjOrSetupId
// to look up the cached Setup; if it's a raw GfxObj
// (0x010xxxxx, no Setup metadata) we keep the old fallback
// behaviour because GfxObjs don't expose phantom intent.
bool isPhantomSetup = false;
if ((entity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
{
var setupInfoForPhantom = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
if (setupInfoForPhantom is not null
&& setupInfoForPhantom.CylSpheres.Count == 0
&& setupInfoForPhantom.Spheres.Count == 0
&& setupInfoForPhantom.Radius <= 0.0001f)
{
isPhantomSetup = true;
}
}
// Issue #101 (2026-05-25): retail-faithful phantom check for
// GfxObj-only entity sources. Retail's CPartArray::InitParts
// emits NO collision shapes when the source GfxObj has
// HasPhysics=False / null PhysicsBSP.Root. Our mesh-aabb-fallback
// below previously synthesized a clamped [0.30, 1.50]m cylinder
// from the visual mesh AABB — that's the source of the 10
// phantom 0.80m cyls that block the Holtburg upper-floor
// staircase (issue #101). Suppress synthesis for these.
// Spec: docs/research/2026-05-25-a6-stairs-cyl-retail-investigation.md.
bool isPhantomGfxObj = _physicsDataCache.IsPhantomGfxObjSource(entity.SourceGfxObjOrSetupId);
// VISUAL mesh-bounds collision: for SCENERY entities (IDs with
// 0x80000000 bit set, indicating procedurally-placed scenery),
// ALWAYS compute a cylinder from the world-space mesh AABB.
// This catches trees whose BSP is only on the canopy (player
// walks under) AND corrects CylSphere positioning issues caused
// by mesh files having vertices offset from the mesh origin.
//
// For stabs (low IDs) and live entities, keep the existing Setup
// CylSphere / BSP registrations — those are placed with precise
// frame data and don't have the scenery offset issue.
//
// L-fix3: skip entirely when the Setup is phantom — retail
// decorative meshes have no collision data on purpose.
if (!isPhantomSetup
&& !isPhantomGfxObj
&& !_isLandblockStab
&& (_isOutdoorMesh || (entityBsp == 0 && entityCyl == 0))
&& entity.MeshRefs.Count > 0)
{
float entScale = entity.Scale > 0f ? entity.Scale : 1f;
bool haveBounds = false;
var worldMin = new System.Numerics.Vector3(float.MaxValue);
var worldMax = new System.Numerics.Vector3(float.MinValue);
var entRoot =
System.Numerics.Matrix4x4.CreateFromQuaternion(entity.Rotation) *
System.Numerics.Matrix4x4.CreateTranslation(entity.Position);
// First pass: compute overall vertical extent in world Z.
float overallMinZ = float.MaxValue;
float overallMaxZ = float.MinValue;
foreach (var mr in entity.MeshRefs)
{
var vb = _physicsDataCache.GetVisualBounds(mr.GfxObjId);
if (vb is null || vb.Radius <= 0f) continue;
var partWorld = mr.PartTransform * entRoot;
for (int bi = 0; bi < 8; bi++)
{
var corner = new System.Numerics.Vector3(
(bi & 1) != 0 ? vb.Max.X : vb.Min.X,
(bi & 2) != 0 ? vb.Max.Y : vb.Min.Y,
(bi & 4) != 0 ? vb.Max.Z : vb.Min.Z);
var w = System.Numerics.Vector3.Transform(corner, partWorld);
if (w.Z < overallMinZ) overallMinZ = w.Z;
if (w.Z > overallMaxZ) overallMaxZ = w.Z;
}
}
// Second pass: use TRUNK HEIGHT ONLY (bottom 25% of the mesh
// or first 2.5m, whichever is smaller) for horizontal radius.
// This gives us the trunk thickness — not the canopy spread.
// The Z extent still uses the full mesh (so tall trees have
// tall collision cylinders).
float trunkHeight = MathF.Min(2.5f, (overallMaxZ - overallMinZ) * 0.25f);
if (trunkHeight < 0.5f) trunkHeight = 0.5f;
float trunkTopZ = overallMinZ + trunkHeight;
foreach (var mr in entity.MeshRefs)
{
var vb = _physicsDataCache.GetVisualBounds(mr.GfxObjId);
if (vb is null || vb.Radius <= 0f) continue;
var partWorld = mr.PartTransform * entRoot;
// Only accumulate horizontal extents from corners within the
// trunk height range. Pass the full vertical extent through.
for (int bi = 0; bi < 8; bi++)
{
var corner = new System.Numerics.Vector3(
(bi & 1) != 0 ? vb.Max.X : vb.Min.X,
(bi & 2) != 0 ? vb.Max.Y : vb.Min.Y,
(bi & 4) != 0 ? vb.Max.Z : vb.Min.Z);
var w = System.Numerics.Vector3.Transform(corner, partWorld);
// Always track vertical extent
if (w.Z < worldMin.Z) worldMin.Z = w.Z;
if (w.Z > worldMax.Z) worldMax.Z = w.Z;
// Only track horizontal extent for TRUNK-level vertices
if (w.Z <= trunkTopZ)
{
if (w.X < worldMin.X) worldMin.X = w.X;
if (w.Y < worldMin.Y) worldMin.Y = w.Y;
if (w.X > worldMax.X) worldMax.X = w.X;
if (w.Y > worldMax.Y) worldMax.Y = w.Y;
haveBounds = true;
}
}
}
if (haveBounds)
{
if (_isScenery) scHaveBounds++;
// RADIUS: prefer the Setup's CylSphere radius (the retail
// trunk radius — thin, matches tree trunks). Fall back to
// Setup.Radius or mesh AABB if CylSphere is unavailable.
// Always scale by entity.Scale.
float entScaleLocal = entity.Scale > 0f ? entity.Scale : 1f;
float cylRadius = -1f;
float cylHeight;
var setupInfo = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
if (setupInfo is not null)
{
if (setupInfo.CylSpheres.Count > 0 && setupInfo.CylSpheres[0].Radius > 0f)
{
// Retail CylSphere — the definitive trunk collision.
cylRadius = setupInfo.CylSpheres[0].Radius * entScaleLocal;
}
else if (setupInfo.Radius > 0f)
{
// Setup.Radius — the overall bounding radius. For
// thin trunks this might be the full tree radius
// (canopy included) but often it's the trunk.
cylRadius = setupInfo.Radius * entScaleLocal;
}
}
// Fall back to mesh AABB trunk-level radius if no Setup data.
if (cylRadius < 0f)
{
float halfX = (worldMax.X - worldMin.X) * 0.5f;
float halfY = (worldMax.Y - worldMin.Y) * 0.5f;
cylRadius = MathF.Max(halfX, halfY);
}
// Clamp: retail AC trunks are 0.3-1.0m. Bigger radii (from
// the AABB fallback for canopy-heavy meshes) are clearly
// wrong; clamp to a reasonable tree-trunk maximum.
if (cylRadius < 0.3f) cylRadius = 0.3f;
if (cylRadius > 1.5f) cylRadius = 1.5f;
// HEIGHT: use Setup.Height scaled, or mesh AABB vertical extent.
if (setupInfo is not null && setupInfo.Height > 0f)
cylHeight = setupInfo.Height * entScaleLocal;
else
cylHeight = MathF.Max(worldMax.Z - entity.Position.Z, cylRadius);
// CENTER: entity.Position (the rendered root).
var baseCenter = new System.Numerics.Vector3(
entity.Position.X, entity.Position.Y, entity.Position.Z);
_physicsEngine.ShadowObjects.Register(
entity.Id,
entity.SourceGfxObjOrSetupId,
baseCenter, entity.Rotation, cylRadius,
origin.X, origin.Y, lb.LandblockId,
AcDream.Core.Physics.ShadowCollisionType.Cylinder, cylHeight,
seedCellId: entity.ParentCellId ?? 0u);
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
// state/flags literals: landblock-baked scenery; no server PhysicsState.
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=mesh-aabb-fallback state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}"));
entityCyl++;
if (_isScenery) scRegistered++;
}
else if (_isScenery) scNoBounds++;
}
// Tally per-entity collision presence (debug counter — optional).
if (entityBsp > 0) lbBspCount++;
if (entityCyl > 0) lbCylCount++;
@ -7580,8 +7379,7 @@ public sealed class GameWindow : IDisposable
}
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled && scTried > 0)
Console.WriteLine(
$"lb 0x{lb.LandblockId:X8}: scenery tried={scTried} registered={scRegistered} " +
$"noBounds={scNoBounds} tooThin={scTooThin} (outdoorNone={lbNoneCount})");
$"lb 0x{lb.LandblockId:X8}: scenery tried={scTried} (outdoorNone={lbNoneCount})");
// Find scenery WITHOUT any cached visual bounds at all
int sceneryNoCache = 0;

View file

@ -384,24 +384,6 @@ public sealed class PhysicsDataCache
public GfxObjPhysics? GetGfxObj(uint id) => _gfxObj.TryGetValue(id, out var p) ? p : null;
/// <summary>
/// Issue #101 (2026-05-25): retail-faithful phantom check for
/// GfxObj-only entity sources. Returns true when the entity's
/// <c>SourceGfxObjOrSetupId</c> is a GfxObj (high byte
/// <c>0x01</c>) AND has no cached <see cref="GfxObjPhysics"/> —
/// meaning the underlying GfxObj had <c>HasPhysics=False</c> or
/// a null <c>PhysicsBSP.Root</c>, so <see cref="CacheGfxObj"/>
/// short-circuited at the early-return on line 45/46. Retail's
/// <c>CPartArray::InitParts</c> emits NO collision shapes for
/// these — acdream's <c>mesh-aabb-fallback</c> synthesis at
/// <c>GameWindow.cs:6127</c> must do the same.
/// </summary>
public bool IsPhantomGfxObjSource(uint sourceId)
{
if ((sourceId & 0xFF000000u) != 0x01000000u) return false;
return GetGfxObj(sourceId)?.BSP?.Root is null;
}
public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : null;
public CellPhysics? GetCellStruct(uint id) => _cellStruct.TryGetValue(id, out var p) ? p : null;
public int GfxObjCount => _gfxObj.Count;