test(app): freeze GameWindow lifecycle boundaries

Pin the accepted startup, input, frame, resize, shutdown, and native-window order before Slice 8 moves those edges, while deleting only unread duplicate state and test-only GameWindow facades.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 09:27:55 +02:00
parent 20ebf217e2
commit 476c2e6de1
7 changed files with 380 additions and 56 deletions

View file

@ -14,7 +14,7 @@ audit.
## Progress ledger
- [ ] A — freeze construction/callback/shutdown order and delete proven dead or
- [x] A — freeze construction/callback/shutdown order and delete proven dead or
test-facade residue.
- [ ] B — make native-window callback intake an explicit reversible owner and
define host-quiescence failure semantics.
@ -401,6 +401,12 @@ fixes these audited gaps:
`_streamingRadius`, unused snap constants, `IsPlayerGuid`, `IsDoorName`, and
test-only forwarding helpers. Tests call canonical owners directly.
Result: the exact native attributes/callbacks, input subscription priority,
frame-root/session-start boundary, framebuffer behavior, shutdown stages, and
native-window-last edge are frozen. Dead duplicate state and two test-only
window facades are removed. Three corrected-diff reviews are clean; 56 focused
tests and the complete App suite (2,991 pass / 3 intentional skips) pass.
### B — native window binding and host quiescence
- Implement exact attach/reverse-detach/rollback and the no-throw quiescence

View file

@ -30,7 +30,6 @@ public sealed class GameWindow : IDisposable
/// <see cref="_terrain"/> at draw time but allocated + disposed here.</summary>
private Shader? _terrainModernShader;
private CameraController? _cameraController;
private IMouse? _capturedMouse;
private IDatReaderWriter? _dats;
private readonly AcDream.App.Input.PointerPositionState _pointerPosition = new();
private float _lastMouseX
@ -118,7 +117,6 @@ public sealed class GameWindow : IDisposable
_localPlayerTeleportSink = new();
private AcDream.App.Streaming.LocalPlayerTeleportController?
_localPlayerTeleport;
private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback)
private readonly AcDream.App.Rendering.WorldRenderRangeState _renderRange =
new(nearRadius: 4, farRadius: 12);
private int _nearRadius
@ -317,37 +315,6 @@ public sealed class GameWindow : IDisposable
private AcDream.App.World.LiveEntityRuntime? _liveEntities;
private AcDream.App.World.LiveEntityLivenessController? _liveEntityLiveness;
/// <summary>Soft-snap decay rate (1/sec). At this rate the residual
/// halves every 1/rate seconds. 8.0 → ~100ms half-life, so even a
/// 2m residual fades within ~300ms without visible snap.</summary>
private const float SnapResidualDecayRate = 8.0f;
/// <summary>
/// When the prediction error exceeds this many meters, we treat the
/// update as a teleport / rubber-band and hard-snap (no soft lerp).
/// Prevents the soft-snap logic from trying to smooth a genuine portal
/// or force-move event.
///
/// <para>
/// Matches retail's <c>GetAutonomyBlipDistance</c> (ACE
/// <c>PhysicsObj.cs:545</c>): 20m for creatures, 25m for players.
/// We use 20m as a conservative default — any delta larger than this
/// must be a teleport (portal, recall, spawn). A running character
/// with 1-second UpdatePosition cadence at 9.5 m/s produces deltas
/// of ~9.5m, well below this threshold, so normal movement flows
/// through the interpolation queue instead of hard-snapping.
/// </para>
/// </summary>
private const float SnapHardSnapThreshold = 20.0f;
/// <summary>
/// Soft-snap window in seconds: after an UpdatePosition arrives for a
/// remote entity, dead-reckoning continues but the "origin" for
/// predicted position is the server pos. This matches retail's snap
/// behavior — the server is authoritative, we just interpolate between
/// authoritative samples.
/// </summary>
private const float DeadReckonMaxPredictSeconds = 1.0f;
// Phase F.1-H.1 — client-side state classes fed by GameEventWiring.
// Exposed publicly so plugins + UI panels can bind directly.
public readonly AcDream.Core.Chat.ChatLog Chat = new();
@ -611,8 +578,6 @@ public sealed class GameWindow : IDisposable
// path. Populated for remotes plus the PlayerModeController local entry
// (player); pruned only by logical LiveEntityRuntime teardown.
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
private static bool IsDoorName(string? name) => name == "Door";
// ServerControlledVelocityStaleSeconds moved to RemotePhysicsUpdater (#184
// Slice 2a — the DR tick's stale-velocity anim-stop was its only user).
public GameWindow(
@ -2349,7 +2314,6 @@ public sealed class GameWindow : IDisposable
if (_options.LegacyStreamRadius is { } sr)
{
_nearRadius = sr;
_streamingRadius = sr; // keep debug overlay in sync
_farRadius = System.Math.Max(sr, _farRadius);
}
Console.WriteLine(
@ -3340,8 +3304,6 @@ public sealed class GameWindow : IDisposable
session.SendTurbineChatTo(
roomId, chatType, dispatchType, senderGuid, text, cookie),
Log: Console.WriteLine);
internal static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity) =>
entity.Id;
/// <summary>
/// Phase 5d — retail <c>AdminEnvirons</c> (0xEA60) dispatcher.
/// Routes fog presets into the weather system's sticky override
@ -3427,7 +3389,6 @@ public sealed class GameWindow : IDisposable
// held is handled separately by the gameplay input owner.
bool needsRawCursor = _cameraController?.IsFlyMode == true;
mouse.Cursor.CursorMode = needsRawCursor ? CursorMode.Raw : CursorMode.Normal;
_capturedMouse = needsRawCursor ? mouse : null;
}
// Performance overlay state — updated every ~0.5s and written to the
@ -4217,10 +4178,6 @@ public sealed class GameWindow : IDisposable
_debugVm?.AddToast(text);
}
internal static AcDream.Core.Physics.RawMotionState BuildOutboundRawMotionState(
AcDream.App.Input.MovementResult result) =>
AcDream.App.Input.LocalPlayerOutboundController.BuildRawMotionState(result);
/// <summary>
/// Item-target-mode world pick at the current cursor. The renderer supplies the
/// exact visible CPhysicsPart equivalents and RetailWorldPicker performs

View file

@ -1,6 +1,5 @@
using System.Buffers.Binary;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
using AcDream.Core.Physics;
@ -21,7 +20,7 @@ public sealed class LocalPlayerOutboundCombatStyleTests
MovementResult movement = controller.CaptureMovementResult(
mouseLookEvent: false);
RawMotionState outbound = GameWindow.BuildOutboundRawMotionState(
RawMotionState outbound = LocalPlayerOutboundController.BuildRawMotionState(
movement);
Assert.Equal(combatStyle, movement.CurrentStyle);
@ -43,7 +42,7 @@ public sealed class LocalPlayerOutboundCombatStyleTests
{
var controller = new PlayerMovementController(new PhysicsEngine());
RawMotionState outbound = GameWindow.BuildOutboundRawMotionState(
RawMotionState outbound = LocalPlayerOutboundController.BuildRawMotionState(
controller.CaptureMovementResult(mouseLookEvent: false));
Assert.Equal(RawMotionState.Default.CurrentStyle, outbound.CurrentStyle);

View file

@ -1,6 +1,5 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
@ -259,7 +258,7 @@ public sealed class PlayerMouseLookMovementTests
controller.BeginMouseLook(new MovementInput(Run: true));
MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false);
RawMotionState wire = GameWindow.BuildOutboundRawMotionState(press);
RawMotionState wire = LocalPlayerOutboundController.BuildRawMotionState(press);
Assert.Equal(HoldKey.Run, controller.Motion.RawState.CurrentHoldKey);
Assert.True(press.IsRunning);
@ -325,7 +324,7 @@ public sealed class PlayerMouseLookMovementTests
controller.BeginMouseLook(held);
MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false);
RawMotionState wire = GameWindow.BuildOutboundRawMotionState(press);
RawMotionState wire = LocalPlayerOutboundController.BuildRawMotionState(press);
Assert.Equal(MotionCommand.SideStepRight, press.SidestepCommand);
Assert.True(press.SidestepUsesRunHold);

View file

@ -0,0 +1,277 @@
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Temporary source-shape freeze for the Slice 8 cutover. Each later checkpoint
/// replaces the corresponding assertion with functional tests on the extracted
/// owner; this file prevents a mechanical move from silently reordering host
/// behavior before that owner exists.
/// </summary>
public sealed class GameWindowSlice8BoundaryTests
{
[Fact]
public void Run_PreservesNativeAttributeAndCallbackOrder()
{
string body = MethodBody("public void Run()", "private void OnLoad()");
AssertAppearsInOrder(
body,
"startupStore.LoadDisplay()",
"QualitySettings.WithEnvOverrides(startupBase)",
"_displayFramePacing.InitializeStartup(startupDisplay.VSync)",
"VSync = startupPacing.UseVSync",
"Samples = startupQuality.MsaaSamples",
"Window.Create(options)",
"_displayFramePacing.BindSurface(",
"_window.Load += OnLoad;",
"_window.Update += OnUpdate;",
"_window.Render += OnRender;",
"_window.Render += _displayFramePacing.OnFrameRendered;",
"_window.Closing += OnClosing;",
"_window.FocusChanged += OnFocusChanged;",
"_window.Move += _displayFramePacing.OnWindowMoved;",
"_window.StateChanged += _displayFramePacing.OnWindowStateChanged;",
"_window.FramebufferResize += OnFramebufferResize;",
"_window.Run();");
string[] registrations =
[
"_window.Load += OnLoad;",
"_window.Update += OnUpdate;",
"_window.Render += OnRender;",
"_window.Render += _displayFramePacing.OnFrameRendered;",
"_window.Closing += OnClosing;",
"_window.FocusChanged += OnFocusChanged;",
"_window.Move += _displayFramePacing.OnWindowMoved;",
"_window.StateChanged += _displayFramePacing.OnWindowStateChanged;",
"_window.FramebufferResize += OnFramebufferResize;",
];
foreach (string registration in registrations)
Assert.Equal(1, CountOccurrences(body, registration));
}
[Fact]
public void SessionStart_FollowsFrameGraphPublication()
{
string body = MethodBody(
"private void OnLoad()",
"private AcDream.App.Net.LiveSessionLifecycleHost");
AssertAppearsInOrder(
body,
"_gl = GL.GetApi(_window!);",
"_input = _window!.CreateInput();",
"_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);",
"_mouseSource = new AcDream.App.Input.SilkMouseSource(",
"_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(",
"_movementInput.Bind(_inputDispatcher);",
"_cameraInput.Bind(_inputDispatcher);",
"_inputDispatcher.Fired += OnInputAction;",
"mouse.MouseMove +=",
"_cameraController = new CameraController(orbit, fly);",
"_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);",
"LoadAndApplyPersistedSettings();",
"_uiHost = new AcDream.App.UI.UiHost(",
"_uiHost.WireMouse(m)",
"_uiHost.WireKeyboard(kb)",
"_retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount(",
"_liveEntities = new AcDream.App.World.LiveEntityRuntime(",
"_renderFrameOrchestrator =",
"_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(",
"_liveSessionController.Start(");
Assert.Equal(1, CountOccurrences(body, "_liveSessionController.Start("));
int start = body.IndexOf("_liveSessionController.Start(", StringComparison.Ordinal);
string postStart = body[start..];
Assert.Contains("switch (liveStart.Status)", postStart, StringComparison.Ordinal);
Assert.Empty(System.Text.RegularExpressions.Regex.Matches(
postStart,
@"(?m)^\s+_[A-Za-z]\w*\s*(?:\?\?=|=)"));
Assert.DoesNotContain("new ", postStart, StringComparison.Ordinal);
Assert.DoesNotContain(".Mount(", postStart, StringComparison.Ordinal);
Assert.DoesNotContain(".Bind(", postStart, StringComparison.Ordinal);
Assert.DoesNotContain(".Attach(", postStart, StringComparison.Ordinal);
Assert.DoesNotContain(".Compose(", postStart, StringComparison.Ordinal);
Assert.DoesNotContain("PrepareResources(", postStart, StringComparison.Ordinal);
}
[Fact]
public void InputAction_PreservesRetailAcceptedPriority()
{
string body = MethodBody(
"private void OnInputAction(",
"private void ToggleLiveCombatMode()");
AssertAppearsInOrder(
body,
"HandlePointerAction(action, activation)",
"action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp",
"HandleScrollAction(action);",
"HandleCombatAction(action, activation)",
"activation != AcDream.UI.Abstractions.Input.ActivationType.Press",
"_retailUiRuntime?.HandleInputAction(action)",
"_selectionInteractions?.HandleInputAction(action)",
"HandlePressedMovementAction(action)",
"switch (action)");
}
[Fact]
public void FramebufferResize_PreservesViewportCameraAndDevtoolsOrder()
{
string body = MethodBody(
"private void OnFramebufferResize(",
"private void ApplyDisplayWindowState(");
AssertAppearsInOrder(
body,
"if (newSize.X <= 0 || newSize.Y <= 0) return;",
"_gl?.Viewport(",
"_viewportAspect.Update(",
"_cameraController?.SetAspect(",
"_devToolsFramePresenter?.ResetLayout(");
Assert.DoesNotContain("_uiHost", body, StringComparison.Ordinal);
}
[Fact]
public void UpdateRenderFocusAndCloseRemainNarrowHostEdges()
{
string source = GameWindowSource();
string update = Slice(
source,
"private void OnUpdate(double dt)",
"private void OnCameraModeChanged(");
string render = Slice(
source,
"private void OnRender(double deltaSeconds)",
"private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot");
string focus = Slice(
source,
"private void OnFocusChanged(bool focused)",
"public void Dispose()");
string close = Slice(
source,
"private void OnClosing()",
"private ResourceShutdownTransaction CreateShutdownTransaction()");
Assert.Equal(1, CountOccurrences(update, "_updateFrameOrchestrator.Tick("));
Assert.Equal(1, CountOccurrences(render, "_renderFrameOrchestrator!.Render("));
AssertAppearsInOrder(
render,
"Vector2D<int> size = _window!.Size;",
"_renderFrameOrchestrator!.Render(",
"new AcDream.App.Rendering.RenderFrameInput(");
Assert.DoesNotContain("FramebufferSize", render, StringComparison.Ordinal);
AssertAppearsInOrder(focus, "if (!focused)", "_gameplayInputFrame?.EndMouseLook();");
AssertAppearsInOrder(close, "=> CompleteShutdown();", "_shutdown.CompleteOrThrow();");
}
[Fact]
public void Shutdown_PreservesDependencyStagesAndNativeWindowLast()
{
string source = GameWindowSource();
string manifest = Slice(
source,
"private ResourceShutdownTransaction CreateShutdownTransaction()",
"private void OnFocusChanged(bool focused)");
string dispose = Slice(
source,
"public void Dispose()",
"private sealed class NullAnimLoader");
string[] stages =
[
"new ResourceShutdownStage(\"session lifetime\"",
"new ResourceShutdownStage(\"frame borrowers\"",
"new ResourceShutdownStage(\"session dependents\"",
"new ResourceShutdownStage(\"live entities\"",
"new ResourceShutdownStage(\"live entity dependents\"",
"new ResourceShutdownStage(\"submitted GPU work\"",
"new ResourceShutdownStage(\"render frontends\"",
"new ResourceShutdownStage(\"shared texture owners\"",
"new ResourceShutdownStage(\"mesh adapter\"",
"new ResourceShutdownStage(\"remaining render owners\"",
"new ResourceShutdownStage(\"frame flight owner\"",
"new ResourceShutdownStage(\"content mappings\"",
"new ResourceShutdownStage(\"input\"",
"new ResourceShutdownStage(\"OpenGL context\"",
];
AssertAppearsInOrder(manifest, stages);
Assert.Equal(stages.Length, CountOccurrences(manifest, "new ResourceShutdownStage("));
foreach (string stage in stages)
Assert.Equal(1, CountOccurrences(manifest, stage));
AssertAppearsInOrder(
dispose,
"CompleteShutdown();",
"_window?.Dispose();",
"_window = null;");
Assert.Equal(1, CountOccurrences(dispose, "CompleteShutdown();"));
Assert.Equal(1, CountOccurrences(dispose, "_window?.Dispose();"));
Assert.Equal(1, CountOccurrences(dispose, "_window = null;"));
int nativeReleased = dispose.IndexOf("_window = null;", StringComparison.Ordinal)
+ "_window = null;".Length;
string afterNativeRelease = dispose[nativeReleased..];
int fallbackDocumentation = afterNativeRelease.IndexOf(
"/// <summary>",
StringComparison.Ordinal);
if (fallbackDocumentation >= 0)
afterNativeRelease = afterNativeRelease[..fallbackDocumentation];
Assert.All(
afterNativeRelease,
character => Assert.True(char.IsWhiteSpace(character) || character == '}'));
}
private static string MethodBody(string start, string end) =>
Slice(GameWindowSource(), start, end);
private static string Slice(string source, string start, string end)
{
int first = source.IndexOf(start, StringComparison.Ordinal);
int last = source.IndexOf(end, first + 1, StringComparison.Ordinal);
Assert.True(first >= 0, $"Missing source boundary: {start}");
Assert.True(last > first, $"Missing source boundary: {end}");
return source[first..last];
}
private static int CountOccurrences(string source, string value)
{
int count = 0;
int cursor = 0;
while ((cursor = source.IndexOf(value, cursor, StringComparison.Ordinal)) >= 0)
{
count++;
cursor += value.Length;
}
return count;
}
private static void AssertAppearsInOrder(string source, params string[] fragments)
{
int cursor = -1;
foreach (string fragment in fragments)
{
int next = source.IndexOf(fragment, cursor + 1, StringComparison.Ordinal);
Assert.True(next >= 0, $"Missing expected source fragment: {fragment}");
Assert.True(next > cursor, $"Out-of-order source fragment: {fragment}");
cursor = next;
}
}
private static string GameWindowSource() => File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"GameWindow.cs"));
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
}

View file

@ -78,4 +78,85 @@ public sealed class ResourceShutdownTransactionTests
Assert.Equal(1, calls);
Assert.True(transaction.IsComplete);
}
[Fact]
public void PersistentFailuresInOneStageAreAllAttemptedAndNamed()
{
int firstCalls = 0;
int secondCalls = 0;
var transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("owners",
[
new("first", () =>
{
firstCalls++;
throw new InvalidOperationException("first failure");
}),
new("second", () =>
{
secondCalls++;
throw new InvalidOperationException("second failure");
}),
]));
AggregateException error = Assert.Throws<AggregateException>(
transaction.CompleteOrThrow);
Assert.True(firstCalls > 0);
Assert.Equal(firstCalls, secondCalls);
Assert.Contains(
error.InnerExceptions,
exception => exception.Message.Contains("first", StringComparison.Ordinal));
Assert.Contains(
error.InnerExceptions,
exception => exception.Message.Contains("second", StringComparison.Ordinal));
Assert.False(transaction.IsComplete);
}
[Fact]
public void ExplicitRetryAfterFailedCompletionDoesNotReplaySuccessfulOperations()
{
int completeCalls = 0;
int pendingCalls = 0;
bool allowPending = false;
var transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("owners",
[
new("complete", () => completeCalls++),
new("pending", () =>
{
pendingCalls++;
if (!allowPending)
throw new InvalidOperationException("not yet");
}),
]));
Assert.Throws<AggregateException>(transaction.CompleteOrThrow);
int pendingCallsBeforeRetry = pendingCalls;
allowPending = true;
transaction.CompleteOrThrow();
Assert.Equal(1, completeCalls);
Assert.Equal(pendingCallsBeforeRetry + 1, pendingCalls);
Assert.True(transaction.IsComplete);
}
[Fact]
public void EmptyStagesConvergeWithoutSkippingLaterOperations()
{
int calls = 0;
var transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("empty", []),
new ResourceShutdownStage("owner",
[
new("owner", () => calls++),
]),
new ResourceShutdownStage("empty tail", []));
transaction.CompleteOrThrow();
Assert.Equal(1, calls);
Assert.True(transaction.IsComplete);
}
}

View file

@ -1,6 +1,5 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
@ -472,7 +471,6 @@ public sealed class EntityScriptActivatorTests
Assert.Contains("globally unique", error.Message, StringComparison.Ordinal);
Assert.Equal(1, p.Runner.ActiveOwnerCount);
Assert.Equal(first.Id, GameWindow.ParticleEntityKey(first));
}
[Fact]
@ -569,8 +567,13 @@ public sealed class EntityScriptActivatorTests
[Fact]
public void LiveParticleFilterUsesCanonicalLocalEffectOwnerNotServerGuid()
{
var p = BuildPipeline();
var activator = new EntityScriptActivator(p.Runner, p.Sink, p.Poses, _ => null);
var p = BuildPipeline(
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 }))));
var activator = new EntityScriptActivator(
p.Runner,
p.Sink,
p.Poses,
StaticResolver(0xAAu));
var entity = new WorldEntity
{
Id = 1_000_123u,
@ -582,9 +585,11 @@ public sealed class EntityScriptActivatorTests
};
activator.OnCreate(entity);
p.Runner.Tick(0.001f);
Assert.Equal(entity.Id, GameWindow.ParticleEntityKey(entity));
Assert.NotEqual(entity.ServerGuid, GameWindow.ParticleEntityKey(entity));
Assert.Single(p.Recording.Calls);
Assert.Equal(entity.Id, p.Recording.Calls[0].EntityId);
Assert.NotEqual(entity.ServerGuid, p.Recording.Calls[0].EntityId);
}
[Fact]