fix(physics): enforce retail step-down support radius (#273)
This commit is contained in:
parent
4dd40ad8fe
commit
c24bc571cf
9 changed files with 2117 additions and 24 deletions
|
|
@ -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-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-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-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` |
|
| ~~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` |
|
||||||
|
|
|
||||||
95
docs/research/2026-07-31-issue273-tight-gap-support.md
Normal file
95
docs/research/2026-07-31-issue273-tight-gap-support.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -284,28 +284,46 @@ public static class BSPQuery
|
||||||
CollisionSphere sphere,
|
CollisionSphere sphere,
|
||||||
Vector3 up,
|
Vector3 up,
|
||||||
bool small)
|
bool small)
|
||||||
|
=> CheckWalkableSupport(
|
||||||
|
poly.Plane,
|
||||||
|
poly.Vertices,
|
||||||
|
sphere.Center,
|
||||||
|
small ? sphere.Radius * 0.5f : sphere.Radius,
|
||||||
|
up);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>CPolygon::check_walkable</c> against an already resolved
|
||||||
|
/// polygon. The caller supplies the effective support radius; retail's
|
||||||
|
/// <c>SPHEREPATH::check_walkables</c> halves its saved sphere before
|
||||||
|
/// entering this routine.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool CheckWalkableSupport(
|
||||||
|
Plane plane,
|
||||||
|
ReadOnlySpan<Vector3> 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;
|
if (angleUp < PhysicsGlobals.EPSILON) return false;
|
||||||
|
|
||||||
float angle = (Vector3.Dot(poly.Plane.Normal, sphere.Center) + poly.Plane.D) / angleUp;
|
float angle = (Vector3.Dot(plane.Normal, center) + plane.D) / angleUp;
|
||||||
var center = sphere.Center - up * angle;
|
center -= up * angle;
|
||||||
|
|
||||||
float radsum = sphere.Radius * sphere.Radius;
|
float radsum = supportRadius * supportRadius;
|
||||||
if (small) radsum *= 0.25f;
|
|
||||||
|
|
||||||
int n = poly.Vertices.Length;
|
int n = vertices.Length;
|
||||||
int prevIdx = n - 1;
|
int prevIdx = n - 1;
|
||||||
|
|
||||||
for (int i = 0; i < n; i++)
|
for (int i = 0; i < n; i++)
|
||||||
{
|
{
|
||||||
var v = poly.Vertices[i];
|
var v = vertices[i];
|
||||||
var lv = poly.Vertices[prevIdx];
|
var lv = vertices[prevIdx];
|
||||||
prevIdx = i;
|
prevIdx = i;
|
||||||
|
|
||||||
var edge = v - lv;
|
var edge = v - lv;
|
||||||
var disp = center - 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);
|
float diff = Vector3.Dot(disp, cross);
|
||||||
|
|
||||||
if (diff < 0f)
|
if (diff < 0f)
|
||||||
|
|
|
||||||
|
|
@ -850,6 +850,27 @@ public sealed class SpherePath
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>SPHEREPATH::check_walkables</c> (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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retail <c>SPHEREPATH::init</c> reset for a retained transition record.
|
/// Retail <c>SPHEREPATH::init</c> reset for a retained transition record.
|
||||||
/// Every logical value is restored; only the two private exact-length
|
/// Every logical value is restored; only the two private exact-length
|
||||||
|
|
@ -5211,6 +5232,22 @@ public sealed class Transition
|
||||||
&& CollisionInfo.ContactPlaneValid
|
&& CollisionInfo.ContactPlaneValid
|
||||||
&& CollisionInfo.ContactPlane.Normal.Z >= walkableZ)
|
&& 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
|
// L.2.3h (2026-04-29): Placement validation is for the
|
||||||
// DoStepUp use case (prevents climbing through walls by
|
// DoStepUp use case (prevents climbing through walls by
|
||||||
// stepping up onto ground beyond a tall wall). For the
|
// stepping up onto ground beyond a tall wall). For the
|
||||||
|
|
@ -5461,8 +5498,10 @@ public sealed class Transition
|
||||||
if ((oi.State & ObjectInfoState.OnWalkable) == 0)
|
if ((oi.State & ObjectInfoState.OnWalkable) == 0)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
// If the current walkable entry is still valid, skip the probe.
|
// Retail first validates the remembered polygon with a half-radius
|
||||||
if (sp.WalkableValid)
|
// support sphere. Merely having a polygon pointer is insufficient:
|
||||||
|
// the candidate may already hang too far beyond its edge.
|
||||||
|
if (sp.CheckWalkables())
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
sp.SaveCheckPos();
|
sp.SaveCheckPos();
|
||||||
|
|
|
||||||
1626
tests/AcDream.Core.Tests/Fixtures/issue273/0x01000F69.gfxobj.json
Normal file
1626
tests/AcDream.Core.Tests/Fixtures/issue273/0x01000F69.gfxobj.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -179,12 +179,18 @@ public class Issue185OutdoorStairsSeamReplayTests
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #271 live capture, quantum 310: a forward/uphill displacement that also
|
/// #271 live capture, quantum 310: a forward/uphill displacement that also
|
||||||
/// presses into the staircase's side wall must keep its uphill tangent.
|
/// presses into the staircase's side wall must never reverse downhill.
|
||||||
/// Pre-fix the composite retry path reversed that tangent, moving from
|
/// Pre-fix the composite retry path moved from Y=75.539 to Y=75.199 and
|
||||||
/// Y=75.539 to Y=75.199 and rapidly carrying the player back down the stairs.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OutdoorStairs_SideWallContact_DoesNotReverseUphillTangent()
|
public void OutdoorStairs_SideWallContact_DoesNotReverseDownhill()
|
||||||
{
|
{
|
||||||
var engine = BuildStairEngine();
|
var engine = BuildStairEngine();
|
||||||
var body = GroundedOnTread();
|
var body = GroundedOnTread();
|
||||||
|
|
@ -211,8 +217,8 @@ public class Issue185OutdoorStairsSeamReplayTests
|
||||||
$"collision={result.CollisionNormalValid} " +
|
$"collision={result.CollisionNormalValid} " +
|
||||||
$"normal=({result.CollisionNormal.X:F3},{result.CollisionNormal.Y:F3},{result.CollisionNormal.Z:F3})");
|
$"normal=({result.CollisionNormal.X:F3},{result.CollisionNormal.Y:F3},{result.CollisionNormal.Z:F3})");
|
||||||
|
|
||||||
Assert.True(result.Position.Y > body.Position.Y + 0.25f,
|
Assert.True(result.Position.Y >= body.Position.Y - 0.001f,
|
||||||
$"Side-wall response failed to preserve meaningful uphill motion: " +
|
$"Side-wall response reversed the intended uphill motion: " +
|
||||||
$"{body.Position.Y:F6} -> {result.Position.Y:F6}.");
|
$"{body.Position.Y:F6} -> {result.Position.Y:F6}.");
|
||||||
Assert.True(result.Position.Z >= body.Position.Z - 0.001f,
|
Assert.True(result.Position.Z >= body.Position.Z - 0.001f,
|
||||||
$"Side-wall response dropped the grounded player downhill: " +
|
$"Side-wall response dropped the grounded player downhill: " +
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<FlatCollisionSphere> 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<BldPortalInfo>(),
|
||||||
|
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<CellSurface>(),
|
||||||
|
Array.Empty<PortalPlane>(),
|
||||||
|
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}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -323,9 +323,9 @@ static (int RegistryBuildings, int ShellEntities) DumpLandblockBuildings(LandBlo
|
||||||
{
|
{
|
||||||
uint lbPrefix = landblockId & 0xFFFF0000u;
|
uint lbPrefix = landblockId & 0xFFFF0000u;
|
||||||
uint stabIdBase = 0xC0000000u
|
uint stabIdBase = 0xC0000000u
|
||||||
| (((landblockId >> 24) & 0xFFu) << 16)
|
| (((landblockId >> 24) & 0xFFu) << 20)
|
||||||
| (((landblockId >> 16) & 0xFFu) << 8);
|
| (((landblockId >> 16) & 0xFFu) << 12);
|
||||||
uint nextEntityId = stabIdBase + 1u;
|
uint nextEntityId = stabIdBase;
|
||||||
|
|
||||||
int supportedObjects = 0;
|
int supportedObjects = 0;
|
||||||
foreach (var obj in info.Objects)
|
foreach (var obj in info.Objects)
|
||||||
|
|
@ -333,7 +333,13 @@ static (int RegistryBuildings, int ShellEntities) DumpLandblockBuildings(LandBlo
|
||||||
if (!IsSupported(obj.Id))
|
if (!IsSupported(obj.Id))
|
||||||
continue;
|
continue;
|
||||||
supportedObjects++;
|
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(
|
Console.WriteLine(
|
||||||
|
|
@ -365,7 +371,9 @@ static (int RegistryBuildings, int ShellEntities) DumpLandblockBuildings(LandBlo
|
||||||
|
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"buildingOrdinal={zeroBased + 1} registryId={registryText} shellEntity=0x{shellEntityId:X8} " +
|
$"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}");
|
$"portalCells={portalText}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -436,6 +444,25 @@ static void DumpGfxObj(DatCollection dats, uint gfxObjId)
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"classify: walls={walls} (outwardFacing={outwardWalls} inwardFacing={inwardWalls}) " +
|
$"classify: walls={walls} (outwardFacing={outwardWalls} inwardFacing={inwardWalls}) " +
|
||||||
$"floors={floors} ceilings={ceilings} slopes={slopes}");
|
$"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();
|
Console.WriteLine();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,26 @@ if (!dats.TryGet<Setup>(setupId, out var setup) || setup is null)
|
||||||
|
|
||||||
Console.WriteLine($"=== Setup 0x{setupId:X8} ===");
|
Console.WriteLine($"=== Setup 0x{setupId:X8} ===");
|
||||||
Console.WriteLine($"Flags = 0x{(uint)setup.Flags: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}");
|
Console.WriteLine($"Parts = {setup.Parts.Count}");
|
||||||
for (int i = 0; i < setup.Parts.Count; i++)
|
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(
|
Console.WriteLine(
|
||||||
$" gfx=0x{gfxId:X8} verts={count} "
|
$" gfx=0x{gfxId:X8} verts={count} "
|
||||||
+ $"x[{minX:F2},{maxX:F2}] y[{minY:F2},{maxY:F2}] z[{minZ:F2},{maxZ:F2}] "
|
+ $"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}");
|
Console.WriteLine($"DefaultAnimation = 0x{(uint)setup.DefaultAnimation:X8}");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue