diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index f68e1b5e..6ebad661 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -128,7 +128,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|---|---|---|---|---|---|
| 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-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-3 | Step-down chain also runs for a valid contact plane when that plane is steeper than walkable; retail's `transitional_insert` OK-path returns immediately for every valid contact plane and enters the step-down tail only when contact is invalid | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`) | The added steep-contact entry preserves the current cliff-slide compensation while the response-layer state/order family remains open (AP-4/AD-53/AD-54/TS-4) | A steep valid contact can enter step-down/edge response where retail restores or validates state through its normal contact path, producing different retry and slide behavior | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191–273307 |
| 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 |
| ~~AP-7~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the "state gate" was a BN decompiler artifact, not a locomotion exemption.** `calc_friction` now ports retail's confirmed 0.25f threshold (`if (angle >= 0.25f) return;`) unconditionally, no special-cased gate. The "state check at pc:276702" the old row cited is `PhysicsState.Sledding` (confirmed via ACE's `PhysicsObj.calc_friction`, references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2120-2141, and `SLEDDING_PS=0x800000` in acclient.h:2838) — it gates the 1.5625/6.25/near-flat friction-value OVERRIDE, not the threshold return itself; acdream had no live Sledding setter then or now (see #166 research, docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §3), so the branch was simply unreachable dead code, not an exemption for ordinary walking. The reverted 2026-04-30 L.3c attempt (naive 0.0→0.25 bump, forward locomotion 3→0.16 m/s in `PlayerMovementControllerTests`) does not reproduce on the production graphical local-player path post-R6: `PlayerMovementController` zeroes `Velocity.X/Y` to exactly zero every tick before `calc_friction` runs whenever animation root motion drives the walk, so friction has no horizontal velocity left to hammer (pinned at the PhysicsBody level by `GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests`). The headless/`get_state_velocity` movement-controller path and remote/NPC movers still feed real velocity into this function and remain the ones to watch if a similar regression resurfaces there. **CORRECTION (2026-07-30, same day, #265/#166 capture bisect):** the sentence above undersold the gap — `calc_friction` wasn't merely "no horizontal velocity to hammer," it was structurally UNREACHABLE with meaningful data on ANY grounded path: (a) the animation-root-motion path zeroed `Velocity.X/Y` outright every tick (the actual #265/#166 root cause, ten days pre-existing, not a Campaign-P regression), and (b) `PhysicsBody.GroundNormal` — the vector `calc_friction` dots velocity against — had ZERO production writers anywhere and silently defaulted to `Vector3.UnitZ` forever, so even surviving velocity would have been tested against a fake flat-ground normal on any real slope. Both gaps are now closed: `PlayerMovementController.cs`'s grounded block no longer reconstructs `Velocity` for the animation-root-motion case, and `PhysicsEngine.cs` syncs `body.GroundNormal` from the committed `ContactPlane.Normal` at the same commit point that already publishes `ContactPlane`. The 0.25f threshold port itself (this row's original subject) was always correct — it just had nothing real to operate on until this fix. See `docs/research/2026-07-30-265-capture-bisect.md`'s as-fixed addendum. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`GroundNormal` wiring); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (grounded-velocity fix); `tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs` (AP-7 test block); `tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs`; `tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs` | — | — | `CPhysicsObj::calc_friction` pc:276694-276822 (0050ee70); ACE `PhysicsObj.calc_friction` PhysicsObj.cs:2120-2141; `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §1; `docs/research/2026-07-30-265-capture-bisect.md` |
diff --git a/docs/research/2026-07-31-issue273-tight-gap-support.md b/docs/research/2026-07-31-issue273-tight-gap-support.md
new file mode 100644
index 00000000..7ee1c305
--- /dev/null
+++ b/docs/research/2026-07-31-issue273-tight-gap-support.md
@@ -0,0 +1,95 @@
+# Issue #273 — Holtburg tight-gap support validation
+
+**Date:** 2026-07-31
+**Status:** implementation, automated gates, and exact live gate pass
+**Scope:** grounded player step-down support at a floor edge beside a static
+cylinder
+
+## Captured scene
+
+The reproducible gap is in outdoor cell `0xA9B40032`, between:
+
+- building shell GfxObj `0x01000F69`, placed at
+ `(158.178, 37.7055, 94.0)` with quaternion
+ `(w=.939319, x=0, y=0, z=-.343045)`;
+- static post `0xCA9B4027`, placed at `(160.173, 34.487, 95.975)`,
+ represented by its Setup-authored cylinder (`radius=.282`,
+ `height=5.564`);
+- the local player Setup's exact two spheres (`radius=.48`, origins
+ `z=.475` and `z=1.35`).
+
+The building's supporting ledge terminates at local `x=4`. The first
+post-side response moved the player's foot-sphere center to approximately
+local `x=4.33`. The full `.48` movement sphere still overlapped the floor, so
+the existing step-down path accepted the candidate. Repeated frames then
+carried the player around the post and outside the building shell.
+
+The fixture
+`tests/AcDream.Core.Tests/Fixtures/issue273/0x01000F69.gfxobj.json` preserves
+the installed DAT PhysicsBSP. The replay in
+`Issue273HoltburgTightGapReplayTests` uses the captured object placement,
+player spheres, static posts, and movement offsets.
+
+## Retail mechanism
+
+The missing rule is not extra collision padding and is not a larger player
+sphere. It is retail's second-stage support validation:
+
+1. `CTransition::step_down` (`0x0050B2A0`) performs the ordinary downward
+ collision probe.
+2. After finding a walkable contact plane, an EdgeSlide mover that is not in
+ StepUp calls `CTransition::check_walkable` (`0x0050AFF0`). The binary
+ sequence is `test ah,2` at `0x0050B36A`, which is state bit `0x200`
+ (`EdgeSlide`), followed by the `step_up == 0` test and call at
+ `0x0050B380`.
+3. `CTransition::check_walkable` first calls
+ `SPHEREPATH::check_walkables` (`0x0050C3E0`).
+4. `SPHEREPATH::check_walkables` halves the saved foot-sphere radius and
+ calls `CPolygon::check_walkable` (`0x00538E60`).
+5. If the remembered polygon does not support that smaller sphere,
+ `CTransition::check_walkable` performs a downward CheckWalkable insertion.
+ BSP leaves require both `walkable_hits_sphere` and
+ `CPolygon::check_small_walkable` (`BSPLEAF::hits_walkable`,
+ `0x0053D670`).
+6. If neither check finds support, `CTransition::step_down` rejects the
+ candidate and the existing edge-response chain handles it.
+
+ACDream already had the small-radius BSP-leaf test, but
+`DoCheckWalkable` treated the mere presence of a remembered polygon as
+success, and the ordinary `DoStepDown(..., runPlacement:false)` path never
+called it. This let a full-radius overlap stand in for actual foot support.
+
+## Port
+
+- `BSPQuery.CheckWalkableSupport` is the shared resolved-polygon form of
+ retail `CPolygon::check_walkable`.
+- `SpherePath.CheckWalkables` implements the retail half-radius remembered
+ polygon check without mutating canonical sphere state.
+- `Transition.DoCheckWalkable` now tests the remembered polygon rather than
+ treating a non-null polygon as sufficient.
+- `Transition.DoStepDown` restores the EdgeSlide/non-StepUp support gate
+ before the existing placement-policy seam.
+
+There are no location checks, object IDs, guessed radii, widened collision
+shapes, or gap-specific tolerances in the production fix.
+
+## Regression impact
+
+The existing #271 staircase-side replay begins with its center `.288 m`
+outside a tread whose retail half-radius support boundary is `.24 m`.
+Retail may therefore stop that exact candidate. The test now preserves the
+original user-visible invariant—never reverse or accelerate downhill—without
+requiring forward progress beyond retail's support boundary. The ordinary
+continuous staircase replay still requires and achieves forward progress.
+
+## Gates
+
+- issue #273 fixture/replay: 3 passed;
+- focused BSP, step-up, edge-slide, #185/#271 family: 42 passed / 1 skipped;
+- complete Core tests: 4,111 passed / 2 skipped;
+- Release solution build: passed;
+- complete Release solution tests: 10,068 passed / 5 skipped.
+
+The user accepted the exact in-client Holtburg gap gate on 2026-07-31: the
+gap blocks from the tested approach, and the adjacent movement checks remain
+healthy.
diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs
index 7e314906..2d08eb6e 100644
--- a/src/AcDream.Core/Physics/BSPQuery.cs
+++ b/src/AcDream.Core/Physics/BSPQuery.cs
@@ -284,28 +284,46 @@ public static class BSPQuery
CollisionSphere sphere,
Vector3 up,
bool small)
+ => CheckWalkableSupport(
+ poly.Plane,
+ poly.Vertices,
+ sphere.Center,
+ small ? sphere.Radius * 0.5f : sphere.Radius,
+ up);
+
+ ///
+ /// Retail CPolygon::check_walkable against an already resolved
+ /// polygon. The caller supplies the effective support radius; retail's
+ /// SPHEREPATH::check_walkables halves its saved sphere before
+ /// entering this routine.
+ ///
+ internal static bool CheckWalkableSupport(
+ Plane plane,
+ ReadOnlySpan vertices,
+ Vector3 center,
+ float supportRadius,
+ Vector3 up)
{
- float angleUp = Vector3.Dot(poly.Plane.Normal, up);
+ float angleUp = Vector3.Dot(plane.Normal, up);
if (angleUp < PhysicsGlobals.EPSILON) return false;
- float angle = (Vector3.Dot(poly.Plane.Normal, sphere.Center) + poly.Plane.D) / angleUp;
- var center = sphere.Center - up * angle;
+ float angle = (Vector3.Dot(plane.Normal, center) + plane.D) / angleUp;
+ center -= up * angle;
- float radsum = sphere.Radius * sphere.Radius;
- if (small) radsum *= 0.25f;
+ float radsum = supportRadius * supportRadius;
- int n = poly.Vertices.Length;
+ int n = vertices.Length;
int prevIdx = n - 1;
for (int i = 0; i < n; i++)
{
- var v = poly.Vertices[i];
- var lv = poly.Vertices[prevIdx];
+ var v = vertices[i];
+ var lv = vertices[prevIdx];
prevIdx = i;
var edge = v - lv;
var disp = center - lv;
- var cross = Vector3.Cross(poly.Plane.Normal, edge);
+ var cross = Vector3.Cross(plane.Normal, edge);
float diff = Vector3.Dot(disp, cross);
if (diff < 0f)
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index b3f9cf81..1f2a1de2 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -850,6 +850,27 @@ public sealed class SpherePath
return true;
}
+ ///
+ /// Retail SPHEREPATH::check_walkables (0x0050C3E0). A missing
+ /// remembered polygon passes; otherwise the foot sphere is tested against
+ /// that polygon with half its normal radius. Retail mutates an embedded
+ /// scratch sphere before the test. Our world-space representation can pass
+ /// the equivalent effective radius without mutating canonical sphere state.
+ ///
+ internal bool CheckWalkables()
+ {
+ if (!HasWalkablePolygon || WalkableVertices is null)
+ return true;
+
+ var footSphere = GlobalSphere[0];
+ return BSPQuery.CheckWalkableSupport(
+ WalkablePlane,
+ WalkableVertices,
+ footSphere.Origin,
+ footSphere.Radius * 0.5f,
+ WalkableUp);
+ }
+
///
/// Retail SPHEREPATH::init reset for a retained transition record.
/// Every logical value is restored; only the two private exact-length
@@ -5211,6 +5232,22 @@ public sealed class Transition
&& CollisionInfo.ContactPlaneValid
&& CollisionInfo.ContactPlane.Normal.Z >= walkableZ)
{
+ // Retail CTransition::step_down (0x0050B2A0) validates the
+ // candidate's actual support before placement whenever an
+ // already-grounded mover is not performing a step-up. The first
+ // check uses SPHEREPATH::check_walkables' half-radius foot sphere;
+ // when that fails, DoCheckWalkable performs the downward BSP
+ // probe with the same small-support rule. Omitting this gate let a
+ // wall/post slide leave most of the player sphere beyond a floor
+ // edge while the full-radius step-down overlap still counted as
+ // grounded (issue #273).
+ if (ObjectInfo.EdgeSlide
+ && !sp.StepUp
+ && !DoCheckWalkable(walkableZ, engine))
+ {
+ return false;
+ }
+
// L.2.3h (2026-04-29): Placement validation is for the
// DoStepUp use case (prevents climbing through walls by
// stepping up onto ground beyond a tall wall). For the
@@ -5461,8 +5498,10 @@ public sealed class Transition
if ((oi.State & ObjectInfoState.OnWalkable) == 0)
return true;
- // If the current walkable entry is still valid, skip the probe.
- if (sp.WalkableValid)
+ // Retail first validates the remembered polygon with a half-radius
+ // support sphere. Merely having a polygon pointer is insufficient:
+ // the candidate may already hang too far beyond its edge.
+ if (sp.CheckWalkables())
return true;
sp.SaveCheckPos();
diff --git a/tests/AcDream.Core.Tests/Fixtures/issue273/0x01000F69.gfxobj.json b/tests/AcDream.Core.Tests/Fixtures/issue273/0x01000F69.gfxobj.json
new file mode 100644
index 00000000..bd0deee2
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Fixtures/issue273/0x01000F69.gfxobj.json
@@ -0,0 +1,1626 @@
+{
+ "GfxObjId": 16781161,
+ "BoundingSphereOrigin": {
+ "X": -0.270307,
+ "Y": 0.579602,
+ "Z": 4.34457
+ },
+ "BoundingSphereRadius": 9.92391,
+ "ResolvedPolygons": [
+ {
+ "Id": 0,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0.2503828,
+ "Y": 0,
+ "Z": 0.968147
+ },
+ "D": -9.729877
+ },
+ "Vertices": [
+ {
+ "X": 4.64,
+ "Y": 7.9196,
+ "Z": 8.85
+ },
+ {
+ "X": 2.32,
+ "Y": 7.9196,
+ "Z": 9.45
+ },
+ {
+ "X": 0,
+ "Y": -5.8396,
+ "Z": 10.05
+ },
+ {
+ "X": 2.32,
+ "Y": -5.8396,
+ "Z": 9.45
+ }
+ ]
+ },
+ {
+ "Id": 1,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": 1,
+ "Z": 0
+ },
+ "D": -7.28
+ },
+ "Vertices": [
+ {
+ "X": 4,
+ "Y": 7.28,
+ "Z": 7.55
+ },
+ {
+ "X": -4,
+ "Y": 7.28,
+ "Z": 7.55
+ },
+ {
+ "X": -4,
+ "Y": 7.28,
+ "Z": 8.05
+ },
+ {
+ "X": 4,
+ "Y": 7.28,
+ "Z": 8.05
+ }
+ ]
+ },
+ {
+ "Id": 2,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": -1,
+ "Z": 0
+ },
+ "D": -5.2
+ },
+ "Vertices": [
+ {
+ "X": -4,
+ "Y": -5.2,
+ "Z": 7.55
+ },
+ {
+ "X": 4,
+ "Y": -5.2,
+ "Z": 7.55
+ },
+ {
+ "X": 4,
+ "Y": -5.2,
+ "Z": 8.05
+ },
+ {
+ "X": -4,
+ "Y": -5.2,
+ "Z": 8.05
+ }
+ ]
+ },
+ {
+ "Id": 3,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": -1.3701269E-05,
+ "Y": -0.00013341238,
+ "Z": 1
+ },
+ "D": -1.9990327
+ },
+ "Vertices": [
+ {
+ "X": 4,
+ "Y": 7.25,
+ "Z": 2
+ },
+ {
+ "X": -4,
+ "Y": 7.25,
+ "Z": 2
+ },
+ {
+ "X": -3.3,
+ "Y": 6.50034,
+ "Z": 1.9998
+ },
+ {
+ "X": 3.3,
+ "Y": 6.5003,
+ "Z": 2
+ }
+ ]
+ },
+ {
+ "Id": 4,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0.00012937472,
+ "Y": 9.773888E-06,
+ "Z": 1
+ },
+ "D": -1.9994949
+ },
+ "Vertices": [
+ {
+ "X": -4,
+ "Y": -4.7,
+ "Z": 2
+ },
+ {
+ "X": -3.3,
+ "Y": -2.01574,
+ "Z": 2
+ },
+ {
+ "X": -3.3,
+ "Y": 6.50034,
+ "Z": 1.9998
+ },
+ {
+ "X": -4,
+ "Y": 7.25,
+ "Z": 2
+ }
+ ]
+ },
+ {
+ "Id": 5,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": 0,
+ "Z": 1
+ },
+ "D": -2
+ },
+ "Vertices": [
+ {
+ "X": 4,
+ "Y": 7.25,
+ "Z": 2
+ },
+ {
+ "X": 3.3,
+ "Y": 6.5003,
+ "Z": 2
+ },
+ {
+ "X": 3.3,
+ "Y": -2.0157,
+ "Z": 2
+ },
+ {
+ "X": 4,
+ "Y": -4.7,
+ "Z": 2
+ }
+ ]
+ },
+ {
+ "Id": 6,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": 0,
+ "Z": 1
+ },
+ "D": -2
+ },
+ "Vertices": [
+ {
+ "X": -3.3,
+ "Y": -2.01574,
+ "Z": 2
+ },
+ {
+ "X": -4,
+ "Y": -4.7,
+ "Z": 2
+ },
+ {
+ "X": 1,
+ "Y": -2.01571,
+ "Z": 2
+ }
+ ]
+ },
+ {
+ "Id": 7,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": 0,
+ "Z": 1
+ },
+ "D": -2
+ },
+ "Vertices": [
+ {
+ "X": 1,
+ "Y": -2.01571,
+ "Z": 2
+ },
+ {
+ "X": -4,
+ "Y": -4.7,
+ "Z": 2
+ },
+ {
+ "X": 4,
+ "Y": -4.7,
+ "Z": 2
+ },
+ {
+ "X": 3.3,
+ "Y": -2.0157,
+ "Z": 2
+ }
+ ]
+ },
+ {
+ "Id": 8,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": -4.1392085E-09,
+ "Z": -1
+ },
+ "D": 3.000926E-08
+ },
+ "Vertices": [
+ {
+ "X": -4,
+ "Y": 7.25,
+ "Z": 0
+ },
+ {
+ "X": 4,
+ "Y": 7.25,
+ "Z": 0
+ },
+ {
+ "X": 4,
+ "Y": -7.15,
+ "Z": 5.96046E-08
+ },
+ {
+ "X": -4,
+ "Y": -7.15,
+ "Z": 5.96046E-08
+ }
+ ]
+ },
+ {
+ "Id": 9,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": -0.63237643,
+ "Z": 0.77466124
+ },
+ "D": -4.5214915
+ },
+ "Vertices": [
+ {
+ "X": -4,
+ "Y": -7.15,
+ "Z": 5.96046E-08
+ },
+ {
+ "X": 4,
+ "Y": -7.15,
+ "Z": 5.96046E-08
+ },
+ {
+ "X": 4,
+ "Y": -4.7,
+ "Z": 2
+ },
+ {
+ "X": -4,
+ "Y": -4.7,
+ "Z": 2
+ }
+ ]
+ },
+ {
+ "Id": 10,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": -1,
+ "Y": 0,
+ "Z": 0
+ },
+ "D": -4
+ },
+ "Vertices": [
+ {
+ "X": -4,
+ "Y": -4.7,
+ "Z": 2
+ },
+ {
+ "X": -4,
+ "Y": 7.25,
+ "Z": 2
+ },
+ {
+ "X": -4,
+ "Y": 7.25,
+ "Z": 0
+ }
+ ]
+ },
+ {
+ "Id": 11,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 1,
+ "Y": 0,
+ "Z": 0
+ },
+ "D": -4
+ },
+ "Vertices": [
+ {
+ "X": 4,
+ "Y": 7.25,
+ "Z": 0
+ },
+ {
+ "X": 4,
+ "Y": 7.25,
+ "Z": 2
+ },
+ {
+ "X": 4,
+ "Y": -4.7,
+ "Z": 2
+ }
+ ]
+ },
+ {
+ "Id": 12,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": 1,
+ "Z": 0
+ },
+ "D": -7.25
+ },
+ "Vertices": [
+ {
+ "X": 4,
+ "Y": 7.25,
+ "Z": 2
+ },
+ {
+ "X": 4,
+ "Y": 7.25,
+ "Z": 0
+ },
+ {
+ "X": -4,
+ "Y": 7.25,
+ "Z": 0
+ },
+ {
+ "X": -4,
+ "Y": 7.25,
+ "Z": 2
+ }
+ ]
+ },
+ {
+ "Id": 13,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": 1,
+ "Z": 0
+ },
+ "D": -7.9196
+ },
+ "Vertices": [
+ {
+ "X": 2.32,
+ "Y": 7.9196,
+ "Z": 9.45
+ },
+ {
+ "X": -2.32,
+ "Y": 7.9196,
+ "Z": 9.45
+ },
+ {
+ "X": 0,
+ "Y": 7.9196,
+ "Z": 10.05
+ }
+ ]
+ },
+ {
+ "Id": 14,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": 1,
+ "Z": 0
+ },
+ "D": -7.9196
+ },
+ "Vertices": [
+ {
+ "X": -2.32,
+ "Y": 7.9196,
+ "Z": 9.45
+ },
+ {
+ "X": 0,
+ "Y": 7.9196,
+ "Z": 8.85
+ },
+ {
+ "X": -4.64,
+ "Y": 7.9196,
+ "Z": 8.85
+ }
+ ]
+ },
+ {
+ "Id": 15,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": 1,
+ "Z": 0
+ },
+ "D": -7.9196
+ },
+ "Vertices": [
+ {
+ "X": 0,
+ "Y": 7.9196,
+ "Z": 8.85
+ },
+ {
+ "X": 2.32,
+ "Y": 7.9196,
+ "Z": 9.45
+ },
+ {
+ "X": 4.64,
+ "Y": 7.9196,
+ "Z": 8.85
+ }
+ ]
+ },
+ {
+ "Id": 16,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": 1,
+ "Z": 0
+ },
+ "D": -7.9196
+ },
+ "Vertices": [
+ {
+ "X": 2.32,
+ "Y": 7.9196,
+ "Z": 9.45
+ },
+ {
+ "X": 0,
+ "Y": 7.9196,
+ "Z": 8.85
+ },
+ {
+ "X": -2.32,
+ "Y": 7.9196,
+ "Z": 9.45
+ }
+ ]
+ },
+ {
+ "Id": 17,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": -1,
+ "Z": 0
+ },
+ "D": -5.8396
+ },
+ "Vertices": [
+ {
+ "X": -2.32,
+ "Y": -5.8396,
+ "Z": 9.45
+ },
+ {
+ "X": 2.32,
+ "Y": -5.8396,
+ "Z": 9.45
+ },
+ {
+ "X": 0,
+ "Y": -5.8396,
+ "Z": 10.05
+ }
+ ]
+ },
+ {
+ "Id": 18,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": -1,
+ "Z": 0
+ },
+ "D": -5.8396
+ },
+ "Vertices": [
+ {
+ "X": 2.32,
+ "Y": -5.8396,
+ "Z": 9.45
+ },
+ {
+ "X": 0,
+ "Y": -5.8396,
+ "Z": 8.85
+ },
+ {
+ "X": 4.64,
+ "Y": -5.8396,
+ "Z": 8.85
+ }
+ ]
+ },
+ {
+ "Id": 19,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": -1,
+ "Z": 0
+ },
+ "D": -5.8396
+ },
+ "Vertices": [
+ {
+ "X": 0,
+ "Y": -5.8396,
+ "Z": 8.85
+ },
+ {
+ "X": -2.32,
+ "Y": -5.8396,
+ "Z": 9.45
+ },
+ {
+ "X": -4.64,
+ "Y": -5.8396,
+ "Z": 8.85
+ }
+ ]
+ },
+ {
+ "Id": 20,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": -1,
+ "Z": 0
+ },
+ "D": -5.8396
+ },
+ "Vertices": [
+ {
+ "X": -2.32,
+ "Y": -5.8396,
+ "Z": 9.45
+ },
+ {
+ "X": 0,
+ "Y": -5.8396,
+ "Z": 8.85
+ },
+ {
+ "X": 2.32,
+ "Y": -5.8396,
+ "Z": 9.45
+ }
+ ]
+ },
+ {
+ "Id": 21,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": -0.5302367,
+ "Z": -0.84784967
+ },
+ "D": 4.067959
+ },
+ "Vertices": [
+ {
+ "X": -4,
+ "Y": -5.2,
+ "Z": 8.05
+ },
+ {
+ "X": 4,
+ "Y": -5.2,
+ "Z": 8.05
+ },
+ {
+ "X": 4.62,
+ "Y": -5.8396,
+ "Z": 8.45
+ },
+ {
+ "X": -4.62,
+ "Y": -5.8396,
+ "Z": 8.45
+ }
+ ]
+ },
+ {
+ "Id": 22,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0.5421266,
+ "Y": 0,
+ "Z": -0.8402968
+ },
+ "D": 4.595883
+ },
+ "Vertices": [
+ {
+ "X": 4,
+ "Y": -5.2,
+ "Z": 8.05
+ },
+ {
+ "X": 4,
+ "Y": 7.28,
+ "Z": 8.05
+ },
+ {
+ "X": 4.62,
+ "Y": 7.9196,
+ "Z": 8.45
+ },
+ {
+ "X": 4.62,
+ "Y": -5.8396,
+ "Z": 8.45
+ }
+ ]
+ },
+ {
+ "Id": 23,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": -1,
+ "Y": 0,
+ "Z": 0
+ },
+ "D": -3.3
+ },
+ "Vertices": [
+ {
+ "X": -3.3,
+ "Y": -2.01574,
+ "Z": 7.55
+ },
+ {
+ "X": -3.3,
+ "Y": 6.50034,
+ "Z": 7.55
+ },
+ {
+ "X": -3.3,
+ "Y": 6.50034,
+ "Z": 1.9998
+ },
+ {
+ "X": -3.3,
+ "Y": -2.01574,
+ "Z": 2
+ }
+ ]
+ },
+ {
+ "Id": 24,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 1,
+ "Y": 0,
+ "Z": 0
+ },
+ "D": -3.3
+ },
+ "Vertices": [
+ {
+ "X": 3.3,
+ "Y": 6.5003,
+ "Z": 2
+ },
+ {
+ "X": 3.3,
+ "Y": 6.5003,
+ "Z": 7.55
+ },
+ {
+ "X": 3.3,
+ "Y": -2.0157,
+ "Z": 7.55
+ },
+ {
+ "X": 3.3,
+ "Y": -2.0157,
+ "Z": 2
+ }
+ ]
+ },
+ {
+ "Id": 25,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 6.0688367E-06,
+ "Y": 1,
+ "Z": 0
+ },
+ "D": -6.50032
+ },
+ "Vertices": [
+ {
+ "X": 3.3,
+ "Y": 6.5003,
+ "Z": 2
+ },
+ {
+ "X": -3.3,
+ "Y": 6.50034,
+ "Z": 1.9998
+ },
+ {
+ "X": -3.3,
+ "Y": 6.50034,
+ "Z": 7.55
+ },
+ {
+ "X": 3.3,
+ "Y": 6.5003,
+ "Z": 7.55
+ }
+ ]
+ },
+ {
+ "Id": 26,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": -1,
+ "Y": 0,
+ "Z": 0
+ },
+ "D": -4
+ },
+ "Vertices": [
+ {
+ "X": -4,
+ "Y": 7.28,
+ "Z": 7.55
+ },
+ {
+ "X": -4,
+ "Y": -5.2,
+ "Z": 7.55
+ },
+ {
+ "X": -4,
+ "Y": -5.2,
+ "Z": 8.05
+ },
+ {
+ "X": -4,
+ "Y": 7.28,
+ "Z": 8.05
+ }
+ ]
+ },
+ {
+ "Id": 27,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 1,
+ "Y": 0,
+ "Z": 0
+ },
+ "D": -4
+ },
+ "Vertices": [
+ {
+ "X": 4,
+ "Y": -5.2,
+ "Z": 7.55
+ },
+ {
+ "X": 4,
+ "Y": 7.28,
+ "Z": 7.55
+ },
+ {
+ "X": 4,
+ "Y": 7.28,
+ "Z": 8.05
+ },
+ {
+ "X": 4,
+ "Y": -5.2,
+ "Z": 8.05
+ }
+ ]
+ },
+ {
+ "Id": 28,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0.2503826,
+ "Y": 0,
+ "Z": 0.968147
+ },
+ "D": -9.7298765
+ },
+ "Vertices": [
+ {
+ "X": 2.32,
+ "Y": -5.8396,
+ "Z": 9.45
+ },
+ {
+ "X": 4.64,
+ "Y": -5.8396,
+ "Z": 8.85
+ },
+ {
+ "X": 4.64,
+ "Y": 7.9196,
+ "Z": 8.85
+ }
+ ]
+ },
+ {
+ "Id": 29,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": -0.2503828,
+ "Y": 0,
+ "Z": 0.968147
+ },
+ "D": -9.729877
+ },
+ "Vertices": [
+ {
+ "X": -4.64,
+ "Y": 7.9196,
+ "Z": 8.85
+ },
+ {
+ "X": -4.64,
+ "Y": -5.8396,
+ "Z": 8.85
+ },
+ {
+ "X": 0,
+ "Y": -5.8396,
+ "Z": 10.05
+ },
+ {
+ "X": 0,
+ "Y": 7.9196,
+ "Z": 10.05
+ }
+ ]
+ },
+ {
+ "Id": 30,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": 0.530237,
+ "Z": -0.8478495
+ },
+ "D": 2.965063
+ },
+ "Vertices": [
+ {
+ "X": 4,
+ "Y": 7.28,
+ "Z": 8.05
+ },
+ {
+ "X": -4,
+ "Y": 7.28,
+ "Z": 8.05
+ },
+ {
+ "X": -4.62,
+ "Y": 7.9196,
+ "Z": 8.45
+ },
+ {
+ "X": 4.62,
+ "Y": 7.9196,
+ "Z": 8.45
+ }
+ ]
+ },
+ {
+ "Id": 31,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": -0.9987523,
+ "Y": 0,
+ "Z": -0.049937498
+ },
+ "D": -4.1922636
+ },
+ "Vertices": [
+ {
+ "X": -4.62,
+ "Y": 7.9196,
+ "Z": 8.45
+ },
+ {
+ "X": -4.62,
+ "Y": -5.8396,
+ "Z": 8.45
+ },
+ {
+ "X": -4.64,
+ "Y": -5.8396,
+ "Z": 8.85
+ },
+ {
+ "X": -4.64,
+ "Y": 7.9196,
+ "Z": 8.85
+ }
+ ]
+ },
+ {
+ "Id": 32,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": 1,
+ "Z": 0
+ },
+ "D": -7.9196
+ },
+ "Vertices": [
+ {
+ "X": 4.62,
+ "Y": 7.9196,
+ "Z": 8.45
+ },
+ {
+ "X": -4.62,
+ "Y": 7.9196,
+ "Z": 8.45
+ },
+ {
+ "X": -4.64,
+ "Y": 7.9196,
+ "Z": 8.85
+ },
+ {
+ "X": 4.64,
+ "Y": 7.9196,
+ "Z": 8.85
+ }
+ ]
+ },
+ {
+ "Id": 33,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0.9987523,
+ "Y": 0,
+ "Z": -0.049937498
+ },
+ "D": -4.1922636
+ },
+ "Vertices": [
+ {
+ "X": 4.62,
+ "Y": -5.8396,
+ "Z": 8.45
+ },
+ {
+ "X": 4.62,
+ "Y": 7.9196,
+ "Z": 8.45
+ },
+ {
+ "X": 4.64,
+ "Y": 7.9196,
+ "Z": 8.85
+ },
+ {
+ "X": 4.64,
+ "Y": -5.8396,
+ "Z": 8.85
+ }
+ ]
+ },
+ {
+ "Id": 34,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": -1,
+ "Z": 0
+ },
+ "D": -5.8396
+ },
+ "Vertices": [
+ {
+ "X": -4.62,
+ "Y": -5.8396,
+ "Z": 8.45
+ },
+ {
+ "X": 4.62,
+ "Y": -5.8396,
+ "Z": 8.45
+ },
+ {
+ "X": 4.64,
+ "Y": -5.8396,
+ "Z": 8.85
+ },
+ {
+ "X": -4.64,
+ "Y": -5.8396,
+ "Z": 8.85
+ }
+ ]
+ },
+ {
+ "Id": 35,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": -0.5421266,
+ "Y": 0,
+ "Z": -0.8402968
+ },
+ "D": 4.595883
+ },
+ "Vertices": [
+ {
+ "X": -4,
+ "Y": 7.28,
+ "Z": 8.05
+ },
+ {
+ "X": -4,
+ "Y": -5.2,
+ "Z": 8.05
+ },
+ {
+ "X": -4.62,
+ "Y": -5.8396,
+ "Z": 8.45
+ },
+ {
+ "X": -4.62,
+ "Y": 7.9196,
+ "Z": 8.45
+ }
+ ]
+ },
+ {
+ "Id": 36,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 4.3537307E-06,
+ "Y": -1,
+ "Z": 0
+ },
+ "D": -2.0157256
+ },
+ "Vertices": [
+ {
+ "X": -3.3,
+ "Y": -2.01574,
+ "Z": 2
+ },
+ {
+ "X": -1,
+ "Y": -2.01573,
+ "Z": 2
+ },
+ {
+ "X": -1,
+ "Y": -2.01573,
+ "Z": 5.528
+ },
+ {
+ "X": -3.3,
+ "Y": -2.01574,
+ "Z": 7.55
+ }
+ ]
+ },
+ {
+ "Id": 37,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 7.871597E-06,
+ "Y": -1,
+ "Z": 2.000765E-06
+ },
+ "D": -2.0157309
+ },
+ "Vertices": [
+ {
+ "X": 1,
+ "Y": -2.01571,
+ "Z": 7.55
+ },
+ {
+ "X": -3.3,
+ "Y": -2.01574,
+ "Z": 7.55
+ },
+ {
+ "X": -1,
+ "Y": -2.01573,
+ "Z": 5.528
+ },
+ {
+ "X": 1,
+ "Y": -2.01571,
+ "Z": 5.528
+ }
+ ]
+ },
+ {
+ "Id": 38,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 4.3537307E-06,
+ "Y": -1,
+ "Z": 0
+ },
+ "D": -2.0157144
+ },
+ "Vertices": [
+ {
+ "X": 1,
+ "Y": -2.01571,
+ "Z": 5.528
+ },
+ {
+ "X": 1,
+ "Y": -2.01571,
+ "Z": 2
+ },
+ {
+ "X": 3.3,
+ "Y": -2.0157,
+ "Z": 2
+ },
+ {
+ "X": 3.3,
+ "Y": -2.0157,
+ "Z": 7.55
+ }
+ ]
+ },
+ {
+ "Id": 39,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": 0,
+ "Z": -1
+ },
+ "D": 7.55
+ },
+ "Vertices": [
+ {
+ "X": 4,
+ "Y": 7.28,
+ "Z": 7.55
+ },
+ {
+ "X": 3.3,
+ "Y": 6.5003,
+ "Z": 7.55
+ },
+ {
+ "X": -3.3,
+ "Y": 6.50034,
+ "Z": 7.55
+ },
+ {
+ "X": -4,
+ "Y": 7.28,
+ "Z": 7.55
+ }
+ ]
+ },
+ {
+ "Id": 40,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": 0,
+ "Z": -1
+ },
+ "D": 7.5500007
+ },
+ "Vertices": [
+ {
+ "X": -3.3,
+ "Y": 6.50034,
+ "Z": 7.55
+ },
+ {
+ "X": -3.3,
+ "Y": -2.01574,
+ "Z": 7.55
+ },
+ {
+ "X": -4,
+ "Y": -5.2,
+ "Z": 7.55
+ }
+ ]
+ },
+ {
+ "Id": 41,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": 0,
+ "Z": -1
+ },
+ "D": 7.5500007
+ },
+ "Vertices": [
+ {
+ "X": -3.3,
+ "Y": 6.50034,
+ "Z": 7.55
+ },
+ {
+ "X": -4,
+ "Y": -5.2,
+ "Z": 7.55
+ },
+ {
+ "X": -4,
+ "Y": 7.28,
+ "Z": 7.55
+ }
+ ]
+ },
+ {
+ "Id": 42,
+ "NumPoints": 4,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": 0,
+ "Z": -1
+ },
+ "D": 7.55
+ },
+ "Vertices": [
+ {
+ "X": -3.3,
+ "Y": -2.01574,
+ "Z": 7.55
+ },
+ {
+ "X": 3.3,
+ "Y": -2.0157,
+ "Z": 7.55
+ },
+ {
+ "X": 4,
+ "Y": -5.2,
+ "Z": 7.55
+ },
+ {
+ "X": -4,
+ "Y": -5.2,
+ "Z": 7.55
+ }
+ ]
+ },
+ {
+ "Id": 43,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": 0,
+ "Z": -1
+ },
+ "D": 7.5500007
+ },
+ "Vertices": [
+ {
+ "X": 3.3,
+ "Y": -2.0157,
+ "Z": 7.55
+ },
+ {
+ "X": 3.3,
+ "Y": 6.5003,
+ "Z": 7.55
+ },
+ {
+ "X": 4,
+ "Y": 7.28,
+ "Z": 7.55
+ }
+ ]
+ },
+ {
+ "Id": 44,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0,
+ "Y": 0,
+ "Z": -1
+ },
+ "D": 7.5500007
+ },
+ "Vertices": [
+ {
+ "X": 3.3,
+ "Y": -2.0157,
+ "Z": 7.55
+ },
+ {
+ "X": 4,
+ "Y": 7.28,
+ "Z": 7.55
+ },
+ {
+ "X": 4,
+ "Y": -5.2,
+ "Z": 7.55
+ }
+ ]
+ },
+ {
+ "Id": 45,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 4.3537307E-06,
+ "Y": -1,
+ "Z": 0
+ },
+ "D": -2.0157144
+ },
+ "Vertices": [
+ {
+ "X": 1,
+ "Y": -2.01571,
+ "Z": 5.528
+ },
+ {
+ "X": 3.3,
+ "Y": -2.0157,
+ "Z": 7.55
+ },
+ {
+ "X": 1,
+ "Y": -2.01571,
+ "Z": 7.55
+ }
+ ]
+ },
+ {
+ "Id": 46,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": -1,
+ "Y": 0,
+ "Z": 0
+ },
+ "D": -4
+ },
+ "Vertices": [
+ {
+ "X": -4,
+ "Y": 7.25,
+ "Z": 0
+ },
+ {
+ "X": -4,
+ "Y": -7.15,
+ "Z": 5.96046E-08
+ },
+ {
+ "X": -4,
+ "Y": -4.7,
+ "Z": 2
+ }
+ ]
+ },
+ {
+ "Id": 47,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 1,
+ "Y": 0,
+ "Z": 0
+ },
+ "D": -4
+ },
+ "Vertices": [
+ {
+ "X": 4,
+ "Y": -4.7,
+ "Z": 2
+ },
+ {
+ "X": 4,
+ "Y": -7.15,
+ "Z": 5.96046E-08
+ },
+ {
+ "X": 4,
+ "Y": 7.25,
+ "Z": 0
+ }
+ ]
+ },
+ {
+ "Id": 48,
+ "NumPoints": 3,
+ "SidesType": 0,
+ "Plane": {
+ "Normal": {
+ "X": 0.250383,
+ "Y": 0,
+ "Z": 0.96814686
+ },
+ "D": -9.7298765
+ },
+ "Vertices": [
+ {
+ "X": 2.32,
+ "Y": 7.9196,
+ "Z": 9.45
+ },
+ {
+ "X": 0,
+ "Y": 7.9196,
+ "Z": 10.05
+ },
+ {
+ "X": 0,
+ "Y": -5.8396,
+ "Z": 10.05
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs
index 9ee07592..de6bf12b 100644
--- a/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs
@@ -179,12 +179,18 @@ public class Issue185OutdoorStairsSeamReplayTests
///
/// #271 live capture, quantum 310: a forward/uphill displacement that also
- /// presses into the staircase's side wall must keep its uphill tangent.
- /// Pre-fix the composite retry path reversed that tangent, moving from
- /// Y=75.539 to Y=75.199 and rapidly carrying the player back down the stairs.
+ /// presses into the staircase's side wall must never reverse downhill.
+ /// Pre-fix the composite retry path moved from Y=75.539 to Y=75.199 and
+ /// rapidly carried the player back down the stairs.
+ ///
+ /// The captured center is 0.288 m beyond the tread's side edge. Retail's
+ /// SPHEREPATH::check_walkables uses a 0.24 m half-radius support sphere, so
+ /// stopping at this exact side-wall position is valid; advancing farther
+ /// uphill is not. The regression invariant is therefore no downhill motion,
+ /// not mandatory forward progress beyond retail's support boundary.
///
[Fact]
- public void OutdoorStairs_SideWallContact_DoesNotReverseUphillTangent()
+ public void OutdoorStairs_SideWallContact_DoesNotReverseDownhill()
{
var engine = BuildStairEngine();
var body = GroundedOnTread();
@@ -211,8 +217,8 @@ public class Issue185OutdoorStairsSeamReplayTests
$"collision={result.CollisionNormalValid} " +
$"normal=({result.CollisionNormal.X:F3},{result.CollisionNormal.Y:F3},{result.CollisionNormal.Z:F3})");
- Assert.True(result.Position.Y > body.Position.Y + 0.25f,
- $"Side-wall response failed to preserve meaningful uphill motion: " +
+ Assert.True(result.Position.Y >= body.Position.Y - 0.001f,
+ $"Side-wall response reversed the intended uphill motion: " +
$"{body.Position.Y:F6} -> {result.Position.Y:F6}.");
Assert.True(result.Position.Z >= body.Position.Z - 0.001f,
$"Side-wall response dropped the grounded player downhill: " +
diff --git a/tests/AcDream.Core.Tests/Physics/Issue273HoltburgTightGapReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue273HoltburgTightGapReplayTests.cs
new file mode 100644
index 00000000..66abfcc7
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Issue273HoltburgTightGapReplayTests.cs
@@ -0,0 +1,259 @@
+using System;
+using System.Collections.Immutable;
+using System.IO;
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Issue #273 — exact Holtburg tight-gap replay captured live on 2026-07-31.
+/// The player presses between building shell 0x01000F69 and the timber post
+/// at 0xCA9B4027. Retail blocks this passage; before the fix ACDream lets the
+/// post slide feed a displaced step-down probe into precipice-slide, which
+/// carries the player around the post and along the building's outer edge.
+///
+/// The shell collision is a self-contained dump of the installed DAT's real
+/// PhysicsBSP. Post dimensions, building frame, player Setup spheres, and
+/// movement frames are copied from the live trace.
+///
+public sealed class Issue273HoltburgTightGapReplayTests
+{
+ private readonly ITestOutputHelper _output;
+
+ public Issue273HoltburgTightGapReplayTests(ITestOutputHelper output)
+ => _output = output;
+
+ private const uint Landblock = 0xA9B40000u;
+ private const uint Cell = 0xA9B40032u;
+ private const uint ShellGfxObj = 0x01000F69u;
+ private const uint PlayerEntity = 0x000F4243u;
+
+ private static readonly Vector3 BuildingOrigin = new(158.178f, 37.7055f, 94f);
+ private static readonly Quaternion BuildingRotation =
+ Quaternion.Normalize(new Quaternion(0f, 0f, -0.343045f, 0.939319f));
+ private static readonly Matrix4x4 BuildingTransform =
+ Matrix4x4.CreateFromQuaternion(BuildingRotation)
+ * Matrix4x4.CreateTranslation(BuildingOrigin);
+
+ private static readonly ImmutableArray PlayerSpheres =
+ [
+ new FlatCollisionSphere(new Vector3(0f, 0f, 0.475f), 0.480f),
+ new FlatCollisionSphere(new Vector3(0f, 0f, 1.350f), 0.480f),
+ ];
+
+ private static PhysicsEngine BuildEngine()
+ {
+ var cache = new PhysicsDataCache();
+ var engine = new PhysicsEngine { DataCache = cache };
+
+ string dumpPath = Path.Combine(
+ SolutionRoot(),
+ "tests",
+ "AcDream.Core.Tests",
+ "Fixtures",
+ "issue273",
+ "0x01000F69.gfxobj.json");
+ Assert.True(File.Exists(dumpPath), $"Missing issue #273 fixture: {dumpPath}");
+ cache.RegisterGfxObjForTest(
+ ShellGfxObj,
+ GfxObjDumpSerializer.Hydrate(GfxObjDumpSerializer.Read(dumpPath)));
+
+ // The shell is registered through retail's building channel, not as a
+ // shadow object. Its one portal is irrelevant to this exterior sweep.
+ cache.CacheBuilding(
+ Cell,
+ Array.Empty(),
+ BuildingTransform,
+ ShellGfxObj);
+
+ // Terrain is deliberately below the shell. The player stands on the
+ // shell's authored z=2 ledge (world z=96), not synthetic terrain.
+ var heights = new byte[81];
+ var heightTable = new float[256];
+ Array.Fill(heightTable, -1000f);
+ engine.AddLandblock(
+ Landblock,
+ new TerrainSurface(heights, heightTable),
+ Array.Empty(),
+ Array.Empty(),
+ 0f,
+ 0f);
+
+ RegisterPost(
+ engine,
+ 0xCA9B4027u,
+ new Vector3(160.173f, 34.487f, 95.975f),
+ radius: 0.282f);
+ RegisterPost(
+ engine,
+ 0xCA9B402Eu,
+ new Vector3(158.282f, 34.610f, 95.975f),
+ radius: 0.282f);
+ RegisterPost(
+ engine,
+ 0xCA9B402Fu,
+ new Vector3(157.952f, 32.239f, 96f),
+ radius: 0.600f);
+
+ return engine;
+ }
+
+ private static void RegisterPost(
+ PhysicsEngine engine,
+ uint entityId,
+ Vector3 basePosition,
+ float radius)
+ {
+ engine.ShadowObjects.Register(
+ entityId,
+ gfxObjId: 0u,
+ worldPos: basePosition,
+ rotation: Quaternion.Identity,
+ radius,
+ worldOffsetX: 0f,
+ worldOffsetY: 0f,
+ landblockId: Landblock,
+ collisionType: ShadowCollisionType.Cylinder,
+ cylHeight: 5.564f,
+ state: 0u,
+ seedCellId: Cell,
+ isStatic: true);
+ }
+
+ private static PhysicsBody GroundedBody(Vector3 position)
+ {
+ Vector3[] localWalkable =
+ [
+ new(4f, 7.25f, 2f),
+ new(3.3f, 6.5003f, 2f),
+ new(3.3f, -2.0157f, 2f),
+ new(4f, -4.7f, 2f),
+ ];
+ var worldWalkable = new Vector3[localWalkable.Length];
+ for (int i = 0; i < localWalkable.Length; i++)
+ worldWalkable[i] = Vector3.Transform(localWalkable[i], BuildingTransform);
+
+ var floor = new Plane(Vector3.UnitZ, -96f);
+ return new PhysicsBody
+ {
+ Position = position,
+ Orientation = Quaternion.Identity,
+ ContactPlaneValid = true,
+ ContactPlane = floor,
+ ContactPlaneCellId = Cell,
+ WalkablePolygonValid = true,
+ WalkablePlane = floor,
+ WalkableUp = Vector3.UnitZ,
+ WalkableVertices = worldWalkable,
+ TransientState =
+ TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
+ };
+ }
+
+ [Theory]
+ [InlineData(1.239f, true)]
+ [InlineData(1.241f, false)]
+ public void RetailHalfRadiusSupport_RejectsCenterBeyondQuarterMeterEdge(
+ float centerX,
+ bool expected)
+ {
+ Vector3[] floor =
+ [
+ new(0f, 0f, 0f),
+ new(1f, 0f, 0f),
+ new(1f, 1f, 0f),
+ new(0f, 1f, 0f),
+ ];
+
+ // The player's 0.48 m foot sphere becomes a 0.24 m support sphere in
+ // SPHEREPATH::check_walkables. Just inside that boundary is supported;
+ // just outside it is not, even though the full movement sphere still
+ // overlaps the floor polygon.
+ bool supported = BSPQuery.CheckWalkableSupport(
+ new Plane(Vector3.UnitZ, 0f),
+ floor,
+ new Vector3(centerX, 0.5f, 0.48f),
+ supportRadius: 0.24f,
+ Vector3.UnitZ);
+
+ Assert.Equal(expected, supported);
+ }
+
+ [Fact]
+ public void CapturedRun_DoesNotSqueezeBetweenPostAndBuilding()
+ {
+ PhysicsEngine engine = BuildEngine();
+ Vector3 position = new(160.016f, 33.562f, 96.005f);
+ var body = GroundedBody(position);
+ uint cell = Cell;
+
+ // First frame is copied verbatim from the live capture. Subsequent
+ // held-forward frames use the stable displacement visible in that
+ // same trace after input acceleration settles.
+ Vector3[] offsets =
+ [
+ new(1.396f, 1.123f, 0f),
+ new(0.727f, 0.584f, 0f),
+ new(0.624f, 0.501f, 0f),
+ new(0.727f, 0.585f, 0f),
+ new(0.728f, 0.585f, 0f),
+ new(0.727f, 0.585f, 0f),
+ new(0.727f, 0.585f, 0f),
+ new(0.728f, 0.585f, 0f),
+ ];
+
+ for (int frame = 0; frame < offsets.Length; frame++)
+ {
+ ResolveResult result = engine.ResolveWithTransition(
+ currentPos: position,
+ targetPos: position + offsets[frame],
+ cellId: cell,
+ sphereRadius: 0.48f,
+ sphereHeight: 1.835f,
+ stepUpHeight: 0.6f,
+ stepDownHeight: 1.5f,
+ isOnGround: true,
+ body: body,
+ moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
+ movingEntityId: PlayerEntity,
+ sphereList: PlayerSpheres,
+ sphereScale: 1f);
+
+ _output.WriteLine(
+ $"f{frame}: in=({position.X:F3},{position.Y:F3},{position.Z:F3}) "
+ + $"out=({result.Position.X:F3},{result.Position.Y:F3},{result.Position.Z:F3}) "
+ + $"hit={result.CollisionNormalValid} "
+ + $"normal=({result.CollisionNormal.X:F3},"
+ + $"{result.CollisionNormal.Y:F3},{result.CollisionNormal.Z:F3})");
+
+ position = result.Position;
+ cell = result.CellId;
+ body.Position = position;
+ }
+
+ // The captured broken run reached (163.234, 36.982) by this point,
+ // already beyond the post and sliding along the building. Retail
+ // blocks the passage before the player can cross the post's Y.
+ Assert.True(
+ position.Y < 34.487f,
+ $"Player squeezed through the retail-blocked gap: "
+ + $"final=({position.X:F3},{position.Y:F3},{position.Z:F3}).");
+ }
+
+ private static string SolutionRoot()
+ {
+ string? directory = AppContext.BaseDirectory;
+ while (!string.IsNullOrEmpty(directory))
+ {
+ if (File.Exists(Path.Combine(directory, "AcDream.slnx")))
+ return directory;
+ directory = Path.GetDirectoryName(directory);
+ }
+
+ throw new InvalidOperationException(
+ $"Could not locate AcDream.slnx from {AppContext.BaseDirectory}.");
+ }
+}
diff --git a/tools/A8CellAudit/Program.cs b/tools/A8CellAudit/Program.cs
index d3948ac8..c9d3df60 100644
--- a/tools/A8CellAudit/Program.cs
+++ b/tools/A8CellAudit/Program.cs
@@ -323,9 +323,9 @@ static (int RegistryBuildings, int ShellEntities) DumpLandblockBuildings(LandBlo
{
uint lbPrefix = landblockId & 0xFFFF0000u;
uint stabIdBase = 0xC0000000u
- | (((landblockId >> 24) & 0xFFu) << 16)
- | (((landblockId >> 16) & 0xFFu) << 8);
- uint nextEntityId = stabIdBase + 1u;
+ | (((landblockId >> 24) & 0xFFu) << 20)
+ | (((landblockId >> 16) & 0xFFu) << 12);
+ uint nextEntityId = stabIdBase;
int supportedObjects = 0;
foreach (var obj in info.Objects)
@@ -333,7 +333,13 @@ static (int RegistryBuildings, int ShellEntities) DumpLandblockBuildings(LandBlo
if (!IsSupported(obj.Id))
continue;
supportedObjects++;
- nextEntityId++;
+ uint entityId = nextEntityId++;
+ Console.WriteLine(
+ $"objectOrdinal={supportedObjects} entity=0x{entityId:X8} "
+ + $"model=0x{obj.Id:X8} "
+ + $"pos=({obj.Frame.Origin.X:R},{obj.Frame.Origin.Y:R},{obj.Frame.Origin.Z:R}) "
+ + $"quat=({obj.Frame.Orientation.W:R},{obj.Frame.Orientation.X:R},"
+ + $"{obj.Frame.Orientation.Y:R},{obj.Frame.Orientation.Z:R})");
}
Console.WriteLine(
@@ -365,7 +371,9 @@ static (int RegistryBuildings, int ShellEntities) DumpLandblockBuildings(LandBlo
Console.WriteLine(
$"buildingOrdinal={zeroBased + 1} registryId={registryText} shellEntity=0x{shellEntityId:X8} " +
- $"model=0x{building.ModelId:X8} pos=({building.Frame.Origin.X:F2},{building.Frame.Origin.Y:F2},{building.Frame.Origin.Z:F2}) " +
+ $"model=0x{building.ModelId:X8} pos=({building.Frame.Origin.X:R},{building.Frame.Origin.Y:R},{building.Frame.Origin.Z:R}) " +
+ $"quat=({building.Frame.Orientation.W:R},{building.Frame.Orientation.X:R}," +
+ $"{building.Frame.Orientation.Y:R},{building.Frame.Orientation.Z:R}) " +
$"portalCells={portalText}");
}
@@ -436,6 +444,25 @@ static void DumpGfxObj(DatCollection dats, uint gfxObjId)
Console.WriteLine(
$"classify: walls={walls} (outwardFacing={outwardWalls} inwardFacing={inwardWalls}) " +
$"floors={floors} ceilings={ceilings} slopes={slopes}");
+ Console.WriteLine("physics polygons:");
+ foreach (var (polyId, poly) in g.PhysicsPolygons.OrderBy(p => p.Key))
+ {
+ Vector3 polyMin = new(float.MaxValue);
+ Vector3 polyMax = new(float.MinValue);
+ foreach (ushort vertexId in poly.VertexIds.Select(id => (ushort)id))
+ {
+ if (!g.VertexArray.Vertices.TryGetValue(vertexId, out var vertex))
+ continue;
+ polyMin = Vector3.Min(polyMin, vertex.Origin);
+ polyMax = Vector3.Max(polyMax, vertex.Origin);
+ }
+ Vector3 normal = ComputeNormalG(g, poly);
+ Console.WriteLine(
+ $" poly=0x{polyId:X4} n=({normal.X:F3},{normal.Y:F3},{normal.Z:F3}) "
+ + $"min=({polyMin.X:F3},{polyMin.Y:F3},{polyMin.Z:F3}) "
+ + $"max=({polyMax.X:F3},{polyMax.Y:F3},{polyMax.Z:F3}) "
+ + $"sides={poly.SidesType} stip={poly.Stippling}");
+ }
Console.WriteLine();
}
diff --git a/tools/SetupInspect/Program.cs b/tools/SetupInspect/Program.cs
index c08c1225..54ea24cc 100644
--- a/tools/SetupInspect/Program.cs
+++ b/tools/SetupInspect/Program.cs
@@ -45,6 +45,26 @@ if (!dats.TryGet(setupId, out var setup) || setup is null)
Console.WriteLine($"=== Setup 0x{setupId:X8} ===");
Console.WriteLine($"Flags = 0x{(uint)setup.Flags:X8}");
+Console.WriteLine($"Radius/Height = {setup.Radius:F3} / {setup.Height:F3}");
+Console.WriteLine($"StepUp/StepDown = {setup.StepUpHeight:F3} / {setup.StepDownHeight:F3}");
+Console.WriteLine($"Spheres = {setup.Spheres.Count}");
+for (int i = 0; i < setup.Spheres.Count; i++)
+{
+ Sphere sphere = setup.Spheres[i];
+ Console.WriteLine(
+ $" sphere[{i}] origin=({sphere.Origin.X:R},{sphere.Origin.Y:R},{sphere.Origin.Z:R}) "
+ + $"radius={sphere.Radius:R} "
+ + $"radiusBits=0x{BitConverter.SingleToUInt32Bits(sphere.Radius):X8}");
+}
+Console.WriteLine($"CylSpheres = {setup.CylSpheres.Count}");
+for (int i = 0; i < setup.CylSpheres.Count; i++)
+{
+ CylSphere cylinder = setup.CylSpheres[i];
+ Console.WriteLine(
+ $" cyl[{i}] origin=({cylinder.Origin.X:R},{cylinder.Origin.Y:R},{cylinder.Origin.Z:R}) "
+ + $"radius={cylinder.Radius:R} height={cylinder.Height:R} "
+ + $"radiusBits=0x{BitConverter.SingleToUInt32Bits(cylinder.Radius):X8}");
+}
Console.WriteLine($"Parts = {setup.Parts.Count}");
for (int i = 0; i < setup.Parts.Count; i++)
{
@@ -78,7 +98,10 @@ foreach (uint gfxId in setup.Parts.Select(p => (uint)p).Distinct())
Console.WriteLine(
$" gfx=0x{gfxId:X8} verts={count} "
+ $"x[{minX:F2},{maxX:F2}] y[{minY:F2},{maxY:F2}] z[{minZ:F2},{maxZ:F2}] "
- + $"sortCenter=({gfx.SortCenter.X:F2},{gfx.SortCenter.Y:F2},{gfx.SortCenter.Z:F2})");
+ + $"sortCenter=({gfx.SortCenter.X:F2},{gfx.SortCenter.Y:F2},{gfx.SortCenter.Z:F2}) "
+ + $"flags=0x{(uint)gfx.Flags:X8} "
+ + $"physicsBsp={(gfx.PhysicsBSP?.Root is null ? "none" : "present")} "
+ + $"physicsPolygons={gfx.PhysicsPolygons.Count}");
}
Console.WriteLine($"DefaultAnimation = 0x{(uint)setup.DefaultAnimation:X8}");