From ccfbb89075fb5fdfb807499c3d3988dc83912d9a Mon Sep 17 00:00:00 2001 From: William LaFrance Date: Fri, 9 Oct 2026 15:07:00 -0500 Subject: [PATCH 1/8] Handle the BNFTP version 2 request sequence A version 2 client opens with a 20-byte header (platform, product, ad banner id and extension), expects a 4-byte server token back, and then sends the file request (start position, filetime, client token, CD key fields, filename). Atlas parsed the short header as if it were a full version 1 request and dropped the connection. BNFTPState.Receive now answers the header with the server token and serves the file named in the follow-up request. The reply uses the same header as version 1, including the 16-bit type field; without it the Warcraft III demo reset the connection after each download and retried. Receive returns the number of bytes it consumed and ClientState trims them from the receive buffer. Previously the buffer was never trimmed, so a second request arrived behind the first one's bytes. Co-Authored-By: Claude Sonnet 5.5 --- src/Atlasd/Battlenet/ClientState.cs | 3 +- .../Battlenet/Protocols/BNFTP/BNFTPState.cs | 231 +++++++++--------- 2 files changed, 118 insertions(+), 116 deletions(-) diff --git a/src/Atlasd/Battlenet/ClientState.cs b/src/Atlasd/Battlenet/ClientState.cs index adf116c..20e39a2 100644 --- a/src/Atlasd/Battlenet/ClientState.cs +++ b/src/Atlasd/Battlenet/ClientState.cs @@ -280,7 +280,8 @@ protected void ReceiveProtocolBNFTP(SocketAsyncEventArgs e) lock (_receiveLock) { if (ReceiveBuffer.Length == 0) return; - BNFTPState.Receive(ReceiveBuffer); + var consumed = BNFTPState.Receive(ReceiveBuffer); + ReceiveBuffer = ReceiveBuffer[Math.Min(consumed, ReceiveBuffer.Length)..]; } } diff --git a/src/Atlasd/Battlenet/Protocols/BNFTP/BNFTPState.cs b/src/Atlasd/Battlenet/Protocols/BNFTP/BNFTPState.cs index 6b3b10c..3f263bc 100644 --- a/src/Atlasd/Battlenet/Protocols/BNFTP/BNFTPState.cs +++ b/src/Atlasd/Battlenet/Protocols/BNFTP/BNFTPState.cs @@ -27,17 +27,48 @@ class BNFTPState public UInt32 ClientToken = 0; public GameKey GameKey = null; + private bool AwaitingVersion2Request = false; + public BNFTPState(ClientState client) { Client = client; ServerToken = (uint)new Random().Next(0, 0x7FFFFFFF); } - public void Receive(byte[] buffer) + public int Receive(byte[] buffer) { using var m = new MemoryStream(buffer); using var r = new BinaryReader(m); + if (AwaitingVersion2Request) + { + /** + * ## VERSION 2 <-- ## + * ## Client -> Server ## + * + * (TYPE) (FIELD) (DESCRIPTION) + * UINT32 Starting position Facilitates resuming + * FILETIME Local filetime + * UINT32 Client Token + * UINT32 Key Length + * UINT32 Key's product value + * UINT32 Key's public value + * UINT32 Unknown (Always 0) + * UINT32 [5] CD key hash + * STRING Filename + */ + + FileStartPosition = r.ReadUInt32(); + FileTime = r.ReadUInt64(); + ClientToken = r.ReadUInt32(); + r.ReadBytes(16); + r.ReadBytes(20); + FileName = Encoding.UTF8.GetString(r.ReadByteString()); + + ServeFile(); + return (int)m.Position; + } + /** * ## VERSION 1 <-- ## * ## Client -> Server ## @@ -76,82 +107,8 @@ public void Receive(byte[] buffer) FileTime = r.ReadUInt64(); FileName = Encoding.UTF8.GetString(r.ReadByteString()); - /** - * ## VERSION 1 --> ## - * ## Server -> Client ## - * - * (TYPE) (FIELD) (DESCRIPTION) - * UINT16 Header Length Does not include the file length - * UINT16 Type - * UINT32 File size - * UINT32 Ad Banner ID 0 unless downloading an ad banner - * UINT32 Ad Banner File Extension 0 unless downloading an ad banner - * FILETIME Filetime - * STRING Filename - * VOID File data - */ - - var file = new BNFTP.File(FileName); - if (file == null) - { - Client.Disconnect(); - } - - BinaryReader stream = null; - - bool uploaded = false; - try - { - if (!file.OpenStream()) - { - Client.Disconnect(); - break; - } - - stream = new BinaryReader(file.StreamReader.BaseStream); - stream.BaseStream.Position = Math.Min(stream.BaseStream.Length, FileStartPosition); - - var fileLength = (int)(stream.BaseStream.Length - stream.BaseStream.Position); - HeaderLength = (UInt16)(25 + Encoding.UTF8.GetByteCount(FileName)); - var outBuf = new byte[HeaderLength]; - using var wm = new MemoryStream(outBuf); - using var w = new BinaryWriter(wm); - - w.Write((UInt16)HeaderLength); - w.Write((UInt16)0); // "Type" ??? - w.Write((UInt32)fileLength); - w.Write((UInt32)AdId); - w.Write((UInt32)AdFileExtension); - w.Write((UInt64)new FileInfo(file.Path).LastWriteTimeUtc.ToFileTimeUtc()); - w.Write((string)FileName); - - Write(outBuf); - Write(stream.ReadBytes(fileLength)); - - uploaded = true; - } - catch (Exception ex) - { - if (!(ex is IOException || ex is FileNotFoundException || ex is UnauthorizedAccessException || ex is PathTooLongException)) throw; - - Logging.WriteLine(Logging.LogLevel.Warning, Logging.LogType.Client_BNFTP, Client.RemoteEndPoint, $"{ex.GetType().Name} error encountered for requested file [{FileName}]" + (string.IsNullOrEmpty(ex.Message) ? "" : $"; message: {ex.Message}")); - } - finally - { - if (uploaded) - { - Logging.WriteLine(Logging.LogLevel.Info, Logging.LogType.Client_BNFTP, Client.RemoteEndPoint, $"Uploaded file [{FileName}] ({file.Length} bytes)"); - } - - if (stream != null) - { - stream.Close(); - } - - Client.Disconnect(); - } - - break; + ServeFile(); + return (int)m.Position; } case 0x0200: { @@ -164,18 +121,12 @@ public void Receive(byte[] buffer) * UINT32 Product ID See Product Identification * UINT32 Ad Banner ID 0 unless downloading an ad banner * UINT32 Ad Banner File Extension 0 unless downloading an ad banner - * UINT32 File start position For resuming an incomplete download - * FILETIME Filetime - * STRING Filename */ PlatformId = (Platform.PlatformCode)r.ReadUInt32(); ProductId = (Product.ProductCode)r.ReadUInt32(); AdId = r.ReadUInt32(); AdFileExtension = r.ReadUInt32(); - FileStartPosition = r.ReadUInt32(); - FileTime = r.ReadUInt64(); - FileName = Encoding.UTF8.GetString(r.ReadByteString()); /** * ## VERSION 2 --> ## @@ -185,47 +136,97 @@ public void Receive(byte[] buffer) * UINT32 Server Token */ - /** - * ## VERSION 2 <-- ## - * ## Client -> Server ## - * - * (TYPE) (FIELD) (DESCRIPTION) - * UINT32 Starting position Facilitates resuming - * FILETIME Local filetime - * UINT32 Client Token - * UINT32 Key Length - * UINT32 Key's product value - * UINT32 Key's public value - * UINT32 Unknown (Always 0) - * UINT32 [5] CD key hash - * STRING Filename - */ - - /** - * ## VERSION 2 --> ## - * ## Server -> Client ## - * - * (TYPE) (FIELD) (DESCRIPTION) - * UINT16 Header Length Does not include the file length - * UINT32 File size - * UINT32 Ad Banner ID 0 unless downloading an ad banner - * UINT32 Ad Banner File Extension 0 unless downloading an ad banner - * FILETIME Filetime - * STRING Filename - * VOID File data - */ - - break; + AwaitingVersion2Request = true; + Write(BitConverter.GetBytes(ServerToken)); + return (int)m.Position; } default: { Logging.WriteLine(Logging.LogLevel.Warning, Logging.LogType.Client_BNFTP, $"Received unknown BNFTP protocol version [0x{ProtocolVersion:X4}]"); Client.Disconnect("Unknown BNFTP protocol version"); - break; + return buffer.Length; } } } + private void ServeFile() + { + /** + * ## VERSION 1 and 2 --> ## + * ## Server -> Client ## + * + * (TYPE) (FIELD) (DESCRIPTION) + * UINT16 Header Length Does not include the file length + * UINT16 Type + * UINT32 File size + * UINT32 Ad Banner ID 0 unless downloading an ad banner + * UINT32 Ad Banner File Extension 0 unless downloading an ad banner + * FILETIME Filetime + * STRING Filename + * VOID File data + */ + + var file = new BNFTP.File(FileName); + if (file == null) + { + Client.Disconnect(); + } + + BinaryReader stream = null; + + bool uploaded = false; + try + { + if (!file.OpenStream()) + { + Client.Disconnect(); + return; + } + + stream = new BinaryReader(file.StreamReader.BaseStream); + stream.BaseStream.Position = Math.Min(stream.BaseStream.Length, FileStartPosition); + + var fileLength = (int)(stream.BaseStream.Length - stream.BaseStream.Position); + HeaderLength = (UInt16)(25 + Encoding.UTF8.GetByteCount(FileName)); + var outBuf = new byte[HeaderLength]; + using var wm = new MemoryStream(outBuf); + using var w = new BinaryWriter(wm); + + w.Write((UInt16)HeaderLength); + w.Write((UInt16)0); // "Type" ??? + w.Write((UInt32)fileLength); + w.Write((UInt32)AdId); + w.Write((UInt32)AdFileExtension); + w.Write((UInt64)new FileInfo(file.Path).LastWriteTimeUtc.ToFileTimeUtc()); + w.Write((string)FileName); + + Write(outBuf); + Write(stream.ReadBytes(fileLength)); + + uploaded = true; + } + catch (Exception ex) + { + if (!(ex is IOException || ex is FileNotFoundException || ex is UnauthorizedAccessException || ex is PathTooLongException)) throw; + + Logging.WriteLine(Logging.LogLevel.Warning, Logging.LogType.Client_BNFTP, Client.RemoteEndPoint, $"{ex.GetType().Name} error encountered for requested file [{FileName}]" + (string.IsNullOrEmpty(ex.Message) ? "" : $"; message: {ex.Message}")); + } + finally + { + if (uploaded) + { + Logging.WriteLine(Logging.LogLevel.Info, Logging.LogType.Client_BNFTP, Client.RemoteEndPoint, $"Uploaded file [{FileName}] ({file.Length} bytes)"); + } + + if (stream != null) + { + stream.Close(); + } + + Client.Disconnect(); + } + } + public void Write(byte[] buffer) { Client.Send(buffer); From f87ddfa09c65cf1eb44456f4b274f5fd8c8b170a Mon Sep 17 00:00:00 2001 From: William LaFrance Date: Fri, 9 Oct 2026 15:07:00 -0500 Subject: [PATCH 2/8] Send SID_NEWS_INFO timestamps as Unix time The timestamps were the high 32 bits of a FILETIME, which is about 31 million, so clients showed the news as dated December 1970. They are Unix timestamps (seconds since 1970-01-01). Co-Authored-By: Claude Sonnet 5.5 --- .../Battlenet/Protocols/Game/Messages/SID_NEWS_INFO.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_NEWS_INFO.cs b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_NEWS_INFO.cs index c3beb2f..5ae1fc8 100644 --- a/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_NEWS_INFO.cs +++ b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_NEWS_INFO.cs @@ -69,10 +69,10 @@ public override bool Invoke(MessageContext context) using var w = new BinaryWriter(m); w.Write((byte)1); - w.Write((UInt32)(lastLogon.ToFileTimeUtc() >> 32)); - w.Write((UInt32)(newsTimestamp.ToFileTimeUtc() >> 32)); - w.Write((UInt32)(newsTimestamp.ToFileTimeUtc() >> 32)); - w.Write((UInt32)(newsTimestamp.ToFileTimeUtc() >> 32)); + w.Write((UInt32)new DateTimeOffset(lastLogon).ToUnixTimeSeconds()); + w.Write((UInt32)new DateTimeOffset(newsTimestamp).ToUnixTimeSeconds()); + w.Write((UInt32)new DateTimeOffset(newsTimestamp).ToUnixTimeSeconds()); + w.Write((UInt32)new DateTimeOffset(newsTimestamp).ToUnixTimeSeconds()); w.Write((string)newsGreeting); Logging.WriteLine(Logging.LogLevel.Debug, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"[{Common.DirectionToString(context.Direction)}] {MessageName(Id)} ({4 + Buffer.Length} bytes)"); From 36fc981ffa3f1cbe0a537c5a189efead9936a119 Mon Sep 17 00:00:00 2001 From: William LaFrance Date: Fri, 9 Oct 2026 15:11:00 -0500 Subject: [PATCH 3/8] Add a script that checks BNFTP versions 1 and 2 Like udptest.py, it runs against a live Atlas. It fetches one file over each BNFTP version and fails if either one errors or if the two return different content. Co-Authored-By: Claude Sonnet 5.5 --- src/bnftptest.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/bnftptest.py diff --git a/src/bnftptest.py b/src/bnftptest.py new file mode 100644 index 0000000..b1c362f --- /dev/null +++ b/src/bnftptest.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Fetches one file from a running Atlas over BNFTP version 1 and version 2 and checks that both succeed with the same content. + +usage: bnftptest.py [host] [port] [filename] +""" +import socket +import sys +from struct import pack, unpack_from + +PLATFORM_IX86 = 0x49583836 +PRODUCT_W3DM = 0x5733444d + +host = sys.argv[1] if len(sys.argv) > 1 else '127.0.0.1' +port = int(sys.argv[2]) if len(sys.argv) > 2 else 6112 +name = (sys.argv[3] if len(sys.argv) > 3 else 'ver-IX86-1.mpq').encode() + b'\0' + + +def read_all(sock): + data = b'' + while True: + chunk = sock.recv(65536) + if not chunk: + return data + data += chunk + + +def parse_reply(data): + header_length, type_, size = unpack_from(' Date: Fri, 9 Oct 2026 15:13:28 -0500 Subject: [PATCH 4/8] Handle SID_NETGAMEPORT Warcraft III clients send this right after logging on. Atlas had no handler, so it treated the message as unknown and disconnected the client. The message carries the 16-bit port the client will host games on; store it as the game data port and send no reply, as documented. Co-Authored-By: Claude Sonnet 5.5 --- .../Battlenet/Protocols/Game/Message.cs | 1 + .../Game/Messages/SID_NETGAMEPORT.cs | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 src/Atlasd/Battlenet/Protocols/Game/Messages/SID_NETGAMEPORT.cs diff --git a/src/Atlasd/Battlenet/Protocols/Game/Message.cs b/src/Atlasd/Battlenet/Protocols/Game/Message.cs index 8fcac44..3ee8aea 100644 --- a/src/Atlasd/Battlenet/Protocols/Game/Message.cs +++ b/src/Atlasd/Battlenet/Protocols/Game/Message.cs @@ -79,6 +79,7 @@ public static Message FromByteArray(byte id, byte[] buffer) MessageIds.SID_QUERYREALMS2 => new SID_QUERYREALMS2(buffer), MessageIds.SID_QUERYADURL => new SID_QUERYADURL(buffer), MessageIds.SID_WARCRAFTGENERAL => new SID_WARCRAFTGENERAL(buffer), + MessageIds.SID_NETGAMEPORT => new SID_NETGAMEPORT(buffer), MessageIds.SID_NEWS_INFO => new SID_NEWS_INFO(buffer), MessageIds.SID_AUTH_INFO => new SID_AUTH_INFO(buffer), MessageIds.SID_AUTH_CHECK => new SID_AUTH_CHECK(buffer), diff --git a/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_NETGAMEPORT.cs b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_NETGAMEPORT.cs new file mode 100644 index 0000000..861103d --- /dev/null +++ b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_NETGAMEPORT.cs @@ -0,0 +1,45 @@ +using Atlasd.Battlenet.Exceptions; +using Atlasd.Daemon; +using System; +using System.IO; + +namespace Atlasd.Battlenet.Protocols.Game.Messages +{ + class SID_NETGAMEPORT : Message + { + public SID_NETGAMEPORT() + { + Id = (byte)MessageIds.SID_NETGAMEPORT; + Buffer = new byte[0]; + } + + public SID_NETGAMEPORT(byte[] buffer) + { + Id = (byte)MessageIds.SID_NETGAMEPORT; + Buffer = buffer; + } + + public override bool Invoke(MessageContext context) + { + if (context == null || context.Client == null || !context.Client.Connected || context.Client.GameState == null) return false; + + if (context.Direction != MessageDirection.ClientToServer) + throw new GameProtocolViolationException(context.Client, $"{MessageName(Id)} is a client-to-server message"); + + Logging.WriteLine(Logging.LogLevel.Debug, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"[{Common.DirectionToString(context.Direction)}] {MessageName(Id)} ({4 + Buffer.Length} bytes)"); + + if (Buffer.Length != 2) + throw new GameProtocolViolationException(context.Client, $"{MessageName(Id)} buffer must be 2 bytes"); + + /** + * (UINT16) Port + */ + + using var m = new MemoryStream(Buffer); + using var r = new BinaryReader(m); + + context.Client.GameState.GameDataPort = r.ReadUInt16(); + return true; + } + } +} From c57febbfa5d79e59f837b774ac49b0a7d7dfd859 Mon Sep 17 00:00:00 2001 From: William LaFrance Date: Fri, 9 Oct 2026 15:16:20 -0500 Subject: [PATCH 5/8] Support the name-only logon of the Warcraft III demo The demo (W3DM) has no password field. After SID_AUTH_INFO it sends SID_AUTH_ACCOUNTLOGON with a random 32-byte key and a name, and treats the reply as SID_AUTH_ACCOUNTLOGONPROOF; it never sends a proof of its own and never creates an account. - SID_AUTH_INFO announces logon type NLS to the demo, which drops the connection otherwise. - SID_AUTH_ACCOUNTLOGON logs the demo on by name, creating the account on first use, and answers with SID_AUTH_ACCOUNTLOGONPROOF carrying the status. Name errors use the same status codes as account creation. Only server-to-client SID_AUTH_ACCOUNTLOGONPROOF is supported. This is off unless battlenet.emulation.name_only_logon enables the product (W3DM is enabled in the sample config). Name-only accounts are marked with a new internal account key, are created without any flags (an auto_admin name does not become an admin), and a name-only logon is refused for any account that was not created this way, so it can never log on to an account that has a password. SID_AUTH_INFO's WAR3/W3XP/W3DM signature was always zeros. Warcraft III clients check it against a public key built into the game, so with battlenet.emulation.server_signature (modulus, private_exponent and an optional address) set, Atlas signs the server address the way Blizzard did (address, 0xBB padding, 0x0B). Unset, the signature stays zeros. A client only accepts a signature from a key it has been patched to use. Co-Authored-By: Claude Sonnet 5.5 --- etc/atlasd.sample.json | 8 ++ src/Atlasd/Battlenet/Account.cs | 2 + .../Battlenet/Protocols/Game/Message.cs | 2 + .../Game/Messages/SID_AUTH_ACCOUNTLOGON.cs | 120 ++++++++++++++++++ .../Messages/SID_AUTH_ACCOUNTLOGONPROOF.cs | 60 +++++++++ .../Protocols/Game/Messages/SID_AUTH_INFO.cs | 5 +- src/Atlasd/Battlenet/ServerSignature.cs | 46 +++++++ 7 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_ACCOUNTLOGON.cs create mode 100644 src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_ACCOUNTLOGONPROOF.cs create mode 100644 src/Atlasd/Battlenet/ServerSignature.cs diff --git a/etc/atlasd.sample.json b/etc/atlasd.sample.json index a6a4087..c386d7b 100644 --- a/etc/atlasd.sample.json +++ b/etc/atlasd.sample.json @@ -108,6 +108,9 @@ "mask_admins_in_broadcasts": false, "mask_admins_in_ban_message": true, "mask_admins_in_kick_message": true, + "name_only_logon": { + "W3DM": true + }, "required_game_key_count": { "D2DV": 1, "D2XP": 2, @@ -118,6 +121,11 @@ "W3XP": 2, "WAR3": 1 }, + "server_signature": { + "modulus": "", + "private_exponent": "", + "address": "" + }, "statstring_updates": { "D2DV": true, "D2XP": true, diff --git a/src/Atlasd/Battlenet/Account.cs b/src/Atlasd/Battlenet/Account.cs index 1a2265a..b8dc4ff 100644 --- a/src/Atlasd/Battlenet/Account.cs +++ b/src/Atlasd/Battlenet/Account.cs @@ -18,6 +18,7 @@ class Account public const string IPAddressKey = "System\\IP"; public const string LastLogoffKey = "System\\Last Logoff"; public const string LastLogonKey = "System\\Last Logon"; + public const string NameOnlyKey = "System\\Name Only"; public const string PasswordKey = "System\\Password Digest"; public const string PortKey = "System\\Port"; public const string ProfileAgeKey = "profile\\age"; @@ -73,6 +74,7 @@ private Account() { new AccountKeyValue(IPAddressKey, IPAddress.Any, AccountKeyValue.ReadLevel.Internal, AccountKeyValue.WriteLevel.Internal) }, { new AccountKeyValue(LastLogoffKey, DateTime.Now, AccountKeyValue.ReadLevel.Owner, AccountKeyValue.WriteLevel.Internal) }, { new AccountKeyValue(LastLogonKey, DateTime.Now, AccountKeyValue.ReadLevel.Owner, AccountKeyValue.WriteLevel.Internal) }, + { new AccountKeyValue(NameOnlyKey, 0, AccountKeyValue.ReadLevel.Internal, AccountKeyValue.WriteLevel.Internal) }, { new AccountKeyValue(PortKey, 0, AccountKeyValue.ReadLevel.Internal, AccountKeyValue.WriteLevel.Internal) }, { new AccountKeyValue(PasswordKey, new byte[0], AccountKeyValue.ReadLevel.Internal, AccountKeyValue.WriteLevel.Internal) }, { new AccountKeyValue(ProfileAgeKey, "", AccountKeyValue.ReadLevel.Any, AccountKeyValue.WriteLevel.Owner) }, diff --git a/src/Atlasd/Battlenet/Protocols/Game/Message.cs b/src/Atlasd/Battlenet/Protocols/Game/Message.cs index 3ee8aea..5846a55 100644 --- a/src/Atlasd/Battlenet/Protocols/Game/Message.cs +++ b/src/Atlasd/Battlenet/Protocols/Game/Message.cs @@ -83,6 +83,8 @@ public static Message FromByteArray(byte id, byte[] buffer) MessageIds.SID_NEWS_INFO => new SID_NEWS_INFO(buffer), MessageIds.SID_AUTH_INFO => new SID_AUTH_INFO(buffer), MessageIds.SID_AUTH_CHECK => new SID_AUTH_CHECK(buffer), + MessageIds.SID_AUTH_ACCOUNTLOGON => new SID_AUTH_ACCOUNTLOGON(buffer), + MessageIds.SID_AUTH_ACCOUNTLOGONPROOF => new SID_AUTH_ACCOUNTLOGONPROOF(buffer), MessageIds.SID_SETEMAIL => new SID_SETEMAIL(buffer), MessageIds.SID_FRIENDSLIST => new SID_FRIENDSLIST(buffer), MessageIds.SID_FRIENDSUPDATE => new SID_FRIENDSUPDATE(buffer), diff --git a/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_ACCOUNTLOGON.cs b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_ACCOUNTLOGON.cs new file mode 100644 index 0000000..c67bacc --- /dev/null +++ b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_ACCOUNTLOGON.cs @@ -0,0 +1,120 @@ +using Atlasd.Battlenet.Exceptions; +using Atlasd.Daemon; +using System; +using System.Collections.Generic; +using System.IO; + +namespace Atlasd.Battlenet.Protocols.Game.Messages +{ + class SID_AUTH_ACCOUNTLOGON : Message + { + public SID_AUTH_ACCOUNTLOGON() + { + Id = (byte)MessageIds.SID_AUTH_ACCOUNTLOGON; + Buffer = new byte[0]; + } + + public SID_AUTH_ACCOUNTLOGON(byte[] buffer) + { + Id = (byte)MessageIds.SID_AUTH_ACCOUNTLOGON; + Buffer = buffer; + } + + private static SID_AUTH_ACCOUNTLOGONPROOF.Statuses ToLogonStatus(Account.CreateStatus status) => status switch + { + Account.CreateStatus.UsernameTooShort => SID_AUTH_ACCOUNTLOGONPROOF.Statuses.UsernameTooShort, + Account.CreateStatus.UsernameInvalidChars => SID_AUTH_ACCOUNTLOGONPROOF.Statuses.UsernameInvalidChars, + Account.CreateStatus.UsernameBannedWord => SID_AUTH_ACCOUNTLOGONPROOF.Statuses.UsernameBannedWord, + Account.CreateStatus.UsernameShortAlphanumeric => SID_AUTH_ACCOUNTLOGONPROOF.Statuses.UsernameShortAlphanumeric, + Account.CreateStatus.UsernameAdjacentPunctuation => SID_AUTH_ACCOUNTLOGONPROOF.Statuses.UsernameAdjacentPunctuation, + Account.CreateStatus.UsernameTooManyPunctuation => SID_AUTH_ACCOUNTLOGONPROOF.Statuses.UsernameTooManyPunctuation, + _ => SID_AUTH_ACCOUNTLOGONPROOF.Statuses.BadPassword, + }; + + private static bool Reply(MessageContext context, SID_AUTH_ACCOUNTLOGONPROOF.Statuses status) + { + return new SID_AUTH_ACCOUNTLOGONPROOF().Invoke(new MessageContext(context.Client, MessageDirection.ServerToClient, new Dictionary {{ "status", status }})); + } + + public override bool Invoke(MessageContext context) + { + if (context == null || context.Client == null || !context.Client.Connected || context.Client.GameState == null) return false; + + if (context.Direction != MessageDirection.ClientToServer) + throw new GameProtocolViolationException(context.Client, $"{MessageName(Id)} is not supported from the server"); + + Logging.WriteLine(Logging.LogLevel.Debug, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"[{Common.DirectionToString(context.Direction)}] {MessageName(Id)} ({4 + Buffer.Length} bytes)"); + + var gameState = context.Client.GameState; + + if (gameState.Product != Product.ProductCode.WarcraftIIIDemo || + !Settings.GetBoolean(new[] { "battlenet", "emulation", "name_only_logon", "W3DM" }, false, true)) + throw new GameProtocolViolationException(context.Client, $"{MessageName(Id)} is only supported for name-only logon (see battlenet.emulation.name_only_logon)"); + + if (Buffer.Length < 34) + throw new GameProtocolViolationException(context.Client, $"{MessageName(Id)} buffer must be at least 34 bytes"); + + if (gameState.ActiveAccount != null) + throw new GameProtocolViolationException(context.Client, $"{MessageName(Id)} cannot be sent after logging into an account"); + + /** + * (UINT8)[32] Client key + * (STRING) Username + * + * The Warcraft III demo has no password field. It sends this message and treats the + * reply to it as SID_AUTH_ACCOUNTLOGONPROOF; it never sends a proof of its own. + */ + + using var m = new MemoryStream(Buffer); + using var r = new BinaryReader(m); + + r.ReadBytes(32); + gameState.Username = r.ReadString(); + + if (!Battlenet.Common.AccountsDb.TryGetValue(gameState.Username, out Account account) || account == null) + { + var createStatus = Account.TryCreate(gameState.Username, new byte[20], out account); + if (createStatus != Account.CreateStatus.Success) + { + Logging.WriteLine(Logging.LogLevel.Info, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"Account [{gameState.Username}] could not be created for name-only logon: {createStatus}"); + return Reply(context, ToLogonStatus(createStatus)); + } + + account.Set(Account.FlagsKey, Account.Flags.None); + account.Set(Account.NameOnlyKey, 1); + } + + var flags = (Account.Flags)account.Get(Account.FlagsKey, Account.Flags.None); + if ((int)account.Get(Account.NameOnlyKey, 0) == 0 || (flags & Account.Flags.Closed) != 0) + { + Logging.WriteLine(Logging.LogLevel.Info, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"Account [{gameState.Username}] cannot be used for name-only logon"); + return Reply(context, SID_AUTH_ACCOUNTLOGONPROOF.Statuses.BadPassword); + } + + gameState.ActiveAccount = account; + gameState.FailedLogons = (UInt32)account.Get(Account.FailedLogonsKey, (UInt32)0); + gameState.LastLogon = (DateTime)account.Get(Account.LastLogonKey, DateTime.Now); + + account.Set(Account.IPAddressKey, context.Client.RemoteEndPoint.ToString().Split(":")[0]); + account.Set(Account.LastLogonKey, DateTime.Now); + account.Set(Account.PortKey, context.Client.RemoteEndPoint.ToString().Split(":")[1]); + + var serial = 1; + var onlineName = gameState.Username; + while (!Battlenet.Common.ActiveAccounts.TryAdd(onlineName, account)) onlineName = $"{gameState.Username}#{++serial}"; + gameState.OnlineName = onlineName; + + gameState.Username = (string)account.Get(Account.UsernameKey, gameState.Username); + + if (!Battlenet.Common.ActiveGameStates.TryAdd(gameState.OnlineName, gameState)) + { + Logging.WriteLine(Logging.LogLevel.Error, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"Failed to add game state to active game state cache"); + Battlenet.Common.ActiveAccounts.TryRemove(onlineName, out _); + return false; + } + + Logging.WriteLine(Logging.LogLevel.Info, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"Account [{gameState.Username}] logon success as [{gameState.OnlineName}]"); + return Reply(context, SID_AUTH_ACCOUNTLOGONPROOF.Statuses.Success); + } + } +} diff --git a/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_ACCOUNTLOGONPROOF.cs b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_ACCOUNTLOGONPROOF.cs new file mode 100644 index 0000000..0158460 --- /dev/null +++ b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_ACCOUNTLOGONPROOF.cs @@ -0,0 +1,60 @@ +using Atlasd.Daemon; +using System; +using System.IO; + +namespace Atlasd.Battlenet.Protocols.Game.Messages +{ + class SID_AUTH_ACCOUNTLOGONPROOF : Message + { + public enum Statuses : UInt32 + { + Success = 0x00, + BadPassword = 0x02, + UsernameTooShort = 0x07, + UsernameInvalidChars = 0x08, + UsernameBannedWord = 0x09, + UsernameShortAlphanumeric = 0x0A, + UsernameAdjacentPunctuation = 0x0B, + UsernameTooManyPunctuation = 0x0C, + } + + public SID_AUTH_ACCOUNTLOGONPROOF() + { + Id = (byte)MessageIds.SID_AUTH_ACCOUNTLOGONPROOF; + Buffer = new byte[0]; + } + + public SID_AUTH_ACCOUNTLOGONPROOF(byte[] buffer) + { + Id = (byte)MessageIds.SID_AUTH_ACCOUNTLOGONPROOF; + Buffer = buffer; + } + + public override bool Invoke(MessageContext context) + { + if (context == null || context.Client == null || !context.Client.Connected || context.Client.GameState == null) return false; + + if (context.Direction != MessageDirection.ServerToClient) + throw new Exceptions.GameProtocolViolationException(context.Client, $"{MessageName(Id)} is not supported from clients"); + + /** + * (UINT32) Status + * (UINT8)[20] Server password proof + */ + + var status = (Statuses)context.Arguments["status"]; + + Buffer = new byte[24]; + + using var m = new MemoryStream(Buffer); + using var w = new BinaryWriter(m); + + w.Write((UInt32)status); + w.Write(new byte[20]); + + Logging.WriteLine(Logging.LogLevel.Debug, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"[{Common.DirectionToString(context.Direction)}] {MessageName(Id)} ({4 + Buffer.Length} bytes) (status: 0x{(UInt32)status:X8})"); + context.Client.Send(ToByteArray(context.Client.ProtocolType)); + return true; + } + } +} diff --git a/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_INFO.cs b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_INFO.cs index 4df798b..8e2e04d 100644 --- a/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_INFO.cs +++ b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_AUTH_INFO.cs @@ -61,6 +61,9 @@ public override bool Invoke(MessageContext context) context.Client.GameState.Locale.CountryNameAbbreviated = r.ReadString(); context.Client.GameState.Locale.CountryName = r.ReadString(); + if (context.Client.GameState.Product == Product.ProductCode.WarcraftIIIDemo) + context.Client.GameState.LogonType = GameState.LogonTypes.NLS; + return new SID_PING().Invoke(new MessageContext(context.Client, MessageDirection.ServerToClient, new Dictionary(){{ "token", context.Client.GameState.PingToken }})) && new SID_AUTH_INFO().Invoke(new MessageContext(context.Client, MessageDirection.ServerToClient)); } @@ -108,7 +111,7 @@ public override bool Invoke(MessageContext context) w.Write((byte)0); if (Product.IsWarcraftIII(context.Client.GameState.Product)) - w.Write(new byte[128]); + w.Write(ServerSignature.Create(context.Client)); context.Client.GameState.SetLocale(); diff --git a/src/Atlasd/Battlenet/ServerSignature.cs b/src/Atlasd/Battlenet/ServerSignature.cs new file mode 100644 index 0000000..f6ef2ab --- /dev/null +++ b/src/Atlasd/Battlenet/ServerSignature.cs @@ -0,0 +1,46 @@ +using Atlasd.Daemon; +using System; +using System.Globalization; +using System.Net; +using System.Numerics; + +namespace Atlasd.Battlenet +{ + class ServerSignature + { + public const int Length = 128; + + public static byte[] Create(ClientState client) + { + var signature = new byte[Length]; + + var modulusHex = Settings.GetString(new[] { "battlenet", "emulation", "server_signature", "modulus" }, "", true); + var exponentHex = Settings.GetString(new[] { "battlenet", "emulation", "server_signature", "private_exponent" }, "", true); + if (string.IsNullOrEmpty(modulusHex) || string.IsNullOrEmpty(exponentHex)) return signature; + + var addressText = Settings.GetString(new[] { "battlenet", "emulation", "server_signature", "address" }, "", true); + IPAddress address; + if (!string.IsNullOrEmpty(addressText)) address = IPAddress.Parse(addressText); + else if (client.Socket?.LocalEndPoint is IPEndPoint localEndPoint) address = localEndPoint.Address.MapToIPv4(); + else return signature; + + var modulus = BigInteger.Parse("0" + modulusHex, NumberStyles.HexNumber); + var exponent = BigInteger.Parse("0" + exponentHex, NumberStyles.HexNumber); + + var block = new byte[Length]; + Array.Fill(block, (byte)0xBB); + address.GetAddressBytes().CopyTo(block, 0); + block[Length - 1] = 0x0B; + + var message = new BigInteger(block, isUnsigned: true); + if (message >= modulus) + { + Logging.WriteLine(Logging.LogLevel.Error, Logging.LogType.Config, "Setting [battlenet] -> [emulation] -> [server_signature] -> [modulus] is too small to sign with; check value"); + return signature; + } + + BigInteger.ModPow(message, exponent, modulus).ToByteArray(isUnsigned: true).CopyTo(signature, 0); + return signature; + } + } +} From b0199605c1eb3f144aa02944bb75560c6b2e9a07 Mon Sep 17 00:00:00 2001 From: William LaFrance Date: Fri, 9 Oct 2026 15:23:46 -0500 Subject: [PATCH 6/8] Acknowledge game searches and cancels from the Warcraft III demo The demo's Play Game button sends SID_WARCRAFTGENERAL sub-command 0 and waits for a reply carrying the same cookie and a status. Atlas ignored every SID_WARCRAFTGENERAL sub-command, so the client sat in "searching" with nothing to tell it the search had started or was cancelled. Sub-command 0 stores the cookie and answers with status 0 (search started). Sub-command 3 answers with the stored cookie. No match is ever reported, so a search ends with the client's own timeout. Co-Authored-By: Claude Sonnet 5.5 --- .../Battlenet/Protocols/Game/GameState.cs | 1 + .../Game/Messages/SID_WARCRAFTGENERAL.cs | 55 ++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/Atlasd/Battlenet/Protocols/Game/GameState.cs b/src/Atlasd/Battlenet/Protocols/Game/GameState.cs index 76ee784..4806aa6 100644 --- a/src/Atlasd/Battlenet/Protocols/Game/GameState.cs +++ b/src/Atlasd/Battlenet/Protocols/Game/GameState.cs @@ -56,6 +56,7 @@ public enum LogonTypes : UInt32 public UInt32 PingToken; public UInt32 ProtocolId; public UInt32 ServerToken; + public UInt32 GameSearchCookie; public bool SpawnKey; public byte[] Statstring; public Int32 TimezoneBias; diff --git a/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_WARCRAFTGENERAL.cs b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_WARCRAFTGENERAL.cs index cfb8e19..84d3386 100644 --- a/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_WARCRAFTGENERAL.cs +++ b/src/Atlasd/Battlenet/Protocols/Game/Messages/SID_WARCRAFTGENERAL.cs @@ -32,8 +32,30 @@ public SID_WARCRAFTGENERAL(byte[] buffer) Buffer = buffer; } + private static bool Reply(MessageContext context, byte subcommand, UInt32? status) + { + var buffer = new byte[status.HasValue ? 9 : 5]; + using (var m = new MemoryStream(buffer)) + using (var w = new BinaryWriter(m)) + { + w.Write(subcommand); + w.Write(context.Client.GameState.GameSearchCookie); + if (status.HasValue) w.Write(status.Value); + } + + return new SID_WARCRAFTGENERAL().Invoke(new MessageContext(context.Client, MessageDirection.ServerToClient, new Dictionary {{ "buffer", buffer }})); + } + public override bool Invoke(MessageContext context) { + if (context.Direction == MessageDirection.ServerToClient) + { + Buffer = (byte[])context.Arguments["buffer"]; + Logging.WriteLine(Logging.LogLevel.Debug, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"[{Common.DirectionToString(context.Direction)}] {MessageName(Id)} subcommand {Buffer[0]:X2} ({4 + Buffer.Length} bytes)"); + context.Client.Send(ToByteArray(context.Client.ProtocolType)); + return true; + } + Logging.WriteLine(Logging.LogLevel.Debug, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"[{Common.DirectionToString(context.Direction)}] {MessageName(Id)} ({4 + Buffer.Length} bytes)"); if (context.Client.GameState == null || !Product.IsWarcraftIII(context.Client.GameState.Product)) @@ -45,11 +67,40 @@ public override bool Invoke(MessageContext context) byte subcommand; using (var m = new MemoryStream(Buffer)) using (var r = new BinaryReader(m)) + { subcommand = r.ReadByte(); - Logging.WriteLine(Logging.LogLevel.Debug, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"[{Common.DirectionToString(context.Direction)}] {MessageName(Id)} received subcommand {subcommand:X2}"); + Logging.WriteLine(Logging.LogLevel.Debug, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"[{Common.DirectionToString(context.Direction)}] {MessageName(Id)} received subcommand {subcommand:X2}"); + + switch ((SubCommands)subcommand) + { + case SubCommands.WID_GAMESEARCH: + { + /** + * Warcraft III demo layout: + * (UINT8) Subcommand + * (UINT32) Cookie + * (UINT32) Unknown + * (UINT8) Unknown + * (UINT8) Unknown + * (UINT32) Unknown + * (UINT8) Length of the remaining data (8) + * (UINT32) Tick count + * (UINT32) Race + */ + + if (Buffer.Length < 16) + throw new GameProtocolViolationException(context.Client, $"{MessageName(Id)} game search buffer must be at least 16 bytes"); + + context.Client.GameState.GameSearchCookie = r.ReadUInt32(); + Logging.WriteLine(Logging.LogLevel.Debug, Logging.LogType.Client_Game, context.Client.RemoteEndPoint, $"Game search requested (cookie 0x{context.Client.GameState.GameSearchCookie:X8}): {BitConverter.ToString(Buffer)}"); - // TODO: Compare subcommand variable with SubCommands enum and do procedures, for now just ignore + return Reply(context, subcommand, 0); + } + case SubCommands.WID_CANCELSEARCH: + return Reply(context, subcommand, null); + } + } return true; } From 6540b274db945f3fd4023400dd82ed3ac4270c3a Mon Sep 17 00:00:00 2001 From: William LaFrance Date: Fri, 9 Oct 2026 15:23:51 -0500 Subject: [PATCH 7/8] Add a terms of service file for the Warcraft III demo After SID_AUTH_CHECK the demo asks for termsofservice-enUS.txt with SID_GETFILETIME and shows it on its logon screen. When the file is missing it drops the connection after about 30 seconds. Co-Authored-By: Claude Sonnet 5.5 --- var/bnftp/termsofservice-enUS.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 var/bnftp/termsofservice-enUS.txt diff --git a/var/bnftp/termsofservice-enUS.txt b/var/bnftp/termsofservice-enUS.txt new file mode 100644 index 0000000..dc0b896 --- /dev/null +++ b/var/bnftp/termsofservice-enUS.txt @@ -0,0 +1 @@ +Atlas test server. By continuing you agree to nothing in particular. From ce0201205b8436f72f2415d11274c2c6c2fbcb34 Mon Sep 17 00:00:00 2001 From: William LaFrance Date: Fri, 9 Oct 2026 15:24:18 -0500 Subject: [PATCH 8/8] Add the Warcraft III demo's game type list and its generator After logon the demo requests matchmaking-w3dm-enUS.dat with SID_GETFILETIME, downloads it over BNFTP and fills the Play Game screen from it. Without the file the game type list is empty. make-matchmaking.sh writes the file from a table of game types, styles and maps, using a small C# program run with dotnet. The layout is documented in the script. The demo checks every map path against the files it has installed, so the table lists only (4)Deadlock, the one map it installs; other paths show as missing or corrupt and disable Play Game. .gitattributes keeps the script's line endings LF, like etc/atlasd.service, so it still runs in a checkout that converts text files to CRLF. Co-Authored-By: Claude Sonnet 5.5 --- .gitattributes | 1 + src/make-matchmaking.sh | 85 ++++++++++++++++++++++++++++ var/bnftp/matchmaking-w3dm-enUS.dat | Bin 0 -> 213 bytes 3 files changed, 86 insertions(+) create mode 100755 src/make-matchmaking.sh create mode 100644 var/bnftp/matchmaking-w3dm-enUS.dat diff --git a/.gitattributes b/.gitattributes index 519d6a5..9a21ff9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -66,3 +66,4 @@ # systemd cannot be trusted with CRLF unit files; keep LF on every platform. ############################################################################### etc/atlasd.service text eol=lf +src/make-matchmaking.sh text eol=lf diff --git a/src/make-matchmaking.sh b/src/make-matchmaking.sh new file mode 100755 index 0000000..43edf6d --- /dev/null +++ b/src/make-matchmaking.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Writes the Warcraft III demo's game type list (matchmaking-w3dm-enUS.dat). +# +# usage: make-matchmaking.sh [output file] +# +# The demo downloads this file over BNFTP after logging on and reads it with +# CBattleNetGlueData; it must be consumed exactly. Layout, all integers +# little-endian: +# +# (UINT32) Header +# (UINT8) Number of game types +# For each game type: +# (UINT8) Game type id (the demo recognizes 0, 2, 3 and 4) +# (UINT8) Number of styles +# For each style: +# (UINT8) Style id +# (STRING) Name, at most 127 characters +# (UINT8) Number of maps +# For each map: +# (STRING) Map path relative to the game directory, at most 259 characters +# (UINT32) Unknown +# (STRING) Description, at most 511 characters +# +# Each map path must name a map the client has installed, or the demo lists it +# as missing or corrupt and disables Play Game. + +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +output="${1:-$root/var/bnftp/matchmaking-w3dm-enUS.dat}" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +cat > "$work/make-matchmaking.cs" <<'CSHARP' +using System.Text; + +var maps = new[] { (Path: @"Maps\(4)Deadlock.w3m", Unknown: 0u) }; + +var gameTypes = new[] +{ + (Id: (byte)0, Styles: new[] { (Id: (byte)0, Name: "Solo", Maps: maps, Description: "One versus one") }), + (Id: (byte)2, Styles: new[] { (Id: (byte)0, Name: "2 vs 2", Maps: maps, Description: "Two versus two") }), + (Id: (byte)3, Styles: new[] { (Id: (byte)0, Name: "3 vs 3", Maps: maps, Description: "Three versus three") }), + (Id: (byte)4, Styles: new[] { (Id: (byte)0, Name: "4 vs 4", Maps: maps, Description: "Four versus four") }), +}; + +using var stream = new MemoryStream(); +using var writer = new BinaryWriter(stream); + +void WriteString(string value) +{ + writer.Write(Encoding.ASCII.GetBytes(value)); + writer.Write((byte)0); +} + +writer.Write(1u); +writer.Write((byte)gameTypes.Length); + +foreach (var gameType in gameTypes) +{ + writer.Write(gameType.Id); + writer.Write((byte)gameType.Styles.Length); + + foreach (var style in gameType.Styles) + { + writer.Write(style.Id); + WriteString(style.Name); + writer.Write((byte)style.Maps.Length); + + foreach (var map in style.Maps) + { + WriteString(map.Path); + writer.Write(map.Unknown); + } + + WriteString(style.Description); + } +} + +File.WriteAllBytes(args[0], stream.ToArray()); +Console.WriteLine($"{stream.Length} bytes written to {args[0]}"); +CSHARP + +dotnet run "$work/make-matchmaking.cs" -- "$output" diff --git a/var/bnftp/matchmaking-w3dm-enUS.dat b/var/bnftp/matchmaking-w3dm-enUS.dat new file mode 100644 index 0000000000000000000000000000000000000000..a9bc4af901bd4269d82446fe98c7908498dd9911 GIT binary patch literal 213 zcmZQ%U|?WjU}Ol+&&g+C^i3=%j?pmDbV*H2$;nU7)+;y81u9@*@Xt$CC`&CWE-hBb z&r4-sVq`E