mirror of
https://github.com/modernuo/ModernUO
synced 2026-08-11 22:23:06 -04:00
## The bug #2522 rewrote the outgoing huffman table in `NetworkCompression.cs` and transposed symbol `0x19`'s code from `0x1CE` to `0x12E` (both 9 bits, so the length distribution — and the Kraft sum — stayed valid, which is why nothing obvious tripped). The real damage is that it broke prefix-freeness. `0x12E` is `100101110`, and symbol `0x0D`'s 8-bit code is `10010111` — a proper prefix of it. The client's decoder walks the tree bit by bit, so it hit a valid leaf at `0x0D` after 8 bits, emitted the wrong byte, and then reframed every subsequent code. That is exactly what the reporter's capture shows. Server sends `BF 00 0C 00 19 02 00 00 00 01 00 00`; the client's post-decompression stream reads `BF 00 0C 00 0D 55 00 00 01 00 00` — the literal `0D` is the mis-decoded `0x19`, and the packet is now one byte short, so framing desyncs from there on. ## Impact Any outgoing packet with byte `0x19` anywhere in its body (serials, coordinates, hues, lengths, text) corrupted the stream. Because the desync is in framing rather than a single field, the client silently stops applying server updates while still being able to send — no disconnect, no error. `StatLockInfo` (`0xBF` subcommand `0x19`) is sent during login, so it reproduces on essentially every connection. This is also #2526: "can only walk a few steps, then the client stops responding" is the same desync, not a VPS sizing problem. ## Fix One entry, restored to the canonical value: ```diff - 0x9, 0x191, 0x9, 0x12E, 0x7, 0x03F, ... + 0x9, 0x191, 0x9, 0x1CE, 0x7, 0x03F, ... ``` ## Validation of the whole table Rather than eyeball 257 entries, I diffed the current table against **every revision of it in this repo's history** — all 34, back through the renames to the original import. All 34 agree with each other, and `0x19` is the sole disagreement with #2522's rewrite. No other entry has ever changed. I also validated the table structurally: all 257 lengths in `[2,11]`, every value fits its declared bit-length, Kraft–McMillan sum exactly 1, and no code is a prefix of any other. It passes on all counts now, and the prefix check is what located the bug in the first place. Both checks were one-off validation scripts, not committed — see below. ## Test A single known-answer test (`~10ms`) that compresses all 256 symbols and asserts the exact output bytes. The expected bytes were generated from the canonical table, *not* from the implementation, so the test isn't circular. Any single wrong table entry changes the output, so it pins all 256 entries plus the terminal code, and it exercises the encoder end to end. A round-trip test would **not** catch this class of bug — encoder and decoder built from the same table agree with each other even when the table is wrong. The contract being violated is with the client's hard-coded tree, so the expected bytes have to come from outside the implementation. The structural prefix-free check and a second `StatLockInfo` vector were deliberately dropped after they'd served their purpose: the table is now verified and effectively frozen, so the structural check was guarding a constant, and the `StatLockInfo` vector is a strict subset of the all-symbols one. What remains covers the risk that's still live — `Compress` is a hand-unrolled bit-packing loop that will get optimized again, and this is the guard against that rewrite silently corrupting the wire format, which is precisely what happened here. Verified the test fails when the bug is reintroduced and passes when fixed. Full `Server.Tests` suite green: 727 passed.
183 lines
7.8 KiB
C#
183 lines
7.8 KiB
C#
using System;
|
|
using System.Buffers.Binary;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Runtime.InteropServices;
|
|
|
|
namespace Server.Network;
|
|
|
|
/// <summary>
|
|
/// Handles outgoing packet compression for the network.
|
|
/// </summary>
|
|
public static class NetworkCompression
|
|
{
|
|
// UO packets may not exceed 64kb in length
|
|
public const int BufferSize = 0x10000;
|
|
|
|
// Optimal compression ratio is 2 / 8; worst compression ratio is 11 / 8
|
|
private const int MinimalCodeLength = 2;
|
|
|
|
// Fixed overhead, in bits, per compression call
|
|
private const int TerminalCodeLength = 4;
|
|
|
|
// If our input exceeds this length, we cannot possibly compress it within the buffer
|
|
private const int DefiniteOverflow = (BufferSize * 8 - TerminalCodeLength) / MinimalCodeLength;
|
|
|
|
// Packed Table: High 16 bits = Length, Low 16 bits = Value
|
|
private static readonly uint[] _packedHuffmanTable = new uint[257];
|
|
|
|
static NetworkCompression()
|
|
{
|
|
ReadOnlySpan<int> rawTable = [
|
|
0x2, 0x000, 0x5, 0x01F, 0x6, 0x022, 0x7, 0x034, 0x7, 0x075, 0x6, 0x028, 0x6, 0x03B, 0x7, 0x032,
|
|
0x8, 0x0E0, 0x8, 0x062, 0x7, 0x056, 0x8, 0x079, 0x9, 0x19D, 0x8, 0x097, 0x6, 0x02A, 0x7, 0x057,
|
|
0x8, 0x071, 0x8, 0x05B, 0x9, 0x1CC, 0x8, 0x0A7, 0x7, 0x025, 0x7, 0x04F, 0x8, 0x066, 0x8, 0x07D,
|
|
0x9, 0x191, 0x9, 0x1CE, 0x7, 0x03F, 0x9, 0x090, 0x8, 0x059, 0x8, 0x07B, 0x8, 0x091, 0x8, 0x0C6,
|
|
0x6, 0x02D, 0x9, 0x186, 0x8, 0x06F, 0x9, 0x093, 0xA, 0x1CC, 0x8, 0x05A, 0xA, 0x1AE, 0xA, 0x1C0,
|
|
0x9, 0x148, 0x9, 0x14A, 0x9, 0x082, 0xA, 0x19F, 0x9, 0x171, 0x9, 0x120, 0x9, 0x0E7, 0xA, 0x1F3,
|
|
0x9, 0x14B, 0x9, 0x100, 0x9, 0x190, 0x6, 0x013, 0x9, 0x161, 0x9, 0x125, 0x9, 0x133, 0x9, 0x195,
|
|
0x9, 0x173, 0x9, 0x1CA, 0x9, 0x086, 0x9, 0x1E9, 0x9, 0x0DB, 0x9, 0x1EC, 0x9, 0x08B, 0x9, 0x085,
|
|
0x5, 0x00A, 0x8, 0x096, 0x8, 0x09C, 0x9, 0x1C3, 0x9, 0x19C, 0x9, 0x08F, 0x9, 0x18F, 0x9, 0x091,
|
|
0x9, 0x087, 0x9, 0x0C6, 0x9, 0x177, 0x9, 0x089, 0x9, 0x0D6, 0x9, 0x08C, 0x9, 0x1EE, 0x9, 0x1EB,
|
|
0x9, 0x084, 0x9, 0x164, 0x9, 0x175, 0x9, 0x1CD, 0x8, 0x05E, 0x9, 0x088, 0x9, 0x12B, 0x9, 0x172,
|
|
0x9, 0x10A, 0x9, 0x08D, 0x9, 0x13A, 0x9, 0x11C, 0xA, 0x1E1, 0xA, 0x1E0, 0x9, 0x187, 0xA, 0x1DC,
|
|
0xA, 0x1DF, 0x7, 0x074, 0x9, 0x19F, 0x8, 0x08D, 0x8, 0x0E4, 0x7, 0x079, 0x9, 0x0EA, 0x9, 0x0E1,
|
|
0x8, 0x040, 0x7, 0x041, 0x9, 0x10B, 0x9, 0x0B0, 0x8, 0x06A, 0x8, 0x0C1, 0x7, 0x071, 0x7, 0x078,
|
|
0x8, 0x0B1, 0x9, 0x14C, 0x7, 0x043, 0x8, 0x076, 0x7, 0x066, 0x7, 0x04D, 0x9, 0x08A, 0x6, 0x02F,
|
|
0x8, 0x0C9, 0x9, 0x0CE, 0x9, 0x149, 0x9, 0x160, 0xA, 0x1BA, 0xA, 0x19E, 0xA, 0x39F, 0x9, 0x0E5,
|
|
0x9, 0x194, 0x9, 0x184, 0x9, 0x126, 0x7, 0x030, 0x8, 0x06C, 0x9, 0x121, 0x9, 0x1E8, 0xA, 0x1C1,
|
|
0xA, 0x11D, 0xA, 0x163, 0xA, 0x385, 0xA, 0x3DB, 0xA, 0x17D, 0xA, 0x106, 0xA, 0x397, 0xA, 0x24E,
|
|
0x7, 0x02E, 0x8, 0x098, 0xA, 0x33C, 0xA, 0x32E, 0xA, 0x1E9, 0x9, 0x0BF, 0xA, 0x3DF, 0xA, 0x1DD,
|
|
0xA, 0x32D, 0xA, 0x2ED, 0xA, 0x30B, 0xA, 0x107, 0xA, 0x2E8, 0xA, 0x3DE, 0xA, 0x125, 0xA, 0x1E8,
|
|
0x9, 0x0E9, 0xA, 0x1CD, 0xA, 0x1B5, 0x9, 0x165, 0xA, 0x232, 0xA, 0x2E1, 0xB, 0x3AE, 0xB, 0x3C6,
|
|
0xB, 0x3E2, 0xA, 0x205, 0xA, 0x29A, 0xA, 0x248, 0xA, 0x2CD, 0xA, 0x23B, 0xB, 0x3C5, 0xA, 0x251,
|
|
0xA, 0x2E9, 0xA, 0x252, 0x9, 0x1EA, 0xB, 0x3A0, 0xB, 0x391, 0xA, 0x23C, 0xB, 0x392, 0xB, 0x3D5,
|
|
0xA, 0x233, 0xA, 0x2CC, 0xB, 0x390, 0xA, 0x1BB, 0xB, 0x3A1, 0xB, 0x3C4, 0xA, 0x211, 0xA, 0x203,
|
|
0x9, 0x12A, 0xA, 0x231, 0xB, 0x3E0, 0xA, 0x29B, 0xB, 0x3D7, 0xA, 0x202, 0xB, 0x3AD, 0xA, 0x213,
|
|
0xA, 0x253, 0xA, 0x32C, 0xA, 0x23D, 0xA, 0x23F, 0xA, 0x32F, 0xA, 0x11C, 0xA, 0x384, 0xA, 0x31C,
|
|
0xA, 0x17C, 0xA, 0x30A, 0xA, 0x2E0, 0xA, 0x276, 0xA, 0x250, 0xB, 0x3E3, 0xA, 0x396, 0xA, 0x18F,
|
|
0xA, 0x204, 0xA, 0x206, 0xA, 0x230, 0xA, 0x265, 0xA, 0x212, 0xA, 0x23E, 0xB, 0x3AC, 0xB, 0x393,
|
|
0xB, 0x3E1, 0xA, 0x1DE, 0xB, 0x3D6, 0xA, 0x31D, 0xB, 0x3E5, 0xB, 0x3E4, 0xA, 0x207, 0xB, 0x3C7,
|
|
0xA, 0x277, 0xB, 0x3D4, 0x8, 0x0C0, 0xA, 0x162, 0xA, 0x3DA, 0xA, 0x124, 0xA, 0x1B4, 0xA, 0x264,
|
|
0xA, 0x33D, 0xA, 0x1D1, 0xA, 0x1AF, 0xA, 0x39E, 0xA, 0x24F, 0xB, 0x373, 0xA, 0x249, 0xB, 0x372,
|
|
0x9, 0x167, 0xA, 0x210, 0xA, 0x23A, 0xA, 0x1B8, 0xB, 0x3AF, 0xA, 0x18E, 0xA, 0x2EC, 0x7, 0x062,
|
|
0x4, 0x00D
|
|
];
|
|
|
|
for (var i = 0; i < 257; i++)
|
|
{
|
|
var len = (uint)rawTable[i * 2];
|
|
var val = (uint)rawTable[i * 2 + 1];
|
|
_packedHuffmanTable[i] = (len << 16) | val;
|
|
}
|
|
}
|
|
|
|
public static int Compress(ReadOnlySpan<byte> input, Span<byte> output)
|
|
{
|
|
if (input.Length > DefiniteOverflow)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
ref var inputRef = ref MemoryMarshal.GetReference(input);
|
|
ref var outputRef = ref MemoryMarshal.GetReference(output);
|
|
ref var tableRef = ref MemoryMarshal.GetReference(_packedHuffmanTable.AsSpan());
|
|
|
|
nuint i = 0;
|
|
ulong bitValue = 0;
|
|
var bitCount = 0;
|
|
nuint outputIdx = 0;
|
|
var safeOutputLength = (nuint)output.Length - 4;
|
|
var unrolledLimit = (nuint)input.Length - 1;
|
|
|
|
// Unrolled 2x hot loop using native integer indices
|
|
while (i < unrolledLimit)
|
|
{
|
|
var b1 = Unsafe.Add(ref inputRef, i);
|
|
var entry1 = Unsafe.Add(ref tableRef, b1);
|
|
var len1 = (int)(entry1 >> 16);
|
|
ulong val1 = entry1 & 0xFFFF;
|
|
|
|
bitValue = (bitValue << len1) | val1;
|
|
bitCount += len1;
|
|
|
|
var b2 = Unsafe.Add(ref inputRef, i + 1);
|
|
var entry2 = Unsafe.Add(ref tableRef, b2);
|
|
var len2 = (int)(entry2 >> 16);
|
|
ulong val2 = entry2 & 0xFFFF;
|
|
|
|
bitValue = (bitValue << len2) | val2;
|
|
bitCount += len2;
|
|
|
|
if (bitCount >= 32)
|
|
{
|
|
if (outputIdx > safeOutputLength)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
bitCount -= 32;
|
|
var word = (uint)(bitValue >> bitCount);
|
|
Unsafe.WriteUnaligned(ref Unsafe.Add(ref outputRef, outputIdx), BinaryPrimitives.ReverseEndianness(word));
|
|
outputIdx += 4;
|
|
}
|
|
|
|
i += 2;
|
|
}
|
|
|
|
if (i < (nuint)input.Length)
|
|
{
|
|
var b = Unsafe.Add(ref inputRef, i);
|
|
var entry = Unsafe.Add(ref tableRef, b);
|
|
var len = (int)(entry >> 16);
|
|
ulong val = entry & 0xFFFF;
|
|
|
|
bitValue = (bitValue << len) | val;
|
|
bitCount += len;
|
|
|
|
if (bitCount >= 32)
|
|
{
|
|
if (outputIdx > safeOutputLength)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
bitCount -= 32;
|
|
var word = (uint)(bitValue >> bitCount);
|
|
Unsafe.WriteUnaligned(ref Unsafe.Add(ref outputRef, outputIdx), BinaryPrimitives.ReverseEndianness(word));
|
|
outputIdx += 4;
|
|
}
|
|
}
|
|
|
|
// Terminal code logic
|
|
var termEntry = Unsafe.Add(ref tableRef, 256);
|
|
var termLen = (int)(termEntry >> 16);
|
|
ulong termVal = termEntry & 0xFFFF;
|
|
|
|
bitValue = (bitValue << termLen) | termVal;
|
|
bitCount += termLen;
|
|
|
|
var remainder = bitCount & 7;
|
|
if (remainder != 0)
|
|
{
|
|
var padding = 8 - remainder;
|
|
bitValue <<= padding;
|
|
bitCount += padding;
|
|
}
|
|
|
|
bitValue <<= 64 - bitCount;
|
|
|
|
while (bitCount > 0)
|
|
{
|
|
if (outputIdx >= (nuint)output.Length)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
Unsafe.Add(ref outputRef, outputIdx++) = (byte)(bitValue >> 56);
|
|
bitValue <<= 8;
|
|
bitCount -= 8;
|
|
}
|
|
|
|
return (int)outputIdx;
|
|
}
|
|
}
|