Sequence and XOR Transformation
Most binary packets carry a sequence byte and use three XOR passes over the payload plus a trailing zero. The message indexes call this Encrypted: Yes for protocol familiarity. It is obfuscation rather than authenticated encryption: there is no integrity tag, message authentication code, or cryptographic security boundary.
Wire placement
The transformation applies inside the standard 0xAA frame:
Encrypted: AA [uint16 body_length, big-endian] [action] [sequence] [XOR(payload || 00)]
Not encrypted: AA [uint16 body_length, big-endian] [action] [payload] [00]
body_length counts the action and every subsequent body byte. It excludes the marker and the two-byte length. The action remains visible in both forms. Only the payload and trailing zero enter the XOR passes.
These actions bypass the sequence and XOR code:
| Direction | Encrypted: No |
|---|---|
| Client to server | 0x00, 0x10, 0x48 |
| Server to client | 0x00, 0x03, 0x40 |
All other fixed actions in the current indexes use the encrypted branch.
Runtime state and defaults
| State | Default | Behavior |
|---|---|---|
| Repeating key | Nine ASCII bytes NexonInc. | Repeats across the transformed data. |
| Table function | 0 | Produces the identity table, where the generated byte equals input_value. |
| Client send sequence | 0x00 | Inserted after the action and incremented modulo 256 after each encrypted client packet. |
Clear client packets do not consume the client send sequence. On receive, the decoder uses the sequence carried by the server packet. No independent receive counter or monotonic-sequence check appears in the decoded server path.
Server-controlled key and table
The server can replace both transformation inputs during the clear SVersionCheck action 0x00. Subtype 0x00 has this payload prefix:
| Payload offset | Width | Field | Behavior |
|---|---|---|---|
0x00 | 1 | subtype | Constant 0x00 for transformation negotiation. |
0x01 | 1 | server_version | Stored and compared with the active server version. |
0x02 | 1 | table_function | Queues regeneration with function index 0 through 9. |
0x03 | 1 | key_length | The main-menu handler requires exactly 9. |
0x04 | key_length | key | Copied and queued as the new repeating key. |
ui_main_menu_handle_server_packet queues the table update through net_queue_xor_table_function and the key update through net_queue_xor_key. Socket work codes 11 and 10 apply them in net_process_work_item. The general setter accepts a key length no greater than nine, but this server-negotiation path asserts that the supplied length equals nine.
Because action 0x00 is not encrypted, these values can establish new state without depending on the previous key or table.
Client encoding
The encoder produces:
[action] [packet_sequence] [encoded payload and trailing zero]
It copies packet_sequence to the output, increments the stored counter modulo 256, and transforms payload_and_zero in this order:
void encode_packet_data(
const uint8_t *payload_and_zero,
size_t data_length,
const uint8_t *repeating_key,
size_t key_length,
const uint8_t *xor_table,
uint8_t packet_sequence,
uint8_t *encoded_data)
{
size_t byte_index;
size_t block_index;
size_t key_index;
size_t data_index;
uint8_t block_number;
for (byte_index = 0; byte_index < data_length; ++byte_index) {
encoded_data[byte_index] =
payload_and_zero[byte_index] ^
repeating_key[byte_index % key_length];
}
for (block_index = 0;
block_index * key_length < data_length;
++block_index) {
block_number = (uint8_t)block_index;
if (block_number == packet_sequence)
continue;
for (key_index = 0; key_index < key_length; ++key_index) {
data_index = block_index * key_length + key_index;
if (data_index >= data_length)
break;
encoded_data[data_index] ^=
xor_table[block_number + key_index];
}
}
for (byte_index = 0; byte_index < data_length; ++byte_index) {
encoded_data[byte_index] ^=
xor_table[packet_sequence + byte_index];
}
}
The first pass uses the repeating nine-byte key. The second applies table bytes by key-sized block, except when the block number equals the packet sequence. The third applies table bytes indexed from the sequence.
Server decoding
net_s_decode_packet_body leaves the action in place, reads the following sequence byte, and reverses the XOR passes in the opposite order:
- XOR with table bytes indexed from the received sequence.
- XOR each key-sized block with its table bytes, skipping the block whose byte index equals the received sequence.
- XOR with the repeating key.
- Remove the sequence byte from the application-facing packet.
The decoder returns one fewer byte than the wire body because the sequence is removed. It also writes an additional zero immediately after the returned bytes.
Trailing zero behavior
net_c_queue_send appends a zero before client encoding, so the trailing zero is transformed with the payload. The server is expected to provide the corresponding byte on server packets.
The 4.21 decoder does not verify that this byte becomes zero. It neither rejects the packet nor reports a transformation failure when the decoded value is nonzero. It keeps that byte in the returned logical packet and writes another local zero guard after it. A proxy or compatible server can use the decoded value as a diagnostic, but treating it as a required integrity check would be stricter than this client.
Table generators
net_build_xor_table accepts function indexes 0 through 9. For input_value from 0 through 255, it generates the following byte values. Function 9 first calculates centered_value as ((int32_t)input_value - 128) / 8, using signed C integer division toward zero.
| Function | C expression for the generated byte |
|---|---|
| 0 | input_value |
| 1 | 128 + (((input_value & 1) == 0) ? ((input_value + 1) / 2) : -((input_value + 1) / 2)) |
| 2 | 255 - input_value |
| 3 | 128 + (((input_value & 1) == 0) ? ((255 - input_value) / 2) : -((255 - input_value) / 2)) |
| 4 | (input_value / 16) * (input_value / 16) |
| 5 | (2 * input_value) & 0xFF |
| 6 | 255 - ((2 * input_value) & 0xFF) |
| 7 | (input_value <= 127) ? (255 - 2 * input_value) : (2 * input_value - 256) |
| 8 | (input_value <= 127) ? (2 * input_value) : (511 - 2 * input_value) |
| 9 | 255 - ((centered_value * centered_value) & 0xFF) |
The image starts with function 0. Each generated byte is stored in all four bytes of one dword, allowing the optimized paths to XOR four data bytes at once.
The additive table indexes in the encoder and decoder do not have an explicit modulo operation or bounds check after addition. Compatible implementations should reproduce the observed indexing before adding stricter validation.
Function reference
| Address | Current IDA name | Prototype | Purpose |
|---|---|---|---|
Darkages.exe:0x0045F780 | ui_main_menu_handle_server_packet | int __thiscall(void *this, void *event) | Parse clear action 0x00, including the table selector and negotiated key. |
Darkages.exe:0x004A36C0 | net_queue_xor_key | void __thiscall(void *this, unsigned int key_length, const uint8_t *key) | Copy and queue a key update as Socket work code 10. |
Darkages.exe:0x004A3700 | net_queue_xor_table_function | void __thiscall(void *this, unsigned int function_index) | Queue table regeneration as Socket work code 11. |
Darkages.exe:0x004A44D4 | net_s_decode_packet_body | int __stdcall(const uint8_t *src, int src_size, uint8_t *dest) | Decode a sequence-bearing server body. |
Darkages.exe:0x004A54D0 | net_process_work_item | void __thiscall(void *this, int code, void *data, int value) | Apply queued key and table updates on work codes 10 and 11. |
Darkages.exe:0x004A79E4 | net_c_encode_packet_body | int __stdcall(const uint8_t *src, int src_size, uint8_t *dest) | Insert the client sequence and encode the payload and trailing zero. |
Darkages.exe:0x004A8414 | net_set_xor_key | void __stdcall(int length, const uint8_t *key) | Install and repeat the active key. |
Darkages.exe:0x004A8590 | net_build_xor_table | void __stdcall(int function_index) | Generate the table with one of ten functions. |