diff --git a/CLAUDE.md b/CLAUDE.md index 535498b5..77442454 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,6 +137,7 @@ Communication: - **Maple2.File.Ingest** - Tools for importing game data from MapleStory2 client files - **Maple2.Server.Tests** - NUnit test suite - **Maple2.Server.DebugGame** - Debug/development version of game server +- **Maple2.TestClient** - Headless bot client for automated server testing (login, field entry, combat simulation). See [Maple2.TestClient/CLAUDE.md](Maple2.TestClient/CLAUDE.md) ### Networking Layer @@ -397,6 +398,7 @@ Maple2/ ├── Maple2.Model/ # Shared data models ├── Maple2.File.Ingest/ # Game data import tool ├── Maple2.Server.Tests/ # Test suite +├── Maple2.TestClient/ # Headless bot client for automated ST └── Maple2.Tools/ # Development tools ``` diff --git a/Maple2.Server.Core/Network/Session.cs b/Maple2.Server.Core/Network/Session.cs index 554a4cc4..35a6d409 100644 --- a/Maple2.Server.Core/Network/Session.cs +++ b/Maple2.Server.Core/Network/Session.cs @@ -21,7 +21,8 @@ public enum PatchType : byte { public abstract class Session : IDisposable { public const uint VERSION = 12; - private const uint BLOCK_IV = 12; // TODO: should this be variable + public const uint BLOCK_IV = 12; // TODO: should this be variable + public const int FIELD_KEY = 0x1234; private const int HANDSHAKE_SIZE = 19; private const int STOP_TIMEOUT = 2000; diff --git a/Maple2.Server.Game/Session/GameSession.cs b/Maple2.Server.Game/Session/GameSession.cs index d4644d6a..fe464c8e 100644 --- a/Maple2.Server.Game/Session/GameSession.cs +++ b/Maple2.Server.Game/Session/GameSession.cs @@ -36,7 +36,6 @@ namespace Maple2.Server.Game.Session; public sealed partial class GameSession : Core.Network.Session { protected override PatchType Type => PatchType.Ignore; - public const int FIELD_KEY = 0x1234; // gameDisposeState: 0 = active, 1 = disposing, 2 = disposed private int gameDisposeState; diff --git a/Maple2.TestClient/CLAUDE.md b/Maple2.TestClient/CLAUDE.md new file mode 100644 index 00000000..f9540132 --- /dev/null +++ b/Maple2.TestClient/CLAUDE.md @@ -0,0 +1,141 @@ +# Maple2.TestClient + +Headless bot client for automated server testing. Simulates the real MapleStory2 client protocol to interact with the server without a game client. + +## Purpose + +- Automate system testing (ST) that previously required manual game client interaction +- Simulate login, character selection, field entry, and combat flows via code +- Enable future CI/automated regression testing and load testing + +## Building & Running + +```bash +# Build +dotnet build Maple2.TestClient/Maple2.TestClient.csproj + +# Run (basic login + enter game) +dotnet run --project Maple2.TestClient -- [host] [port] [username] [password] + +# Run with combat simulation +dotnet run --project Maple2.TestClient -- 127.0.0.1 20001 testbot testbot --skill 10000001 + +# Spawn NPC and attack it +dotnet run --project Maple2.TestClient -- 127.0.0.1 20001 testbot testbot --npc 21000001 --skill 10000001 --skill-level 1 +``` + +Defaults: host=`127.0.0.1`, port=`20001`, username=`testbot`, password=`testbot`. + +Requires a running server stack (World + Login + Game) and an existing character on the account (character creation is not implemented). + +## Architecture + +``` +Maple2.TestClient/ +├── Network/ +│ └── MapleClient.cs # Low-level TCP + MapleCipher encryption/decryption + packet dispatch +├── Clients/ +│ ├── LoginClient.cs # Login server flow (handshake → login → character list → select) +│ └── GameClient.cs # Game server flow (auth → field entry → combat → stay alive) +├── Protocol/ +│ └── ClientPacket.cs # Client-to-server packet constructors (RecvOp packets) +└── Program.cs # Entry point with CLI arg parsing and full flow orchestration +``` + +### Dependencies + +- `Maple2.Server.Core` — SendOp/RecvOp enums, MapleCipher (via Maple2.PacketLib NuGet), ByteWriter/ByteReader +- `Maple2.Model` — Game enums (Locale, ChatType, etc.) +- `Serilog.Sinks.Console` — Logging + +## Protocol Flow + +``` +=== Login Phase === +TCP Connect → LoginServer (port 20001) +Server → Handshake (RequestVersion + VERSION + RIV + SIV + BLOCK_IV + PatchType) +Client → ResponseVersion (version=12, unknown=47, locale=NA) +Server → RequestLogin +Client → ResponseLogin (command=CharacterList, username, password, machineId) +Server → LoginResult + CharacterList packets +Client → CharacterManagement (command=Select, characterId) +Server → LoginToGame (gameServerIP, port, token, mapId) + +=== Game Phase === +TCP Connect → GameServer (port from LoginToGame) +Server → Handshake +Client → ResponseVersion +Server → RequestKey +Client → ResponseKey (accountId, token, machineId) +Server → [initialization packets] → RequestFieldEnter +Client → ResponseFieldEnter (FIELD_KEY=0x1234) +Server → [field state packets] → player is in game + +=== Stay Alive === +Server → ResponseTimeSync (periodic) → Client → RequestTimeSync +Server → RequestHeartbeat → Client → ResponseHeartbeat +``` + +## Key Classes + +### MapleClient (Network/MapleClient.cs) + +Low-level network client handling: +- TCP connection and handshake parsing (6-byte header + 19-byte payload) +- MapleCipher IV initialization (server RIV = client send IV, server SIV = client recv IV) +- IV sync: feeds raw handshake bytes through `TryDecrypt` to align with server's cipher state +- Background receive thread with `SendOp`-based dispatch +- `WaitForPacketAsync(SendOp)` — one-shot async waiter (register BEFORE sending to avoid race conditions) +- `On(SendOp, handler)` — persistent packet handler registration + +### LoginClient (Clients/LoginClient.cs) + +High-level login flow: +- `ConnectAsync()` — TCP connect + handshake + version exchange +- `LoginAsync()` — send credentials, parse LoginResult, collect CharacterList (waits for EndList command=4) +- `SelectCharacterAsync()` — select character, return GameServerInfo (IP, port, token, mapId) + +### GameClient (Clients/GameClient.cs) + +High-level game flow: +- `ConnectAsync()` — auth via ResponseKey, wait for RequestFieldEnter, send ResponseFieldEnter +- `CastSkillAsync()` — send Skill.Use, wait for SkillUse broadcast confirmation +- `AttackTargetAsync()` — send Skill.Attack.Target, wait for SkillDamage broadcast confirmation +- `SpawnNpcAsync()` — send GM `/npc` command, wait for FieldAddNpc +- `StayAliveAsync()` — respond to TimeSync and Heartbeat until cancelled +- Tracks field state: ObjectId, MapId, Position, FieldNpcs dictionary + +### ClientPacket (Protocol/ClientPacket.cs) + +Static packet constructors for all client-to-server packets: +- `ResponseVersion`, `Login`, `SelectCharacter`, `ResponseKey` +- `ResponseFieldEnter`, `RequestTimeSync`, `ResponseHeartbeat` +- `Chat`, `SkillUse`, `SkillAttackTarget` + +## Implementation Notes + +- MapleCipher IV direction is inverted between client and server: server's RIV is the client's send IV +- After handshake, `recvCipher` must be synced by feeding the raw handshake through `TryDecrypt` once +- Always register `WaitForPacketAsync` BEFORE calling `Send` to avoid race conditions with the receive thread +- Character creation is not implemented — requires an existing character in the database +- ObjectId is 0 at field entry time; it arrives later via FieldAddUser broadcast +- The first FieldAddUser received is assumed to be the bot's own ObjectId + +## Known Limitations + +- Single character per account assumed for character list parsing +- No character creation support +- ObjectId not available until FieldAddUser arrives (after ConnectAsync returns) +- GM permissions required for `/npc` spawn command + +## CLI Arguments + +| Position/Flag | Description | Default | +|---|---|---| +| arg[0] | Login server host | `127.0.0.1` | +| arg[1] | Login server port | `20001` | +| arg[2] | Username | `testbot` | +| arg[3] | Password | `testbot` | +| `--skill ` | Skill ID to cast | (none) | +| `--skill-level ` | Skill level | `1` | +| `--npc ` | NPC ID to spawn via GM command | (none) | diff --git a/Maple2.TestClient/Clients/GameClient.cs b/Maple2.TestClient/Clients/GameClient.cs new file mode 100644 index 00000000..d870a755 --- /dev/null +++ b/Maple2.TestClient/Clients/GameClient.cs @@ -0,0 +1,244 @@ +using System.Collections.Concurrent; +using System.Numerics; +using Maple2.PacketLib.Tools; +using Maple2.Server.Core.Constants; +using Maple2.TestClient.Network; +using Maple2.TestClient.Protocol; +using Serilog; + +namespace Maple2.TestClient.Clients; + +public record NpcInfo(int ObjectId, int NpcId, Vector3 Position); + +/// +/// High-level client for interacting with the Game server. +/// Handles: connect → handshake → ResponseKey auth → enter field → stay alive (TimeSync + Heartbeat). +/// Also tracks field state (ObjectId, NPCs) and provides combat methods. +/// +public class GameClient : IDisposable { + private static readonly ILogger Logger = Log.Logger.ForContext(); + + private readonly MapleClient client = new(); + private long nextSkillUid = 1; + + public int MapId { get; private set; } + public int ObjectId { get; private set; } + public Vector3 Position { get; private set; } + public ConcurrentDictionary FieldNpcs { get; } = new(); + + /// + /// Connect to the GameServer, authenticate, and enter the field. + /// + public async Task ConnectAsync(GameServerInfo serverInfo, long accountId, ulong token, Guid machineId) { + string host = serverInfo.Address.ToString(); + ushort port = serverInfo.Port; + + await client.ConnectAsync(host, port); + + // Register persistent handlers before auth so we capture everything + RegisterTimeSyncHandler(); + RegisterHeartbeatHandler(); + RegisterFieldAddUserHandler(); + RegisterFieldNpcHandlers(); + RegisterSkillDamageHandler(); + + // Step 1: Send ResponseVersion, wait for RequestKey + var requestKeyTask = client.WaitForPacketAsync(SendOp.RequestKey); + client.Send(ClientPacket.ResponseVersion()); + Logger.Information("Sent ResponseVersion, waiting for RequestKey..."); + await requestKeyTask; + Logger.Information("Received RequestKey"); + + // Step 2: Send ResponseKey, wait for RequestFieldEnter + var fieldEnterTask = client.WaitForPacketAsync(SendOp.RequestFieldEnter, TimeSpan.FromSeconds(30)); + client.Send(ClientPacket.ResponseKey(accountId, token, machineId)); + Logger.Information("Sent ResponseKey (AccountId={AccountId}), waiting for RequestFieldEnter...", accountId); + byte[] fieldEnterRaw = await fieldEnterTask; + var reader = new ByteReader(fieldEnterRaw, 0); + reader.Read(); // skip opcode + + byte migrationError = reader.ReadByte(); + if (migrationError != 0) { + throw new InvalidOperationException($"RequestFieldEnter failed: MigrationError={migrationError}"); + } + + int mapId = reader.ReadInt(); + MapId = mapId; + + // Parse position from RequestFieldEnter + // After mapId: FieldType(byte) + InstanceType(byte) + InstanceId(int) + DungeonId(int) + Position(Vector3) + reader.ReadByte(); // FieldType + reader.ReadByte(); // InstanceType + reader.ReadInt(); // InstanceId + reader.ReadInt(); // DungeonId + Position = reader.Read(); + Logger.Information("Received RequestFieldEnter, MapId={MapId}, Position={Position}", mapId, Position); + + // Step 3: Send ResponseFieldEnter to complete field entry + client.Send(ClientPacket.ResponseFieldEnter()); + Logger.Information("Sent ResponseFieldEnter, entered field! MapId={MapId}", mapId); + } + + /// + /// Send a chat message or GM command. + /// + public void SendChat(string message) { + client.Send(ClientPacket.Chat(message)); + Logger.Information("Sent chat: {Message}", message); + } + + /// + /// Spawn an NPC via GM command. Waits for FieldAddNpc confirmation. + /// + public async Task SpawnNpcAsync(int npcId, TimeSpan? timeout = null) { + timeout ??= TimeSpan.FromSeconds(5); + var npcTask = client.WaitForPacketAsync(SendOp.FieldAddNpc, timeout); + SendChat($"/npc {npcId}"); + try { + byte[] raw = await npcTask; + var r = new ByteReader(raw, 0); + r.Read(); + int objectId = r.ReadInt(); + int id = r.ReadInt(); + var pos = r.Read(); + var info = new NpcInfo(objectId, id, pos); + FieldNpcs[objectId] = info; // Manually add since one-shot waiter consumed the packet before persistent handler + Logger.Information("NPC spawned: ObjectId={ObjectId}, NpcId={NpcId}, Position={Position}", objectId, id, pos); + return info; + } catch (TimeoutException) { + Logger.Warning("SpawnNpc timeout — NPC {NpcId} may not have spawned (check admin permissions)", npcId); + return null; + } + } + + /// + /// Cast a skill (Skill.Use). Returns the skillUid for use in AttackTarget. + /// Waits for SendOp.SkillUse broadcast to confirm server processed it. + /// + public async Task CastSkillAsync(int skillId, short level = 1, TimeSpan? timeout = null) { + timeout ??= TimeSpan.FromSeconds(5); + long skillUid = Interlocked.Increment(ref nextSkillUid); + int serverTick = Environment.TickCount; + + var skillUseTask = client.WaitForPacketAsync(SendOp.SkillUse, timeout); + client.Send(ClientPacket.SkillUse(skillUid, serverTick, skillId, level, 0, Position, Vector3.UnitY, Vector3.Zero)); + Logger.Information("Sent Skill.Use: SkillId={SkillId}, Level={Level}, SkillUid={SkillUid}", skillId, level, skillUid); + + try { + await skillUseTask; + Logger.Information("Verified: Received SkillUse broadcast for SkillUid={SkillUid}", skillUid); + } catch (TimeoutException) { + Logger.Warning("Skill.Use verification timeout — server may not have processed SkillId={SkillId}", skillId); + } + + return skillUid; + } + // PLACEHOLDER_GAMECLIENT_ATTACK + + /// + /// Attack a target NPC (Skill.Attack.Target). + /// Waits for SendOp.SkillDamage broadcast to confirm damage was applied. + /// + public async Task AttackTargetAsync(long skillUid, int targetObjectId, TimeSpan? timeout = null) { + timeout ??= TimeSpan.FromSeconds(5); + long targetUid = Interlocked.Increment(ref nextSkillUid); + + var damageTask = client.WaitForPacketAsync(SendOp.SkillDamage, timeout); + client.Send(ClientPacket.SkillAttackTarget(skillUid, targetUid, Position, Vector3.UnitY, 0, 1, [targetObjectId])); + Logger.Information("Sent Skill.Attack.Target: SkillUid={SkillUid}, TargetObjectId={TargetObjectId}", skillUid, targetObjectId); + + try { + await damageTask; + Logger.Information("Verified: Received SkillDamage broadcast for target ObjectId={TargetObjectId}", targetObjectId); + } catch (TimeoutException) { + Logger.Warning("Skill.Attack.Target verification timeout — damage may not have been applied to ObjectId={TargetObjectId}", targetObjectId); + } + } + + /// + /// Stay connected by keeping the receive loop alive. + /// + public async Task StayAliveAsync(CancellationToken ct) { + Logger.Information("Staying alive (Ctrl+C to exit)..."); + try { + while (!ct.IsCancellationRequested) { + await Task.Delay(1000, ct); + } + } catch (OperationCanceledException) { + // Normal exit via cancellation + } + Logger.Information("StayAlive ended"); + } + + #region Persistent Handlers + + private void RegisterTimeSyncHandler() { + client.On(SendOp.ResponseTimeSync, raw => { + var r = new ByteReader(raw, 0); + r.Read(); + byte command = r.ReadByte(); + if (command == 2) { + client.Send(ClientPacket.RequestTimeSync(0)); + } + }); + } + // PLACEHOLDER_GAMECLIENT_HANDLERS + + private void RegisterHeartbeatHandler() { + client.On(SendOp.RequestHeartbeat, raw => { + var r = new ByteReader(raw, 0); + r.Read(); + int serverTick = r.ReadInt(); + client.Send(ClientPacket.ResponseHeartbeat(serverTick, Environment.TickCount)); + }); + } + + private void RegisterFieldAddUserHandler() { + bool first = true; + client.On(SendOp.FieldAddUser, raw => { + var r = new ByteReader(raw, 0); + r.Read(); + int objectId = r.ReadInt(); + if (first) { + ObjectId = objectId; + first = false; + Logger.Information("My ObjectId={ObjectId}", objectId); + } + }); + } + + private void RegisterFieldNpcHandlers() { + client.On(SendOp.FieldAddNpc, raw => { + var r = new ByteReader(raw, 0); + r.Read(); + int objectId = r.ReadInt(); + int npcId = r.ReadInt(); + var pos = r.Read(); + FieldNpcs[objectId] = new NpcInfo(objectId, npcId, pos); + Logger.Debug("FieldAddNpc: ObjectId={ObjectId}, NpcId={NpcId}", objectId, npcId); + }); + + client.On(SendOp.FieldRemoveNpc, raw => { + var r = new ByteReader(raw, 0); + r.Read(); + int objectId = r.ReadInt(); + FieldNpcs.TryRemove(objectId, out _); + Logger.Debug("FieldRemoveNpc: ObjectId={ObjectId}", objectId); + }); + } + + private void RegisterSkillDamageHandler() { + client.On(SendOp.SkillDamage, raw => { + var r = new ByteReader(raw, 0); + r.Read(); + byte command = r.ReadByte(); + Logger.Debug("SkillDamage received: Command={Command}, Length={Length}", command, raw.Length); + }); + } + + #endregion + + public void Dispose() { + client.Dispose(); + } +} diff --git a/Maple2.TestClient/Clients/LoginClient.cs b/Maple2.TestClient/Clients/LoginClient.cs new file mode 100644 index 00000000..2d391b1c --- /dev/null +++ b/Maple2.TestClient/Clients/LoginClient.cs @@ -0,0 +1,173 @@ +using System.Net; +using Maple2.PacketLib.Tools; +using Maple2.Server.Core.Constants; +using Maple2.TestClient.Network; +using Maple2.TestClient.Protocol; +using Serilog; + +namespace Maple2.TestClient.Clients; + +/// +/// Result of a login attempt. +/// +public record LoginResult(bool Success, long AccountId, List Characters, byte ErrorCode = 0, string ErrorMessage = ""); + +/// +/// Basic character info parsed from CharacterList packet. +/// +public record CharacterInfo(long CharacterId, string Name); + +/// +/// Game server connection info returned after character selection. +/// +public record GameServerInfo(IPAddress Address, ushort Port, ulong Token, int MapId); + +/// +/// High-level client for interacting with the Login server. +/// Handles: connect → handshake → login/register → character list → character select. +/// +public class LoginClient : IDisposable { + private static readonly ILogger Logger = Log.Logger.ForContext(); + + private readonly MapleClient client = new(); + public Guid MachineId { get; } = Guid.NewGuid(); + + public long AccountId { get; private set; } + + /// + /// Connect to the login server and complete the version handshake. + /// + public async Task ConnectAsync(string host = "127.0.0.1", int port = 20001) { + await client.ConnectAsync(host, port); + + // Register waiter BEFORE sending to avoid race condition + var requestLoginTask = client.WaitForPacketAsync(SendOp.RequestLogin); + + client.Send(ClientPacket.ResponseVersion()); + Logger.Information("Sent ResponseVersion, waiting for RequestLogin..."); + + await requestLoginTask; + Logger.Information("Received RequestLogin prompt"); + } + + /// + /// Login with username/password. If AutoRegister is enabled on the server, + /// a new account will be created automatically for unknown usernames. + /// + public async Task LoginAsync(string username, string password) { + // Collect CharacterList packets via persistent handler + var characters = new List(); + var charListDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + client.On(SendOp.CharacterList, raw => { + var r = new ByteReader(raw, 0); + r.Read(); // skip opcode + byte command = r.ReadByte(); + switch (command) { + case 0: // List - contains character entries + byte count = r.ReadByte(); + if (count > 1) { + Logger.Warning("Multiple characters ({Count}) found; only first will be parsed correctly", count); + } + for (int i = 0; i < count; i++) { + long charId = ParseCharacterId(r); + characters.Add(new CharacterInfo(charId, "")); + if (i == 0 && count > 1) break; // avoid corrupted reads + } + break; + case 4: // EndList + charListDone.TrySetResult(); + break; + } + }); + + // Register waiter BEFORE sending to avoid race condition + var loginResultTask = client.WaitForPacketAsync(SendOp.LoginResult); + + client.Send(ClientPacket.Login(username, password, MachineId)); + Logger.Information("Sent login request for user: {Username}", username); + + byte[] raw = await loginResultTask; + var reader = new ByteReader(raw, 0); + reader.Read(); // skip opcode + + byte loginState = reader.ReadByte(); + int constVal = reader.ReadInt(); + string banReason = reader.ReadUnicodeString(); + long accountId = reader.ReadLong(); + + if (loginState != 0) { + Logger.Warning("Login failed: state={State}, reason={Reason}", loginState, banReason); + return new LoginResult(false, accountId, [], loginState, banReason); + } + + AccountId = accountId; + Logger.Information("Login successful! AccountId={AccountId}", accountId); + + // Wait for character list to finish (StartList → List → EndList) + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + cts.Token.Register(() => charListDone.TrySetCanceled()); + await charListDone.Task; + + Logger.Information("Received character list: {Count} character(s)", characters.Count); + foreach (var c in characters) { + Logger.Information(" CharacterId={CharacterId}", c.CharacterId); + } + + return new LoginResult(true, accountId, characters); + } + + /// + /// Parse character ID from the CharacterList.List entry. + /// The entry starts with WriteCharacter which begins with accountId(long) + characterId(long) + name(unicode). + /// We skip accountId and read characterId, then skip the rest. + /// Since the entry is complex and variable-length, we only extract the characterId. + /// + private static long ParseCharacterId(ByteReader reader) { + // WriteCharacter format starts with: + // accountId(long) + characterId(long) + name(unicodeString) + ... + reader.ReadLong(); // accountId + long characterId = reader.ReadLong(); + string name = reader.ReadUnicodeString(); + // We can't easily skip the rest of the variable-length entry, + // so for now we just return what we have. This works for single-character accounts. + return characterId; + } + + /// + /// Select a character and get the game server connection info. + /// The characterId must be a valid character belonging to the logged-in account. + /// + public async Task SelectCharacterAsync(long characterId) { + // Register waiter BEFORE sending to avoid race condition + var loginToGameTask = client.WaitForPacketAsync(SendOp.LoginToGame, TimeSpan.FromSeconds(10)); + + client.Send(ClientPacket.SelectCharacter(characterId)); + Logger.Information("Sent character select for CharacterId={CharacterId}", characterId); + + byte[] raw = await loginToGameTask; + var reader = new ByteReader(raw, 0); + reader.Read(); // skip opcode + + // MigrationError is a byte enum + byte error = reader.ReadByte(); + if (error != 0) { + throw new InvalidOperationException($"Character select failed: MigrationError={error}"); + } + + // Success: ip(4 bytes) + port(ushort) + token(ulong) + mapId(int) + byte[] ipBytes = reader.ReadBytes(4); + var address = new IPAddress(ipBytes); + ushort port = reader.Read(); + ulong token = reader.Read(); + int mapId = reader.ReadInt(); + + var info = new GameServerInfo(address, port, token, mapId); + Logger.Information("Character selected! GameServer={Address}:{Port}, MapId={MapId}", address, port, mapId); + + return info; + } + + public void Dispose() { + client.Dispose(); + } +} diff --git a/Maple2.TestClient/Maple2.TestClient.csproj b/Maple2.TestClient/Maple2.TestClient.csproj new file mode 100644 index 00000000..53b0fad5 --- /dev/null +++ b/Maple2.TestClient/Maple2.TestClient.csproj @@ -0,0 +1,22 @@ + + + + Exe + net8.0 + enable + Maple2.TestClient + Maple2.TestClient + 12 + enable + + + + + + + + + + + + diff --git a/Maple2.TestClient/Network/MapleClient.cs b/Maple2.TestClient/Network/MapleClient.cs new file mode 100644 index 00000000..f45d3bb9 --- /dev/null +++ b/Maple2.TestClient/Network/MapleClient.cs @@ -0,0 +1,250 @@ +using System.Buffers; +using System.Collections.Concurrent; +using System.Net.Sockets; +using Maple2.PacketLib.Crypto; +using Maple2.PacketLib.Tools; +using Maple2.Server.Core.Constants; +using Maple2.Server.Core.Network; +using Serilog; + +namespace Maple2.TestClient.Network; + +/// +/// Low-level TCP client that handles the Maple handshake, MapleCipher encryption/decryption, +/// and packet send/receive with dispatch by SendOp. +/// +public class MapleClient : IDisposable { + private const int HANDSHAKE_HEADER_SIZE = 6; // WriteHeader prepends a 6-byte header for unencrypted packets + + private static readonly ILogger Logger = Log.Logger.ForContext(); + + private TcpClient? tcpClient; + private NetworkStream? stream; + private MapleCipher.Encryptor? sendCipher; + private MapleCipher.Decryptor? recvCipher; + private Thread? recvThread; + private volatile bool disposed; + + // One-shot waiters: first packet matching the opcode completes the TCS + private readonly ConcurrentDictionary>> waiters = new(); + + // Persistent handlers + private readonly ConcurrentDictionary> handlers = new(); + + // All received packets (for debugging) + public event Action? OnPacketReceived; + + /// + /// Connect to the server, read the handshake, and initialize ciphers. + /// + public async Task ConnectAsync(string host, int port) { + tcpClient = new TcpClient(); + await tcpClient.ConnectAsync(host, port); + stream = tcpClient.GetStream(); + + // Server sends handshake as: [header(6 bytes)][payload(19 bytes)] + // The header written by WriteHeader for unencrypted packets is: + // sequenceId(2) + packetLength(4) — total 6 bytes + // Payload: SendOp(2) + version(4) + riv(4) + siv(4) + blockIV(4) + patchType(1) = 19 bytes + byte[] headerBuf = new byte[HANDSHAKE_HEADER_SIZE]; + await ReadExactAsync(stream, headerBuf, HANDSHAKE_HEADER_SIZE); + + int payloadLength = BitConverter.ToInt32(headerBuf, 2); + byte[] payload = new byte[payloadLength]; + await ReadExactAsync(stream, payload, payloadLength); + + var reader = new ByteReader(payload, 0); + var opcode = reader.Read(); + if (opcode != SendOp.RequestVersion) { + throw new InvalidOperationException($"Expected RequestVersion handshake, got {opcode}"); + } + + uint version = reader.Read(); + uint serverRiv = reader.Read(); // server's recv IV = our send IV + uint serverSiv = reader.Read(); // server's send IV = our recv IV + uint blockIv = reader.Read(); + byte patchType = reader.ReadByte(); + + if (version != Session.VERSION) { + throw new InvalidOperationException($"Version mismatch: server={version}, expected={Session.VERSION}"); + } + + // Client sends with server's RIV, client receives with server's SIV + sendCipher = new MapleCipher.Encryptor(Session.VERSION, serverRiv, blockIv); + recvCipher = new MapleCipher.Decryptor(Session.VERSION, serverSiv, blockIv); + + // IMPORTANT: Server's sendCipher.WriteHeader() advanced its IV once during handshake. + // We must advance recvCipher's IV to stay in sync by feeding the raw handshake through TryDecrypt. + byte[] rawHandshake = new byte[HANDSHAKE_HEADER_SIZE + payloadLength]; + Buffer.BlockCopy(headerBuf, 0, rawHandshake, 0, HANDSHAKE_HEADER_SIZE); + Buffer.BlockCopy(payload, 0, rawHandshake, HANDSHAKE_HEADER_SIZE, payloadLength); + recvCipher.TryDecrypt(new ReadOnlySequence(rawHandshake), out PoolByteReader handshakeReader); + handshakeReader.Dispose(); + + Logger.Information("Connected to {Host}:{Port} (version={Version}, patchType={PatchType})", host, port, version, patchType); + + // Start background receive loop + recvThread = new Thread(ReceiveLoop) { + Name = $"MapleClient-Recv-{host}:{port}", + IsBackground = true, + }; + recvThread.Start(); + } + + /// + /// Send a packet to the server (encrypts automatically). + /// The packet buffer should start with RecvOp (2 bytes). + /// + public void Send(ByteWriter packet) { + if (disposed || sendCipher == null || stream == null) { + throw new InvalidOperationException("Not connected"); + } + + lock (sendCipher) { + using PoolByteWriter encrypted = sendCipher.Encrypt(packet.Buffer, 0, packet.Length); + stream.Write(encrypted.Buffer, 0, encrypted.Length); + } + } + + /// + /// Wait for a single packet with the given opcode. Returns the raw decrypted payload (including opcode). + /// + public Task WaitForPacketAsync(SendOp opcode, TimeSpan? timeout = null) { + timeout ??= TimeSpan.FromSeconds(10); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var queue = waiters.GetOrAdd(opcode, _ => new ConcurrentQueue>()); + queue.Enqueue(tcs); + + // Timeout cancellation + var cts = new CancellationTokenSource(timeout.Value); + cts.Token.Register(() => tcs.TrySetException( + new TimeoutException($"Timed out waiting for {opcode} after {timeout.Value.TotalSeconds}s"))); + + // Dispose the CTS when the task completes regardless of outcome + tcs.Task.ContinueWith(_ => cts.Dispose(), TaskContinuationOptions.ExecuteSynchronously); + return tcs.Task; + } + + /// + /// Register a persistent handler for a given opcode. + /// + public void On(SendOp opcode, Action handler) { + handlers[opcode] = handler; + } + + public void Disconnect() { + Dispose(); + } + + private void ReceiveLoop() { + try { + var buffer = new byte[4096]; + var accumulator = new MemoryStream(); + + while (!disposed && stream != null) { + int bytesRead; + try { + bytesRead = stream.Read(buffer, 0, buffer.Length); + } catch (Exception) when (disposed) { + break; + } + + if (bytesRead <= 0) { + Logger.Debug("ReceiveLoop: connection closed (bytesRead=0)"); + break; + } + + Logger.Debug("ReceiveLoop: received {Bytes} bytes", bytesRead); + accumulator.Write(buffer, 0, bytesRead); + + // Try to decrypt complete packets from the accumulated buffer + ProcessAccumulatedData(accumulator); + } + } catch (Exception ex) when (!disposed) { + Logger.Error(ex, "ReceiveLoop error"); + } + } + + private void ProcessAccumulatedData(MemoryStream accumulator) { + if (recvCipher == null) return; + + byte[] data = accumulator.ToArray(); + var sequence = new ReadOnlySequence(data); + + int totalConsumed = 0; + while (true) { + int bytesConsumed = recvCipher.TryDecrypt(sequence, out PoolByteReader packet); + if (bytesConsumed <= 0) break; + + try { + byte[] raw = new byte[packet.Length]; + Array.Copy(packet.Buffer, 0, raw, 0, packet.Length); + DispatchPacket(raw); + } finally { + packet.Dispose(); + } + + totalConsumed += bytesConsumed; + sequence = sequence.Slice(bytesConsumed); + } + + if (totalConsumed > 0) { + // Remove consumed bytes from accumulator + byte[] remaining = data.AsSpan(totalConsumed).ToArray(); + accumulator.SetLength(0); + accumulator.Write(remaining, 0, remaining.Length); + } + } + + private void DispatchPacket(byte[] raw) { + if (raw.Length < 2) return; + + var opcode = (SendOp) (raw[1] << 8 | raw[0]); + Logger.Debug("Dispatching packet: {Opcode} (0x{Code:X4}), length={Length}", opcode, (ushort) opcode, raw.Length); + OnPacketReceived?.Invoke(opcode, raw); + + // Check one-shot waiters first + if (waiters.TryGetValue(opcode, out var queue)) { + while (queue.TryDequeue(out var tcs)) { + if (tcs.TrySetResult(raw)) { + return; // Only one waiter gets this packet + } + } + } + + // Then persistent handlers + if (handlers.TryGetValue(opcode, out var handler)) { + try { + handler(raw); + } catch (Exception ex) { + Logger.Error(ex, "Handler error for {Opcode}", opcode); + } + } + } + + private static async Task ReadExactAsync(NetworkStream stream, byte[] buffer, int count) { + int offset = 0; + while (offset < count) { + int read = await stream.ReadAsync(buffer.AsMemory(offset, count - offset)); + if (read <= 0) throw new EndOfStreamException("Connection closed during read"); + offset += read; + } + } + + public void Dispose() { + if (disposed) return; + disposed = true; + + try { stream?.Close(); } catch { } + try { tcpClient?.Close(); } catch { } + recvThread?.Join(2000); + + // Cancel all pending waiters + foreach (var (_, queue) in waiters) { + while (queue.TryDequeue(out var tcs)) { + tcs.TrySetCanceled(); + } + } + } +} diff --git a/Maple2.TestClient/Program.cs b/Maple2.TestClient/Program.cs new file mode 100644 index 00000000..66533d68 --- /dev/null +++ b/Maple2.TestClient/Program.cs @@ -0,0 +1,123 @@ +using Maple2.TestClient.Clients; +using Maple2.Tools; +using Serilog; + +// Load .env for configuration (DB, server IPs, etc.) +DotEnv.Load(); + +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .CreateLogger(); + +// Parse positional args +string host = args.Length > 0 ? args[0] : "127.0.0.1"; +int port = args.Length > 1 ? int.Parse(args[1]) : 20001; +string username = args.Length > 2 ? args[2] : "testbot"; +string password = args.Length > 3 ? args[3] : "testbot"; + +// Parse optional named args +int? npcId = null; +int skillId = 0; +short skillLevel = 1; +for (int i = 0; i < args.Length; i++) { + switch (args[i]) { + case "--npc" when i + 1 < args.Length: + npcId = int.Parse(args[++i]); + break; + case "--skill" when i + 1 < args.Length: + skillId = int.Parse(args[++i]); + break; + case "--skill-level" when i + 1 < args.Length: + skillLevel = short.Parse(args[++i]); + break; + } +} + +Log.Information("=== Maple2 TestClient ==="); +Log.Information("Target: {Host}:{Port}, User: {Username}", host, port, username); +if (skillId != 0) Log.Information("Combat: SkillId={SkillId}, Level={Level}, NpcId={NpcId}", skillId, skillLevel, npcId?.ToString() ?? "none"); + +try { + long accountId; + GameServerInfo gameServer; + Guid machineId; + + // Steps 1-3: Login and select character + using (var loginClient = new LoginClient()) { + Log.Information("--- Step 1: Connecting to Login Server ---"); + await loginClient.ConnectAsync(host, port); + + // Step 2: Login (auto-registers if AutoRegister=true) + Log.Information("--- Step 2: Logging in ---"); + LoginResult result = await loginClient.LoginAsync(username, password); + if (!result.Success) { + Log.Error("Login failed: code={Code}, message={Message}", result.ErrorCode, result.ErrorMessage); + return; + } + Log.Information("Logged in as AccountId={AccountId}", result.AccountId); + + // Step 3: Select character + if (result.Characters.Count == 0) { + Log.Information("No characters found. Create a character first using the game client."); + return; + } + + long characterId = result.Characters[0].CharacterId; + Log.Information("--- Step 3: Selecting character {CharacterId} ---", characterId); + gameServer = await loginClient.SelectCharacterAsync(characterId); + Log.Information("Game server: {Address}:{Port}, MapId={MapId}", + gameServer.Address, gameServer.Port, gameServer.MapId); + + accountId = result.AccountId; + machineId = loginClient.MachineId; + } // LoginClient disposed here before GameServer connection + + // Step 4: Connect to GameServer and enter field + Log.Information("--- Step 4: Connecting to Game Server ---"); + using var gameClient = new GameClient(); + await gameClient.ConnectAsync(gameServer, accountId, gameServer.Token, machineId); + Log.Information("In game! MapId={MapId}, ObjectId={ObjectId}, Position={Position}", gameClient.MapId, gameClient.ObjectId, gameClient.Position); + + // Step 5 (optional): Spawn NPC via GM command + NpcInfo? spawnedNpc = null; + if (npcId.HasValue) { + Log.Information("--- Step 5: Spawning NPC {NpcId} ---", npcId.Value); + // Wait a moment for field initialization to complete + await Task.Delay(500); + spawnedNpc = await gameClient.SpawnNpcAsync(npcId.Value); + } + + // Step 6: Cast skill + if (skillId != 0) { + Log.Information("--- Step 6: Casting skill {SkillId} (level {Level}) ---", skillId, skillLevel); + await Task.Delay(300); + long skillUid = await gameClient.CastSkillAsync(skillId, skillLevel); + + // Step 7 (optional): Attack target NPC + int? targetObjectId = spawnedNpc?.ObjectId ?? gameClient.FieldNpcs.Values.FirstOrDefault()?.ObjectId; + if (targetObjectId.HasValue) { + Log.Information("--- Step 7: Attacking target ObjectId={TargetObjectId} ---", targetObjectId.Value); + await Task.Delay(300); + await gameClient.AttackTargetAsync(skillUid, targetObjectId.Value); + } else { + Log.Information("No target NPC available, skipping attack"); + } + + // Step 8: Summary + Log.Information("--- Step 8: Combat simulation complete ---"); + Log.Information("Field NPCs tracked: {Count}", gameClient.FieldNpcs.Count); + } + + // Stay alive until Ctrl+C + using var cts = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => { + e.Cancel = true; + cts.Cancel(); + }; + await gameClient.StayAliveAsync(cts.Token); + + Log.Information("=== TestClient finished ==="); +} catch (Exception ex) { + Log.Error(ex, "TestClient error"); +} diff --git a/Maple2.TestClient/Protocol/ClientPacket.cs b/Maple2.TestClient/Protocol/ClientPacket.cs new file mode 100644 index 00000000..35d6f0d5 --- /dev/null +++ b/Maple2.TestClient/Protocol/ClientPacket.cs @@ -0,0 +1,165 @@ +using System.Numerics; +using Maple2.Model.Enum; +using Maple2.PacketLib.Tools; +using Maple2.Server.Core.Constants; +using Maple2.Server.Core.Network; + +namespace Maple2.TestClient.Protocol; + +/// +/// Constructs client-to-server packets (RecvOp). +/// These mirror what the real game client sends. +/// +public static class ClientPacket { + private static ByteWriter Of(RecvOp opcode, int size = 128) { + var packet = new ByteWriter(size); + packet.Write(opcode); + return packet; + } + + /// + /// Response to handshake. Server expects: version(uint) + unknown(short) + locale. + /// See: ResponseVersionHandler.cs + /// + public static ByteWriter ResponseVersion() { + var pWriter = Of(RecvOp.ResponseVersion); + pWriter.Write(12); // VERSION + pWriter.WriteShort(47); // unknown constant + pWriter.Write(Locale.NA); + return pWriter; + } + + /// + /// Login request. Server expects: command(byte) + username(unicode) + password(unicode) + short(1) + machineId(Guid). + /// See: LoginHandler.cs:30-44 + /// + public static ByteWriter Login(string username, string password, Guid machineId) { + var pWriter = Of(RecvOp.ResponseLogin); + pWriter.WriteByte(2); // Command.CharacterList + pWriter.WriteUnicodeString(username); + pWriter.WriteUnicodeString(password); + pWriter.WriteShort(1); + pWriter.Write(machineId); + return pWriter; + } + + /// + /// Select a character to enter the game. + /// See: CharacterManagementHandler.cs:74-76 + /// + public static ByteWriter SelectCharacter(long characterId) { + var pWriter = Of(RecvOp.CharacterManagement); + pWriter.WriteByte(0); // Command.Select + pWriter.WriteLong(characterId); + pWriter.WriteShort(1); // world/channel + return pWriter; + } + + /// + /// Response key sent to GameServer after migration. + /// See: ResponseKeyHandler.cs:25-27 + /// + public static ByteWriter ResponseKey(long accountId, ulong token, Guid machineId) { + var pWriter = Of(RecvOp.ResponseKey); + pWriter.WriteLong(accountId); + pWriter.Write(token); + pWriter.Write(machineId); + return pWriter; + } + + /// + /// Response to RequestFieldEnter after EnterServer completes. + /// See: FieldEnterHandler.cs:13 — expects FIELD_KEY + /// + public static ByteWriter ResponseFieldEnter() { + var pWriter = Of(RecvOp.ResponseFieldEnter); + pWriter.WriteInt(Session.FIELD_KEY); + return pWriter; + } + + /// + /// Time sync request sent in response to server's TimeSyncPacket.Request(). + /// See: TimeSyncHandler.cs:12 — expects key(int) + /// + public static ByteWriter RequestTimeSync(int key = 0) { + var pWriter = Of(RecvOp.RequestTimeSync); + pWriter.WriteInt(key); + return pWriter; + } + + /// + /// Heartbeat response sent in response to server's RequestPacket.Heartbeat(). + /// See: ResponseHeartbeatHandler.cs:13-14 — expects serverTick(int) + clientTick(int) + /// + public static ByteWriter ResponseHeartbeat(int serverTick, int clientTick) { + var pWriter = Of(RecvOp.ResponseHeartbeat); + pWriter.WriteInt(serverTick); + pWriter.WriteInt(clientTick); + return pWriter; + } + + /// + /// Send a chat message or GM command (e.g. "/npc 21000001"). + /// See: UserChatHandler.cs:28-33 + /// + public static ByteWriter Chat(string message) { + var pWriter = Of(RecvOp.UserChat); + pWriter.Write(ChatType.Normal); + pWriter.WriteUnicodeString(message); + pWriter.WriteUnicodeString(string.Empty); // recipient + pWriter.WriteLong(0); // clubId + return pWriter; + } + + /// + /// Cast a skill (Skill.Use). Server reads in SkillHandler.HandleUse. + /// See: SkillHandler.cs:73-131 + /// + public static ByteWriter SkillUse(long skillUid, int serverTick, int skillId, short level, + byte motionPoint, Vector3 position, Vector3 direction, Vector3 rotation) { + var pWriter = Of(RecvOp.Skill, 256); + pWriter.WriteByte(0); // Command.Use + pWriter.WriteLong(skillUid); + pWriter.WriteInt(serverTick); + pWriter.WriteInt(skillId); + pWriter.WriteShort(level); + pWriter.WriteByte(motionPoint); + pWriter.Write(position); + pWriter.Write(direction); + pWriter.Write(rotation); + pWriter.WriteFloat(0f); // rotate2Z + pWriter.WriteInt(Environment.TickCount); // clientTick + pWriter.WriteBool(false); // unknown + pWriter.WriteLong(0); // itemUid + pWriter.WriteBool(false); // isHold + return pWriter; + } + + /// + /// Attack targets (Skill.Attack.Target). Server reads in SkillHandler.HandleTarget. + /// See: SkillHandler.cs:188-260 + /// + public static ByteWriter SkillAttackTarget(long skillUid, long targetUid, + Vector3 impactPosition, Vector3 direction, byte attackPoint, + byte targetCount, int[] targetObjectIds) { + if (targetObjectIds.Length < targetCount) { + throw new ArgumentException($"targetObjectIds length ({targetObjectIds.Length}) must be >= targetCount ({targetCount})"); + } + var pWriter = Of(RecvOp.Skill, 256); + pWriter.WriteByte(1); // Command.Attack + pWriter.WriteByte(1); // SubCommand.Target + pWriter.WriteLong(skillUid); + pWriter.WriteLong(targetUid); + pWriter.Write(impactPosition); // impactPos + pWriter.Write(impactPosition); // impactPos2 + pWriter.Write(direction); + pWriter.WriteByte(attackPoint); + pWriter.WriteByte(targetCount); + pWriter.WriteInt(0); // iterations + for (int i = 0; i < targetCount; i++) { + pWriter.WriteInt(targetObjectIds[i]); + pWriter.WriteByte(0); // unknown + } + return pWriter; + } +} diff --git a/Maple2.sln b/Maple2.sln index 5e849043..2b7a3c35 100644 --- a/Maple2.sln +++ b/Maple2.sln @@ -37,6 +37,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Maple2.Server.DebugGame", "Maple2.Server.DebugGame\Maple2.Server.DebugGame.csproj", "{F0E28D7F-A88B-4DFE-BCA6-C36C18FBF269}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Maple2.TestClient", "Maple2.TestClient\Maple2.TestClient.csproj", "{DCE05B96-A01B-4FD7-9003-B0E45A12C72D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -179,6 +181,18 @@ Global {F0E28D7F-A88B-4DFE-BCA6-C36C18FBF269}.Release|x64.Build.0 = Release|Any CPU {F0E28D7F-A88B-4DFE-BCA6-C36C18FBF269}.Release|x86.ActiveCfg = Release|Any CPU {F0E28D7F-A88B-4DFE-BCA6-C36C18FBF269}.Release|x86.Build.0 = Release|Any CPU + {DCE05B96-A01B-4FD7-9003-B0E45A12C72D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DCE05B96-A01B-4FD7-9003-B0E45A12C72D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DCE05B96-A01B-4FD7-9003-B0E45A12C72D}.Debug|x64.ActiveCfg = Debug|Any CPU + {DCE05B96-A01B-4FD7-9003-B0E45A12C72D}.Debug|x64.Build.0 = Debug|Any CPU + {DCE05B96-A01B-4FD7-9003-B0E45A12C72D}.Debug|x86.ActiveCfg = Debug|Any CPU + {DCE05B96-A01B-4FD7-9003-B0E45A12C72D}.Debug|x86.Build.0 = Debug|Any CPU + {DCE05B96-A01B-4FD7-9003-B0E45A12C72D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DCE05B96-A01B-4FD7-9003-B0E45A12C72D}.Release|Any CPU.Build.0 = Release|Any CPU + {DCE05B96-A01B-4FD7-9003-B0E45A12C72D}.Release|x64.ActiveCfg = Release|Any CPU + {DCE05B96-A01B-4FD7-9003-B0E45A12C72D}.Release|x64.Build.0 = Release|Any CPU + {DCE05B96-A01B-4FD7-9003-B0E45A12C72D}.Release|x86.ActiveCfg = Release|Any CPU + {DCE05B96-A01B-4FD7-9003-B0E45A12C72D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE