fix(physics): enforce retail step-down support radius (#273)

This commit is contained in:
Erik 2026-07-31 12:10:03 +02:00
parent 4dd40ad8fe
commit c24bc571cf
9 changed files with 2117 additions and 24 deletions

View file

@ -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:273191273307 |
| 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` |

View 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.

View file

@ -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)

View file

@ -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();

File diff suppressed because it is too large Load diff

View file

@ -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: " +

View file

@ -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}.");
}
}

View file

@ -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();
}

View file

@ -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}");