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
|
|
@ -104,7 +104,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||
|---|---|---|---|---|---|
|
||||
| AP-1 | Snap-path Z settle: validated claims ground on their own walkable polys, but floor-less claims (thresholds, stair lips) fall through to a legacy nearest-in-Z scan over every CellSurface in the landblock; retail settles via `CheckPositionInternal` → `find_valid_position` | `src/AcDream.Core/Physics/PhysicsEngine.cs:614` | `find_valid_position` unported; the **#111** fix narrowed the legacy pick's blast radius (validated claims bypass it) rather than replacing it | A threshold/stair-lip snap can still pick a neighbouring cell's same-height floor by iteration order — wrong cell or Z at login/teleport arrival (the #111 clobber class) | `SetPositionInternal` :283426 → find_valid_position |
|
||||
| AP-2 | Visual-AABB fallback collision shape for Setups with no retail physics data; retail emits NO shapes (phantom). **#101** fixed the GfxObj-only class; the Setup-without-shapes fallback remains | `src/AcDream.Core/Physics/PhysicsDataCache.cs:96` | Lets the player collide with decorative meshes shipping no CylSphere/part-BSP instead of walking through furniture-like props | Retail-phantom entities block movement (the **#100/#101** family), and the synthetic box gives non-retail push-out normals when it collides | `CPartArray::InitParts` (cited at PhysicsDataCache.cs:386-389) |
|
||||
|
||||
| AP-3 | Step-down chain triggered only when contact is invalid OR steeper than walkable; retail's `transitional_insert` OK-path ALWAYS runs it | `src/AcDream.Core/Physics/TransitionTypes.cs:1197` | Conditional preserves the observed-to-matter cases (edge departure, steep cliff-slide) without running the chain every step (per pc:273191 agent reports) | Steps where retail runs step-down despite a valid walkable contact (bump maintenance, edge-slide arming) are skipped — float-off or missed edge slides in untested geometry | `transitional_insert` OK-path pc:273191 |
|
||||
| AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) |
|
||||
| AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 |
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #101 (2026-05-25) — phantom-stair fix. Retail's
|
||||
/// <c>CPartArray::InitParts</c> emits collision shapes only from
|
||||
/// Setup-level <c>CylSpheres</c>/<c>Spheres</c> or per-Part
|
||||
/// <c>PhysicsBSP</c>. There is NO synthesis from visual mesh AABB.
|
||||
/// Acdream's <c>mesh-aabb-fallback</c> path at
|
||||
/// <c>GameWindow.cs:6127</c> previously fired for ANY entity that
|
||||
/// reached it with <c>entityBsp==0 && entityCyl==0</c>,
|
||||
/// including GfxObj-only stabs whose GfxObj has
|
||||
/// <c>HasPhysics=False</c>. This produced the 10 phantom 0.80 m
|
||||
/// cylinders that block the Holtburg upper-floor staircase (see
|
||||
/// <c>docs/research/2026-05-25-a6-stairs-cyl-retail-investigation.md</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="PhysicsDataCache.IsPhantomGfxObjSource"/> captures the
|
||||
/// retail rule as a predicate: "the entity's source is a GfxObj
|
||||
/// (high byte 0x01) AND the cache has no <see cref="GfxObjPhysics"/>
|
||||
/// entry for it." When this returns true, the caller suppresses the
|
||||
/// fallback synthesis.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class PhysicsDataCachePhantomSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsPhantomGfxObjSource_SetupHighByte_ReturnsFalse()
|
||||
{
|
||||
// Setup source (high byte 0x02) is never the GfxObj-phantom case.
|
||||
// The existing isPhantomSetup check at GameWindow.cs:6090 handles
|
||||
// the Setup-side phantom. This predicate is scoped to GfxObj only.
|
||||
var cache = new PhysicsDataCache();
|
||||
Assert.False(cache.IsPhantomGfxObjSource(0x020019FFu)); // door setup
|
||||
Assert.False(cache.IsPhantomGfxObjSource(0x02000266u)); // some setup
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPhantomGfxObjSource_GfxObjUncached_ReturnsTrue()
|
||||
{
|
||||
// GfxObj source (high byte 0x01) with NO cached GfxObjPhysics
|
||||
// = the phantom case. The stair-step GfxObj 0x0100081A from
|
||||
// issue #101's broken-stairs capture has HasPhysics=False and
|
||||
// does not enter the cache. Acdream should treat it as phantom.
|
||||
var cache = new PhysicsDataCache();
|
||||
Assert.True(cache.IsPhantomGfxObjSource(0x0100081Au));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPhantomGfxObjSource_GfxObjCached_ReturnsFalse()
|
||||
{
|
||||
// GfxObj source (high byte 0x01) WITH a cached GfxObjPhysics
|
||||
// (i.e. the GfxObj's HasPhysics flag was set and its PhysicsBSP
|
||||
// root is non-null) is NOT phantom. The staircase BSP entity
|
||||
// 0x40B50089 from issue #101's capture, backed by GfxObj
|
||||
// 0x01000C16 with hasPhys=True, is this case.
|
||||
var cache = new PhysicsDataCache();
|
||||
var leaf = new PhysicsBSPNode { Type = BSPNodeType.Leaf };
|
||||
var fakePhysics = new GfxObjPhysics
|
||||
{
|
||||
BSP = new PhysicsBSPTree { Root = leaf },
|
||||
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
|
||||
Vertices = new VertexArray(),
|
||||
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
};
|
||||
cache.RegisterGfxObjForTest(0x01000C16u, fakePhysics);
|
||||
Assert.False(cache.IsPhantomGfxObjSource(0x01000C16u));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the DAT-only shape-source rule (Slice 1 of the unified
|
||||
/// collision-inclusion phase, 2026-06-24).
|
||||
///
|
||||
/// <para>
|
||||
/// Retail oracle: <c>CPartArray::InitParts@0x00517F40</c> (parts come from
|
||||
/// the Setup list only), <c>CGfxObj::Serialize@0x00534970</c> (physics_bsp
|
||||
/// gated on serialized-flags bit-0), <c>CPhysicsPart::find_obj_collisions@0x0050D8D0</c>
|
||||
/// (returns OK / passable when <c>physics_bsp == null</c>). Render-mesh bounds
|
||||
/// never enter collision.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class ShadowShapeBuilderShapeSourceTests
|
||||
{
|
||||
// Retail: an object with no DAT physics shape (no CylSphere, no Sphere,
|
||||
// no part with a PhysicsBSP) emits NO collision shape and is passable.
|
||||
// CPhysicsPart::find_obj_collisions@0x0050D8D0 returns OK when physics_bsp==null.
|
||||
[Fact]
|
||||
public void Setup_WithNoCylSpheres_NoSpheres_NoPhysicsBspParts_YieldsEmptyShapeList()
|
||||
{
|
||||
var setup = new Setup
|
||||
{
|
||||
CylSpheres = new List<CylSphere>(),
|
||||
Spheres = new List<Sphere>(),
|
||||
Parts = { 0x01000ABCu },
|
||||
PlacementFrames = new Dictionary<Placement, AnimationFrame>(),
|
||||
};
|
||||
var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f, hasPhysicsBsp: _ => false);
|
||||
Assert.Empty(shapes);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue