acdream/src/AcDream.App/Diagnostics/RuntimeDiagnosticCommandController.cs
Erik 6a15dd063c
All checks were successful
CI / linux-portable (push) Successful in 3m13s
CI / windows-gate (push) Successful in 5m15s
CI / release (push) Successful in 2m2s
fix: retail text and golden-string tests must not follow the machine's locale
Run 170's Windows gate went red on 37 tests across four assemblies while the
same commit passed 14,370/0 locally. The failures were all one family:

  Expected: "You have 1 500p"     <- built with the machine's culture
  Actual:   "You have 1,500p"     <- production, correctly invariant

The runner is Swedish; this dev box is not. These tests had been passing on CI
only because that machine's registry locale had been pinned by hand — machine
state, which came undone (almost certainly the reboot after today's hang).
Re-pinning it would be a workaround on one machine for a defect in the repo,
so this fixes the repo instead.

Two genuinely different bugs were hiding in that one symptom.

1. TESTS that build an expected string with the ambient culture and compare it
   to invariant production output, and test-side recording sinks whose traces
   are compared against literal golden strings. Those only ever passed on a
   machine that happens to format like the invariant culture. Pinned to
   InvariantCulture: the vendor purse/cost expectations, and the motion-funnel,
   animation-sequencer, framebuffer-resize, resource-slot, and runtime-attack
   trace sinks.

2. PRODUCTION that formats player-visible retail text with the ambient culture.
   This one matters beyond CI: retail is a US client, so it shows "2.50",
   "1,500p" and "(-20)" to everyone. On a Swedish machine acdream was showing
   "2,50", "1 500p" and "(-20)" with U+2212 MINUS SIGN — the audience for this
   alpha is literally Swedish. Converted 76 sites to InvariantCulture across the
   item/creature appraisal formatters, the character stat panel's buff and vitae
   parentheticals, the appraisal and link-status controllers, the chat
   /framerate and /location output, the camera sensitivity toast, the
   time-override toast, the F3 dump, the sky diagnostics, and the world-frame
   invariant-failure message.

   DATES are deliberately left on the current culture (CharacterController's
   birth/login stamp, RuntimeHouseState's purchase expiry). Retail has no answer
   for a non-US player's date format, and forcing "08/19/2026 7:00:00 PM" on
   them is a UX decision, not a retail-fidelity one.

Apparatus, so the next occurrence is reproducible instead of mysterious:
tests/TestCultureInitializer.cs adds an opt-in ACDREAM_TEST_CULTURE knob to
every test assembly, linked in through a new tests/Directory.Build.props.
Unset — what CI and everyone runs — it changes nothing.

  ACDREAM_TEST_CULTURE=sv-SE dotnet test ...

reproduced all 37 CI failures on this machine plus 6 more the runner's own
locale does not surface (the Unicode-minus family), and drove the fix.

Verified both ways on the full solution under the release-gate filter:
default culture 14,370 passed / 0 failed, and ACDREAM_TEST_CULTURE=sv-SE
14,370 passed / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:56:09 +02:00

380 lines
12 KiB
C#

using System.Globalization;
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.World;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Diagnostics;
internal interface IRuntimeDiagnosticCommands
{
bool Handle(InputAction action);
void CycleTimeOfDay();
void CycleWeather();
void ToggleCollisionWireframes();
}
/// <summary>
/// Focused deferred edge used by the early-created developer panel and the
/// later-created gameplay router. It owns no diagnostic state and binds one
/// window-lifetime command owner exactly once.
/// </summary>
internal sealed class RuntimeDiagnosticCommandSlot : IRuntimeDiagnosticCommands
{
private readonly object _gate = new();
private IRuntimeDiagnosticCommands? _target;
private bool _deactivated;
public void Bind(IRuntimeDiagnosticCommands target)
{
ArgumentNullException.ThrowIfNull(target);
lock (_gate)
{
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_target is not null && !ReferenceEquals(_target, target))
{
throw new InvalidOperationException(
"Runtime diagnostic commands are already bound.");
}
_target = target;
}
}
public void Unbind(IRuntimeDiagnosticCommands target)
{
ArgumentNullException.ThrowIfNull(target);
lock (_gate)
{
if (ReferenceEquals(_target, target))
_target = null;
}
}
public IDisposable BindOwned(IRuntimeDiagnosticCommands target)
{
ArgumentNullException.ThrowIfNull(target);
lock (_gate)
{
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_target is not null)
{
throw new InvalidOperationException(
"Runtime diagnostic commands are already bound.");
}
_target = target;
}
return new Binding(this, target);
}
public void Deactivate()
{
lock (_gate)
{
_deactivated = true;
_target = null;
}
}
public bool Handle(InputAction action)
{
lock (_gate)
{
if (!_deactivated && _target is { } target)
return target.Handle(action);
}
return RuntimeDiagnosticCommandController.IsDiagnosticAction(action);
}
public void CycleTimeOfDay()
{
lock (_gate)
{
if (!_deactivated)
_target?.CycleTimeOfDay();
}
}
public void CycleWeather()
{
lock (_gate)
{
if (!_deactivated)
_target?.CycleWeather();
}
}
public void ToggleCollisionWireframes()
{
lock (_gate)
{
if (!_deactivated)
_target?.ToggleCollisionWireframes();
}
}
private sealed class Binding : IDisposable
{
private RuntimeDiagnosticCommandSlot? _owner;
private readonly IRuntimeDiagnosticCommands _expected;
public Binding(
RuntimeDiagnosticCommandSlot owner,
IRuntimeDiagnosticCommands expected)
{
_owner = owner;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
}
}
internal interface INearbyWorldDiagnosticSource
{
Vector3 ObserverPosition { get; }
int LiveCenterX { get; }
int LiveCenterY { get; }
int TotalShadowObjects { get; }
IReadOnlyList<WorldEntity> WorldEntities { get; }
IEnumerable<ShadowEntry> ShadowEntries { get; }
}
/// <summary>
/// Read-only view over canonical runtime owners. It does not cache the current
/// player, camera, entity list, origin, or shadow registry.
/// </summary>
internal sealed class RuntimeNearbyWorldDiagnosticSource : INearbyWorldDiagnosticSource
{
private readonly ILocalPlayerModeSource _mode;
private readonly IRuntimeLocalPlayerControllerSource _player;
private readonly CameraController _camera;
private readonly LiveWorldOriginState _origin;
private readonly GpuWorldState _world;
private readonly PhysicsEngine _physics;
public RuntimeNearbyWorldDiagnosticSource(
ILocalPlayerModeSource mode,
IRuntimeLocalPlayerControllerSource player,
CameraController camera,
LiveWorldOriginState origin,
GpuWorldState world,
PhysicsEngine physics)
{
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
_player = player ?? throw new ArgumentNullException(nameof(player));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
_world = world ?? throw new ArgumentNullException(nameof(world));
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
}
public Vector3 ObserverPosition
{
get
{
if (_mode.IsPlayerMode && _player.Controller is { } player)
return player.Position;
Matrix4x4.Invert(_camera.Active.View, out Matrix4x4 inverseView);
return new Vector3(
inverseView.M41,
inverseView.M42,
inverseView.M43);
}
}
public int LiveCenterX => _origin.CenterX;
public int LiveCenterY => _origin.CenterY;
public int TotalShadowObjects => _physics.ShadowObjects.TotalRegistered;
public IReadOnlyList<WorldEntity> WorldEntities => _world.Entities;
public IEnumerable<ShadowEntry> ShadowEntries =>
_physics.ShadowObjects.AllEntriesForDebug();
}
internal sealed class NearbyWorldDiagnosticDumper
{
private const float NearbyDistance = 15f;
private readonly INearbyWorldDiagnosticSource _source;
private readonly Action<string> _log;
public NearbyWorldDiagnosticDumper(
INearbyWorldDiagnosticSource source,
Action<string>? log = null)
{
_source = source ?? throw new ArgumentNullException(nameof(source));
_log = log ?? (_ => { });
}
public void Dump()
{
Vector3 position = _source.ObserverPosition;
int centerX = _source.LiveCenterX;
int centerY = _source.LiveCenterY;
int landblockX = centerX + (int)MathF.Floor(position.X / 192f);
int landblockY = centerY + (int)MathF.Floor(position.Y / 192f);
// Invariant: a diagnostic dump is pasted into issues and compared
// against other machines' dumps, so its numbers must not change shape
// with the reporter's locale.
_log(string.Create(
CultureInfo.InvariantCulture,
$"=== F3 DEBUG DUMP ===\n" +
$" player pos=({position.X:F2},{position.Y:F2},{position.Z:F2})\n" +
$" landblock=0x{(uint)((landblockX << 24) | (landblockY << 16) | 0xFFFF):X8} " +
$"local=({position.X - (landblockX - centerX) * 192f:F2}," +
$"{position.Y - (landblockY - centerY) * 192f:F2})\n" +
$" total shadow objects: {_source.TotalShadowObjects}"));
List<WorldEntity> visibleNearby = _source.WorldEntities
.Where(entity => HorizontalDistanceSquared(entity.Position, position)
< NearbyDistance * NearbyDistance)
.OrderBy(entity => (entity.Position - position).Length())
.ToList();
_log($" VISIBLE entities within 15m: {visibleNearby.Count}");
foreach (WorldEntity entity in visibleNearby.Take(12))
{
float distance = (entity.Position - position).Length();
_log(
$" VIS id=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} " +
$"pos=({entity.Position.X:F2},{entity.Position.Y:F2},{entity.Position.Z:F2}) " +
$"dist={distance:F2} scale={entity.Scale:F2}");
}
List<(ShadowEntry Entry, float Distance)> shadows = _source.ShadowEntries
.Select(entry =>
{
float dx = entry.Position.X - position.X;
float dy = entry.Position.Y - position.Y;
return (Entry: entry, Distance: MathF.Sqrt(dx * dx + dy * dy));
})
.Where(item => item.Distance < NearbyDistance)
.OrderBy(item => item.Distance)
.ToList();
_log($" SHADOW objects within 15m: {shadows.Count}");
foreach ((ShadowEntry entry, float distance) in shadows.Take(12))
{
_log(
$" SHAD id=0x{entry.EntityId:X8} {entry.CollisionType} " +
$"r={entry.Radius:F2} h={entry.CylHeight:F2} " +
$"pos=({entry.Position.X:F2},{entry.Position.Y:F2},{entry.Position.Z:F2}) " +
$"dist={distance:F2}");
}
}
private static float HorizontalDistanceSquared(Vector3 left, Vector3 right)
{
float dx = left.X - right.X;
float dy = left.Y - right.Y;
return dx * dx + dy * dy;
}
}
/// <summary>
/// Routes developer-only semantic actions while leaving every mutable value in
/// its canonical owner. No diagnostic command owns a second clock, weather,
/// camera-sensitivity, collision, entity, or shadow state.
/// </summary>
internal sealed class RuntimeDiagnosticCommandController : IRuntimeDiagnosticCommands
{
private readonly WorldEnvironmentController _environment;
private readonly WorldSceneDebugState _sceneDebug;
private readonly IPointerSensitivityCommands? _pointer;
private readonly NearbyWorldDiagnosticDumper _nearby;
private readonly Action<string>? _toast;
public RuntimeDiagnosticCommandController(
WorldEnvironmentController environment,
WorldSceneDebugState sceneDebug,
IPointerSensitivityCommands? pointer,
NearbyWorldDiagnosticDumper nearby,
Action<string>? toast = null)
{
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
_sceneDebug = sceneDebug ?? throw new ArgumentNullException(nameof(sceneDebug));
_pointer = pointer;
_nearby = nearby ?? throw new ArgumentNullException(nameof(nearby));
_toast = toast;
}
public bool Handle(InputAction action)
{
switch (action)
{
case InputAction.AcdreamToggleCollisionWires:
ToggleCollisionWireframes();
return true;
case InputAction.AcdreamDumpNearby:
_nearby.Dump();
return true;
case InputAction.AcdreamCycleTimeOfDay:
CycleTimeOfDay();
return true;
case InputAction.AcdreamSensitivityDown:
if (_pointer is not null)
{
string message = _pointer.AdjustSensitivity(1f / 1.2f);
_toast?.Invoke(message);
}
return true;
case InputAction.AcdreamSensitivityUp:
if (_pointer is not null)
{
string message = _pointer.AdjustSensitivity(1.2f);
_toast?.Invoke(message);
}
return true;
case InputAction.AcdreamCycleWeather:
CycleWeather();
return true;
default:
return false;
}
}
public void CycleTimeOfDay()
{
string message = _environment.CycleTimeOfDay();
_toast?.Invoke(message);
}
public void CycleWeather()
{
string message = _environment.CycleWeather();
_toast?.Invoke(message);
}
public void ToggleCollisionWireframes()
{
bool visible = _sceneDebug.ToggleCollisionWireframes();
_toast?.Invoke($"Collision wireframes {(visible ? "ON" : "OFF")}");
}
internal static bool IsDiagnosticAction(InputAction action) => action is
InputAction.AcdreamToggleCollisionWires
or InputAction.AcdreamDumpNearby
or InputAction.AcdreamCycleTimeOfDay
or InputAction.AcdreamSensitivityDown
or InputAction.AcdreamSensitivityUp
or InputAction.AcdreamCycleWeather;
}