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-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` |
|
||||
|
|
|
|||
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,
|
||||
Vector3 up,
|
||||
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;
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -850,6 +850,27 @@ public sealed class SpherePath
|
|||
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>
|
||||
/// Retail <c>SPHEREPATH::init</c> 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();
|
||||
|
|
|
|||
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>
|
||||
/// #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.
|
||||
/// </summary>
|
||||
[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: " +
|
||||
|
|
|
|||
|
|
@ -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 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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,26 @@ if (!dats.TryGet<Setup>(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}");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue