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:
parent
b03371c03d
commit
68578fa5fa
10 changed files with 372 additions and 41 deletions
|
|
@ -102,6 +102,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses ONLY `handle_all_collisions` + `cached_velocity` on a no-move frame; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.App/Input/PlayerMovementController.cs` (`candidateMoved` guard) | The load-bearing effect is not re-zeroing the gravity velocity that rebuilds after a stuck-fall bleed; the zero-distance resolve is a near-no-op (numSteps 0 → the zero-step early return, no ValidateTransition, contact plane persists via the writeback), so running it is harmless while keeping acdream's per-frame cell/membership refresh | If the zero-distance resolve ever gains a side effect on a no-move frame (a contact-plane clear, an fsf change), it would diverge from retail's skip — a no-move frame must stay a near-no-op | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 pc:283657 (candidate-moved gate) |
|
||||
| AD-42 | Enter-world placement is split across two Core calls: legacy `Resolve` performs retail `AdjustPosition` + the host's established floor snap, then `ResolvePlacement` runs the verbatim object-aware `find_placement_pos` ring search. Retail runs initial environment placement, ring search, and final step-down inside one `find_placement_position` transition | `src/AcDream.App/Rendering/GameWindow.cs` (`EnterPlayerModeNow`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolvePlacement`) | The first call has already committed the same validated cell/floor point that feeds the ring search; the second call uses the same sphere dimensions, collision registry, and cell id. Keeping the split preserves the proven indoor-login snap while adding the missing occupied-position behavior | A spawn that requires retail's final placement step-down after a ring candidate (rather than the existing floor snap before it) could settle at a slightly different Z on a ledge/water boundary; the overlap is still cleared | `CPhysicsObj::enter_world` 0x00516170; `CTransition::find_placement_position` 0x0050C170; `CTransition::find_placement_pos` 0x0050BA50 |
|
||||
| AD-43 | A malformed/custom PhysicsScript `CallPES` cycle whose script timeline never advances is rejected with a diagnostic; retail's linked scheduler would continue draining that zero-time tail indefinitely | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` (timeline-progress ancestry guard) | Prevents corrupt DAT content from hanging the single update/render thread. Installed-DAT audit plus conformance tests prove the real rolling-weather cycles advance 2.8 seconds per edge and continue unchanged; only a no-progress strongly connected cycle is rejected | A custom DAT that deliberately relies on an infinite zero-time loop observes a rejected play instead of freezing the client | `ScriptManager::AddScriptInternal` 0x0051B310; `ScriptManager::UpdateScripts` 0x0051B480; `CPhysicsObj::CallPES` 0x00511AF0 |
|
||||
| AD-44 | Native-window close performs retail's complete character-logoff handshake and transport disconnect, then exits the process instead of retaining the authenticated logon connection and returning to character selection. acdream has one active `ReceiverData` equivalent, so `ClientNet::LogOffServer`'s per-receiver disconnect loop sends one header. | `src/AcDream.Core.Net/WorldSession.cs` (`Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | The application currently auto-selects one character and has no character-selection presentation/state owner to receive the returned authenticated session. Close must still release ACE immediately and in retail order. | Closing is correct, but an eventual in-client "log off character" action cannot reuse this process-exit path; it needs an App/session transition that retains the socket after server `0xF653` rather than calling `Dispose`. | `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ In-scope: 51. Implemented in acdream: 21. Phase M target delta: 30.
|
|||
| 0xEA60 | inbound | AdminEnvirons | `CPlayerSystem::Handle_Admin__Environs` | – | B | P+W | P+W | Fog presets / sound cues |
|
||||
| 0xF625 | inbound | ObjDescEvent | `SmartBox::HandleObjDescEvent` | PB | B | P+W | P+W | Per-entity appearance update |
|
||||
| 0xF643 | inbound | CharacterCreateResponse | – | PB | B | – | –defer:char-creation | Char-creation flow not yet built |
|
||||
| 0xF653 | outbound | CharacterLogOff | – | PB | P | B | PB+W | Sent on Dispose; ACE accepts |
|
||||
| 0xF653 | both | CharacterLogOff | – | PB | P | B | PB+W | Request carries active character GUID; opcode-only server confirmation gates transport disconnect |
|
||||
| 0xF655 | both | CharacterDelete | – | PB | P | – | –defer:char-mgmt | Char-management UI deferred |
|
||||
| 0xF656 | outbound | CharacterCreate | – | PB | P | – | –defer:char-creation | Char-creation flow not yet built |
|
||||
| 0xF657 | outbound | CharacterEnterWorld | `CM_Login::SendNotice_BeginEnterWorld` [^m-2] | PB | P | B | PB+W | Built; sent during handshake |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
# Retail graceful character logout
|
||||
|
||||
## Oracle
|
||||
|
||||
- `Proto_UI::LogOffCharacter` `0x00546A20`
|
||||
- `CPlayerSystem::RequestLogOff` `0x00562DD0`
|
||||
- inbound login-message dispatch at `0x0055C963`
|
||||
- `CPlayerSystem::ExecuteLogOff` `0x0055D780`
|
||||
- `ClientNet::ExitWorldDisconnect` `0x00541E00`
|
||||
- `ClientNet::LogOffServer` `0x00543EF0`
|
||||
- `SharedNet::SendOptionalHeader` `0x00543160`
|
||||
- `CPlayerSystem::LogOffCharacter` `0x00563520`
|
||||
|
||||
Cross-checks:
|
||||
|
||||
- ACE `CharacterHandler.CharacterLogOff` begins asynchronous player removal.
|
||||
- ACE `Session.SendFinalLogOffMessages` sends the opcode-only `CharacterLogOff`
|
||||
confirmation only after the player no longer owns a landblock, then returns
|
||||
the session to `AuthConnected`.
|
||||
- Holtburger currently sends opcode-only and disconnects immediately. Its own
|
||||
comment records that AC normally waits for the server response. That shortcut
|
||||
is not the retail oracle and was the source of acdream's reconnect race.
|
||||
|
||||
## Pseudocode
|
||||
|
||||
```text
|
||||
LogOffCharacter(force):
|
||||
save player module
|
||||
if force:
|
||||
ExecuteLogOff()
|
||||
return
|
||||
if no interaction is pending:
|
||||
RequestLogOff()
|
||||
else:
|
||||
print "Logging off..."
|
||||
remember that logout must begin after the interaction
|
||||
|
||||
RequestLogOff():
|
||||
print "Logging off..."
|
||||
send_to_logon([0xF653, active_player_id])
|
||||
logOffRequested = true
|
||||
logOffRequestTime = now + 3 seconds
|
||||
if player is PK:
|
||||
logOffRequestTime += 20 seconds
|
||||
|
||||
on inbound login message 0xF653:
|
||||
ExecuteLogOff()
|
||||
|
||||
ExecuteLogOff():
|
||||
clear player-login state
|
||||
for every live ReceiverData connection:
|
||||
send a cleartext, empty DISCONNECT optional header
|
||||
use sequence 0 plus that receiver's network id and iteration
|
||||
disconnect the world connection
|
||||
clear the active player id
|
||||
```
|
||||
|
||||
## acdream close-only adaptation
|
||||
|
||||
acdream does not yet return to character selection when its native window is
|
||||
closed. It retains retail's important ordering: send the eight-byte request,
|
||||
continue receiving until the server's opcode-only confirmation arrives, then
|
||||
send the transport `DISCONNECT` and release the socket. World callbacks are not
|
||||
dispatched while shutdown drains the raw receive queue because their App owners
|
||||
may already be tearing down. A bounded 35-second wait covers retail's additional
|
||||
20-second PK logout delay and is only a transport safety
|
||||
limit; the normal path advances on the authoritative confirmation, not elapsed
|
||||
time. The connected gate separately observes ACE accepting the transport header
|
||||
before opening a replacement process; ACE removes the authenticated session on
|
||||
its world-manager tick, after the UDP sender has already completed.
|
||||
|
|
@ -181,7 +181,7 @@ is the opcode itself, followed immediately by the payload.
|
|||
| 0xF619 | PositionAndMovement | GM | S→C | unhandled | P4 | Ghost opcode — declared but never fired by ACE. |
|
||||
| 0xF625 | ObjDescEvent | GM | S→C | unhandled | P1 | `u32 guid, ObjectDescription` — full re-send of visual description (body parts, textures, palettes). Critical for seeing other players' gear changes. |
|
||||
| 0xF643 | CharacterCreateResponse / CharacterRestoreResponse | GM | S→C | unhandled | P0+ | `u32 responseCode` — login-phase, only relevant after char-create. Same opcode, two semantics (disambiguated by session state). |
|
||||
| 0xF653 | CharacterLogOff | GM | bi | partial | P0 | No payload. Client sends before Disconnect to release the character lock immediately. acdream sends it from `Dispose`. |
|
||||
| 0xF653 | CharacterLogOff | GM | bi | parsed | P0 | Client request is opcode + active character GUID; server confirmation is opcode-only. acdream waits for confirmation before transport Disconnect. |
|
||||
| 0xF655 | CharacterDelete | GM | bi | unhandled | P4 | `u32 slot` — char-select-screen deletion. |
|
||||
| 0xF656 | CharacterCreate | GM | C→S | unhandled | P4 | Full character-creation blob — heritage, gender, starting town, appearance. |
|
||||
| 0xF657 | CharacterEnterWorld | GM | C→S | done | P0 | `u32 characterGuid, string16L account`. Built by `Messages/CharacterEnterWorld.cs`. |
|
||||
|
|
@ -859,8 +859,9 @@ All the P3/P4 tails. Ship when we need them, not before.
|
|||
- **0x00A3/0x00A4 FellowshipQuit/Dismiss** — same pairing; C→S in
|
||||
GameAction, S→C in GameEvent.
|
||||
- **0xF653 CharacterLogOff** — bi-directional, same opcode, no
|
||||
envelope. Client sends to request logout; server sends the echo
|
||||
before Disconnect.
|
||||
envelope. Retail's client request carries the active character GUID;
|
||||
the server confirmation is opcode-only. The client waits for that
|
||||
confirmation before disconnecting the world transport.
|
||||
- **0x01A8 MagicRemoveSpell** — C→S is GameAction, S→C is GameEvent.
|
||||
- **0xF7DE TurbineChat** — bi-directional GameMessage. Blob layout
|
||||
includes a `ChatNetworkBlobType` discriminant (EVENT_BINARY=1,
|
||||
|
|
|
|||
38
src/AcDream.Core.Net/Messages/CharacterLogOff.cs
Normal file
38
src/AcDream.Core.Net/Messages/CharacterLogOff.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Packets;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Retail character-logoff request and server confirmation (opcode
|
||||
/// <c>0xF653</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail <c>Proto_UI::LogOffCharacter</c> at <c>0x00546A20</c> sends the
|
||||
/// opcode followed by the active character id. The server confirmation uses
|
||||
/// the same opcode with no trailing payload. <c>CPlayerSystem::RequestLogOff</c>
|
||||
/// at <c>0x00562DD0</c> waits for that confirmation; its inbound dispatch calls
|
||||
/// <c>CPlayerSystem::ExecuteLogOff</c> at <c>0x0055D780</c>, which only then
|
||||
/// disconnects the world connection.
|
||||
/// </remarks>
|
||||
public static class CharacterLogOff
|
||||
{
|
||||
public const uint Opcode = 0xF653u;
|
||||
|
||||
/// <summary>Builds retail's eight-byte client request.</summary>
|
||||
public static byte[] BuildRequestBody(uint characterId)
|
||||
{
|
||||
var writer = new PacketWriter(8);
|
||||
writer.WriteUInt32(Opcode);
|
||||
writer.WriteUInt32(characterId);
|
||||
return writer.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether a complete game-message body is the server's logoff
|
||||
/// confirmation. ACE emits the canonical four-byte opcode-only form.
|
||||
/// </summary>
|
||||
public static bool IsConfirmation(ReadOnlySpan<byte> body) =>
|
||||
body.Length >= sizeof(uint) &&
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body) == Opcode;
|
||||
}
|
||||
31
src/AcDream.Core.Net/Packets/TransportDisconnect.cs
Normal file
31
src/AcDream.Core.Net/Packets/TransportDisconnect.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
namespace AcDream.Core.Net.Packets;
|
||||
|
||||
/// <summary>
|
||||
/// Builds retail's connection-level disconnect packet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>ClientNet::LogOffServer</c> at <c>0x00543EF0</c> creates the
|
||||
/// <c>0x8000</c> optional header and sends it through
|
||||
/// <c>SharedNet::SendOptionalHeader</c> at <c>0x00543160</c>. That path
|
||||
/// zero-initializes the packet sequence and copies the receiver's network id
|
||||
/// and iteration into the fixed header. The disconnect is cleartext and has
|
||||
/// no body.
|
||||
/// </remarks>
|
||||
public static class TransportDisconnect
|
||||
{
|
||||
public static byte[] Build(ushort networkId, ushort iteration)
|
||||
{
|
||||
var header = new PacketHeader
|
||||
{
|
||||
Sequence = 0,
|
||||
Flags = PacketHeaderFlags.Disconnect,
|
||||
Id = networkId,
|
||||
Iteration = iteration,
|
||||
};
|
||||
|
||||
return PacketCodec.Encode(
|
||||
header,
|
||||
ReadOnlySpan<byte>.Empty,
|
||||
outboundIsaac: null);
|
||||
}
|
||||
}
|
||||
|
|
@ -28,10 +28,9 @@ namespace AcDream.Core.Net;
|
|||
/// </code>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Not yet provided</b> (deferred): ACK pump, retransmit handling,
|
||||
/// delete-object processing, position updates, chat, disconnect detection.
|
||||
/// The current client is one-shot — connect, enter the world, stream
|
||||
/// events for a few seconds, let the test harness tear it down.
|
||||
/// <b>Still deferred:</b> retransmit handling and unsolicited-disconnect
|
||||
/// recovery. ACKs, world updates, chat, and retail-ordered graceful logout
|
||||
/// are live.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class WorldSession : IDisposable
|
||||
|
|
@ -627,6 +626,7 @@ public sealed class WorldSession : IDisposable
|
|||
private IsaacRandom? _inboundIsaac;
|
||||
private IsaacRandom? _outboundIsaac;
|
||||
private ushort _sessionClientId;
|
||||
private ushort _sessionIteration;
|
||||
private uint _clientPacketSequence;
|
||||
private uint _fragmentSequence = 1;
|
||||
|
||||
|
|
@ -639,6 +639,9 @@ public sealed class WorldSession : IDisposable
|
|||
private ushort _serverControlSequence;
|
||||
private ushort _teleportSequence;
|
||||
private ushort _forcePositionSequence;
|
||||
private uint _activeCharacterId;
|
||||
private int _characterLogOffConfirmed;
|
||||
private int _disposeStarted;
|
||||
|
||||
// Phase A.3: background receive thread buffers raw UDP datagrams into
|
||||
// a channel so the render thread never blocks on socket I/O.
|
||||
|
|
@ -739,6 +742,10 @@ public sealed class WorldSession : IDisposable
|
|||
_inboundIsaac = new IsaacRandom(serverSeedBytes);
|
||||
_outboundIsaac = new IsaacRandom(clientSeedBytes);
|
||||
_sessionClientId = (ushort)opt.ConnectRequestClientId;
|
||||
// SharedNet::SendOptionalHeader @ 0x00543160 copies this ReceiverData
|
||||
// generation into connection-level control packets, including the
|
||||
// final disconnect. ACE currently emits iteration 1.
|
||||
_sessionIteration = cr.Header.Iteration;
|
||||
_clientPacketSequence = 2;
|
||||
|
||||
byte[] crBody = new byte[8];
|
||||
|
|
@ -772,6 +779,7 @@ public sealed class WorldSession : IDisposable
|
|||
|
||||
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
|
||||
var chosen = Characters.Characters[characterIndex];
|
||||
_activeCharacterId = chosen.Id;
|
||||
Transition(State.EnteringWorld);
|
||||
|
||||
SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody());
|
||||
|
|
@ -910,7 +918,10 @@ public sealed class WorldSession : IDisposable
|
|||
return true;
|
||||
}
|
||||
|
||||
private void ProcessDatagram(byte[] bytes, List<uint>? opcodesOut = null)
|
||||
private void ProcessDatagram(
|
||||
byte[] bytes,
|
||||
List<uint>? opcodesOut = null,
|
||||
bool dispatchWorldEvents = true)
|
||||
{
|
||||
var dec = PacketCodec.TryDecode(bytes, _inboundIsaac);
|
||||
if (!dec.IsOk) return;
|
||||
|
|
@ -954,6 +965,19 @@ public sealed class WorldSession : IDisposable
|
|||
uint op = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
||||
opcodesOut?.Add(op);
|
||||
|
||||
// Retail waits for the server's opcode-only CharacterLogOff echo
|
||||
// before tearing down the world connection. Record it even while
|
||||
// Dispose is draining the raw receive queue without dispatching
|
||||
// world callbacks to owners that are already shutting down.
|
||||
if (CharacterLogOff.IsConfirmation(body))
|
||||
{
|
||||
Interlocked.Exchange(ref _characterLogOffConfirmed, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!dispatchWorldEvents)
|
||||
continue;
|
||||
|
||||
if (op == CharacterList.Opcode && Characters is null)
|
||||
{
|
||||
try { Characters = CharacterList.Parse(body); }
|
||||
|
|
@ -1915,41 +1939,48 @@ public sealed class WorldSession : IDisposable
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Graceful shutdown: tell the server we're leaving so it releases the
|
||||
/// character lock immediately instead of waiting 60s for the session to
|
||||
/// time out. Pattern from
|
||||
/// <c>references/holtburger/crates/holtburger-core/src/client/commands.rs</c>
|
||||
/// lines 879-892: send <c>CharacterLogOff</c> game message (opcode
|
||||
/// 0xF653, no payload) then send a bare <c>DISCONNECT</c> control
|
||||
/// packet (header flag 0x8000, no payload).
|
||||
/// Graceful shutdown: request character logoff, wait for the server's
|
||||
/// authoritative <c>0xF653</c> confirmation, and only then disconnect the
|
||||
/// transport. This is retail's <c>CPlayerSystem::RequestLogOff</c> to
|
||||
/// inbound confirmation to <c>ExecuteLogOff</c> ordering. It prevents a
|
||||
/// replacement session from racing the old character's asynchronous
|
||||
/// removal on ACE.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
|
||||
return;
|
||||
|
||||
if (CurrentState == State.InWorld)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Tell ACE "player is leaving the world" so it cleans up
|
||||
// the character immediately.
|
||||
var logoff = new Packets.PacketWriter(8);
|
||||
logoff.WriteUInt32(0xF653u); // CharacterLogOff opcode
|
||||
SendGameMessage(logoff.ToArray());
|
||||
// Proto_UI::LogOffCharacter @ 0x00546A20 sends the active id.
|
||||
Interlocked.Exchange(ref _characterLogOffConfirmed, 0);
|
||||
SendGameMessage(CharacterLogOff.BuildRequestBody(_activeCharacterId));
|
||||
Console.WriteLine(
|
||||
$"[session] graceful logout requested character=0x{_activeCharacterId:X8}");
|
||||
|
||||
// Tell the transport layer "close this session."
|
||||
var disconnectHeader = new PacketHeader
|
||||
{
|
||||
Sequence = _clientPacketSequence++,
|
||||
Flags = PacketHeaderFlags.Disconnect,
|
||||
Id = _sessionClientId,
|
||||
};
|
||||
byte[] disconnectPacket = PacketCodec.Encode(
|
||||
disconnectHeader, ReadOnlySpan<byte>.Empty, outboundIsaac: null);
|
||||
_net.Send(disconnectPacket);
|
||||
if (WaitForCharacterLogOffConfirmation(TimeSpan.FromSeconds(35)))
|
||||
Console.WriteLine("[session] graceful logout confirmed");
|
||||
else
|
||||
Console.Error.WriteLine(
|
||||
"[session] graceful logout confirmation timed out; disconnecting transport");
|
||||
}
|
||||
catch
|
||||
catch (Exception error)
|
||||
{
|
||||
// Best-effort — if the socket is already dead, eat the
|
||||
// exception and let Dispose finish cleaning up.
|
||||
Console.Error.WriteLine(
|
||||
$"[session] graceful logout failed: {error.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
SendTransportDisconnect();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[session] transport disconnect failed: {error.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1961,5 +1992,47 @@ public sealed class WorldSession : IDisposable
|
|||
_netCancel.Dispose();
|
||||
|
||||
_net.Dispose();
|
||||
Transition(State.Disconnected);
|
||||
}
|
||||
|
||||
private bool WaitForCharacterLogOffConfirmation(TimeSpan timeout)
|
||||
{
|
||||
using var timeoutSource = new CancellationTokenSource(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
while (Volatile.Read(ref _characterLogOffConfirmed) == 0)
|
||||
{
|
||||
while (_inboundQueue.Reader.TryRead(out byte[]? bytes))
|
||||
{
|
||||
ProcessDatagram(bytes, dispatchWorldEvents: false);
|
||||
if (Volatile.Read(ref _characterLogOffConfirmed) != 0)
|
||||
return true;
|
||||
}
|
||||
|
||||
_inboundQueue.Reader.WaitToReadAsync(timeoutSource.Token)
|
||||
.AsTask()
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (ChannelClosedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void SendTransportDisconnect()
|
||||
{
|
||||
byte[] disconnectPacket = TransportDisconnect.Build(
|
||||
_sessionClientId,
|
||||
_sessionIteration);
|
||||
_net.Send(disconnectPacket);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
public sealed class CharacterLogOffTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildRequestBody_MatchesRetailOpcodeThenCharacterId()
|
||||
{
|
||||
byte[] body = CharacterLogOff.BuildRequestBody(0x5000000Au);
|
||||
|
||||
Assert.Equal(8, body.Length);
|
||||
Assert.Equal(
|
||||
CharacterLogOff.Opcode,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body));
|
||||
Assert.Equal(
|
||||
0x5000000Au,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsConfirmation_AcceptsServerOpcodeOnlyBody()
|
||||
{
|
||||
byte[] body = BitConverter.GetBytes(CharacterLogOff.Opcode);
|
||||
|
||||
Assert.True(CharacterLogOff.IsConfirmation(body));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsConfirmation_RejectsTruncatedOrDifferentMessage()
|
||||
{
|
||||
Assert.False(CharacterLogOff.IsConfirmation([0x53, 0xF6, 0x00]));
|
||||
Assert.False(CharacterLogOff.IsConfirmation(BitConverter.GetBytes(0xF654u)));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
using AcDream.Core.Net.Packets;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Packets;
|
||||
|
||||
public sealed class TransportDisconnectTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_MatchesRetailOptionalHeaderShape()
|
||||
{
|
||||
byte[] datagram = TransportDisconnect.Build(0x1234, 0x0001);
|
||||
|
||||
Assert.Equal(PacketHeader.Size, datagram.Length);
|
||||
|
||||
PacketHeader header = PacketHeader.Unpack(datagram);
|
||||
Assert.Equal(0u, header.Sequence);
|
||||
Assert.Equal(PacketHeaderFlags.Disconnect, header.Flags);
|
||||
Assert.Equal((ushort)0x1234, header.Id);
|
||||
Assert.Equal((ushort)0x0001, header.Iteration);
|
||||
Assert.Equal((ushort)0, header.DataSize);
|
||||
|
||||
PacketCodec.PacketDecodeResult decoded = PacketCodec.TryDecode(
|
||||
datagram,
|
||||
inboundIsaac: null);
|
||||
Assert.True(decoded.IsOk);
|
||||
}
|
||||
}
|
||||
|
|
@ -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') `
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue