Implementing login messages
The login message data can be found in
the intermediate_representation.json
file.
The intermediate_representation_schema.json
contains a JSON type def schema for the intermediate_representation.json
.
The json-typedef-codegen
program can be used to generate
bindings for many popular languages.
The wow_messages_python
repository contains the Python code used to
generate the Python message library.
It can be used as inspiration for your own implementation.
The generator
directory contains the actual code
generation,
and the wow_login_messages
directory
contains the generated library.
Python code will be shown in this document to provide examples of how libraries could be implemented. If you want to use the Python library then just bypass this and use the library directly instead. A C# library is available here.
Message Layout
All interactions start with the client sending a message, then reading the reply from the server.
All login messages start with an opcode field field that specifies the message contents. This is the only thing the messages have in common.
The only way to know how much data to expect in a message is by a combination of the protocol version sent
in the very first message by the
client, CMD_AUTH_LOGON_CHALLENGE_Client
,
and the opcode.
Take a look at the wiki page on login in order to get an understanding for what is going to happen.
Types used for login messages
Login messages use the following types, including enums, flags, and structs:
Type | Purpose | C Name |
---|---|---|
u8 | Unsigned 8 bit integer. Min value 0, max value 256. | unsigned char |
u16 | Unsigned 16 bit integer. Min value 0, max value 65536. | unsigned short |
u32 | Unsigned 32 bit integer. Min value 0, max value 4294967296. | unsigned int |
u64 | Unsigned 64 bit integer. Min value 0, max value 18446744073709551616. | unsigned long long |
i32 | Unsigned 32 bit integer. Min value -2147483648, max value 4294967296. | signed int |
Bool | Unsigned 1 bit integer. 0 means false and all other values mean true . | unsigned char |
CString | UTF-8 string type that is terminated by a zero byte value. | char* |
String | UTF-8 string type of exactly length len . | unsigned char + char* |
Population | f32 with the special behavior that a value of 200 always means GREEN_RECOMMENDED , 400 always means RED_FULL , and 600 always means BLUE_RECOMMENDED . | float |
IpAddress | Alias for big endian u32 . | unsigned int |
Library layout
Consider what you want the layout of the library to look like regarding files and directories.
There are several different protocol versions, objects that are valid for more than one version, as well as objects that are valid for all versions. Consider if your programming language benefits from having a type that is identical, but for a different version actually be the same type.
For the Python library, I chose to have a Python file for each version, as well as one for all
.
Objects that are valid for more than one version are reexported from the lowest version.
The folder layout for the Python library is as follows:
├── README.md
└── wow_login_messages
├── all.py
├── __init__.py
├── opcodes.py
├── util.py
├── version2.py
├── version3.py
├── version5.py
├── version6.py
├── version7.py
└── version8.py
The __init__.py
file is what is imported when just importing wow_login_messages
.
The opcodes.py
file just contains the opcode values for the different messages.
The util.py
file contains utility functions intended to be used by the user of the library,
such as a function that reads the opcode and automatically reads the remaining fields and returns the message as a
Python class.
Enums
Enums can only have a single enumerator value, and it can not have any other values than those specifically described. Consider how you would implement and use the ProtocolVersion enum in your programming language.
Newer Python versions have the enum
module, which provide an easy way
of creating enum types.
So the ProtocolVersion enum in Python looks like:
import enum
class ProtocolVersion(enum.Enum):
TWO = 2
THREE = 3
FIVE = 5
SIX = 6
SEVEN = 7
EIGHT = 8
protocol_version = ProtocolVersion(2)
value = protocol_version.value
This simple snippet allows construction of ProtocolVersion
types through ProtocolVersion(2)
and getting the value of
an enumerator through protocol_version.value
.
I have chosen not to include a read
or write
function on the enum, since this is handled by the object that needs to
read/write the enum.
The python library places all imports at the top of the file, and all objects for the same version are in the same file,
so it is not necessary to import enum
for every enum.
Flags
Flags are like enums, but can have multiple enumerator values at the same time, or none of them. Consider how you would implement and use the RealmFlag flag in your programming language.
The enum
module used for enums can also be used for flags.
So the RealmFlag enum in Python looks like:
import enum
class RealmFlag(enum.Flag):
NONE = 0
INVALID = 1
OFFLINE = 2
FORCE_BLUE_RECOMMENDED = 32
FORCE_GREEN_RECOMMENDED = 64
FORCE_RED_FULL = 128
realm_flag = RealmFlag(32) | RealmFlag(2)
assert realm_flag.OFFLINE
assert realm_flag.FORCE_BLUE_RECOMMENDED
value = realm_flag.value
This snippet allows the construction of RealmFlag
through RealmFlag(32)
, the
bitwise or through the |
operator and getting the value
through realm_flag.value
.
Flags also do not handle reading and writing, that is handled by the objects containing them, although it could be different in your programming language.
CMD_AUTH_LOGON_CHALLENGE_Client
This is the first message sent by the client. It is sent automatically upon connecting.
It is the only message to have the String
type, which is a single length byte (u8
) followed by as many
bytes as the length byte says of string.
All other messages use the CString
type, which is an arbitrary amount of bytes terminated by a 0 byte.
It is the only message that contains the IpAddress
type alias.
This is not a "real" type, but simply a u32
that should be interpreted as an IP address if the programming language
has built in ways of dealing with IP addresses.
It contains the ProtocolVersion enum, which determines which version of messages is used.
It contains the Version struct.
For writing a server, it requires being able to be read, and for clients it requires being able to be written.
When sent by clients, it has to calculate how large the actual message being sent is, and put it in the size
field.
In Python, the definition would be:
import dataclasses
@dataclasses.dataclass
class CMD_AUTH_LOGON_CHALLENGE_Client:
protocol_version: ProtocolVersion
version: Version
platform: Platform
os: Os
locale: Locale
utc_timezone_offset: int
client_ip_address: int
account_name: str
The types after the colon (:
) are type hints.
The @dataclasses.dataclass
attribute adds some niceties like automatic constructors.
All containers have read
, write
and _size
methods.
The underscore in _size
is a Python way of signalling that the method should be private.
The read
function looks like
@staticmethod
async def read(reader: asyncio.StreamReader):
# protocol_version: DataTypeEnum(data_type_tag='Enum', content=DataTypeEnumContent(integer_type=<IntegerType.U8: 'U8'>, type_name='ProtocolVersion', upcast=False))
protocol_version = ProtocolVersion(int.from_bytes(await reader.readexactly(1), 'little'))
# size: DataTypeInteger(data_type_tag='Integer', content=<IntegerType.U16: 'U16'>)
_size = int.from_bytes(await reader.readexactly(2), 'little')
# game_name: DataTypeInteger(data_type_tag='Integer', content=<IntegerType.U32: 'U32'>)
_game_name = int.from_bytes(await reader.readexactly(4), 'little')
# version: DataTypeStruct(data_type_tag='Struct', content=DataTypeStructContent(sizes=Sizes(constant_sized=True, maximum_size=5, minimum_size=5), type_name='Version'))
version = await Version.read(reader)
# platform: DataTypeEnum(data_type_tag='Enum', content=DataTypeEnumContent(integer_type=<IntegerType.U32: 'U32'>, type_name='Platform', upcast=False))
platform = Platform(int.from_bytes(await reader.readexactly(4), 'little'))
# os: DataTypeEnum(data_type_tag='Enum', content=DataTypeEnumContent(integer_type=<IntegerType.U32: 'U32'>, type_name='Os', upcast=False))
os = Os(int.from_bytes(await reader.readexactly(4), 'little'))
# locale: DataTypeEnum(data_type_tag='Enum', content=DataTypeEnumContent(integer_type=<IntegerType.U32: 'U32'>, type_name='Locale', upcast=False))
locale = Locale(int.from_bytes(await reader.readexactly(4), 'little'))
# utc_timezone_offset: DataTypeInteger(data_type_tag='Integer', content=<IntegerType.U32: 'U32'>)
utc_timezone_offset = int.from_bytes(await reader.readexactly(4), 'little')
# client_ip_address: DataTypeIPAddress(data_type_tag='IpAddress')
client_ip_address = int.from_bytes(await reader.readexactly(4), 'big')
# account_name: DataTypeString(data_type_tag='String')
account_name = int.from_bytes(await reader.readexactly(1), 'little')
account_name = (await reader.readexactly(account_name)).decode('utf-8')
return CMD_AUTH_LOGON_CHALLENGE_Client(
protocol_version,
version,
platform,
os,
locale,
utc_timezone_offset,
client_ip_address,
account_name,
)
It reads every member in sequence before returning an object with all the fields. The comments print the name and type of variables, in order to make debugging easier.
This showcases how enums are ready by reading an appropriately sized integer and then passing it into the enum constructor.
The struct Version
is read by calling a read
function defined on that object.
The IpAddress
is simply read as an integer.
Python allows changing the type of a variable, so the account_name
variable is first used for the length, and then for
the contents.
_size
is not included in the object, since manually modifying the size is error prone and tedious.
It is instead calculated automatically in write
.
_game_name
is not included in the object since it has a constant value.
The write function looks like
def write(self, writer: asyncio.StreamWriter):
fmt = '<B' # opcode
data = [0]
# protocol_version: DataTypeEnum(data_type_tag='Enum', content=DataTypeEnumContent(integer_type=<IntegerType.U8: 'U8'>, type_name='ProtocolVersion', upcast=False))
fmt += 'B'
data.append(self.protocol_version.value)
# size: DataTypeInteger(data_type_tag='Integer', content=<IntegerType.U16: 'U16'>)
fmt += 'H'
data.append(self.size())
# game_name: DataTypeInteger(data_type_tag='Integer', content=<IntegerType.U32: 'U32'>)
fmt += 'I'
data.append(5730135)
# version: DataTypeStruct(data_type_tag='Struct', content=DataTypeStructContent(sizes=Sizes(constant_sized=True, maximum_size=5, minimum_size=5), type_name='Version'))
fmt, data = self.version.write(fmt, data)
# platform: DataTypeEnum(data_type_tag='Enum', content=DataTypeEnumContent(integer_type=<IntegerType.U32: 'U32'>, type_name='Platform', upcast=False))
fmt += 'I'
data.append(self.platform.value)
# os: DataTypeEnum(data_type_tag='Enum', content=DataTypeEnumContent(integer_type=<IntegerType.U32: 'U32'>, type_name='Os', upcast=False))
fmt += 'I'
data.append(self.os.value)
# locale: DataTypeEnum(data_type_tag='Enum', content=DataTypeEnumContent(integer_type=<IntegerType.U32: 'U32'>, type_name='Locale', upcast=False))
fmt += 'I'
data.append(self.locale.value)
# utc_timezone_offset: DataTypeInteger(data_type_tag='Integer', content=<IntegerType.U32: 'U32'>)
fmt += 'I'
data.append(self.utc_timezone_offset)
# client_ip_address: DataTypeIPAddress(data_type_tag='IpAddress')
fmt += 'I'
data.append(self.client_ip_address)
# account_name: DataTypeString(data_type_tag='String')
fmt += f'B{len(self.account_name)}s'
data.append(len(self.account_name))
data.append(self.account_name.encode('utf-8'))
data = struct.pack(fmt, *data)
writer.write(data)
The complexity of this is because of how Pythons struct module for
writing values to bytes works.
The line with data = struct.pack(fmt, *data)
writes the data to a byte array that is then written to the stream on the
next line.
Hopefully your programming language has a more sane way of writing data to a stream.
The final part is the size calculations, it looks like
def size(self):
size = 0
# protocol_version: DataTypeEnum(data_type_tag='Enum', content=DataTypeEnumContent(integer_type=<IntegerType.U8: 'U8'>, type_name='ProtocolVersion', upcast=False))
size += 1
# size: DataTypeInteger(data_type_tag='Integer', content=<IntegerType.U16: 'U16'>)
size += 2
# game_name: DataTypeInteger(data_type_tag='Integer', content=<IntegerType.U32: 'U32'>)
size += 4
# version: DataTypeStruct(data_type_tag='Struct', content=DataTypeStructContent(sizes=Sizes(constant_sized=True, maximum_size=5, minimum_size=5), type_name='Version'))
size += 5
# platform: DataTypeEnum(data_type_tag='Enum', content=DataTypeEnumContent(integer_type=<IntegerType.U32: 'U32'>, type_name='Platform', upcast=False))
size += 4
# os: DataTypeEnum(data_type_tag='Enum', content=DataTypeEnumContent(integer_type=<IntegerType.U32: 'U32'>, type_name='Os', upcast=False))
size += 4
# locale: DataTypeEnum(data_type_tag='Enum', content=DataTypeEnumContent(integer_type=<IntegerType.U32: 'U32'>, type_name='Locale', upcast=False))
size += 4
# utc_timezone_offset: DataTypeInteger(data_type_tag='Integer', content=<IntegerType.U32: 'U32'>)
size += 4
# client_ip_address: DataTypeIPAddress(data_type_tag='IpAddress')
size += 4
# account_name: DataTypeString(data_type_tag='String')
size += len(self.account_name)
return size - 3
This sums up the sizes of all members, and then subtracts the fields that come before the size field.
CMD_AUTH_LOGON_CHALLENGE_Server
The servers reply to the client has the same opcode as the initial message, and it provides the first example
of a message that has control flow (if
statements).
@dataclasses.dataclass
class CMD_AUTH_LOGON_CHALLENGE_Server:
result: LoginResult
server_public_key: typing.Optional[typing.List[int]]
generator: typing.Optional[typing.List[int]]
large_safe_prime: typing.Optional[typing.List[int]]
salt: typing.Optional[typing.List[int]]
crc_salt: typing.Optional[typing.List[int]]
The Python version solves this issue by making every variable that is not certain to be in the message Optional
.
This type hint means that the variables can also be None
, and have no value of their actual type.
The python code for reading reads the result
and then branches based on the value.
@staticmethod
async def read(reader: asyncio.StreamReader):
server_public_key = None
generator_length = None
generator = None
large_safe_prime_length = None
large_safe_prime = None
salt = None
crc_salt = None
# protocol_version: DataTypeInteger(data_type_tag='Integer', content=<IntegerType.U8: 'U8'>)
_protocol_version = int.from_bytes(await reader.readexactly(1), "little")
# result: DataTypeEnum(data_type_tag='Enum', content=DataTypeEnumContent(integer_type=<IntegerType.U8: 'U8'>, type_name='LoginResult', upcast=False))
result = LoginResult(int.from_bytes(await reader.readexactly(1), "little"))
if result == LoginResult.SUCCESS:
# server_public_key: DataTypeArray(data_type_tag='Array', content=Array(inner_type=ArrayTypeInteger(array_type_tag='Integer', inner_type=<IntegerType.U8: 'U8'>), size=ArraySizeFixed(array_size_tag='Fixed', size='32')))
server_public_key = []
for _ in range(0, 32):
server_public_key.append(
int.from_bytes(await reader.readexactly(1), "little")
)
# generator_length: DataTypeInteger(data_type_tag='Integer', content=<IntegerType.U8: 'U8'>)
generator_length = int.from_bytes(await reader.readexactly(1), "little")
# generator: DataTypeArray(data_type_tag='Array', content=Array(inner_type=ArrayTypeInteger(array_type_tag='Integer', inner_type=<IntegerType.U8: 'U8'>), size=ArraySizeVariable(array_size_tag='Variable', size='generator_length')))
generator = []
for _ in range(0, generator_length):
generator.append(int.from_bytes(await reader.readexactly(1), "little"))
# large_safe_prime_length: DataTypeInteger(data_type_tag='Integer', content=<IntegerType.U8: 'U8'>)
large_safe_prime_length = int.from_bytes(
await reader.readexactly(1), "little"
)
# large_safe_prime: DataTypeArray(data_type_tag='Array', content=Array(inner_type=ArrayTypeInteger(array_type_tag='Integer', inner_type=<IntegerType.U8: 'U8'>), size=ArraySizeVariable(array_size_tag='Variable', size='large_safe_prime_length')))
large_safe_prime = []
for _ in range(0, large_safe_prime_length):
large_safe_prime.append(
int.from_bytes(await reader.readexactly(1), "little")
)
# salt: DataTypeArray(data_type_tag='Array', content=Array(inner_type=ArrayTypeInteger(array_type_tag='Integer', inner_type=<IntegerType.U8: 'U8'>), size=ArraySizeFixed(array_size_tag='Fixed', size='32')))
salt = []
for _ in range(0, 32):
salt.append(int.from_bytes(await reader.readexactly(1), "little"))
# crc_salt: DataTypeArray(data_type_tag='Array', content=Array(inner_type=ArrayTypeInteger(array_type_tag='Integer', inner_type=<IntegerType.U8: 'U8'>), size=ArraySizeFixed(array_size_tag='Fixed', size='16')))
crc_salt = []
for _ in range(0, 16):
crc_salt.append(int.from_bytes(await reader.readexactly(1), "little"))
return CMD_AUTH_LOGON_CHALLENGE_Server(
result,
server_public_key,
generator,
large_safe_prime,
salt,
crc_salt,
)
CMD_AUTH_LOGON_PROOF_Client
This message is much like the others, but it contains an array of structs.
CMD_REALM_LIST_Client
This message is not empty, but has a padding variable that should always be a constant value, so it becomes empty if constant values are removed.
CMD_XFER_ACCEPT
This message has an empty body.
CMD_REALM_LIST_Server
This message has a bunch of padding and an array of Realm structs, as well as having a size field.
Realm
This struct has several CString
variables, which are read by reading until finding a 0 byte.
It also has the Population
alias, which can just be substituted for a 4 byte floating point value.
Version 8 additionally has an if statement that uses a flag instead of an enum.