fix(net): honor retail graceful logout handshake

Send the active character id, drain until the authoritative server confirmation, then emit retail's zero-sequence connection disconnect with the negotiated receiver iteration. The connected gate now waits for ACE to remove the exact UDP session before reconnecting, eliminating fixed-delay races.
This commit is contained in:
Erik 2026-07-20 23:31:23 +02:00
parent b03371c03d
commit 68578fa5fa
10 changed files with 372 additions and 41 deletions

View file

@ -3,6 +3,7 @@ param(
[string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
[string]$Account = $env:ACDREAM_TEST_USER,
[string]$Password = $env:ACDREAM_TEST_PASS,
[string]$AceLogPath = 'C:\ACE\Server\ACE_Log.txt',
[switch]$SkipBuild,
[int]$SessionTimeoutSeconds = 420
)
@ -47,11 +48,44 @@ function Wait-ForPattern(
throw "timed out after $TimeoutSeconds seconds waiting for '$Pattern'"
}
function Wait-ForFileAppendPattern(
[string]$Path,
[long]$StartOffset,
[string]$Pattern,
[int]$TimeoutSeconds)
{
$deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds)
while ([DateTime]::UtcNow -lt $deadline) {
if (Test-Path -LiteralPath $Path) {
$stream = [System.IO.File]::Open(
$Path,
[System.IO.FileMode]::Open,
[System.IO.FileAccess]::Read,
[System.IO.FileShare]::ReadWrite)
try {
if ($stream.Length -gt $StartOffset) {
$null = $stream.Seek($StartOffset, [System.IO.SeekOrigin]::Begin)
$reader = [System.IO.StreamReader]::new($stream)
try { $appended = $reader.ReadToEnd() }
finally { $reader.Dispose() }
if ([Text.RegularExpressions.Regex]::IsMatch(
$appended,
$Pattern,
[Text.RegularExpressions.RegexOptions]::CultureInvariant)) { return }
}
}
finally { $stream.Dispose() }
}
Start-Sleep -Milliseconds 100
}
throw "timed out after $TimeoutSeconds seconds waiting for ACE log '$Pattern'"
}
function Close-ClientGracefully([Diagnostics.Process]$Client) {
$Client.Refresh()
if ($Client.HasExited) { return $true }
if (-not $Client.CloseMainWindow()) { return $false }
if (-not $Client.WaitForExit(30000)) { return $false }
if (-not $Client.WaitForExit(45000)) { return $false }
$Client.WaitForExit()
return $true
}
@ -79,7 +113,10 @@ function Add-LogFailures([string]$Label, [string]$Stdout, [string]$Stderr) {
'device removed',
'GPU reset',
'live: disconnected',
'screenshot-failed'
'screenshot-failed',
'graceful logout confirmation timed out',
'graceful logout failed',
'transport disconnect failed'
)
foreach ($pattern in $fatalPatterns) {
$count = (Get-PatternCount $Stdout $pattern) + (Get-PatternCount $Stderr $pattern)
@ -155,9 +192,11 @@ function Invoke-Session(
$stderr = Join-Path $sessionDir 'stderr.log'
$timeline = Join-Path $artifactDir 'world-lifecycle.checkpoints.jsonl'
$client = $null
$clientPort = $null
$graceful = $false
$exitCode = $null
$elapsed = [Diagnostics.Stopwatch]::StartNew()
$aceLogOffset = (Get-Item -LiteralPath $AceLogPath).Length
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = '1'
@ -180,6 +219,12 @@ function Invoke-Session(
-RedirectStandardOutput $stdout -RedirectStandardError $stderr -PassThru
Wait-ForPattern $client $stdout '[UI-PROBE] UI probe script complete' $SessionTimeoutSeconds
$clientPort = @(Get-NetUDPEndpoint -OwningProcess $client.Id -ErrorAction SilentlyContinue |
Select-Object -First 1 -ExpandProperty LocalPort)
if ($clientPort.Count -ne 1) {
throw "could not resolve the client's UDP endpoint for ACE disconnect verification"
}
$client.Refresh()
$processSample = [pscustomobject][ordered]@{
WorkingSetMiB = [Math]::Round($client.WorkingSet64 / 1MB, 1)
@ -206,7 +251,6 @@ function Invoke-Session(
if (-not (Test-Png $png)) { $failures.Add("${Label}: missing or invalid screenshot '$png'") }
}
Add-LogFailures $Label $stdout $stderr
$graceful = Close-ClientGracefully $client
$client.Refresh()
if ($client.HasExited) { $exitCode = [int]$client.ExitCode }
@ -214,6 +258,15 @@ function Invoke-Session(
if ($null -ne $exitCode -and $exitCode -ne 0) {
$failures.Add("${Label}: client exited with code $exitCode")
}
Add-LogFailures $Label $stdout $stderr
if ((Get-PatternCount $stdout '[session] graceful logout confirmed') -ne 1) {
$failures.Add("${Label}: server did not authoritatively confirm graceful character logout")
}
Wait-ForFileAppendPattern `
$AceLogPath `
$aceLogOffset `
"Session .*\\127\.0\.0\.1:$clientPort dropped\..*Reason: PacketHeader Disconnect" `
15
$session = [pscustomobject][ordered]@{
Label = $Label
@ -281,6 +334,9 @@ if (@(Get-Process -Name AcDream.App -ErrorAction SilentlyContinue).Count -gt 0)
if (@(Get-NetUDPEndpoint -LocalPort 9000 -ErrorAction SilentlyContinue).Count -eq 0) {
throw 'local ACE is not listening on UDP port 9000'
}
if (-not (Test-Path -LiteralPath $AceLogPath)) {
throw "ACE log was not found: $AceLogPath"
}
if (-not $SkipBuild) {
& dotnet build (Join-Path $Repository 'AcDream.slnx') -c Release --no-restore
@ -297,10 +353,9 @@ $capped = Invoke-Session `
Add-SameLocationGates $capped
# The second process is both the session-teardown/reconnect gate and the
# uncapped renderer sample. A short pause lets ACE finish releasing the first
# session before the same account logs in again.
Start-Sleep -Seconds 3
# The second process starts as soon as ACE records accepting the first
# process's transport Disconnect. No elapsed-time settle delay hides a
# shutdown race.
$null = Invoke-Session `
'uncapped-reconnect' `
(Join-Path $Repository 'tools\connected-world-reconnect.route.txt') `