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:
parent
4f26067755
commit
79dee342f2
5 changed files with 44 additions and 297 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue