diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index 56348666..5b4dbf6e 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -55,6 +55,12 @@ public sealed class PhysicsEngine
/// Number of registered landblocks (diagnostic).
public int LandblockCount => _landblocks.Count;
+ ///
+ /// Optional high-volume collision trace sink. Production leaves this
+ /// unset; focused diagnostic gates may opt in explicitly.
+ ///
+ public Action? DiagnosticLog { get; set; }
+
///
/// True once the landblock covering has had its
/// terrain + cells registered via . Accepts a canonical
@@ -797,8 +803,8 @@ public sealed class PhysicsEngine
if (physics is null)
{
- if (snapDiag)
- Console.WriteLine(System.FormattableString.Invariant(
+ if (snapDiag && DiagnosticLog is { } noLandblockLog)
+ noLandblockLog(System.FormattableString.Invariant(
$"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) branch=NO-LANDBLOCK (lbs={_landblocks.Count}) -> verbatim"));
return new ResolveResult(candidatePos, cellId, IsOnGround: false);
}
@@ -831,8 +837,11 @@ public sealed class PhysicsEngine
float? claimFloorZ = WalkableFloorZNearest(cellId, candidatePos, currentPos.Z);
if (claimFloorZ is not null)
{
- Console.WriteLine(System.FormattableString.Invariant(
- $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) VALIDATED -> grounded to its walkable floor z={claimFloorZ.Value:F3}"));
+ if (DiagnosticLog is { } validatedLog)
+ {
+ validatedLog(System.FormattableString.Invariant(
+ $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) VALIDATED -> grounded to its walkable floor z={claimFloorZ.Value:F3}"));
+ }
// #133 (2026-06-13): return the VALIDATED claim's OWN full cell id,
// NOT lbPrefix | (cellId & 0xFFFF). lbPrefix is found by scanning
// resident landblocks for one whose [0,192) local bounds contain
@@ -1017,10 +1026,14 @@ public sealed class PhysicsEngine
// max(terrain, z) stays as the under-terrain sanity bound —
// our recoverable stand-in for retail's lost-cell machinery
// (documented divergence, same as the #107 demote).
- if (snapDiag && currentPos.Z > terrainZ)
+ if (snapDiag
+ && currentPos.Z > terrainZ)
{
- Console.WriteLine(System.FormattableString.Invariant(
+ if (DiagnosticLog is { } outdoorLog)
+ {
+ outdoorLog(System.FormattableString.Invariant(
$"[snap] OUTDOOR claim 0x{cellId:X8} z={currentPos.Z:F3} above terrain {terrainZ:F3} — committing the server Z (retail SetPositionInternal shape; physics settles on tick 1)"));
+ }
targetZ = currentPos.Z;
}
}
@@ -1028,8 +1041,8 @@ public sealed class PhysicsEngine
// Step-height enforcement: block upward movement that exceeds the limit.
float zDelta = targetZ - currentPos.Z;
- if (snapDiag)
- Console.WriteLine(System.FormattableString.Invariant(
+ if (snapDiag && DiagnosticLog is { } resultLog)
+ resultLog(System.FormattableString.Invariant(
$"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cells={physics.Cells.Count} bestCell=0x{(bestCell?.CellId ?? 0u):X8} bestZ={(bestCellZ?.ToString("F3") ?? "none")} terrainZ={terrainZ:F3} indoor={currentlyIndoor} -> targetZ={targetZ:F3} targetCell=0x{(lbPrefix | (targetCellId & 0xFFFFu)):X8} stepReject={zDelta > stepUpHeight}"));
if (zDelta > stepUpHeight)
{
diff --git a/src/AcDream.Headless/Configuration/HeadlessCommandLine.cs b/src/AcDream.Headless/Configuration/HeadlessCommandLine.cs
index ad60c815..38009512 100644
--- a/src/AcDream.Headless/Configuration/HeadlessCommandLine.cs
+++ b/src/AcDream.Headless/Configuration/HeadlessCommandLine.cs
@@ -3,7 +3,8 @@ namespace AcDream.Headless.Configuration;
internal sealed record HeadlessCommandLine(
string Command,
string ConfigurationPath,
- HeadlessPathOverrides Paths)
+ HeadlessPathOverrides Paths,
+ HeadlessDirectCredentials? DirectCredentials)
{
internal static HeadlessCommandLine Parse(
IReadOnlyList arguments)
@@ -20,6 +21,8 @@ internal sealed record HeadlessCommandLine(
string? configDirectory = null;
string? dataDirectory = null;
string? cacheDirectory = null;
+ string? user = null;
+ string? password = null;
for (int index = 1; index < arguments.Count; index += 2)
{
if (index + 1 >= arguments.Count)
@@ -50,6 +53,14 @@ internal sealed record HeadlessCommandLine(
case "--cache-dir":
SetOnce(ref cacheDirectory, value);
break;
+ case "-user":
+ case "--user":
+ SetOnce(ref user, value);
+ break;
+ case "-password":
+ case "--password":
+ SetOnce(ref password, value);
+ break;
default:
throw new HeadlessCommandLineException(
"Unknown command option.");
@@ -61,6 +72,16 @@ internal sealed record HeadlessCommandLine(
throw new HeadlessCommandLineException(
"The --config option is required.");
}
+ if ((user is null) != (password is null))
+ {
+ throw new HeadlessCommandLineException(
+ "The user and password options must be supplied together.");
+ }
+ if (user is not null && command != "run")
+ {
+ throw new HeadlessCommandLineException(
+ "Direct credentials are valid only for run mode.");
+ }
return new HeadlessCommandLine(
command,
@@ -68,7 +89,10 @@ internal sealed record HeadlessCommandLine(
new HeadlessPathOverrides(
configDirectory,
dataDirectory,
- cacheDirectory));
+ cacheDirectory),
+ user is null
+ ? null
+ : new HeadlessDirectCredentials(user, password!));
}
private static void SetOnce(ref string? destination, string value)
@@ -81,3 +105,7 @@ internal sealed record HeadlessCommandLine(
destination = value;
}
}
+
+internal sealed record HeadlessDirectCredentials(
+ string User,
+ string Password);
diff --git a/src/AcDream.Headless/HeadlessEntryPoint.cs b/src/AcDream.Headless/HeadlessEntryPoint.cs
index 590b09aa..e5576e54 100644
--- a/src/AcDream.Headless/HeadlessEntryPoint.cs
+++ b/src/AcDream.Headless/HeadlessEntryPoint.cs
@@ -14,7 +14,7 @@ internal static class HeadlessEntryPoint
Usage:
acdream-headless --help
acdream-headless validate --config [path overrides]
- acdream-headless run --config [path overrides]
+ acdream-headless run --config [path overrides] [credentials]
Commands:
validate Validate a versioned headless configuration without connecting.
@@ -25,7 +25,9 @@ internal static class HeadlessEntryPoint
--data-dir
--cache-dir
- Secrets are never accepted on the command line.
+ Direct single-session credentials:
+ -user -password
+ --user --password
""";
internal static int Run(
@@ -76,7 +78,9 @@ internal static class HeadlessEntryPoint
configuration,
paths,
standardInput,
- output);
+ output,
+ directCredentials:
+ commandLine.DirectCredentials);
return (int)host.RunAsync(cancellationToken)
.GetAwaiter()
.GetResult();
diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
index 7892b97a..db943b7f 100644
--- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
@@ -23,7 +23,8 @@ internal sealed class HeadlessProcessHost : IDisposable
TextWriter diagnostics,
ILiveSessionOperations? sessionOperations = null,
TimeProvider? timeProvider = null,
- IHeadlessProcessContentFactory? contentFactory = null)
+ IHeadlessProcessContentFactory? contentFactory = null,
+ HeadlessDirectCredentials? directCredentials = null)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(paths);
@@ -34,6 +35,12 @@ internal sealed class HeadlessProcessHost : IDisposable
throw new HeadlessConfigurationException(
"Headless run mode requires at least one session.");
}
+ if (directCredentials is not null
+ && configuration.Sessions.Count != 1)
+ {
+ throw new HeadlessConfigurationException(
+ "Direct credentials require exactly one configured session.");
+ }
HeadlessStaticStateAudit.ValidateProcessIsolation();
_diagnostics = new HeadlessDiagnosticWriter(diagnostics);
@@ -61,10 +68,19 @@ internal sealed class HeadlessProcessHost : IDisposable
HeadlessSessionDescriptor descriptor = candidate
?? throw new HeadlessConfigurationException(
"Headless run mode cannot contain a null session.");
- HeadlessCredentialSecret secret =
- credentials.Resolve(
+ if (directCredentials is not null)
+ {
+ descriptor = WithAccount(
+ descriptor,
+ directCredentials.User);
+ }
+ HeadlessCredentialSecret secret = directCredentials is null
+ ? credentials.Resolve(
descriptor.Id,
- descriptor.Credential);
+ descriptor.Credential)
+ : new HeadlessCredentialSecret(
+ "command-line",
+ directCredentials.Password);
HeadlessProcessContentOwner.HeadlessProcessContentLease?
contentLease = content?.AcquireLease(descriptor.Id);
try
@@ -111,6 +127,19 @@ internal sealed class HeadlessProcessHost : IDisposable
internal HeadlessProcessContentSnapshot? Content =>
_content?.CaptureSnapshot();
+ private static HeadlessSessionDescriptor WithAccount(
+ HeadlessSessionDescriptor source,
+ string account) =>
+ new()
+ {
+ Id = source.Id,
+ Endpoint = source.Endpoint,
+ Account = account,
+ Character = source.Character,
+ Policy = source.Policy,
+ Credential = source.Credential,
+ };
+
internal async Task RunAsync(
CancellationToken cancellationToken)
{
diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs
index 4f4f8f3c..373a4d3d 100644
--- a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs
@@ -293,10 +293,20 @@ internal sealed class HeadlessSessionWorldProjection
public void ProjectPosition(
RuntimeEntityRecord record,
- bool isLocalPlayer)
+ bool isLocalPlayer,
+ PositionTimestampDisposition disposition)
{
- if (isLocalPlayer)
+ if (!isLocalPlayer)
+ return;
+
+ if (_runtime.MovementOwner.Controller is null)
+ {
SynchronizeLocalPlayer(record);
+ return;
+ }
+
+ if (disposition is PositionTimestampDisposition.ForcePosition)
+ BlipLocalPlayer(record);
}
public void BeginTeleport()
@@ -383,6 +393,28 @@ internal sealed class HeadlessSessionWorldProjection
controller.SetBodyOrientation(orientation);
}
+ private void BlipLocalPlayer(RuntimeEntityRecord record)
+ {
+ if (record.ServerGuid
+ != _runtime.PlayerIdentity.ServerGuid
+ || record.Snapshot.Position is not { } position
+ || _runtime.MovementOwner.Controller is not { } controller)
+ {
+ return;
+ }
+
+ _collision.CenterOn(position.LandblockId);
+ Vector3 wirePosition = new(
+ position.PositionX,
+ position.PositionY,
+ position.PositionZ);
+ controller.LocalEntityId = record.LocalEntityId ?? 0u;
+ controller.BlipPosition(
+ wirePosition,
+ position.LandblockId,
+ wirePosition);
+ }
+
private PlayerMovementController CreateController(
RuntimeEntityRecord record)
{
diff --git a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs
index 2ac23bf8..a38ae1ad 100644
--- a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs
+++ b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs
@@ -1,5 +1,6 @@
using AcDream.Headless.Configuration;
using AcDream.Runtime;
+using AcDream.Runtime.Gameplay;
namespace AcDream.Headless.Policies;
@@ -17,6 +18,8 @@ internal static class HeadlessBotPolicyFactory
{
"idle" => new IdleHeadlessBotPolicy(),
"lifecycle-smoke" => new LifecycleSmokeHeadlessBotPolicy(),
+ "observer-movement" => new ObserverMovementHeadlessBotPolicy(),
+ "portal-route-smoke" => new PortalRouteSmokeHeadlessBotPolicy(),
_ => throw new HeadlessConfigurationException(
$"Unknown headless bot policy '{id}'."),
};
@@ -189,3 +192,384 @@ internal sealed class LifecycleSmokeHeadlessBotPolicy
}
}
}
+
+///
+/// Long-lived connected observer gate. It materializes at the canonical
+/// Rynthid checkpoint, announces itself, and alternates run/turn intents
+/// through the same typed Runtime command API used by graphical hosts. The
+/// process remains connected until externally cancelled so a second client
+/// has an unambiguous observation window.
+///
+internal sealed class ObserverMovementHeadlessBotPolicy
+ : IHeadlessBotPolicy
+{
+ private const uint RynthidCell = 0xF6820033u;
+ private int _stage;
+ private long _issuedAfterPortalGeneration;
+ private double _movementDeadline;
+ private bool _turning;
+
+ public bool IsComplete => false;
+
+ public void Tick(
+ IGameRuntimeView view,
+ IGameRuntimeCommands commands)
+ {
+ ArgumentNullException.ThrowIfNull(view);
+ ArgumentNullException.ThrowIfNull(commands);
+
+ if (!HasLocalPlayer(view))
+ return;
+
+ if (_stage == 0)
+ {
+ _issuedAfterPortalGeneration =
+ view.Portal.Snapshot.Generation;
+ Require(commands.Chat.Execute(
+ view.Generation,
+ new RuntimeChatCommand(
+ RuntimeChatChannel.Say,
+ "@teleloc 0xF6820033 145.7 49.855 58.010 1 0 0 0")));
+ _stage = 1;
+ return;
+ }
+
+ if (_stage == 1)
+ {
+ RuntimePortalSnapshot portal = view.Portal.Snapshot;
+ if (portal.Generation <= _issuedAfterPortalGeneration
+ || portal.Kind is not RuntimePortalKind.Portal
+ || !portal.Completed)
+ {
+ return;
+ }
+ if (portal.DestinationCell != RynthidCell)
+ {
+ throw new InvalidOperationException(
+ $"Observer gate completed at "
+ + $"0x{portal.DestinationCell:X8}; expected "
+ + $"0x{RynthidCell:X8}.");
+ }
+
+ Require(commands.Chat.Execute(
+ view.Generation,
+ new RuntimeChatCommand(
+ RuntimeChatChannel.Say,
+ "[acdream headless observer active]")));
+ StartMovement(view, commands);
+ _stage = 2;
+ return;
+ }
+
+ if (view.Clock.SimulationTimeSeconds
+ < _movementDeadline)
+ {
+ return;
+ }
+
+ StartMovement(view, commands);
+ }
+
+ public void OnLifecycle(in RuntimeLifecycleDelta delta)
+ {
+ }
+
+ public void OnCommand(in RuntimeCommandDelta delta)
+ {
+ }
+
+ public void OnEntity(in RuntimeEntityDelta delta)
+ {
+ }
+
+ public void OnInventory(in RuntimeInventoryDelta delta)
+ {
+ }
+
+ public void OnChat(in RuntimeChatDelta delta)
+ {
+ }
+
+ public void OnMovement(in RuntimeMovementDelta delta)
+ {
+ }
+
+ public void OnPortal(in RuntimePortalDelta delta)
+ {
+ }
+
+ public void OnCombat(in RuntimeCombatDelta delta)
+ {
+ }
+
+ public void Dispose()
+ {
+ }
+
+ private void StartMovement(
+ IGameRuntimeView view,
+ IGameRuntimeCommands commands)
+ {
+ _turning = !_turning;
+ MovementInput input = _turning
+ ? new MovementInput(TurnRight: true)
+ : new MovementInput(Forward: true, Run: true);
+ Require(commands.Movement.SetIntent(
+ view.Generation,
+ input));
+ _movementDeadline =
+ view.Clock.SimulationTimeSeconds
+ + (_turning ? 1.25 : 4.0);
+ }
+
+ private static bool HasLocalPlayer(
+ IGameRuntimeView view) =>
+ view.Lifecycle.State
+ is RuntimeLifecycleState.InWorld
+ && view.Lifecycle.PlayerGuid != 0u
+ && view.Entities.TryGet(
+ view.Lifecycle.PlayerGuid,
+ out _);
+
+ private static void Require(
+ in RuntimeCommandResult result)
+ {
+ if (!result.Accepted)
+ {
+ throw new InvalidOperationException(
+ $"Observer gate command was rejected with "
+ + $"{result.Status}.");
+ }
+ }
+}
+
+///
+/// Explicit connected portal-route gate. It visits the same named locations
+/// used by the canonical graphical soak, then performs a typed lifestone
+/// recall. A later command is never issued until the preceding portal has
+/// produced a new completed Runtime portal generation.
+///
+internal sealed class PortalRouteSmokeHeadlessBotPolicy
+ : IHeadlessBotPolicy
+{
+ private const uint AerlintheCell = 0x09040008u;
+ private const uint CaulCell = 0xC95B0001u;
+ private const uint RynthidCell = 0xF6820033u;
+
+ private static readonly RouteStop[] Stops =
+ [
+ new(
+ "aerlinthe",
+ "@teleloc 0x09040008 11.4 188.6 87.705 1 0 0 0",
+ AerlintheCell),
+ new(
+ "caul",
+ "@teleloc 0xC95B0001 14.8 0.3 12.005 1 0 0 0",
+ CaulCell),
+ new(
+ "rynthid",
+ "@teleloc 0xF6820033 145.7 49.855 58.010 1 0 0 0",
+ RynthidCell),
+ new("lifestone", null, null),
+ ];
+
+ private int _nextStop;
+ private bool _awaitingPortal;
+ private long _issuedAfterPortalGeneration;
+ private RouteMovementStage _movementStage;
+ private double _movementDeadline;
+
+ public bool IsComplete =>
+ _nextStop == Stops.Length
+ && !_awaitingPortal
+ && _movementStage is RouteMovementStage.None;
+
+ public void Tick(
+ IGameRuntimeView view,
+ IGameRuntimeCommands commands)
+ {
+ ArgumentNullException.ThrowIfNull(view);
+ ArgumentNullException.ThrowIfNull(commands);
+
+ if (!HasLocalPlayer(view))
+ return;
+
+ if (_awaitingPortal)
+ {
+ RuntimePortalSnapshot portal = view.Portal.Snapshot;
+ if (portal.Generation <= _issuedAfterPortalGeneration
+ || portal.Kind is not RuntimePortalKind.Portal
+ || !portal.Completed)
+ {
+ return;
+ }
+
+ RouteStop completed = Stops[_nextStop];
+ if (completed.ExpectedCell is uint expectedCell
+ && portal.DestinationCell != expectedCell)
+ {
+ throw new InvalidOperationException(
+ $"Portal route '{completed.Name}' completed at "
+ + $"0x{portal.DestinationCell:X8}; expected "
+ + $"0x{expectedCell:X8}.");
+ }
+
+ _awaitingPortal = false;
+ StartMovement(
+ view,
+ commands,
+ RouteMovementStage.ForwardOne);
+ return;
+ }
+
+ if (_movementStage is not RouteMovementStage.None)
+ {
+ TickMovement(view, commands);
+ return;
+ }
+
+ if (_nextStop == Stops.Length)
+ return;
+
+ RouteStop stop = Stops[_nextStop];
+ _issuedAfterPortalGeneration = view.Portal.Snapshot.Generation;
+ RuntimeCommandResult result = stop.ServerCommand is string text
+ ? commands.Chat.Execute(
+ view.Generation,
+ new RuntimeChatCommand(RuntimeChatChannel.Say, text))
+ : commands.Portal.Execute(
+ view.Generation,
+ RuntimePortalCommand.RecallLifestone);
+ Require(result, stop.Name);
+ _awaitingPortal = true;
+ }
+
+ private void TickMovement(
+ IGameRuntimeView view,
+ IGameRuntimeCommands commands)
+ {
+ if (view.Clock.SimulationTimeSeconds < _movementDeadline)
+ return;
+
+ switch (_movementStage)
+ {
+ case RouteMovementStage.ForwardOne:
+ StartMovement(
+ view,
+ commands,
+ RouteMovementStage.TurnRight);
+ break;
+ case RouteMovementStage.TurnRight:
+ StartMovement(
+ view,
+ commands,
+ RouteMovementStage.ForwardTwo);
+ break;
+ case RouteMovementStage.ForwardTwo:
+ Require(
+ commands.Movement.ClearIntent(view.Generation),
+ Stops[_nextStop].Name);
+ _movementStage = RouteMovementStage.None;
+ _nextStop++;
+ break;
+ }
+ }
+
+ private void StartMovement(
+ IGameRuntimeView view,
+ IGameRuntimeCommands commands,
+ RouteMovementStage stage)
+ {
+ MovementInput input;
+ double durationSeconds;
+ switch (stage)
+ {
+ case RouteMovementStage.ForwardOne:
+ case RouteMovementStage.ForwardTwo:
+ input = new MovementInput(Forward: true, Run: true);
+ durationSeconds = 4.0;
+ break;
+ case RouteMovementStage.TurnRight:
+ input = new MovementInput(TurnRight: true);
+ durationSeconds = 2.0;
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(stage));
+ }
+
+ Require(
+ commands.Movement.SetIntent(view.Generation, input),
+ Stops[_nextStop].Name);
+ _movementStage = stage;
+ _movementDeadline =
+ view.Clock.SimulationTimeSeconds + durationSeconds;
+ }
+
+ public void OnLifecycle(in RuntimeLifecycleDelta delta)
+ {
+ }
+
+ public void OnCommand(in RuntimeCommandDelta delta)
+ {
+ }
+
+ public void OnEntity(in RuntimeEntityDelta delta)
+ {
+ }
+
+ public void OnInventory(in RuntimeInventoryDelta delta)
+ {
+ }
+
+ public void OnChat(in RuntimeChatDelta delta)
+ {
+ }
+
+ public void OnMovement(in RuntimeMovementDelta delta)
+ {
+ }
+
+ public void OnPortal(in RuntimePortalDelta delta)
+ {
+ }
+
+ public void OnCombat(in RuntimeCombatDelta delta)
+ {
+ }
+
+ public void Dispose()
+ {
+ }
+
+ private static bool HasLocalPlayer(IGameRuntimeView view) =>
+ view.Lifecycle.State is RuntimeLifecycleState.InWorld
+ && view.Lifecycle.PlayerGuid != 0u
+ && view.Entities.TryGet(
+ view.Lifecycle.PlayerGuid,
+ out _);
+
+ private static void Require(
+ in RuntimeCommandResult result,
+ string stop)
+ {
+ if (!result.Accepted)
+ {
+ throw new InvalidOperationException(
+ $"Portal route '{stop}' command was rejected with "
+ + $"{result.Status}.");
+ }
+ }
+
+ private readonly record struct RouteStop(
+ string Name,
+ string? ServerCommand,
+ uint? ExpectedCell);
+
+ private enum RouteMovementStage
+ {
+ None,
+ ForwardOne,
+ TurnRight,
+ ForwardTwo,
+ }
+}
diff --git a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs
index 8d0559b5..4fada9f0 100644
--- a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs
+++ b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs
@@ -3,6 +3,7 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
+using AcDream.Runtime.Gameplay;
using AcDream.Runtime.World;
namespace AcDream.Runtime.Session;
@@ -15,7 +16,8 @@ public interface IRuntimeDirectWorldProjection
void ProjectPosition(
RuntimeEntityRecord record,
- bool isLocalPlayer);
+ bool isLocalPlayer,
+ PositionTimestampDisposition disposition);
void BeginTeleport();
@@ -36,6 +38,8 @@ public sealed class RuntimeLiveEntitySessionController
private readonly WorldSession _session;
private readonly Action _log;
private readonly IRuntimeDirectWorldProjection? _worldProjection;
+ private readonly LocalPlayerOutboundController _localPlayerOutbound =
+ new((_, _, _, _, _, _) => { });
public RuntimeLiveEntitySessionController(
GameRuntime runtime,
@@ -135,11 +139,13 @@ public sealed class RuntimeLiveEntitySessionController
{
bool isLocal =
update.Guid == _runtime.PlayerIdentity.ServerGuid;
+ PlayerMovementController? localController =
+ isLocal ? _runtime.MovementOwner.Controller : null;
bool known = Entities.TryApplyPosition(
update,
isLocal,
- forcePositionRotation: null,
- currentLocalVelocity: null,
+ forcePositionRotation: localController?.BodyOrientation,
+ currentLocalVelocity: localController?.BodyVelocity,
projectionRequiresTeleportHook: false,
acknowledgeProjection: null,
out PositionTimestampDisposition disposition,
@@ -152,34 +158,44 @@ public sealed class RuntimeLiveEntitySessionController
return;
}
- var position = update.Position;
- var destination = new RuntimeTeleportDestination(
- update.Guid,
- update.InstanceSequence,
- update.PositionSequence,
- update.TeleportSequence,
- update.ForcePositionSequence,
- new Position(
- position.LandblockId,
- new Vector3(
- position.PositionX,
- position.PositionY,
- position.PositionZ),
- new Quaternion(
- position.RotationX,
- position.RotationY,
- position.RotationZ,
- position.RotationW)));
- _runtime.TransitOwner.OfferTeleportDestination(
- destination,
- timestamps.TeleportAdvanced);
+ if (disposition is PositionTimestampDisposition.Apply)
+ {
+ var position = update.Position;
+ var destination = new RuntimeTeleportDestination(
+ update.Guid,
+ update.InstanceSequence,
+ update.PositionSequence,
+ update.TeleportSequence,
+ update.ForcePositionSequence,
+ new Position(
+ position.LandblockId,
+ new Vector3(
+ position.PositionX,
+ position.PositionY,
+ position.PositionZ),
+ new Quaternion(
+ position.RotationX,
+ position.RotationY,
+ position.RotationZ,
+ position.RotationW)));
+ _runtime.TransitOwner.OfferTeleportDestination(
+ destination,
+ timestamps.TeleportAdvanced);
+ }
if (Entities.Entities.TryGetActive(
update.Guid,
out RuntimeEntityRecord record))
{
_worldProjection?.ProjectPosition(
record,
- isLocalPlayer: true);
+ isLocalPlayer: true,
+ disposition);
+ }
+ if (disposition is PositionTimestampDisposition.ForcePosition)
+ {
+ _localPlayerOutbound.SendImmediatePosition(
+ _session,
+ _runtime.MovementOwner.Controller);
}
TryCompletePortal();
}
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
index b61d4329..473650b3 100644
--- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
@@ -31,6 +31,33 @@ public class PhysicsEngineTests
return engine;
}
+ [Fact]
+ public void Resolve_ZeroDeltaSnapTrace_IsExplicitlyOptIn()
+ {
+ var engine = new PhysicsEngine();
+ var diagnostics = new List();
+
+ Assert.Null(engine.DiagnosticLog);
+ _ = engine.Resolve(
+ Vector3.Zero,
+ cellId: 0xA9B40001u,
+ delta: Vector3.Zero,
+ stepUpHeight: 0.5f);
+
+ engine.DiagnosticLog = diagnostics.Add;
+ _ = engine.Resolve(
+ Vector3.Zero,
+ cellId: 0xA9B40001u,
+ delta: Vector3.Zero,
+ stepUpHeight: 0.5f);
+
+ Assert.Single(diagnostics);
+ Assert.StartsWith(
+ "[snap] claim=0xA9B40001",
+ diagnostics[0],
+ StringComparison.Ordinal);
+ }
+
[Fact]
public void Resolve_FlatTerrain_ZMatchesTerrain()
{
diff --git a/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs b/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs
index 7667e25c..02ffca6e 100644
--- a/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs
+++ b/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs
@@ -1,3 +1,5 @@
+using AcDream.Headless.Configuration;
+
namespace AcDream.Headless.Tests;
public sealed class HeadlessEntryPointTests
@@ -17,10 +19,48 @@ public sealed class HeadlessEntryPointTests
Assert.Equal(0, exitCode);
Assert.Contains("acdream-headless", output.ToString());
- Assert.Contains("Secrets are never accepted", output.ToString());
+ Assert.Contains("--password", output.ToString());
Assert.Equal(string.Empty, error.ToString());
}
+ [Fact]
+ public void RunCommandAcceptsDirectCredentialAliases()
+ {
+ HeadlessCommandLine command = HeadlessCommandLine.Parse(
+ [
+ "run",
+ "--config",
+ "bot.json",
+ "-user",
+ "testaccount",
+ "--password",
+ "fixture-password",
+ ]);
+
+ Assert.Equal("testaccount", command.DirectCredentials?.User);
+ Assert.Equal(
+ "fixture-password",
+ command.DirectCredentials?.Password);
+ }
+
+ [Theory]
+ [InlineData("-user", "testaccount")]
+ [InlineData("--password", "fixture-password")]
+ public void DirectCredentialPairMustBeComplete(
+ string option,
+ string value)
+ {
+ Assert.Throws(
+ () => HeadlessCommandLine.Parse(
+ [
+ "run",
+ "--config",
+ "bot.json",
+ option,
+ value,
+ ]));
+ }
+
[Fact]
public void ValidateAcceptsStrictEmptyNoConnectConfiguration()
{
diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
index e64812be..32823794 100644
--- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
+++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
@@ -95,6 +95,41 @@ public sealed class HeadlessSessionHostTests
diagnostics.ToString());
}
+ [Fact]
+ public async Task DirectCredentialsOverrideSingleConfiguredSession()
+ {
+ var configuration = new HeadlessConfiguration
+ {
+ Version = 1,
+ Sessions = [Descriptor()],
+ };
+ HeadlessPathSet paths = HeadlessPathSet.Resolve(
+ new HeadlessPathOverrides());
+ using var diagnostics = new StringWriter();
+ var operations = new FixtureSessionOperations();
+ using var host = new HeadlessProcessHost(
+ configuration,
+ paths,
+ TextReader.Null,
+ diagnostics,
+ operations,
+ directCredentials: new HeadlessDirectCredentials(
+ "direct-account",
+ "direct-secret"));
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+
+ HeadlessExitCode result =
+ await host.RunAsync(cancellation.Token);
+
+ Assert.Equal(HeadlessExitCode.Success, result);
+ Assert.Equal("direct-account", operations.LastUser);
+ Assert.Equal("direct-secret", operations.LastPassword);
+ Assert.DoesNotContain(
+ "direct-secret",
+ diagnostics.ToString());
+ }
+
[Fact]
public void DirectFrameUsesSharedRetailOrderAndMovementCadence()
{
@@ -220,10 +255,17 @@ public sealed class HeadlessSessionHostTests
PlayerMovementController controller =
Assert.IsType(
runtime.MovementOwner.Controller);
- projection.ProjectPosition(record, isLocalPlayer: true);
+ controller.SetPosition(
+ new Vector3(48f, 49f, 50f),
+ 0xA9B40001u);
+ projection.ProjectPosition(
+ record,
+ isLocalPlayer: true,
+ PositionTimestampDisposition.Apply);
Assert.Same(controller, runtime.MovementOwner.Controller);
Assert.Equal(record.LocalEntityId, controller.LocalEntityId);
+ Assert.Equal(new Vector3(48f, 49f, 50f), controller.Position);
Assert.Equal(
0xA9B40000u,
controller.CellId & 0xFFFF0000u);
@@ -248,10 +290,89 @@ public sealed class HeadlessSessionHostTests
Assert.True(readiness.IsCollisionReady);
Assert.False(readiness.IsUnhydratable);
Assert.Equal(PlayerState.InWorld, controller.State);
- Assert.Equal(4, collision.CenterCount);
+ Assert.Equal(3, collision.CenterCount);
Assert.Equal(0xA9B40001u, collision.LastCell);
}
+ [Fact]
+ public void WorldProjectionIgnoresNormalEchoButBlipsForcePosition()
+ {
+ var operations = new FixtureSessionOperations();
+ using var credential = new HeadlessCredentialSecret(
+ "fixture",
+ "password");
+ using var host = new HeadlessSessionHost(
+ Descriptor(),
+ credential,
+ new HeadlessDiagnosticWriter(TextWriter.Null),
+ operations);
+ GameRuntime runtime = host.Runtime;
+ const uint player = 0x50000003u;
+ runtime.PlayerIdentity.ServerGuid = player;
+ AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
+ RuntimeEntityRecord record = runtime.EntityObjects
+ .RegisterEntity(Spawn(player))
+ .Canonical!;
+ Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
+ record,
+ record.CreateIntegrationVersion,
+ record.Snapshot,
+ replaceGeneration: false));
+ var collision = new FixtureCollisionNeighborhood();
+ var projection = new HeadlessSessionWorldProjection(
+ runtime,
+ collision);
+ projection.ProjectSpawn(record, isLocalPlayer: true);
+ PlayerMovementController controller =
+ Assert.IsType(
+ runtime.MovementOwner.Controller);
+
+ controller.SetPosition(
+ new Vector3(48f, 49f, 50f),
+ 0xA9B40001u);
+ projection.ProjectPosition(
+ record,
+ isLocalPlayer: true,
+ PositionTimestampDisposition.Apply);
+
+ Assert.Equal(new Vector3(48f, 49f, 50f), controller.Position);
+
+ var force = new WorldSession.EntityPositionUpdate(
+ player,
+ record.Snapshot.Position!.Value with
+ {
+ PositionX = 72f,
+ PositionY = 73f,
+ },
+ Velocity: null,
+ PlacementId: null,
+ IsGrounded: true,
+ InstanceSequence: 1,
+ PositionSequence: 2,
+ TeleportSequence: 0,
+ ForcePositionSequence: 1);
+ Assert.True(runtime.EntityObjects.TryApplyPosition(
+ force,
+ isLocalPlayer: true,
+ forcePositionRotation: Quaternion.Identity,
+ currentLocalVelocity: controller.BodyVelocity,
+ projectionRequiresTeleportHook: false,
+ acknowledgeProjection: null,
+ out PositionTimestampDisposition disposition,
+ out _,
+ out _));
+ Assert.Equal(
+ PositionTimestampDisposition.ForcePosition,
+ disposition);
+ projection.ProjectPosition(
+ record,
+ isLocalPlayer: true,
+ disposition);
+
+ Assert.Equal(new Vector3(72f, 73f, 50f), controller.Position);
+ Assert.Equal(2, collision.CenterCount);
+ }
+
private static HeadlessSessionDescriptor Descriptor(
HeadlessCredentialProviderKind provider =
HeadlessCredentialProviderKind.Environment,
@@ -389,6 +510,8 @@ public sealed class HeadlessSessionHostTests
public List Sessions { get; } = [];
public int CreatedSessionCount { get; private set; }
public int DisposedSessionCount { get; private set; }
+ public string? LastUser { get; private set; }
+ public string? LastPassword { get; private set; }
public IPEndPoint ResolveEndpoint(string host, int port) =>
new(IPAddress.Loopback, port);
@@ -406,6 +529,8 @@ public sealed class HeadlessSessionHostTests
string user,
string password)
{
+ LastUser = user;
+ LastPassword = password;
}
public CharacterList.Parsed GetCharacters(
diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs
index 12286bca..524bdb92 100644
--- a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs
+++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs
@@ -145,6 +145,9 @@ public sealed class RuntimeLiveEntitySessionControllerTests
Assert.Equal(1, projection.PrepareCount);
Assert.True(projection.LastSpawnWasLocal);
Assert.True(projection.LastPositionWasLocal);
+ Assert.Equal(
+ PositionTimestampDisposition.Apply,
+ projection.LastPositionDisposition);
Assert.Equal(playerGuid, projection.LastRecord?.ServerGuid);
Assert.Equal(0x01020001u, projection.LastDestination.CellId);
Assert.True(runtime.TransitOwner.CaptureOwnership().IsSessionIdle);
@@ -269,6 +272,11 @@ public sealed class RuntimeLiveEntitySessionControllerTests
public int PrepareCount { get; private set; }
public bool LastSpawnWasLocal { get; private set; }
public bool LastPositionWasLocal { get; private set; }
+ public PositionTimestampDisposition LastPositionDisposition
+ {
+ get;
+ private set;
+ }
public RuntimeEntityRecord? LastRecord { get; private set; }
public RuntimeTeleportDestination LastDestination { get; private set; }
@@ -283,11 +291,13 @@ public sealed class RuntimeLiveEntitySessionControllerTests
public void ProjectPosition(
RuntimeEntityRecord record,
- bool isLocalPlayer)
+ bool isLocalPlayer,
+ PositionTimestampDisposition disposition)
{
PositionCount++;
LastRecord = record;
LastPositionWasLocal = isLocalPlayer;
+ LastPositionDisposition = disposition;
}
public void BeginTeleport() => TeleportStartCount++;