diff --git a/AGENTS.md b/AGENTS.md index eed2efa2..0a692f96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,9 +113,10 @@ component/target gates, live enchantment wire, Magic-scoped input, spell bar, sp book, effect indicators, shared main-panel switching, and positive/negative effects UI are implemented over the Step-9 projectile/DAT-effect foundation. The 2026-07-20 R6 complete-root-Frame/object-workset cutover is automated-test complete; three independent conformance/architecture/adversarial re-reviews are clean, the Release build has -zero warnings, and the full suite is 6,446 passed / 5 skipped. Its remaining ownership cleanup and -registered TS-50/TS-51 timing residuals stay carried. **Next:** connected R6 -locomotion/collision/projectile/teleport visual and performance gate, then the final two-client +zero warnings, and the full suite is 6,452 passed / 5 skipped. Its unattended seven-destination +connected input/portal/performance rebaseline also passes without an interactive desktop. Remaining +ownership cleanup and registered TS-50/TS-51 timing residuals stay carried. **Next:** connected R6 +locomotion/collision/projectile/teleport visual gate, then the final two-client portal-out/materialization observer gate. **Carried:** #145-residual, #116, R6 residuals, Track MP0. Start magic work at `claude-memory/project_magic_ui_and_casting.md`; detailed state is below. diff --git a/CLAUDE.md b/CLAUDE.md index 4f3e6be9..b2f821d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,9 +111,10 @@ component/target gates, live enchantment wire, Magic-scoped input, spell bar, sp book, effect indicators, shared main-panel switching, and positive/negative effects UI are implemented over the Step-9 projectile/DAT-effect foundation. The 2026-07-20 R6 complete-root-Frame/object-workset cutover is automated-test complete; three independent conformance/architecture/adversarial re-reviews are clean, the Release build has -zero warnings, and the full suite is 6,446 passed / 5 skipped. Its remaining ownership cleanup and -registered TS-50/TS-51 timing residuals stay carried. **Next:** connected R6 -locomotion/collision/projectile/teleport visual and performance gate, then the final two-client +zero warnings, and the full suite is 6,452 passed / 5 skipped. Its unattended seven-destination +connected input/portal/performance rebaseline also passes without an interactive desktop. Remaining +ownership cleanup and registered TS-50/TS-51 timing residuals stay carried. **Next:** connected R6 +locomotion/collision/projectile/teleport visual gate, then the final two-client portal-out/materialization observer gate. **Carried:** #145-residual, #116, R6 residuals, Track MP0. Start magic work at `claude-memory/project_magic_ui_and_casting.md`; detailed state is below. diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index d6180334..f7ddaa22 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1,6 +1,6 @@ # acdream — strategic roadmap -**Status:** Living document. Updated 2026-07-17. **M3 active; automated implementation complete, visual gates pending.** M2's connected demo loop is complete. M3 now has the retail cast-intent/component/target lifecycle, exact live enchantment wire state, scoped Magic input, retained spell bar, spellbook, component book, positive/negative effects panels with authored rows and selected-spell details, the complete seven-control gameplay indicator strip and its Character Information/Link/Vitae detail pages, and shared `gmPanelUI` geometry/switching over the Step 9 projectile/PhysicsScript/particle/portal foundation. The remaining M3 work is the connected single-client magic-UI/casting gate and final two-client portal-out/materialization observer gate. +**Status:** Living document. Updated 2026-07-20. **M3 active; automated implementation complete, visual gates pending.** M2's connected demo loop is complete. M3 now has the retail cast-intent/component/target lifecycle, exact live enchantment wire state, scoped Magic input, retained spell bar, spellbook, component book, positive/negative effects panels with authored rows and selected-spell details, the complete seven-control gameplay indicator strip and its Character Information/Link/Vitae detail pages, and shared `gmPanelUI` geometry/switching over the Step 9 projectile/PhysicsScript/particle/portal foundation. The remaining M3 work is the connected single-client magic-UI/casting gate and final two-client portal-out/materialization observer gate. **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. --- @@ -916,8 +916,17 @@ diagnostic scaffolding, not yet the final collision system. PositionManager facade remains the final R6 ownership cleanup. Final 2026-07-20 gate: three independent retail/architecture/adversarial re-reviews are clean; Release build is warning-free; the repository suite is - 6,446 passed / 5 skipped. A connected locomotion/collision/projectile/ - teleport visual and performance rebaseline remains the next gate. + 6,452 passed / 5 skipped. The unattended connected Release rebaseline also + passes: seven portal materializations across Caul, Sawato, Rynthid, + Aerlinthe, Holtburg, and same-location revisits; production-dispatcher turn, + forward, charged-jump, and combat inputs; outbound movement evidence; + post-25-second lifecycle samples; no fatal runtime diagnostics; and graceful + WM_CLOSE teardown. Same-location update p95 remained 1.2 -> 1.1 ms at Caul + and 0.2 -> 0.3 ms at Sawato. ACE generator population changes are reported + rather than treated as client leaks. Absolute FPS/GPU timing remains + session/display-path evidence, not an RDP-stable gate. The connected visual + locomotion/collision/projectile/teleport comparison remains for the next + local viewing session. - **L.2g — Inbound motion interpretation (remote-entity CMotionInterp funnel).** ACTIVE 2026-07-02. Port retail's inbound motion pipeline verbatim for ALL remote entities (players, NPCs, monsters — one funnel, user-approved): diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index eea2d513..221a7926 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -11,13 +11,14 @@ presentation have passed their single-client visual gates. The corrective bounde render/resource-lifetime integration passed its seven-destination connected stress gate without a crash or cumulative collapse. The remaining M3 visual requirement is the final two-client portal-out/materialize observer gate. The -new R6 object-update cutover also requires one connected locomotion/collision/ -projectile/teleport visual and performance rebaseline before that observer gate. +new R6 object-update cutover has passed its unattended connected input/portal/ +performance rebaseline; its locomotion/collision/projectile/teleport visual +gate remains for the next local viewing session before that observer gate. Carried: #145-residual, #116 slide-response, the remaining R6 ownership cleanup plus registered TS-50/TS-51 timing residuals (the complete-root-Frame/object-workset cutover is automated-test complete 2026-07-20; three independent re-reviews are -clean, Release builds with zero warnings, and 6,446 tests pass / 5 skip), and +clean, Release builds with zero warnings, and 6,452 tests pass / 5 skip), and Track MP0. --- diff --git a/docs/research/2026-07-20-connected-r6-soak.md b/docs/research/2026-07-20-connected-r6-soak.md new file mode 100644 index 00000000..d041abd8 --- /dev/null +++ b/docs/research/2026-07-20-connected-r6-soak.md @@ -0,0 +1,73 @@ +# Connected R6 unattended soak (2026-07-20) + +## Purpose + +Provide a repeatable connected-client correctness and performance gate when no +interactive Windows desktop is attached. This complements, but does not replace, +the user-visible retail comparison. + +The route uses the normal retained UI/chat command path for `/teleloc` and the +production `InputDispatcher` for turning, forward movement, charged jump, and +combat transitions. It does not call movement controllers directly and does not +use Windows keyboard synthesis. + +## Run + +With local ACE listening on UDP port 9000: + +```powershell +pwsh -File tools/run-connected-r6-soak.ps1 +``` + +Credentials default to the local test account and can be overridden with +`ACDREAM_TEST_USER` / `ACDREAM_TEST_PASS` or `-Account` / `-Password`. The +script refuses to start when another acdream client is running, builds Release +unless `-SkipBuild` is supplied, and always requests normal WM_CLOSE shutdown. +Artifacts are written under `logs/connected-r6-soak-.*` as raw +stdout/stderr, a marker timeline, sample CSV, and structured JSON report. + +## Route and gates + +The route visits Caul, Sawato, Rynthid, Aerlinthe, Sawato again, Holtburg, and +Caul again. Every location performs a six-second visibility turn. Caul, +Holtburg, and returned Caul also perform five forward press/release cycles, a +charged jump, two combat transitions, and verify outbound movement records. + +Stationary samples are taken only after `LiveEntityLivenessController`'s +25-second authoritative-absence deadline. Hard failures cover: + +- missing portal materializations or semantic input edges; +- insufficient outbound movement evidence; +- same-location private/working-set, allocation, or update-p95 regression; +- unhandled exceptions, access violations, out-of-memory, Weenie errors, + disconnects, or GPU/device resets; +- nonzero process exit or failure to close gracefully. + +Entity and animation counts remain in the report, but changes are warnings. +ACE generators legitimately spawn and despawn creatures while the route is at +another location; two consecutive runs demonstrated both +1 and -8 connected +population changes without pending teardown or rescue retention. Deterministic +live-owner balance is instead covered by `LiveEntityLifecycleStressTests`, +while this connected gate catches aggregate resource retention through the +same-location memory/allocation/update measurements. + +## Accepted run + +Artifact stem: `connected-r6-soak-20260720-120120`. + +| Stationary sample | Working/private MiB | CPU p50/p95 ms | GPU p50/p95 ms | Update p95 ms | +|---|---:|---:|---:|---:| +| Caul | 735.9 / 986.5 | 11.1 / 13.1 | 11.1 / 13.0 | 1.2 | +| Sawato | 790.7 / 1046.0 | 2.6 / 3.0 | 2.6 / 2.9 | 0.2 | +| Rynthid | 850.1 / 1093.5 | 4.3 / 4.9 | 4.2 / 4.8 | 0.3 | +| Aerlinthe | 852.6 / 1088.7 | 6.5 / 7.0 | 6.4 / 6.9 | 0.4 | +| Sawato revisit | 837.9 / 1073.1 | 3.1 / 3.7 | 3.1 / 3.6 | 0.3 | +| Holtburg | 979.5 / 1201.9 | 6.7 / 7.6 | 6.6 / 7.4 | 0.7 | +| Caul return | 976.5 / 1178.6 | 12.5 / 14.6 | 12.4 / 14.3 | 1.1 | + +The client completed all seven teleports and all three input exercises, emitted +no fatal diagnostic, and exited with code zero through WM_CLOSE. The report +retained two known context warnings: missing installed DAT VFX entries and the +expected empty landblock misses at the Rynthid world edge. Absolute FPS/GPU +numbers depend on the active Windows/RDP presentation path and are recorded for +comparison, not used as cross-session pass/fail thresholds. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 025205c1..c34f12bb 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2450,7 +2450,10 @@ public sealed class GameWindow : IDisposable _options.UiProbeEnabled, _options.UiProbeScript, _options.UiProbeDump, - UiProbeLog))); + UiProbeLog, + action => _inputDispatcher?.TryInvokeAutomationAction(action) == true, + (action, held) => + _inputDispatcher?.TrySetAutomationActionHeld(action, held) == true))); } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index b2ab101e..44ecb911 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -17,6 +17,7 @@ using AcDream.UI.Abstractions; using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Panels.Vitals; +using AcDream.UI.Abstractions.Input; using DatReaderWriter; using Silk.NET.Input; @@ -146,7 +147,9 @@ public sealed record RetailUiProbeBindings( bool Enabled, string? ScriptPath, bool DumpOnStart, - Action Log); + Action Log, + Func PressInput, + Func SetInputHeld); public sealed record RetailUiCursorBindings( CursorFeedbackController Feedback, @@ -261,7 +264,9 @@ public sealed class RetailUiRuntime : IDisposable text, bindings.Chat.ViewModel, bindings.Chat.CommandBus(), - ChatChannelKind.Say)); + ChatChannelKind.Say), + bindings.Probe.PressInput, + bindings.Probe.SetInputHeld); } } @@ -1723,6 +1728,7 @@ public sealed class RetailUiRuntime : IDisposable return; _shutdown ??= CreateShutdownTransaction( + () => _automation?.Dispose(), () => _persistence?.Dispose(), () => Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged, () => _itemConfirmationController?.Dispose(), @@ -1735,6 +1741,7 @@ public sealed class RetailUiRuntime : IDisposable } internal static ResourceShutdownTransaction CreateShutdownTransaction( + Action disposeAutomation, Action disposePersistence, Action unsubscribeWindowVisibility, Action disposeItemConfirmation, @@ -1743,6 +1750,7 @@ public sealed class RetailUiRuntime : IDisposable Action disposePanelController, Action disposeHost) { + ArgumentNullException.ThrowIfNull(disposeAutomation); ArgumentNullException.ThrowIfNull(disposePersistence); ArgumentNullException.ThrowIfNull(unsubscribeWindowVisibility); ArgumentNullException.ThrowIfNull(disposeItemConfirmation); @@ -1754,6 +1762,7 @@ public sealed class RetailUiRuntime : IDisposable return new ResourceShutdownTransaction( new ResourceShutdownStage("retail UI observers", [ + new("UI automation", disposeAutomation), new("window persistence", disposePersistence), new("window visibility", unsubscribeWindowVisibility), ]), diff --git a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs index 762c257c..8ddf5cf7 100644 --- a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs +++ b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs @@ -3,21 +3,25 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using AcDream.Core.Items; +using AcDream.UI.Abstractions.Input; namespace AcDream.App.UI.Testing; /// /// Tiny one-thread script runner for . /// It is intended for local diagnostic launches, not for gameplay. Commands -/// execute on render ticks and use the same input path as -/// physical mouse input. +/// execute on render ticks. Pointer commands use the same +/// path as physical mouse input; semantic input uses the production dispatcher. /// -public sealed class RetailUiAutomationScriptRunner +public sealed class RetailUiAutomationScriptRunner : IDisposable { private readonly RetailUiAutomationProbe _probe; private readonly Action _log; private readonly Action? _submitCommand; + private readonly Func? _pressInput; + private readonly Func? _setInputHeld; private readonly List _commands = new(); + private readonly HashSet _heldInputs = new(); private readonly bool _dumpOnStart; private readonly string? _loadError; private int _index; @@ -26,17 +30,22 @@ public sealed class RetailUiAutomationScriptRunner private double _commandElapsedCompensationMs; private bool _started; private bool _completed; + private bool _disposed; public RetailUiAutomationScriptRunner( RetailUiAutomationProbe probe, string? scriptPath, bool dumpOnStart, Action? log = null, - Action? submitCommand = null) + Action? submitCommand = null, + Func? pressInput = null, + Func? setInputHeld = null) { _probe = probe ?? throw new ArgumentNullException(nameof(probe)); _log = log ?? (_ => { }); _submitCommand = submitCommand; + _pressInput = pressInput; + _setInputHeld = setInputHeld; _dumpOnStart = dumpOnStart; if (!string.IsNullOrWhiteSpace(scriptPath)) @@ -63,7 +72,7 @@ public sealed class RetailUiAutomationScriptRunner public void Tick(double deltaSeconds) { - if (_completed) return; + if (_completed || _disposed) return; // Preserve sub-millisecond frame deltas. Rounding every uncapped frame // up to one millisecond makes script time run several times faster than @@ -130,6 +139,7 @@ public sealed class RetailUiAutomationScriptRunner if (_index >= _commands.Count && !_completed) { + ReleaseHeldInputs(); _completed = true; _log("UI probe script complete"); } @@ -151,6 +161,7 @@ public sealed class RetailUiAutomationScriptRunner "sleep" => DoSleep(command), "assert" => DoAssert(command), "command" => DoCommand(command), + "input" => DoInput(command), _ => Stop(command, $"unknown command '{p[0]}'"), }; } @@ -302,6 +313,49 @@ public sealed class RetailUiAutomationScriptRunner return true; } + private bool DoInput(ScriptCommand command) + { + var p = command.Parts; + if (p.Length != 3) + return Stop(command, "usage: input press|down|up "); + if (!Enum.TryParse(p[2], ignoreCase: true, out InputAction action) + || action == InputAction.None + || !Enum.IsDefined(action)) + return Stop(command, $"unknown input action '{p[2]}'"); + + switch (p[1].ToLowerInvariant()) + { + case "press": + if (_pressInput is null) + return Stop(command, "input press injection is unavailable"); + return _pressInput(action) + || Stop(command, $"input press {action} was captured"); + + case "down": + if (_setInputHeld is null) + return Stop(command, "input held injection is unavailable"); + if (_heldInputs.Contains(action)) + return Stop(command, $"input {action} is already down"); + if (!_setInputHeld(action, true)) + return Stop(command, $"input down {action} was captured"); + _heldInputs.Add(action); + return true; + + case "up": + if (_setInputHeld is null) + return Stop(command, "input held injection is unavailable"); + if (!_heldInputs.Contains(action)) + return Stop(command, $"input {action} is not down"); + if (!_setInputHeld(action, false)) + return Stop(command, $"input up {action} failed"); + _heldInputs.Remove(action); + return true; + + default: + return Stop(command, "usage: input press|down|up "); + } + } + private bool WaitOrTimeout(ScriptCommand command, int timeoutMs, string label) { if (!HasExceeded(timeoutMs)) return false; @@ -334,10 +388,30 @@ public sealed class RetailUiAutomationScriptRunner private bool Stop(ScriptCommand command, string message) { _log($"line {command.LineNumber}: {message}; command: {command.Text}"); + ReleaseHeldInputs(); _completed = true; return false; } + private void ReleaseHeldInputs() + { + if (_heldInputs.Count == 0 || _setInputHeld is null) + return; + + var snapshot = new InputAction[_heldInputs.Count]; + _heldInputs.CopyTo(snapshot); + _heldInputs.Clear(); + foreach (InputAction action in snapshot) + _setInputHeld(action, false); + } + + public void Dispose() + { + if (_disposed) return; + ReleaseHeldInputs(); + _disposed = true; + } + private static string StripComment(string line) { int hash = line.IndexOf('#'); diff --git a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs index 9de7a208..4c9dec74 100644 --- a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs +++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs @@ -38,6 +38,7 @@ public sealed class InputDispatcher private readonly Stack _scopes = new(); private InputScope? _combatScope; private readonly HashSet _heldHoldChords = new(); + private readonly HashSet _automationHeldActions = new(); // Double-click detection. _lastMouseDownButton == null means no recent press. // _lastMouseDownTickMs is Environment.TickCount64 at the time of that press. @@ -72,6 +73,52 @@ public sealed class InputDispatcher _mouse.Scroll += OnScroll; } + /// + /// Diagnostic-only semantic press injection used by the connected retail-UI + /// automation runner. The action enters through the same multicast + /// stream as a resolved physical binding, but does not + /// invent a keyboard chord or depend on an interactive Windows desktop. + /// + /// when modal/text capture owns the + /// keyboard and a physical press would have been suppressed. + public bool TryInvokeAutomationAction(InputAction action) + { + if (action == InputAction.None || !Enum.IsDefined(action)) + throw new ArgumentOutOfRangeException(nameof(action)); + if (_captureCallback is not null || _mouse.WantCaptureKeyboard) + return false; + + Fired?.Invoke(action, ActivationType.Press); + return true; + } + + /// + /// Diagnostic-only held-action injection. A down edge emits the same + /// transition as a Hold binding and is + /// visible to until the matching up edge emits + /// . This keeps unattended connected + /// tests on the production input/movement path without OS-level key + /// synthesis, which Windows drops when no interactive desktop is attached. + /// + public bool TrySetAutomationActionHeld(InputAction action, bool held) + { + if (action == InputAction.None || !Enum.IsDefined(action)) + throw new ArgumentOutOfRangeException(nameof(action)); + + if (held) + { + if (_captureCallback is not null || _mouse.WantCaptureKeyboard) + return false; + if (_automationHeldActions.Add(action)) + Fired?.Invoke(action, ActivationType.Press); + return true; + } + + if (_automationHeldActions.Remove(action)) + Fired?.Invoke(action, ActivationType.Release); + return true; + } + /// Topmost scope on the stack — what the dispatcher looks up first. public InputScope ActiveScope => _scopes.Peek() == InputScope.Game && _combatScope is { } combat ? combat @@ -177,6 +224,7 @@ public sealed class InputDispatcher // movement only — latched state that isn't a key (e.g. autorun, ORed into Forward at // the call site) keeps driving the character, so chat doesn't cancel autorun. if (_mouse.WantCaptureKeyboard) return false; + if (_automationHeldActions.Contains(action)) return true; foreach (var b in _bindings.ForAction(action)) { if (!IsChordHeld(b.Chord)) continue; diff --git a/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs b/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs index 8ec20467..f9d50060 100644 --- a/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs @@ -3,6 +3,7 @@ using System.IO; using AcDream.App.UI; using AcDream.App.UI.Testing; using AcDream.Core.Items; +using AcDream.UI.Abstractions.Input; namespace AcDream.App.Tests.UI; @@ -441,4 +442,89 @@ public sealed class RetailUiAutomationProbeTests File.Delete(path); } } + + [Fact] + public void ScriptRunner_input_drivesSemanticPressAndHeldEdges() + { + var (root, _, _, _, objects) = RootWithTwoItemLists(); + var probe = new RetailUiAutomationProbe(root, objects); + var presses = new List(); + var held = new List<(InputAction Action, bool Held)>(); + string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt"); + File.WriteAllLines(path, + [ + "input down MovementForward", + "sleep 10", + "input up MovementForward", + "input press CombatToggleCombat", + ]); + + try + { + using var runner = new RetailUiAutomationScriptRunner( + probe, + path, + dumpOnStart: false, + pressInput: action => + { + presses.Add(action); + return true; + }, + setInputHeld: (action, isHeld) => + { + held.Add((action, isHeld)); + return true; + }); + + runner.Tick(0d); + Assert.Equal([(InputAction.MovementForward, true)], held); + Assert.False(runner.Completed); + + runner.Tick(0.010d); + + Assert.True(runner.Completed); + Assert.Equal( + [(InputAction.MovementForward, true), (InputAction.MovementForward, false)], + held); + Assert.Equal([InputAction.CombatToggleCombat], presses); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ScriptRunner_dispose_releasesInjectedHeldActions() + { + var (root, _, _, _, objects) = RootWithTwoItemLists(); + var probe = new RetailUiAutomationProbe(root, objects); + var held = new List<(InputAction Action, bool Held)>(); + string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt"); + File.WriteAllLines(path, ["input down MovementForward", "sleep 10000"]); + + try + { + var runner = new RetailUiAutomationScriptRunner( + probe, + path, + dumpOnStart: false, + setInputHeld: (action, isHeld) => + { + held.Add((action, isHeld)); + return true; + }); + + runner.Tick(0d); + runner.Dispose(); + + Assert.Equal( + [(InputAction.MovementForward, true), (InputAction.MovementForward, false)], + held); + } + finally + { + File.Delete(path); + } + } } diff --git a/tests/AcDream.App.Tests/UI/UiCompositeShutdownTests.cs b/tests/AcDream.App.Tests/UI/UiCompositeShutdownTests.cs index cfc96f01..1dc34789 100644 --- a/tests/AcDream.App.Tests/UI/UiCompositeShutdownTests.cs +++ b/tests/AcDream.App.Tests/UI/UiCompositeShutdownTests.cs @@ -79,6 +79,7 @@ public sealed class UiCompositeShutdownTests bool failGameplay = true; ResourceShutdownTransaction? transaction = null; transaction = RetailUiRuntime.CreateShutdownTransaction( + () => calls.Add("automation"), () => calls.Add("persistence"), () => calls.Add("visibility"), () => @@ -98,6 +99,7 @@ public sealed class UiCompositeShutdownTests Assert.Throws(transaction.CompleteOrThrow); + Assert.Equal(1, calls.Count(call => call == "automation")); Assert.Equal(1, calls.Count(call => call == "persistence")); Assert.Equal(1, calls.Count(call => call == "visibility")); Assert.Equal(1, calls.Count(call => call == "item")); diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs index 936aeb20..c46d7a1d 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs @@ -285,4 +285,68 @@ public class InputDispatcherTests mouse.EmitScroll(1.0f); Assert.Empty(fired); } + + [Fact] + public void AutomationHeldAction_usesFiredTransitionsAndHeldPolling() + { + var (dispatcher, _, _, _, fired) = Build(); + + Assert.True(dispatcher.TrySetAutomationActionHeld(InputAction.MovementForward, true)); + Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward)); + Assert.Equal([(InputAction.MovementForward, ActivationType.Press)], fired); + + Assert.True(dispatcher.TrySetAutomationActionHeld(InputAction.MovementForward, false)); + Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward)); + Assert.Equal( + [ + (InputAction.MovementForward, ActivationType.Press), + (InputAction.MovementForward, ActivationType.Release), + ], + fired); + } + + [Fact] + public void AutomationPress_obeysKeyboardCapture() + { + var (dispatcher, _, mouse, _, fired) = Build(); + mouse.WantCaptureKeyboard = true; + + Assert.False(dispatcher.TryInvokeAutomationAction(InputAction.CombatToggleCombat)); + Assert.False(dispatcher.TrySetAutomationActionHeld(InputAction.MovementForward, true)); + Assert.Empty(fired); + + mouse.WantCaptureKeyboard = false; + Assert.True(dispatcher.TryInvokeAutomationAction(InputAction.CombatToggleCombat)); + Assert.Equal([(InputAction.CombatToggleCombat, ActivationType.Press)], fired); + } + + [Fact] + public void AutomationHeldRelease_isDeliveredAfterKeyboardCaptureBegins() + { + var (dispatcher, _, mouse, _, fired) = Build(); + Assert.True(dispatcher.TrySetAutomationActionHeld(InputAction.MovementForward, true)); + + mouse.WantCaptureKeyboard = true; + Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward)); + Assert.True(dispatcher.TrySetAutomationActionHeld(InputAction.MovementForward, false)); + + Assert.Equal( + [ + (InputAction.MovementForward, ActivationType.Press), + (InputAction.MovementForward, ActivationType.Release), + ], + fired); + } + + [Fact] + public void AutomationInjection_rejectsUndefinedActions() + { + var (dispatcher, _, _, _, _) = Build(); + var undefined = (InputAction)int.MaxValue; + + Assert.Throws( + () => dispatcher.TryInvokeAutomationAction(undefined)); + Assert.Throws( + () => dispatcher.TrySetAutomationActionHeld(undefined, true)); + } } diff --git a/tools/connected-r6-soak.route.txt b/tools/connected-r6-soak.route.txt new file mode 100644 index 00000000..4e7b457b --- /dev/null +++ b/tools/connected-r6-soak.route.txt @@ -0,0 +1,145 @@ +# Connected R6 soak route. Commands enter through the retained-UI/chat path; +# semantic input enters through InputDispatcher on the game thread, so this +# remains deterministic when no RDP/interactive desktop is attached. + +sleep 15000 + +# Caul baseline + local movement/jump/combat exercise. +command /teleloc 0xC95B0001 14.8 0.3 12.005 +sleep 8000 +input down MovementTurnRight +sleep 6000 +input up MovementTurnRight +sleep 20000 +input down MovementForward +sleep 150 +input up MovementForward +sleep 350 +input down MovementForward +sleep 150 +input up MovementForward +sleep 350 +input down MovementForward +sleep 150 +input up MovementForward +sleep 350 +input down MovementForward +sleep 900 +input up MovementForward +sleep 800 +input down MovementJump +sleep 350 +input up MovementJump +sleep 1800 +input press CombatToggleCombat +sleep 1200 +input down MovementForward +sleep 500 +input up MovementForward +sleep 700 +input press CombatToggleCombat +sleep 7400 + +# Sawato baseline. +command /teleloc 0x3032001C 83.3 89.2 133.005 +sleep 8000 +input down MovementTurnRight +sleep 6000 +input up MovementTurnRight +sleep 22000 + +# Rynthid world-edge streaming. +command /teleloc 0xF6820033 145.7 49.855 58.010 +sleep 8000 +input down MovementTurnRight +sleep 6000 +input up MovementTurnRight +sleep 22000 + +# Aerlinthe dense island. +command /teleloc 0x09040008 11.4 188.6 87.705 +sleep 8000 +input down MovementTurnRight +sleep 6000 +input up MovementTurnRight +sleep 22000 + +# Same-location lifecycle revisit. +command /teleloc 0x3032001C 83.3 89.2 133.005 +sleep 8000 +input down MovementTurnRight +sleep 6000 +input up MovementTurnRight +sleep 22000 + +# Holtburg mixed-town workload + local exercise. +command /teleloc 0xA9B40019 84.0 7.1 94.005 +sleep 8000 +input down MovementTurnRight +sleep 6000 +input up MovementTurnRight +sleep 20000 +input down MovementForward +sleep 150 +input up MovementForward +sleep 350 +input down MovementForward +sleep 150 +input up MovementForward +sleep 350 +input down MovementForward +sleep 150 +input up MovementForward +sleep 350 +input down MovementForward +sleep 900 +input up MovementForward +sleep 800 +input down MovementJump +sleep 350 +input up MovementJump +sleep 1800 +input press CombatToggleCombat +sleep 1200 +input down MovementForward +sleep 500 +input up MovementForward +sleep 700 +input press CombatToggleCombat +sleep 7400 + +# Caul return/leak oracle + final local exercise. +command /teleloc 0xC95B0001 14.8 0.3 12.005 +sleep 8000 +input down MovementTurnRight +sleep 6000 +input up MovementTurnRight +sleep 20000 +input down MovementForward +sleep 150 +input up MovementForward +sleep 350 +input down MovementForward +sleep 150 +input up MovementForward +sleep 350 +input down MovementForward +sleep 150 +input up MovementForward +sleep 350 +input down MovementForward +sleep 900 +input up MovementForward +sleep 800 +input down MovementJump +sleep 350 +input up MovementJump +sleep 1800 +input press CombatToggleCombat +sleep 1200 +input down MovementForward +sleep 500 +input up MovementForward +sleep 700 +input press CombatToggleCombat +sleep 15000 diff --git a/tools/run-connected-r6-soak.ps1 b/tools/run-connected-r6-soak.ps1 new file mode 100644 index 00000000..1afbaf6b --- /dev/null +++ b/tools/run-connected-r6-soak.ps1 @@ -0,0 +1,485 @@ +[CmdletBinding()] +param( + [string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, + [string]$Account = $env:ACDREAM_TEST_USER, + [string]$Password = $env:ACDREAM_TEST_PASS, + [switch]$SkipBuild, + [int]$LoginTimeoutSeconds = 90 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($Account)) { $Account = 'testaccount' } +if ([string]::IsNullOrWhiteSpace($Password)) { $Password = 'testpassword' } + +$destinations = @( + [pscustomobject]@{ Name = 'Caul'; Occurrence = 1; Exercise = $true }, + [pscustomobject]@{ Name = 'Sawato'; Occurrence = 2; Exercise = $false }, + [pscustomobject]@{ Name = 'Rynthid'; Occurrence = 3; Exercise = $false }, + [pscustomobject]@{ Name = 'Aerlinthe'; Occurrence = 4; Exercise = $false }, + [pscustomobject]@{ Name = 'Sawato revisit'; Occurrence = 5; Exercise = $false }, + [pscustomobject]@{ Name = 'Holtburg'; Occurrence = 6; Exercise = $true }, + [pscustomobject]@{ Name = 'Caul return'; Occurrence = 7; Exercise = $true } +) + +$stamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$logDir = Join-Path $Repository 'logs' +$null = New-Item -ItemType Directory -Force -Path $logDir +$prefix = Join-Path $logDir "connected-r6-soak-$stamp" +$stdoutLog = "$prefix.out.log" +$stderrLog = "$prefix.err.log" +$markerLog = "$prefix.markers.log" +$sampleCsv = "$prefix.samples.csv" +$reportJson = "$prefix.report.json" +$routePath = Join-Path $Repository 'tools\connected-r6-soak.route.txt' +$exe = Join-Path $Repository 'src\AcDream.App\bin\Release\net10.0\AcDream.App.exe' + +$samples = [System.Collections.Generic.List[object]]::new() +$failures = [System.Collections.Generic.List[string]]::new() +$warnings = [System.Collections.Generic.List[string]]::new() +$process = $null +$stopwatch = [Diagnostics.Stopwatch]::StartNew() +$gracefulExit = $false +$exitCode = $null + +function Read-LogLines([string]$Path) { + if (-not (Test-Path -LiteralPath $Path)) { return @() } + return @(Get-Content -LiteralPath $Path -ErrorAction SilentlyContinue) +} + +function Write-Marker([string]$Text) { + $line = "{0:O} {1}" -f [DateTime]::UtcNow, $Text + Add-Content -LiteralPath $markerLog -Value $line + Write-Output $line +} + +function Get-PatternCount([string]$Path, [string]$Pattern) { + return @(Read-LogLines $Path | Select-String -SimpleMatch $Pattern).Count +} + +function Get-FrameProfiles([string]$Path) { + return @(Read-LogLines $Path | Where-Object { $_.StartsWith('[frame-prof]', [StringComparison]::Ordinal) }) +} + +function Wait-ForLogPattern( + [Diagnostics.Process]$Client, + [string]$Path, + [string]$Pattern, + [int]$MinimumCount, + [int]$TimeoutSeconds) +{ + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + while ([DateTime]::UtcNow -lt $deadline) { + $Client.Refresh() + if ($Client.HasExited) { + throw "client exited with code $($Client.ExitCode) while waiting for '$Pattern'" + } + + $count = Get-PatternCount $Path $Pattern + if ($count -ge $MinimumCount) { return $count } + Start-Sleep -Milliseconds 250 + } + + throw "timed out after $TimeoutSeconds seconds waiting for occurrence $MinimumCount of '$Pattern'" +} + +function Wait-ForNextFrameProfile( + [Diagnostics.Process]$Client, + [string]$Path, + [int]$AfterCount, + [int]$TimeoutSeconds) +{ + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + while ([DateTime]::UtcNow -lt $deadline) { + $Client.Refresh() + if ($Client.HasExited) { + throw "client exited with code $($Client.ExitCode) while waiting for a frame-prof boundary" + } + + $profiles = Get-FrameProfiles $Path + if ($profiles.Count -gt $AfterCount) { + return [pscustomobject]@{ Count = $profiles.Count; Line = $profiles[-1] } + } + Start-Sleep -Milliseconds 100 + } + + throw "timed out after $TimeoutSeconds seconds waiting for a frame-prof boundary" +} + +function Parse-FrameProfile([string]$Line) { + $values = [ordered]@{ + FrameCount = $null + CpuP50Ms = $null + CpuP95Ms = $null + CpuP99Ms = $null + CpuMaxMs = $null + GpuP50Ms = $null + GpuP95Ms = $null + AllocP50Kb = $null + AllocMaxKb = $null + UpdateP50Ms = $null + UpdateP95Ms = $null + } + + if ($Line -match '^\[frame-prof\] n=(?\d+)') { + $values.FrameCount = [int]$Matches.n + } + if ($Line -match 'cpu_ms p50=(?[\d.]+) p95=(?[\d.]+) p99=(?[\d.]+) max=(?[\d.]+)') { + $values.CpuP50Ms = [double]$Matches.p50 + $values.CpuP95Ms = [double]$Matches.p95 + $values.CpuP99Ms = [double]$Matches.p99 + $values.CpuMaxMs = [double]$Matches.max + } + if ($Line -match 'gpu_ms p50=(?[\d.]+) p95=(?[\d.]+)') { + $values.GpuP50Ms = [double]$Matches.p50 + $values.GpuP95Ms = [double]$Matches.p95 + } + if ($Line -match 'alloc_kb p50=(?[\d.]+) max=(?[\d.]+)') { + $values.AllocP50Kb = [double]$Matches.p50 + $values.AllocMaxKb = [double]$Matches.max + } + if ($Line -match 'upd p50=(?[\d.]+) p95=(?[\d.]+)') { + $values.UpdateP50Ms = [double]$Matches.p50 + $values.UpdateP95Ms = [double]$Matches.p95 + } + + return [pscustomobject]$values +} + +function Parse-WindowTitle([string]$Title) { + $values = [ordered]@{ + Fps = $null + FrameMs = $null + LoadedLandblocks = $null + TotalLandblocks = $null + EntityCount = $null + AnimationCount = $null + } + + if ($Title -match 'acdream \| (?\d+) fps \| (?[\d.]+) ms \| lb (?\d+)/(?\d+) \| ent (?\d+)/anim (?\d+)') { + $values.Fps = [int]$Matches.fps + $values.FrameMs = [double]$Matches.ms + $values.LoadedLandblocks = [int]$Matches.loaded + $values.TotalLandblocks = [int]$Matches.total + $values.EntityCount = [int]$Matches.ent + $values.AnimationCount = [int]$Matches.anim + } + + return [pscustomobject]$values +} + +function Capture-Sample( + [Diagnostics.Process]$Client, + [string]$Destination, + [string]$Phase, + [string]$ProfileLine) +{ + $Client.Refresh() + $profile = Parse-FrameProfile $ProfileLine + $title = Parse-WindowTitle $Client.MainWindowTitle + $sample = [pscustomobject][ordered]@{ + Destination = $Destination + Phase = $Phase + ElapsedSeconds = [Math]::Round($stopwatch.Elapsed.TotalSeconds, 3) + WorkingSetMiB = [Math]::Round($Client.WorkingSet64 / 1MB, 1) + PrivateMiB = [Math]::Round($Client.PrivateMemorySize64 / 1MB, 1) + HandleCount = $Client.HandleCount + ThreadCount = $Client.Threads.Count + Fps = $title.Fps + TitleFrameMs = $title.FrameMs + LoadedLandblocks = $title.LoadedLandblocks + TotalLandblocks = $title.TotalLandblocks + EntityCount = $title.EntityCount + AnimationCount = $title.AnimationCount + ProfileFrames = $profile.FrameCount + CpuP50Ms = $profile.CpuP50Ms + CpuP95Ms = $profile.CpuP95Ms + CpuP99Ms = $profile.CpuP99Ms + CpuMaxMs = $profile.CpuMaxMs + GpuP50Ms = $profile.GpuP50Ms + GpuP95Ms = $profile.GpuP95Ms + AllocP50Kb = $profile.AllocP50Kb + AllocMaxKb = $profile.AllocMaxKb + UpdateP50Ms = $profile.UpdateP50Ms + UpdateP95Ms = $profile.UpdateP95Ms + Profile = $ProfileLine + } + $samples.Add($sample) + return $sample +} + +function Observe-R6Exercise( + [Diagnostics.Process]$Client, + [string]$Destination, + [pscustomobject]$Before) +{ + $null = Wait-ForLogPattern $Client $stdoutLog '[input] MovementForward Press' ($Before.ForwardPress + 5) 30 + $null = Wait-ForLogPattern $Client $stdoutLog '[input] MovementForward Release' ($Before.ForwardRelease + 5) 10 + $null = Wait-ForLogPattern $Client $stdoutLog '[input] MovementJump Press' ($Before.JumpPress + 1) 10 + $null = Wait-ForLogPattern $Client $stdoutLog '[input] MovementJump Release' ($Before.JumpRelease + 1) 10 + $null = Wait-ForLogPattern $Client $stdoutLog '[input] CombatToggleCombat Press' ($Before.Combat + 2) 15 + $null = Wait-ForLogPattern $Client $stdoutLog 'move-truth OUT' ($Before.MoveTruth + 2) 10 + + $forwardPressDelta = (Get-PatternCount $stdoutLog '[input] MovementForward Press') - $Before.ForwardPress + $forwardReleaseDelta = (Get-PatternCount $stdoutLog '[input] MovementForward Release') - $Before.ForwardRelease + $jumpPressDelta = (Get-PatternCount $stdoutLog '[input] MovementJump Press') - $Before.JumpPress + $jumpReleaseDelta = (Get-PatternCount $stdoutLog '[input] MovementJump Release') - $Before.JumpRelease + $combatDelta = (Get-PatternCount $stdoutLog '[input] CombatToggleCombat Press') - $Before.Combat + $moveTruthDelta = (Get-PatternCount $stdoutLog 'move-truth OUT') - $Before.MoveTruth + + if ($forwardPressDelta -lt 5 -or $forwardReleaseDelta -lt 5) { + $failures.Add("${Destination}: production forward input did not deliver five press/release pairs ($forwardPressDelta/$forwardReleaseDelta)") + } + if ($jumpPressDelta -lt 1 -or $jumpReleaseDelta -lt 1) { + $failures.Add("${Destination}: production jump input did not deliver a press/release pair ($jumpPressDelta/$jumpReleaseDelta)") + } + if ($combatDelta -lt 2) { + $failures.Add("${Destination}: combat toggle did not dispatch twice ($combatDelta)") + } + if ($moveTruthDelta -lt 2) { + $failures.Add("${Destination}: movement/jump exercise produced too few outbound movement records ($moveTruthDelta)") + } + + Write-Marker "exercise destination='$Destination' forward=$forwardPressDelta/$forwardReleaseDelta jump=$jumpPressDelta/$jumpReleaseDelta combat=$combatDelta moveTruth=$moveTruthDelta" +} + +function Add-RelativeGateFailures { + $caulFirst = $samples | Where-Object { $_.Destination -eq 'Caul' -and $_.Phase -eq 'stationary' } | Select-Object -First 1 + $caulReturn = $samples | Where-Object { $_.Destination -eq 'Caul return' -and $_.Phase -eq 'stationary' } | Select-Object -First 1 + $sawatoFirst = $samples | Where-Object { $_.Destination -eq 'Sawato' -and $_.Phase -eq 'stationary' } | Select-Object -First 1 + $sawatoReturn = $samples | Where-Object { $_.Destination -eq 'Sawato revisit' -and $_.Phase -eq 'stationary' } | Select-Object -First 1 + + foreach ($pair in @( + [pscustomobject]@{ Label = 'Caul return'; First = $caulFirst; Return = $caulReturn }, + [pscustomobject]@{ Label = 'Sawato revisit'; First = $sawatoFirst; Return = $sawatoReturn })) + { + if ($null -eq $pair.First -or $null -eq $pair.Return) { + $failures.Add("$($pair.Label): missing same-location stationary sample") + continue + } + + # A connected ACE world is not a fixed fixture: generators can spawn or + # despawn creatures while this four-minute route is away from a location. + # Keep the counts in the report as a population/workload signal, but do + # not mistake an authoritative server population change for a client + # lifetime leak. Retention is gated below through same-location memory, + # allocation, and update-cost growth; deterministic lifecycle ownership + # remains covered by LiveEntityLifecycleStressTests. + if ($pair.First.EntityCount -ne $pair.Return.EntityCount) { + $warnings.Add("$($pair.Label): connected entity population changed $($pair.First.EntityCount) -> $($pair.Return.EntityCount)") + } + if ($pair.First.AnimationCount -ne $pair.Return.AnimationCount) { + $warnings.Add("$($pair.Label): connected animation population changed $($pair.First.AnimationCount) -> $($pair.Return.AnimationCount)") + } + + $privateLimit = [Math]::Max(384.0, $pair.First.PrivateMiB * 0.35) + $workingLimit = [Math]::Max(384.0, $pair.First.WorkingSetMiB * 0.35) + if (($pair.Return.PrivateMiB - $pair.First.PrivateMiB) -gt $privateLimit) { + $failures.Add("$($pair.Label): private memory grew by $([Math]::Round($pair.Return.PrivateMiB - $pair.First.PrivateMiB, 1)) MiB") + } + if (($pair.Return.WorkingSetMiB - $pair.First.WorkingSetMiB) -gt $workingLimit) { + $failures.Add("$($pair.Label): working set grew by $([Math]::Round($pair.Return.WorkingSetMiB - $pair.First.WorkingSetMiB, 1)) MiB") + } + + if ($null -ne $pair.First.UpdateP95Ms -and $null -ne $pair.Return.UpdateP95Ms) { + $updateLimit = $pair.First.UpdateP95Ms * 1.5 + 0.5 + if ($pair.Return.UpdateP95Ms -gt $updateLimit) { + $failures.Add("$($pair.Label): update p95 regressed $($pair.First.UpdateP95Ms) -> $($pair.Return.UpdateP95Ms) ms") + } + } + if ($null -ne $pair.First.AllocP50Kb -and $null -ne $pair.Return.AllocP50Kb) { + $allocLimit = $pair.First.AllocP50Kb * 1.5 + 16.0 + if ($pair.Return.AllocP50Kb -gt $allocLimit) { + $failures.Add("$($pair.Label): allocation p50 regressed $($pair.First.AllocP50Kb) -> $($pair.Return.AllocP50Kb) KiB/frame") + } + } + } +} + +function Add-LogFailures { + $fatalPatterns = @( + 'Unhandled exception', + 'AccessViolation', + 'OutOfMemoryException', + 'WeenieError', + 'device removed', + 'GPU reset', + 'live: disconnected' + ) + foreach ($pattern in $fatalPatterns) { + $count = (Get-PatternCount $stdoutLog $pattern) + (Get-PatternCount $stderrLog $pattern) + if ($count -gt 0) { $failures.Add("diagnostic '$pattern' appeared $count time(s)") } + } + + $missingVfx = (Get-PatternCount $stderrLog 'No PhysicsScriptTable') + + (Get-PatternCount $stderrLog 'ParticleEmitterInfo') + if ($missingVfx -gt 0) { + $warnings.Add("DAT-driven VFX diagnostics: $missingVfx missing table/emitter message(s)") + } + + $missingLandblocks = Get-PatternCount $stdoutLog 'LandblockLoader.Load returned null' + if ($missingLandblocks -gt 0) { + $warnings.Add("World-edge empty landblocks: $missingLandblocks load miss(es)") + } +} + +function Close-ClientGracefully([Diagnostics.Process]$Client) { + $Client.Refresh() + if ($Client.HasExited) { return $true } + $requested = $Client.CloseMainWindow() + if (-not $requested) { return $false } + if (-not $Client.WaitForExit(30000)) { return $false } + $Client.WaitForExit() + return $true +} + +if (-not (Test-Path -LiteralPath $routePath)) { throw "route file not found: $routePath" } +if (@(Get-Process -Name AcDream.App -ErrorAction SilentlyContinue).Count -gt 0) { + throw 'an AcDream.App client is already running; close it gracefully before the soak' +} + +$udpOwner = @(Get-NetUDPEndpoint -LocalPort 9000 -ErrorAction SilentlyContinue) +if ($udpOwner.Count -eq 0) { + throw 'local ACE is not listening on UDP port 9000' +} + +if (-not $SkipBuild) { + Write-Output 'Building Release solution...' + & dotnet build (Join-Path $Repository 'AcDream.slnx') -c Release --no-restore + if ($LASTEXITCODE -ne 0) { throw "Release build failed with exit code $LASTEXITCODE" } +} +if (-not (Test-Path -LiteralPath $exe)) { throw "client executable not found: $exe" } + +$commit = (& git -C $Repository rev-parse HEAD).Trim() +$sourceStatus = @(& git -C $Repository status --short) +$videoControllers = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | ForEach-Object { + [pscustomobject]@{ Name = $_.Name; DriverVersion = $_.DriverVersion; AdapterRam = $_.AdapterRAM } +}) + +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_LIVE = '1' +$env:ACDREAM_TEST_HOST = '127.0.0.1' +$env:ACDREAM_TEST_PORT = '9000' +$env:ACDREAM_TEST_USER = $Account +$env:ACDREAM_TEST_PASS = $Password +$env:ACDREAM_RETAIL_UI = '1' +$env:ACDREAM_FRAME_PROF = '1' +$env:ACDREAM_UNCAPPED_RENDER = '1' +$env:ACDREAM_DEVTOOLS = '0' +$env:ACDREAM_UI_PROBE_DUMP = '0' +$env:ACDREAM_UI_PROBE_SCRIPT = $routePath +$env:ACDREAM_DUMP_MOVE_TRUTH = '1' +$env:ACDREAM_NO_AUDIO = $null +$env:ACDREAM_WB_DIAG = $null + +try { + $process = Start-Process -FilePath $exe -WorkingDirectory $Repository ` + -RedirectStandardOutput $stdoutLog -RedirectStandardError $stderrLog -PassThru + Write-Marker "launch pid=$($process.Id) commit=$commit session='$env:SESSIONNAME'" + + $null = Wait-ForLogPattern $process $stdoutLog 'live: in world' 1 $LoginTimeoutSeconds + $null = Wait-ForLogPattern $process $stdoutLog 'live: first player position' 1 $LoginTimeoutSeconds + Write-Marker ("in-world elapsed={0:N1}s" -f $stopwatch.Elapsed.TotalSeconds) + + foreach ($destination in $destinations) { + $null = Wait-ForLogPattern $process $stdoutLog 'live: teleport materialized' $destination.Occurrence 60 + Write-Marker ("materialized destination='{0}' occurrence={1} elapsed={2:N1}s" -f ` + $destination.Name, $destination.Occurrence, $stopwatch.Elapsed.TotalSeconds) + + $exerciseBaseline = [pscustomobject]@{ + ForwardPress = Get-PatternCount $stdoutLog '[input] MovementForward Press' + ForwardRelease = Get-PatternCount $stdoutLog '[input] MovementForward Release' + JumpPress = Get-PatternCount $stdoutLog '[input] MovementJump Press' + JumpRelease = Get-PatternCount $stdoutLog '[input] MovementJump Release' + Combat = Get-PatternCount $stdoutLog '[input] CombatToggleCombat Press' + MoveTruth = Get-PatternCount $stdoutLog 'move-truth OUT' + } + + $null = Wait-ForLogPattern $process $stdoutLog '[input] MovementTurnRight Press' $destination.Occurrence 25 + $turnProfileStart = (Get-FrameProfiles $stdoutLog).Count + $turnResult = Wait-ForNextFrameProfile $process $stdoutLog $turnProfileStart 12 + $turnSample = Capture-Sample $process $destination.Name 'turn' $turnResult.Line + Write-Marker "turn-sample destination='$($destination.Name)' cpuP95=$($turnSample.CpuP95Ms) gpuP95=$($turnSample.GpuP95Ms)" + + $null = Wait-ForLogPattern $process $stdoutLog '[input] MovementTurnRight Release' $destination.Occurrence 12 + # LiveEntityLivenessController's authoritative absence deadline is 25 + # seconds. Sample only after that deadline so a previous destination's + # records cannot masquerade as retained memory in the return gate. + Start-Sleep -Seconds 12 + $stationaryProfileStart = (Get-FrameProfiles $stdoutLog).Count + $stationaryResult = Wait-ForNextFrameProfile $process $stdoutLog $stationaryProfileStart 12 + $stationarySample = Capture-Sample $process $destination.Name 'stationary' $stationaryResult.Line + Write-Marker "stationary-sample destination='$($destination.Name)' wsMiB=$($stationarySample.WorkingSetMiB) privateMiB=$($stationarySample.PrivateMiB) ent=$($stationarySample.EntityCount) anim=$($stationarySample.AnimationCount) updateP95=$($stationarySample.UpdateP95Ms)" + + if ($destination.Exercise) { + Observe-R6Exercise $process $destination.Name $exerciseBaseline + } + } + + if ((Get-PatternCount $stdoutLog 'live: teleport materialized') -ne $destinations.Count) { + $failures.Add("expected $($destinations.Count) teleport materializations") + } + + Add-RelativeGateFailures + Add-LogFailures + + Write-Marker 'route-complete requesting graceful close' + $gracefulExit = Close-ClientGracefully $process + $process.Refresh() + if ($process.HasExited) { $exitCode = [int]$process.ExitCode } + if (-not $gracefulExit) { $failures.Add('client did not exit through the graceful WM_CLOSE path') } + if ($null -ne $exitCode -and $exitCode -ne 0) { $failures.Add("client exit code was $exitCode") } +} +catch { + $failures.Add($_.Exception.Message) + Write-Marker "ERROR $($_.Exception.Message)" +} +finally { + if ($null -ne $process) { + $process.Refresh() + if (-not $process.HasExited) { + $gracefulExit = Close-ClientGracefully $process + if (-not $gracefulExit -and -not $process.HasExited) { + $failures.Add('client remained alive 30 seconds after WM_CLOSE and required forced termination') + Stop-Process -Id $process.Id -Force + $process.WaitForExit(10000) + } + } + if ($process.HasExited -and $null -eq $exitCode) { $exitCode = [int]$process.ExitCode } + $process.Dispose() + } + + $samples | Export-Csv -LiteralPath $sampleCsv -NoTypeInformation + $report = [pscustomobject][ordered]@{ + Passed = $failures.Count -eq 0 + StartedUtc = [DateTime]::UtcNow.Subtract($stopwatch.Elapsed).ToString('O') + FinishedUtc = [DateTime]::UtcNow.ToString('O') + ElapsedSeconds = [Math]::Round($stopwatch.Elapsed.TotalSeconds, 3) + Commit = $commit + SessionName = $env:SESSIONNAME + ExitCode = $exitCode + GracefulExit = $gracefulExit + Failures = @($failures) + Warnings = @($warnings) + SourceStatus = $sourceStatus + VideoControllers = $videoControllers + Samples = @($samples) + Artifacts = [pscustomobject]@{ + Stdout = $stdoutLog + Stderr = $stderrLog + Markers = $markerLog + SamplesCsv = $sampleCsv + ReportJson = $reportJson + } + } + $report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $reportJson -Encoding utf8 + + Write-Output "REPORT=$reportJson" + Write-Output "SAMPLES=$sampleCsv" + Write-Output "RESULT=$(if ($report.Passed) { 'PASS' } else { 'FAIL' })" + foreach ($failure in $failures) { Write-Output "FAILURE=$failure" } + foreach ($warning in $warnings) { Write-Output "WARNING=$warning" } +} + +if ($failures.Count -gt 0) { exit 1 }