acdream/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs
Erik 45d7154712
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
probe(physics): ACDREAM_PROBE_STEP_HEIGHTS — three readings along #338's chain
The live reading of 0.400 says the controller held its default at that
instant, not why. Two very different causes produce it: the
Setup-derived prepare/publish path never runs for the local player, or
it runs and a later writer clobbers the result. Fixing without knowing
which is a coin flip.

One reading at each hop — prepare (Setup value computed and scaled),
publish (assigned to the controller), resolve (what the resolver is
actually handed) — with a decision table on the flag mapping each
pattern to its cause, including the 0.000 case that would mean a null
Setup took the retail dummy path.

Edge-triggered per site, so the per-tick resolve site prints once per
distinct pair and cannot drown the two one-shot sites it exists to be
compared against. Prepare prints the raw authored pair beside the
scaled one, so a surprise separates wrong-Setup from wrong-scale
without a second run.

Lives in PhysicsDiagnostics per code-structure rule 5 rather than as
per-call-site env reads. Zero cost when off.

Suite 11,231 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:50:42 +02:00

227 lines
8.7 KiB
C#

using System.Collections.Immutable;
using System.Numerics;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
namespace AcDream.Runtime.Physics;
internal enum RuntimeSetPositionMoverPreparationStatus
{
Prepared,
/// <summary>
/// The authored Setup collision payload has not been read yet. Resolves
/// when the prepared-asset read completes.
/// </summary>
RetrySetupUnavailable,
/// <summary>
/// #284: Runtime's world frame is not published yet, so a landblock-local
/// Create origin cannot be converted
/// (<see cref="RuntimePhysicsState.TryGetWorldFrameOffset"/>). Previously
/// this reported itself as <see cref="RetrySetupUnavailable"/>, which sent
/// anyone diagnosing a parked placement to the asset pipeline instead of
/// the missing local-player Create that actually publishes the frame.
/// Retryable exactly like Setup - only the reason differs.
/// </summary>
RetryWorldFrameUnavailable,
RejectedAuthority,
InvalidData,
}
internal static class RuntimeSetPositionMoverPreparationStatusExtensions
{
/// <summary>
/// True for the statuses whose work can still succeed on a later pump.
/// Call sites must use this rather than comparing against one reason, so
/// a newly added retry reason cannot be silently treated as a rejection.
/// </summary>
internal static bool IsRetryable(
this RuntimeSetPositionMoverPreparationStatus status) =>
status is RuntimeSetPositionMoverPreparationStatus
.RetrySetupUnavailable
or RuntimeSetPositionMoverPreparationStatus
.RetryWorldFrameUnavailable;
/// <summary>
/// The parked-placement reason a retryable status contributes to the
/// ownership ledger, or <see cref="RuntimeSetPositionParkReason.None"/>
/// when the status is not a park.
/// </summary>
internal static RuntimeSetPositionParkReason ParkReason(
this RuntimeSetPositionMoverPreparationStatus status) =>
status switch
{
RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable =>
RuntimeSetPositionParkReason.AwaitingSetupCollision,
RuntimeSetPositionMoverPreparationStatus
.RetryWorldFrameUnavailable =>
RuntimeSetPositionParkReason.AwaitingWorldFrame,
_ => RuntimeSetPositionParkReason.None,
};
}
/// <summary>
/// #284: why a first-entry placement is currently parked. Retained on the
/// operation so <c>CaptureOwnership</c> can report parked work by cause
/// instead of leaving it invisible until a downstream symptom appears.
/// </summary>
internal enum RuntimeSetPositionParkReason
{
None,
AwaitingSetupCollision,
AwaitingWorldFrame,
}
/// <summary>
/// Distinguishes a Setup payload which has not arrived yet from a completed
/// lookup whose result is absent. Retail supplies its dummy placement sphere
/// only inside <c>CPhysicsObj::SetPosition</c>; an unavailable lookup must not
/// manufacture that fallback while asynchronous preparation is still live.
/// </summary>
internal readonly record struct RuntimeSetPositionMoverSetup(
bool IsResolved,
uint SetupTableId,
FlatSetupCollision? Collision)
{
internal static RuntimeSetPositionMoverSetup Unavailable => default;
internal static RuntimeSetPositionMoverSetup ResolvedAbsent =>
new(true, 0u, null);
internal static RuntimeSetPositionMoverSetup Resolved(
uint setupTableId,
FlatSetupCollision collision) =>
new(
true,
setupTableId != 0u
? setupTableId
: throw new ArgumentOutOfRangeException(nameof(setupTableId)),
collision ?? throw new ArgumentNullException(nameof(collision)));
}
/// <summary>
/// Explicit, immutable inputs surrounding retail's authored mover shape.
/// None of these values are inferred from presentation state.
/// </summary>
internal readonly record struct RuntimeSetPositionMoverPreparation(
RuntimeSetPositionMoverSetup Setup,
RuntimeSetPositionOperationKind Kind,
double GameTime,
PhysicsPlacementClass PlacementClass,
PhysicsSetPositionFlags Flags,
Vector3 Line = default,
float ScatterRadiusX = 0f,
float ScatterRadiusY = 0f,
uint ScatterAttempts = 0u,
float ShadowWorldOffsetX = 0f,
float ShadowWorldOffsetY = 0f,
RuntimePortalPlacementAuthority Portal = default,
bool ResolveWorldOffsetFromRuntimeFrame = false);
/// <summary>
/// Pure preparation port of the mover inputs consumed by retail
/// <c>CPhysicsObj::SetPosition</c> (0x005160C0). It preserves the complete DAT
/// sphere order; Core's SPHEREPATH port applies retail's two-sphere cap later.
/// </summary>
internal static class RuntimeSetPositionMoverPreparer
{
internal static bool TryBuild(
RuntimeEntityRecord record,
in CreateObject.ServerPosition acceptedPosition,
uint canonicalSetupTableId,
RuntimeSetPositionOperationKind acceptedKind,
RuntimePortalPlacementAuthority acceptedPortal,
ulong velocityAuthorityVersion,
in RuntimeSetPositionMoverPreparation preparation,
out RuntimeSetPositionCommand command)
{
ArgumentNullException.ThrowIfNull(record);
command = default;
if (!preparation.Setup.IsResolved
|| preparation.Kind != acceptedKind
|| preparation.Portal != acceptedPortal
|| (canonicalSetupTableId == 0u
? preparation.Setup.SetupTableId != 0u
|| preparation.Setup.Collision is not null
: preparation.Setup.SetupTableId != canonicalSetupTableId
|| preparation.Setup.Collision is null))
{
return false;
}
CreateObject.ServerPosition position = acceptedPosition;
Vector3 cellLocal = new(
position.PositionX,
position.PositionY,
position.PositionZ);
Vector3 world = new(
cellLocal.X + preparation.ShadowWorldOffsetX,
cellLocal.Y + preparation.ShadowWorldOffsetY,
cellLocal.Z);
Quaternion orientation = new(
position.RotationX,
position.RotationY,
position.RotationZ,
position.RotationW);
float scale = record.Snapshot.Physics?.Scale
?? record.Snapshot.ObjScale
?? 1f;
FlatSetupCollision? setup = preparation.Setup.Collision;
ImmutableArray<FlatCollisionSphere> spheres = setup?.Spheres
?? ImmutableArray<FlatCollisionSphere>.Empty;
// CPartArray::GetStepUpHeight/GetStepDownHeight are Setup properties,
// not sphere properties. An authored Setup with no collision spheres
// still supplies both values; only a genuinely absent Setup takes the
// retail dummy path with exact zero steps and no scale multiplication.
float stepUp = setup is not null ? setup.StepUpHeight * scale : 0f;
float stepDown = setup is not null ? setup.StepDownHeight * scale : 0f;
// #338 (TEMPORARY): first of three readings along the chain. Prints
// the raw authored pair alongside the scaled one, so a surprise here
// separates "wrong Setup" from "wrong scale" without a second run.
PhysicsDiagnostics.LogStepHeights(
"prepare", stepUp, stepDown,
setup is not null
? $"authored=({setup.StepUpHeight:F3},{setup.StepDownHeight:F3}) scale={scale:F3}"
: "setup=NULL (retail dummy path, exact zero steps)");
EntityCollisionFlags collisionFlags =
EntityCollisionFlagsExt.FromPwdBitfield(
record.Snapshot.ObjectDescriptionFlags ?? 0u);
ObjectInfoState moverFlags = collisionFlags.ToMoverState();
if (collisionFlags.HasFlag(EntityCollisionFlags.IsPlayer))
moverFlags |= ObjectInfoState.IsPlayer;
var request = new PhysicsSetPositionRequest(
world,
orientation,
position.LandblockId,
cellLocal,
spheres,
scale,
stepUp,
stepDown,
record.FinalPhysicsState,
moverFlags,
record.Key?.LocalEntityId ?? 0u,
preparation.PlacementClass,
preparation.Flags,
preparation.Line,
preparation.ScatterRadiusX,
preparation.ScatterRadiusY,
preparation.ScatterAttempts);
command = new RuntimeSetPositionCommand(
request,
preparation.Kind,
preparation.GameTime,
velocityAuthorityVersion,
preparation.ShadowWorldOffsetX,
preparation.ShadowWorldOffsetY,
preparation.Portal);
return true;
}
}