test(phys): A6.P3 #98 — comparison harness reproduces cottage-floor cap
Apparatus convergence. With the cottage GfxObj 0x01000A2B registered as
a ShadowEntry in BuildEngineWithCellarFixtures, the harness now reproduces
the live cap-event collision normal (cn=(0,0,-1)) exactly, ending the
"harness doesn't reproduce" divergence the prior session's findings doc
identified.
Concretely:
* Adds a minimum-stub landblock (TerrainSurface at z=-1000) so
TryGetLandblockContext succeeds at the cellar XY — production's
FindObjCollisions early-returns without a landblock and would skip
the cottage shadow query.
* Adds RegisterCottageGfxObj that loads the 74-polygon cottage fixture
via GfxObjDumpSerializer.Hydrate, then registers it at the cottage's
world transform (translation (130.5, 11.5, 94.0) + 180° around Z,
derived from the cellar cell's WorldTransform), matching
GameWindow.cs:5893's landblock-baked-static registration shape.
* LiveCompare_FirstCap_HarnessMissesCottageFloorBecauseCottageGfxObjNotRegistered
flips: the cap-normal reproduction is now enforced by
LiveCompare_FirstCap_HarnessReproducesCottageFloorCapNormal.
* The full per-field round-trip uncovered ONE residual divergence:
live preserves +0.0266m of +X motion through the cap event (edge-
slide along the floor in XY); harness blocks ALL motion at the cap.
Captured by LiveCompare_FirstCap_ResidualXMotionDivergence_Docs...
in documents-the-bug form so the next session has a concrete next
target.
Fixture: tests/AcDream.Core.Tests/Fixtures/issue98/0x01000A2B.gfxobj.json
(74 polygons, 6 downward-facing cottage-floor triangles at object-local
Z=0, BSP radius 13.989m matching the live [resolve-bldg] bspR=13.99).
Captured via launch-a6-issue98-cottage-gfxobj-dump.ps1.
In-isolation: all 12 CellarUpTrajectoryReplayTests + 4 GfxObjDumpRoundTripTests
+ 1 new PhysicsDiagnosticsTests pass.
Note on full-suite baseline: the full xUnit serial run shows 8–19
failures depending on order (pre-existing test interaction with shared
statics across PlayerMovementControllerTests, MotionInterpreterTests,
PositionManagerTests, etc.). The flakiness is independent of this
change — confirmed by stashing the harness changes and observing the
same flaky range. Investigating the static-state isolation problem is
out of scope for issue #98; tracked as a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cc3afbcbeb
commit
97fec19dbb
3 changed files with 2586 additions and 132 deletions
46
launch-a6-issue98-cottage-gfxobj-dump.ps1
Normal file
46
launch-a6-issue98-cottage-gfxobj-dump.ps1
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||||
$env:ACDREAM_LIVE = '1'
|
||||
$env:ACDREAM_TEST_HOST = '127.0.0.1'
|
||||
$env:ACDREAM_TEST_PORT = '9000'
|
||||
$env:ACDREAM_TEST_USER = 'testaccount'
|
||||
$env:ACDREAM_TEST_PASS = 'testpassword'
|
||||
|
||||
# A6.P3 #98 (2026-05-23 evening v2) — focused capture of the cottage
|
||||
# GfxObj 0x01000A2B's full polygon table. ACDREAM_DUMP_GFXOBJS triggers
|
||||
# a one-shot JSON dump the first time PhysicsDataCache.CacheGfxObj fires
|
||||
# for the listed id. The dump lands in the tests' fixture directory under
|
||||
# the worktree, so the harness can load it without copying.
|
||||
#
|
||||
# Reproduction steps for the user:
|
||||
# 1. Run this script (it launches in the foreground; log streams to the
|
||||
# file path below).
|
||||
# 2. Log into +Acdream. The cottage GfxObj caches when the streaming
|
||||
# worker walks the cottage building's mesh — which happens as soon
|
||||
# as the cottage landblock enters the streaming N1 (near) tier.
|
||||
# Holtburg's cottage (the one with the cellar) is at the spawn area,
|
||||
# so just being in-world is enough.
|
||||
# 3. Watch the log for "[gfxobj-dump] wrote 0x01000A2B polys=N → ..."
|
||||
# then close the client.
|
||||
#
|
||||
# After the dump file exists at
|
||||
# tests/AcDream.Core.Tests/Fixtures/issue98/0x01000A2B.gfxobj.json
|
||||
# come back to Claude to continue with the RegisterCottageGfxObj wiring.
|
||||
|
||||
$env:ACDREAM_DUMP_GFXOBJS = '0x01000A2B'
|
||||
|
||||
# Output dir is the relative fixture path; the dump infrastructure
|
||||
# resolves it against the worktree current dir (Set-Location below).
|
||||
$env:ACDREAM_DUMP_GFXOBJS_DIR = 'tests/AcDream.Core.Tests/Fixtures/issue98'
|
||||
|
||||
# Keep the cell-transit probe on so the launch log shows when the player
|
||||
# enters cells — helps correlate the dump event with player position.
|
||||
$env:ACDREAM_PROBE_CELL = '1'
|
||||
|
||||
$logPath = 'C:\Users\erikn\source\repos\acdream\.claude\worktrees\strange-albattani-3fc83c\a6-issue98-cottage-gfxobj-dump-launch.log'
|
||||
Write-Host "Log path: $logPath"
|
||||
Write-Host "Dump target: $env:ACDREAM_DUMP_GFXOBJS_DIR\0x01000A2B.gfxobj.json"
|
||||
Write-Host ''
|
||||
Write-Host 'After login, watch the log for [gfxobj-dump] then close the client.'
|
||||
|
||||
Set-Location 'C:\Users\erikn\source\repos\acdream\.claude\worktrees\strange-albattani-3fc83c'
|
||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug *> $logPath
|
||||
2346
tests/AcDream.Core.Tests/Fixtures/issue98/0x01000A2B.gfxobj.json
Normal file
2346
tests/AcDream.Core.Tests/Fixtures/issue98/0x01000A2B.gfxobj.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -484,31 +484,105 @@ public class CellarUpTrajectoryReplayTests
|
|||
/// First-cap event — the failing tick. Live engine reports cn=(0,0,-1),
|
||||
/// a downward-facing collision normal, capping the foot sphere at
|
||||
/// world Z=92.74. Math: head sphere TOP reaches Z=94.0 (the cottage
|
||||
/// floor) when foot Z = 94.0 - sphereHeight = 92.80. So the head is
|
||||
/// bumping the cottage floor from BELOW.
|
||||
/// floor) when foot Z = 94.0 - sphereHeight = 92.80. The head bumps
|
||||
/// the cottage floor from BELOW — NOT a step-up / AdjustOffset bug.
|
||||
///
|
||||
/// <para>
|
||||
/// This is the actual #98 bug, NOT a step-up / AdjustOffset problem.
|
||||
/// Live capture's <c>[resolve]</c> probe pinpoints the blocking
|
||||
/// Live capture's <c>[resolve]</c> probe pinpointed the blocking
|
||||
/// entity: <c>obj=0xA9B47900</c> — a landblock-baked static building
|
||||
/// (the cottage GfxObj). The cottage's floor polygons live in this
|
||||
/// GfxObj, registered as a ShadowEntry, NOT in any of the cottage's
|
||||
/// cells. The harness's <see cref="BuildEngineWithCellarFixtures"/>
|
||||
/// loads cell fixtures but does NOT register the cottage GfxObj, so
|
||||
/// the harness fails to reproduce the cap — DOCUMENTED here as the
|
||||
/// divergence pattern.
|
||||
/// (the cottage GfxObj <c>0x01000A2B</c>). The cottage's floor polys
|
||||
/// live in this GfxObj as a ShadowEntry, NOT in any cottage cell.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Documents-the-bug pattern: passes WHILE the harness lacks the
|
||||
/// cottage GfxObj. When a future session adds the cottage GfxObj
|
||||
/// (full polygon list extracted from the live <c>[poly-dump]</c> +
|
||||
/// <c>[resolve-bldg]</c> probes), this test will start failing —
|
||||
/// the signal to flip it from documenting-the-bug to enforcing-the-fix.
|
||||
/// Apparatus-convergence form (2026-05-23 evening v2): with the
|
||||
/// cottage GfxObj registered via <see cref="RegisterCottageGfxObj"/>,
|
||||
/// the harness reproduces the live cn=(0,0,-1) cap event. This test
|
||||
/// enforces THAT specific reproduction. The post-cap position
|
||||
/// processing has a separate residual divergence — documented by
|
||||
/// <see cref="LiveCompare_FirstCap_ResidualXMotionDivergence_DocumentsNextInvestigation"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LiveCompare_FirstCap_HarnessMissesCottageFloorBecauseCottageGfxObjNotRegistered()
|
||||
public void LiveCompare_FirstCap_HarnessReproducesCottageFloorCapNormal()
|
||||
{
|
||||
var (engine, _) = BuildEngineWithCellarFixtures();
|
||||
var captured = LoadCapturedRecord(record =>
|
||||
record.Result.CollisionNormalValid
|
||||
&& record.Result.CollisionNormal.Z < -0.99f);
|
||||
|
||||
// Live must have cn=(0,0,-1) at this point — sanity check.
|
||||
Assert.True(captured.Result.CollisionNormalValid,
|
||||
"Captured record must have collisionNormalValid=true.");
|
||||
Assert.True(captured.Result.CollisionNormal.Z < -0.99f,
|
||||
$"Captured record must have downward collision normal; got " +
|
||||
$"{captured.Result.CollisionNormal}.");
|
||||
|
||||
// Replay the call.
|
||||
Assert.NotNull(captured.BodyBefore);
|
||||
var body = SeedBodyFromSnapshot(captured.BodyBefore);
|
||||
var harnessResult = engine.ResolveWithTransition(
|
||||
currentPos: captured.Input.CurrentPos,
|
||||
targetPos: captured.Input.TargetPos,
|
||||
cellId: captured.Input.CellId,
|
||||
sphereRadius: captured.Input.SphereRadius,
|
||||
sphereHeight: captured.Input.SphereHeight,
|
||||
stepUpHeight: captured.Input.StepUpHeight,
|
||||
stepDownHeight: captured.Input.StepDownHeight,
|
||||
isOnGround: captured.Input.IsOnGround,
|
||||
body: body,
|
||||
moverFlags: (ObjectInfoState)captured.Input.MoverFlags,
|
||||
movingEntityId: captured.Input.MovingEntityId);
|
||||
|
||||
// Apparatus convergence: harness reproduces the cap-event collision
|
||||
// normal exactly. If this fails, the cottage GfxObj registration
|
||||
// has regressed (RegisterCottageGfxObj is broken, the dump fixture
|
||||
// is stale, or the harness wiring lost the landblock context).
|
||||
Assert.True(harnessResult.CollisionNormalValid,
|
||||
"Harness must reproduce the live collision-normal-valid signal " +
|
||||
"now that the cottage GfxObj is registered.");
|
||||
Assert.True(harnessResult.CollisionNormal.Z < -0.99f,
|
||||
$"Harness must reproduce the live downward-facing cap normal " +
|
||||
$"(live cn={captured.Result.CollisionNormal}, harness cn={harnessResult.CollisionNormal}).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A6.P3 issue #98 (2026-05-23 evening v2) — documents-the-bug for the
|
||||
/// residual divergence the apparatus surfaced. With the cottage GfxObj
|
||||
/// registered, the harness reproduces the live cap-event collision
|
||||
/// normal, but the POST-CAP POSITION processing diverges:
|
||||
/// <list type="bullet">
|
||||
/// <item>Live: full +X motion preserved (sphere slides +0.0266 m in X
|
||||
/// along the cottage floor before the Y motion is capped).</item>
|
||||
/// <item>Harness: ZERO X motion (sphere stays at its input X position,
|
||||
/// only the Y component is blocked).</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// Both X positions agree on Y=7.2243 and Z=92.7390. Only X differs:
|
||||
/// live X=141.3865 (requested target), harness X=141.3599 (current = no
|
||||
/// move). The requested delta was (+0.0266, -0.4022, 0); live applied
|
||||
/// the +X portion, harness applied nothing.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Hypothesis (to investigate next session): live's response to a
|
||||
/// cn=(0,0,-1) head-bump treats it as a Z-only constraint and lets the
|
||||
/// XY component of the move complete via edge-slide. Harness's BSP path
|
||||
/// is rejecting the WHOLE move vector when the cottage floor poly
|
||||
/// intersects the head sphere, instead of computing a slid offset.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Documents-the-bug: PASSES today on the asserted residual magnitude.
|
||||
/// When a future session fixes the post-cap edge-slide, harness X will
|
||||
/// match live X — this test FAILS at that point, signaling that the
|
||||
/// X divergence is closed and the test should be folded back into the
|
||||
/// strict <see cref="AssertCallMatchesCapture"/> path.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LiveCompare_FirstCap_ResidualXMotionDivergence_DocumentsNextInvestigation()
|
||||
{
|
||||
var (engine, _) = BuildEngineWithCellarFixtures();
|
||||
var captured = LoadCapturedRecord(record =>
|
||||
|
|
@ -530,26 +604,20 @@ public class CellarUpTrajectoryReplayTests
|
|||
moverFlags: (ObjectInfoState)captured.Input.MoverFlags,
|
||||
movingEntityId: captured.Input.MovingEntityId);
|
||||
|
||||
// Live reported cn=(0,0,-1) blocking the climb at this point.
|
||||
Assert.True(captured.Result.CollisionNormalValid,
|
||||
"Captured record must have collisionNormalValid=true.");
|
||||
Assert.True(captured.Result.CollisionNormal.Z < -0.99f,
|
||||
$"Captured record must have downward collision normal; got " +
|
||||
$"{captured.Result.CollisionNormal}.");
|
||||
// Live preserved the full +X motion through the cap event; harness
|
||||
// blocked it. Y and Z agree.
|
||||
Assert.Equal(captured.Result.Position.Y, harnessResult.Position.Y, 4);
|
||||
Assert.Equal(captured.Result.Position.Z, harnessResult.Position.Z, 4);
|
||||
|
||||
// Harness does NOT reproduce the live downward push because the
|
||||
// cottage GfxObj is not registered — the blocking polygon lives
|
||||
// in static obj 0xA9B47900, which BuildEngineWithCellarFixtures
|
||||
// intentionally skips today (RegisterStairRampGfxObj is commented
|
||||
// out). When the cottage GfxObj's full polygon set is added to
|
||||
// the harness, this assertion will start to fail — flip the test
|
||||
// to assert the live cn=(0,0,-1) round-trips at that point.
|
||||
Assert.False(
|
||||
harnessResult.CollisionNormalValid
|
||||
&& harnessResult.CollisionNormal.Z < -0.99f,
|
||||
"Harness should NOT reproduce the cottage-floor cap yet — " +
|
||||
"if it does, the cottage GfxObj has been added and this test " +
|
||||
"needs to flip to AssertCallMatchesCapture(engine, captured).");
|
||||
float liveDeltaX = captured.Result.Position.X - captured.Input.CurrentPos.X;
|
||||
float harnessDeltaX = harnessResult.Position.X - captured.Input.CurrentPos.X;
|
||||
|
||||
Assert.True(liveDeltaX > 0.02f,
|
||||
$"Live must show +X motion after cap (expected ~+0.0266 m, got {liveDeltaX:F4}).");
|
||||
Assert.True(MathF.Abs(harnessDeltaX) < 0.001f,
|
||||
$"Harness currently zeros X motion through the cap (expected ~0, got {harnessDeltaX:F4}). " +
|
||||
"If this assertion starts failing because harness now preserves +X, the post-cap " +
|
||||
"edge-slide divergence is closed — fold this back into AssertCallMatchesCapture.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -868,25 +936,44 @@ public class CellarUpTrajectoryReplayTests
|
|||
cache.RegisterCellStructForTest(cellId, cellWithBsp);
|
||||
}
|
||||
|
||||
// ── 2. NO landblock registered ──────────────────────────────
|
||||
// Without a landblock, SampleTerrainWalkable returns null and
|
||||
// FindEnvCollisions's outdoor-fallback path returns OK without
|
||||
// running ValidateWalkable on stub terrain. This is the right
|
||||
// shape for indoor-only tests — the cell's BSP would handle
|
||||
// collision if hydrated, and falling through to stub terrain
|
||||
// produces spurious (0,1,0) wall hits. FindObjCollisions also
|
||||
// early-returns without landblock context (line 2153 of
|
||||
// TransitionTypes.cs), so the synthetic stair GfxObj is also
|
||||
// skipped — fine for the airborne-at-tick-1 isolation.
|
||||
// ── 2. Minimum landblock context for FindObjCollisions ──────
|
||||
// FindObjCollisions (TransitionTypes.cs:2153) early-returns
|
||||
// TransitionState.OK when TryGetLandblockContext fails for the
|
||||
// sphere XY. Without a landblock the harness can't query the
|
||||
// cottage GfxObj's shadow entries — and that's where the
|
||||
// first-cap collision actually lives (live capture confirmed
|
||||
// obj=0xA9B47900 fires the cn=(0,0,-1) push).
|
||||
//
|
||||
// Register an EMPTY-terrain landblock 0xA9B40000 anchored at
|
||||
// world origin (0,0). The landblock test
|
||||
// (worldX >= 0 && worldX < 192) covers every harness sphere
|
||||
// position (X≈141, Y≈7). TerrainSurface gets a flat far-below
|
||||
// surface so SampleTerrainZ returns something the indoor BSP
|
||||
// path never consults (FindEnvCollisions's indoor branch fires
|
||||
// first when the cell has BSP). Outdoor-fallback queries are
|
||||
// harmless because the cell's synthetic BSP returns Collided
|
||||
// before terrain is checked.
|
||||
var heights = new byte[81]; // 9x9 corners
|
||||
var heightTable = new float[256];
|
||||
for (int i = 0; i < 256; i++) heightTable[i] = -1000f; // far below cellar
|
||||
var stubTerrain = new TerrainSurface(heights, heightTable);
|
||||
engine.AddLandblock(
|
||||
landblockId: 0xA9B40000u,
|
||||
terrain: stubTerrain,
|
||||
cells: Array.Empty<CellSurface>(),
|
||||
portals: Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
|
||||
// ── 3. Synthetic stair-piece GfxObj + ShadowEntry ──────────
|
||||
// Temporarily disabled while debugging the airborne-at-tick-1
|
||||
// issue. Re-enable once the cell-BSP-is-null + landblock-stub
|
||||
// interaction is understood, AND we have a way to register
|
||||
// the stair without needing a landblock (e.g., extend
|
||||
// FindObjCollisions to query cellScope-only shadows without
|
||||
// landblock context).
|
||||
// RegisterStairRampGfxObj(engine, cache);
|
||||
// ── 3. Cottage GfxObj 0x01000A2B from dumped fixture ────────
|
||||
// Live capture (2026-05-23 PM v2) attributes the first-cap event
|
||||
// to obj=0xA9B47900 (entity 0x00A9B479 partIdx=0) — a landblock-
|
||||
// baked static building registered as a ShadowEntry. The full
|
||||
// polygon table was extracted via ACDREAM_DUMP_GFXOBJS=0x01000A2B
|
||||
// (issue #98 evening-v2 apparatus); 74 polygons including six
|
||||
// downward-facing cottage-floor triangles at object-local Z=0
|
||||
// that the head sphere bumps from below at world Z=94.
|
||||
RegisterCottageGfxObj(engine, cache);
|
||||
|
||||
return (engine, cache);
|
||||
}
|
||||
|
|
@ -939,100 +1026,75 @@ public class CellarUpTrajectoryReplayTests
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a synthetic GfxObj containing the cellar ramp polygon
|
||||
/// in WORLD coordinates and registers it as a ShadowEntry scoped to
|
||||
/// the cellar cell. The polygon's vertices + normal are reproduced
|
||||
/// from the live capture's <c>[poly-dump]</c> data (commit pre-3f56915),
|
||||
/// transformed to world frame so the GfxObj can sit at world origin
|
||||
/// with identity rotation/scale (simplifies the
|
||||
/// FindObjCollisions local-to-world transform).
|
||||
/// A6.P3 issue #98 (2026-05-23 evening v2). Loads the cottage GfxObj
|
||||
/// <c>0x01000A2B</c> from the JSON fixture
|
||||
/// (<c>tests/AcDream.Core.Tests/Fixtures/issue98/0x01000A2B.gfxobj.json</c>,
|
||||
/// produced via the <c>ACDREAM_DUMP_GFXOBJS</c> capture infrastructure),
|
||||
/// hydrates it as a <see cref="GfxObjPhysics"/> with a synthetic
|
||||
/// single-leaf BSP, and registers it as a ShadowEntry at the cottage's
|
||||
/// world transform — the same shape production's GameWindow.cs:5893
|
||||
/// registration uses for landblock-baked statics.
|
||||
///
|
||||
/// <para>
|
||||
/// Live capture's local polygon vertices (in building frame):
|
||||
/// (0.8,-1.59,-1.5), (0.8,1.31,1.5), (-0.8,1.31,1.5), (-0.8,-1.59,-1.5).
|
||||
/// Building's world transform: origin (141.5, 7.155, 92.455), 180° yaw
|
||||
/// around Z. After applying yaw + translation, world vertices are:
|
||||
/// (140.7, 8.745, 90.955), (140.7, 5.845, 93.955),
|
||||
/// (142.3, 5.845, 93.955), (142.3, 8.745, 90.955).
|
||||
/// World normal = (0, 0.719, 0.695), world d = -69.5035 — matches
|
||||
/// the live cdb capture exactly.
|
||||
/// Transform values come from two evidence sources:
|
||||
/// <list type="bullet">
|
||||
/// <item>The cellar cell 0xA9B40147's WorldTransform has translation
|
||||
/// (130.5, 11.5, 94.0) and a 3×3 with M11=M22=-1 / M33=+1
|
||||
/// (a 180° rotation around Z). The cottage GfxObj sits at the
|
||||
/// SAME world transform (its building origin is also at
|
||||
/// (130.5, 11.5, 94.0) per the existing [resolve-bldg] capture
|
||||
/// <c>entOrigin_lb=(130.5,11.5,94.0)</c>).</item>
|
||||
/// <item>BoundingSphere radius from the dump's
|
||||
/// <see cref="GfxObjDump.BoundingSphereRadius"/> — 13.989 m.
|
||||
/// Matches the live <c>bspR=13.99</c> observed in the
|
||||
/// [resolve-bldg] capture; cross-validation that the same
|
||||
/// building is in play.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Entity id <c>0x00A9B479</c> mirrors the live capture's
|
||||
/// <c>obj=0xA9B47900</c> formula (entity.Id × 256 + partIdx=0). Using
|
||||
/// the same id keeps any future probe correlation aligned with live
|
||||
/// log conventions.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static void RegisterStairRampGfxObj(PhysicsEngine engine, PhysicsDataCache cache)
|
||||
private static void RegisterCottageGfxObj(PhysicsEngine engine, PhysicsDataCache cache)
|
||||
{
|
||||
const ushort RampPolyId = 0x0008;
|
||||
const uint StairGfxId = 0xDEADBEEFu;
|
||||
const uint StairEntityId = 0xC0FFEE00u;
|
||||
const uint CottageGfxId = 0x01000A2Bu;
|
||||
const uint CottageEntityId = 0x00A9B479u;
|
||||
|
||||
// World-frame vertices (winding order preserved from live capture).
|
||||
var v0 = new Vector3(140.7f, 8.745f, 90.955f); // ramp foot, X=-side
|
||||
var v1 = new Vector3(140.7f, 5.845f, 93.955f); // ramp top, X=-side
|
||||
var v2 = new Vector3(142.3f, 5.845f, 93.955f); // ramp top, X=+side
|
||||
var v3 = new Vector3(142.3f, 8.745f, 90.955f); // ramp foot, X=+side
|
||||
var verts = new[] { v0, v1, v2, v3 };
|
||||
var fixturePath = Path.Combine(FixtureDir, "0x01000A2B.gfxobj.json");
|
||||
Assert.True(File.Exists(fixturePath),
|
||||
$"Cottage GfxObj fixture missing: {fixturePath}. Re-run live " +
|
||||
$"capture with ACDREAM_DUMP_GFXOBJS=0x01000A2B.");
|
||||
|
||||
// Compute normal from cross(v1-v0, v3-v0).
|
||||
var edge0 = v1 - v0;
|
||||
var edge1 = v3 - v0;
|
||||
var normal = Vector3.Normalize(Vector3.Cross(edge0, edge1));
|
||||
// Plane equation: N·p + d = 0 → d = -N·v0.
|
||||
float d = -Vector3.Dot(normal, v0);
|
||||
var dump = GfxObjDumpSerializer.Read(fixturePath);
|
||||
var physics = GfxObjDumpSerializer.Hydrate(dump);
|
||||
cache.RegisterGfxObjForTest(CottageGfxId, physics);
|
||||
|
||||
var resolved = new Dictionary<ushort, ResolvedPolygon>
|
||||
{
|
||||
[RampPolyId] = new ResolvedPolygon
|
||||
{
|
||||
Vertices = verts,
|
||||
Plane = new System.Numerics.Plane(normal, d),
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.Landblock,
|
||||
},
|
||||
};
|
||||
// World transform from the cellar cell's WorldTransform: translation
|
||||
// (130.5, 11.5, 94.0) + 180° rotation around Z. The cottage GfxObj
|
||||
// shares this transform (it IS the cellar/cottage geometry).
|
||||
var worldPos = new Vector3(130.5f, 11.5f, 94.0f);
|
||||
var worldRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI);
|
||||
|
||||
// Minimal one-leaf BSP containing the ramp poly. Bounding sphere
|
||||
// encompasses the polygon (center at poly centroid).
|
||||
var leaf = new PhysicsBSPNode
|
||||
{
|
||||
Type = BSPNodeType.Leaf,
|
||||
BoundingSphere = new Sphere
|
||||
{
|
||||
Origin = new Vector3(141.5f, 7.295f, 92.455f),
|
||||
Radius = 3.0f,
|
||||
},
|
||||
};
|
||||
leaf.Polygons.Add(RampPolyId);
|
||||
|
||||
var bspTree = new PhysicsBSPTree { Root = leaf };
|
||||
|
||||
var gfxPhysics = new GfxObjPhysics
|
||||
{
|
||||
BSP = bspTree,
|
||||
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
|
||||
Vertices = new VertexArray(),
|
||||
Resolved = resolved,
|
||||
BoundingSphere = leaf.BoundingSphere,
|
||||
};
|
||||
|
||||
cache.RegisterGfxObjForTest(StairGfxId, gfxPhysics);
|
||||
|
||||
// ShadowEntry: object at world origin (0,0,0), identity rotation,
|
||||
// scale 1.0 — keeps the polygon's WORLD-frame vertices intact
|
||||
// through the FindObjCollisions local-transform math.
|
||||
// cellScope = CellarId so the entry is only queried when the sphere
|
||||
// is in cellar cell (matches retail's per-cell shadow scoping for
|
||||
// interior statics — Issue #91 family).
|
||||
engine.ShadowObjects.Register(
|
||||
entityId: StairEntityId,
|
||||
gfxObjId: StairGfxId,
|
||||
worldPos: Vector3.Zero,
|
||||
rotation: Quaternion.Identity,
|
||||
radius: 5.0f,
|
||||
entityId: CottageEntityId,
|
||||
gfxObjId: CottageGfxId,
|
||||
worldPos: worldPos,
|
||||
rotation: worldRot,
|
||||
radius: physics.BoundingSphere?.Radius ?? 14f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: 0xA9B40000u,
|
||||
collisionType: ShadowCollisionType.BSP,
|
||||
scale: 1.0f,
|
||||
cellScope: CellarId);
|
||||
// Landblock-baked statics in production (GameWindow.cs:5899) use
|
||||
// `entity.ParentCellId ?? 0u` — the cottage building has no
|
||||
// ParentCellId (it's a top-level landblock static), so the
|
||||
// scope is landblock-wide (cellScope=0).
|
||||
cellScope: 0u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue