test(runtime): add unattended connected R6 soak
Drive turn, movement, jump, and combat through the production InputDispatcher so connected Release testing works without an interactive Windows desktop. Track held automation actions through normal completion and every shutdown path. Add a seven-destination ACE route with post-liveness memory, allocation, update, fatal-log, outbound-movement, and graceful-close gates. Record dynamic ACE population changes as context instead of a false lifetime oracle, and document the accepted rebaseline. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
f961d70023
commit
a755b764bf
14 changed files with 1021 additions and 20 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
|
|
|||
73
docs/research/2026-07-20-connected-r6-soak.md
Normal file
73
docs/research/2026-07-20-connected-r6-soak.md
Normal file
|
|
@ -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-<timestamp>.*` 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.
|
||||
|
|
@ -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)));
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string> Log);
|
||||
Action<string> Log,
|
||||
Func<InputAction, bool> PressInput,
|
||||
Func<InputAction, bool, bool> 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),
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Tiny one-thread script runner for <see cref="RetailUiAutomationProbe"/>.
|
||||
/// It is intended for local diagnostic launches, not for gameplay. Commands
|
||||
/// execute on render ticks and use the same <see cref="UiRoot"/> input path as
|
||||
/// physical mouse input.
|
||||
/// execute on render ticks. Pointer commands use the same <see cref="UiRoot"/>
|
||||
/// path as physical mouse input; semantic input uses the production dispatcher.
|
||||
/// </summary>
|
||||
public sealed class RetailUiAutomationScriptRunner
|
||||
public sealed class RetailUiAutomationScriptRunner : IDisposable
|
||||
{
|
||||
private readonly RetailUiAutomationProbe _probe;
|
||||
private readonly Action<string> _log;
|
||||
private readonly Action<string>? _submitCommand;
|
||||
private readonly Func<InputAction, bool>? _pressInput;
|
||||
private readonly Func<InputAction, bool, bool>? _setInputHeld;
|
||||
private readonly List<ScriptCommand> _commands = new();
|
||||
private readonly HashSet<InputAction> _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<string>? log = null,
|
||||
Action<string>? submitCommand = null)
|
||||
Action<string>? submitCommand = null,
|
||||
Func<InputAction, bool>? pressInput = null,
|
||||
Func<InputAction, bool, bool>? 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 <InputAction>");
|
||||
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 <InputAction>");
|
||||
}
|
||||
}
|
||||
|
||||
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('#');
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ public sealed class InputDispatcher
|
|||
private readonly Stack<InputScope> _scopes = new();
|
||||
private InputScope? _combatScope;
|
||||
private readonly HashSet<KeyChord> _heldHoldChords = new();
|
||||
private readonly HashSet<InputAction> _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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic-only semantic press injection used by the connected retail-UI
|
||||
/// automation runner. The action enters through the same multicast
|
||||
/// <see cref="Fired"/> stream as a resolved physical binding, but does not
|
||||
/// invent a keyboard chord or depend on an interactive Windows desktop.
|
||||
/// </summary>
|
||||
/// <returns><see langword="false"/> when modal/text capture owns the
|
||||
/// keyboard and a physical press would have been suppressed.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic-only held-action injection. A down edge emits the same
|
||||
/// <see cref="ActivationType.Press"/> transition as a Hold binding and is
|
||||
/// visible to <see cref="IsActionHeld"/> until the matching up edge emits
|
||||
/// <see cref="ActivationType.Release"/>. 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Topmost scope on the stack — what the dispatcher looks up first.</summary>
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<InputAction>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AggregateException>(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"));
|
||||
|
|
|
|||
|
|
@ -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<ArgumentOutOfRangeException>(
|
||||
() => dispatcher.TryInvokeAutomationAction(undefined));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => dispatcher.TrySetAutomationActionHeld(undefined, true));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
145
tools/connected-r6-soak.route.txt
Normal file
145
tools/connected-r6-soak.route.txt
Normal file
|
|
@ -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
|
||||
485
tools/run-connected-r6-soak.ps1
Normal file
485
tools/run-connected-r6-soak.ps1
Normal file
|
|
@ -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=(?<n>\d+)') {
|
||||
$values.FrameCount = [int]$Matches.n
|
||||
}
|
||||
if ($Line -match 'cpu_ms p50=(?<p50>[\d.]+) p95=(?<p95>[\d.]+) p99=(?<p99>[\d.]+) max=(?<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=(?<p50>[\d.]+) p95=(?<p95>[\d.]+)') {
|
||||
$values.GpuP50Ms = [double]$Matches.p50
|
||||
$values.GpuP95Ms = [double]$Matches.p95
|
||||
}
|
||||
if ($Line -match 'alloc_kb p50=(?<p50>[\d.]+) max=(?<max>[\d.]+)') {
|
||||
$values.AllocP50Kb = [double]$Matches.p50
|
||||
$values.AllocMaxKb = [double]$Matches.max
|
||||
}
|
||||
if ($Line -match 'upd p50=(?<p50>[\d.]+) p95=(?<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 \| (?<fps>\d+) fps \| (?<ms>[\d.]+) ms \| lb (?<loaded>\d+)/(?<total>\d+) \| ent (?<ent>\d+)/anim (?<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 }
|
||||
Loading…
Add table
Add a link
Reference in a new issue