From 065f5c5117e84ff402601ef2eb6188e4d41fde26 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:18:00 -0700 Subject: [PATCH] fix(network): restore huffman code for symbol 0x19 #2522 rewrote the outgoing huffman table and transposed symbol 0x19's code from 0x1CE to 0x12E. The resulting code (100101110) is prefixed by symbol 0x0D's code (10010111), so the table was no longer prefix-free: the client's decoder matched 0x0D after 8 bits, emitted the wrong byte, and desynced the framing of everything that followed. Any outgoing packet containing byte 0x19 anywhere in its body was affected. StatLockInfo (0xBF/0x19) is sent during login, so clients jammed shortly after entering the world - frozen world state, no disconnect. Validated all 257 entries against every revision of the table in history: all 34 revisions back to the original import agree, and 0x1CE is the only outlier. Adds regression tests: known-answer vectors generated from the canonical table (pinning every entry), and a structural check that the table is a complete prefix-free code, which is what makes this class of typo detectable at all. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Tests/Network/NetworkCompressionTests.cs | 128 ++++++++++++++++++ Projects/Server/Network/NetworkCompression.cs | 2 +- 2 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 Projects/Server.Tests/Tests/Network/NetworkCompressionTests.cs diff --git a/Projects/Server.Tests/Tests/Network/NetworkCompressionTests.cs b/Projects/Server.Tests/Tests/Network/NetworkCompressionTests.cs new file mode 100644 index 000000000..a133676f7 --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/NetworkCompressionTests.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Server.Network; +using Xunit; + +namespace Server.Tests.Network; + +/// +/// Guards the outgoing huffman table in . +/// The table is a contract with the client's hard-coded decompression tree, so a single wrong +/// entry silently corrupts every packet containing that byte and desyncs the client's framing. +/// The expected bytes below were generated from the canonical table (as it existed before #2522), +/// not from the implementation, so they fail if the table drifts. +/// +public class NetworkCompressionTests +{ + // Every symbol 0x00-0xFF exactly once. Any single wrong table entry changes these bytes, + // so this vector pins all 256 entries plus the terminal code. + private static ReadOnlySpan AllSymbolsCompressed => + [ + 0x3F, 0x13, 0x4E, 0xB4, 0x76, 0xCB, 0x81, 0x8A, 0xB3, 0xCE, 0x76, 0x5E, + 0xAA, 0xEE, 0x2B, 0x7C, 0xCA, 0x74, 0xB3, 0xD9, 0x9F, 0x72, 0x3C, 0xE7, + 0xE9, 0x05, 0x97, 0xB9, 0x1C, 0x6B, 0x70, 0xCD, 0xE9, 0x37, 0x31, 0x69, + 0xAE, 0x70, 0x29, 0x14, 0xA4, 0x13, 0x3F, 0x71, 0x90, 0x39, 0xDF, 0x3A, + 0x5C, 0x03, 0x20, 0x9D, 0x86, 0x4B, 0x33, 0xCA, 0xDC, 0xF9, 0x48, 0x6F, + 0x4B, 0x6F, 0xD8, 0x8B, 0x42, 0xAA, 0x5A, 0x73, 0x87, 0x9C, 0x47, 0xE3, + 0xD2, 0x28, 0x76, 0x35, 0xDD, 0x12, 0xD6, 0x46, 0x7B, 0xBD, 0x68, 0x4B, + 0x25, 0xD7, 0x9A, 0xBC, 0x88, 0x95, 0xDC, 0xA1, 0x48, 0xD9, 0xD4, 0x71, + 0xE1, 0x78, 0x30, 0xEE, 0xE3, 0xBF, 0xD3, 0x3F, 0x1B, 0xC9, 0xE5, 0xD4, + 0xE1, 0x40, 0x83, 0x0B, 0x58, 0x35, 0x60, 0xF1, 0xF1, 0x63, 0x4C, 0x86, + 0xED, 0x9A, 0x6A, 0x2A, 0xFC, 0x96, 0x75, 0x26, 0xC0, 0xDD, 0x33, 0xDC, + 0xFB, 0x97, 0x29, 0x84, 0x93, 0x30, 0x6C, 0x90, 0xFA, 0x1C, 0x14, 0x75, + 0x63, 0xE1, 0x7D, 0xB5, 0xF5, 0x06, 0xE5, 0xE4, 0xE5, 0xD3, 0x19, 0xE6, + 0x5C, 0xF4, 0xAF, 0xFD, 0xF7, 0x77, 0x2D, 0xBB, 0x70, 0xB4, 0x1E, 0xE8, + 0xF7, 0x92, 0x57, 0xA1, 0xD2, 0xE6, 0xB6, 0xB6, 0x58, 0xCA, 0xE1, 0x75, + 0xCF, 0x19, 0xF1, 0x40, 0xB4, 0xD4, 0x91, 0x66, 0xC7, 0x6F, 0x16, 0x51, + 0xBA, 0x65, 0x2F, 0x53, 0xA0, 0x72, 0x31, 0xE3, 0x92, 0x7A, 0xB1, 0x9D, + 0x98, 0xE4, 0x1B, 0xB7, 0x42, 0xF1, 0x21, 0x18, 0x0E, 0x55, 0x18, 0xBE, + 0x0A, 0x6D, 0xEB, 0xC0, 0x4E, 0xB6, 0x13, 0x94, 0xF2, 0xC8, 0xF6, 0x3F, + 0xCB, 0xD1, 0xCE, 0x13, 0x1C, 0x5F, 0x30, 0xAB, 0x82, 0x76, 0x94, 0x1F, + 0x1F, 0x2C, 0xC7, 0xC0, 0x90, 0x34, 0x61, 0x32, 0xC2, 0x51, 0xF3, 0xAC, + 0x72, 0x6F, 0x85, 0xDE, 0x7A, 0xD8, 0xEB, 0xE5, 0x7C, 0x90, 0x3B, 0xC7, + 0x9D, 0xDE, 0xA6, 0x02, 0xC5, 0xED, 0x24, 0x8D, 0xA4, 0xC9, 0x9E, 0xBA, + 0x2D, 0x7F, 0x3D, 0x27, 0xB7, 0x39, 0x25, 0xB9, 0x59, 0xE1, 0x08, 0xE9, + 0xB8, 0x75, 0xEC, 0x75, 0xD9, 0x8B, 0x40 + ]; + + [Fact] + public void Compress_EncodesEverySymbol_MatchingCanonicalTable() + { + Span input = stackalloc byte[256]; + for (var i = 0; i < input.Length; i++) + { + input[i] = (byte)i; + } + + Span output = stackalloc byte[512]; + var length = NetworkCompression.Compress(input, output); + + AssertEqual(AllSymbolsCompressed, output[..length]); + } + + [Fact] + public void Compress_EncodesStatLockInfo() + { + // 0xBF/0x19 StatLockInfo, sent during login. Contains 0x19, the symbol broken by #2522. + ReadOnlySpan input = [0xBF, 0x00, 0x0C, 0x00, 0x19, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00]; + ReadOnlySpan expected = [0x80, 0xCC, 0xE9, 0xCE, 0x88, 0x0F, 0x86, 0x80]; + + Span output = stackalloc byte[64]; + var length = NetworkCompression.Compress(input, output); + + AssertEqual(expected, output[..length]); + } + + [Fact] + public void HuffmanTable_IsCompletePrefixFreeCode() + { + var table = (uint[])typeof(NetworkCompression) + .GetField("_packedHuffmanTable", BindingFlags.NonPublic | BindingFlags.Static)! + .GetValue(null)!; + + Assert.Equal(257, table.Length); + + // A code is only decodable if no code is a prefix of another. #2522 gave symbol 0x19 the + // code 100101110, which begins with symbol 0x0D's code 10010111 -- so the client's decoder + // matched 0x0D, emitted the wrong byte, and desynced from there on. + var codes = new Dictionary<(int Length, uint Value), int>(); + var kraft = 0.0; + + for (var symbol = 0; symbol < table.Length; symbol++) + { + var length = (int)(table[symbol] >> 16); + var value = table[symbol] & 0xFFFF; + + Assert.InRange(length, 2, 11); + Assert.True(value < 1u << length, $"Symbol 0x{symbol:X2} value 0x{value:X3} exceeds {length} bits"); + + codes[(length, value)] = symbol; + kraft += Math.Pow(2, -length); + } + + foreach (var ((length, value), symbol) in codes) + { + for (var prefixLength = 2; prefixLength < length; prefixLength++) + { + var prefix = value >> (length - prefixLength); + Assert.False( + codes.TryGetValue((prefixLength, prefix), out var other), + $"Symbol 0x{symbol:X2} code is prefixed by symbol 0x{other:X2}" + ); + } + } + + // Kraft-McMillan equality: the tree is full, every leaf reachable and none wasted. + Assert.Equal(1.0, kraft, 10); + } + + private static void AssertEqual(ReadOnlySpan expected, ReadOnlySpan actual) + { + Assert.Equal(expected.Length, actual.Length); + for (var i = 0; i < expected.Length; i++) + { + Assert.True(expected[i] == actual[i], $"Byte {i}: expected 0x{expected[i]:X2}, got 0x{actual[i]:X2}"); + } + } +} diff --git a/Projects/Server/Network/NetworkCompression.cs b/Projects/Server/Network/NetworkCompression.cs index eaef6fa7f..1a0154d40 100644 --- a/Projects/Server/Network/NetworkCompression.cs +++ b/Projects/Server/Network/NetworkCompression.cs @@ -31,7 +31,7 @@ public static class NetworkCompression 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, 0x12E, 0x7, 0x03F, 0x9, 0x090, 0x8, 0x059, 0x8, 0x07B, 0x8, 0x091, 0x8, 0x0C6, + 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,