Files
servuo-plugins/tools/scaffolding/BridgeMythicCliloc.cs
wtclaude 0ce92152a1 feat(asset-bridge): phase 0 spike — the decoders, from inside a live shard
docs/link/v8.md §16 phase 0. §4 chose to CALL ServUO's vendored `Ultima`
rather than reimplement it, on the evidence of a PowerShell probe against a
stock client — neither the process nor the client the extractor will run in.
This runs the same decoders from inside a running ServUO 57.4 against a
client broken in 21 catalogued ways, and it found more than a crash.

Adds, all under tools/ and therefore never deployed:

  * BridgeAssetProbe.cs — the sweep, plus BridgeAssetValidator, a prototype
    of the validate-before-calling response chosen for §4.2's residual risk.
    Runs off the Core thread, snapshots Race.AllRaces on it, and writes the
    id it is ABOUT to touch to a checkpoint file before every call.
  * BridgeMythicCliloc.cs — the §9 Mythic cliloc reader in net48 C#, ported
    from UOFiddler (Beerware) with every file-derived index bounds-checked.
    Phase 2 promotes this into overlay/.
  * patch_client.ps1 — builds the patched client in five tiers. Hashes every
    file it touches in the SOURCE before and after and aborts on a change.
  * an `assetprobe` verb on BridgeRigDriver, so stock and patched can be run
    against one boot rather than two shard processes.

The findings are written up in tools/scaffolding/README.md. The four that
change what phase 1 has to build:

  * FileIndex's UOP constructor ends `MulPath = uopPath`, so artLegacyMUL.uop
    wins outright and art.mul/artidx.mul are never opened on a current
    client. A validator bounding offsets against art.mul is not approximate,
    it is nonsense — the first run refused 34,299 good statics on that
    mistake, and every refusal looked like a real finding.

  * 22,102 WRONG PICTURES on a stock, unmodified client. Empty UOP index
    slots read `lookup 0, length 0`; Seek treats that as a hit, and
    LoadStatic decodes zero bytes into a shared buffer it reuses, only ever
    grows, and fills from a Read whose return value is discarded — so the id
    renders the previously-decoded asset. The mul path does not do this
    (artidx stores -1), which is why the earlier probe counted 32,766 of
    them as "ok". A bulk import that trusted the library would have written
    22,102 duplicate images under ids that have no art.

  * The validator caught all 8 record-level defects — 7 of which the library
    rendered without raising anything, including a verdata lookup past
    verdata.mul's own end (Verdata.Seek is bounds-checked nowhere) and an
    8000x8000 bitmap allocated from two bytes in a file. It refused NOTHING
    across 49,151 statics and 16,384 land tiles on the stock client, which
    is the number that makes the boundary defensible.

  * §4.1's crash reproduces in-process: one Ultima.Gumps.GetGump(2) and the
    ServUO process disappeared — no catch reached, no console line, the
    checkpoint file the only record. "Nothing calls Ultima.Gumps" is now an
    earned safety rule.

§9 is proven: 123,490 entries in 218 ms, byte-identical to UOFiddler's own
output, with no UOFiddler installed and nothing copied to a server.

Not covered, and named as phase 1 work: the animation path has no validator
at all, and the patched wolf decoded something else in silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 03:05:32 -05:00

533 lines
20 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace Server.Custom
{
/// <summary>
/// A reader for the **Mythic compressed** cliloc container, in plain .NET Framework 4.8 C#.
///
/// This is the Asset Bridge's §9 decoder — the ONE decoder Protocol 8 writes rather than
/// calls (docs/link/v8.md §4, §9). ServUO's bundled <c>Ultima.StringList</c> implements only
/// the plain layout and throws <c>Non-negative number required</c> on every modern client's
/// file, which is also why the shard's own <c>VendorSearch.GetItemName</c> is already inert.
///
/// **Provenance.** Ported from UOFiddler's <c>Ultima/Helpers/MythicDecompress.cs</c>,
/// <c>MoveToFront.cs</c> and <c>StringList.TryParse</c> (polserver/UOFiddler). UOFiddler is
/// released under the **Beerware** licence, so carrying its algorithm into this
/// GPL-3.0-or-later tree is clean — see v8.md §9.
///
/// **What the port had to change**, and why the differences are not cosmetic:
///
/// * UOFiddler targets net10.0 and its implementation is written in <c>Span&lt;T&gt;</c>,
/// <c>stackalloc</c>, <c>ArrayPool</c> and <c>BinaryPrimitives</c>. ServUO compiles the
/// overlay against net48 with no package feed, so all of that becomes plain arrays.
/// * Every read of the compressed payload is **bounds-checked here and is not there**.
/// Upstream indexes <c>input[m + 1024]</c> and <c>input[firstVal + 1024]</c> with
/// offsets derived from the file's own frequency header, inside a
/// <c>try { } catch (Exception) { return false; }</c>. That is adequate for a desktop
/// tool and is not adequate for us: this runs inside a live shard, and a corrupt or
/// hostile Cliloc.enu must produce a refusal, not an exception unwinding through the
/// bridge. Every such index is tested before use and returns <c>false</c> instead.
///
/// Phase 0 uses this from <see cref="BridgeAssetProbe"/> to prove the port reproduces
/// UOFiddler's own output exactly. **Phase 2 promotes this file into
/// <c>overlay/Scripts/Custom/Bridge/</c>** — it lives in scaffolding only for as long as it
/// is a spike.
/// </summary>
public static class BridgeMythicCliloc
{
/// <summary>The first DWORD of a compressed file is the decompressed length, XORed with this.</summary>
private const uint HeaderXorKey = 0x8E2C9A3D;
/// <summary>256 little-endian int32 symbol frequencies precede the coded payload.</summary>
private const int FrequencyHeaderSize = 1024;
/// <summary>One decoded cliloc row. Mirrors <c>Ultima.StringEntry</c>'s three fields.</summary>
public struct Entry
{
public int Number;
public byte Flag;
public string Text;
public Entry(int number, byte flag, string text)
{
Number = number;
Flag = flag;
Text = text;
}
}
// ── Container detection ──────────────────────────────────────────────────────────────
/// <summary>
/// True when the file looks like the Mythic container. The marker is the high byte of
/// the first DWORD being <c>0x8E</c> — which is not a magic number in the file so much
/// as a consequence of <see cref="HeaderXorKey"/>: a plausible decompressed length is
/// small enough that its top byte is zero, so the XOR leaves 0x8E showing.
/// </summary>
public static bool LooksCompressed(byte[] buffer)
{
return buffer != null && buffer.Length >= 4 && buffer[3] == 0x8E;
}
// ── The public entry point ───────────────────────────────────────────────────────────
/// <summary>
/// Reads a cliloc file, compressed or plain, and returns its entries.
///
/// Tries the layout the header suggests first and the other one second — the same
/// fallback UOFiddler performs, and the reason an already-converted file passes
/// straight through. <paramref name="warning"/> is non-null when a layout parsed
/// *partially*: that is the case a caller must surface rather than swallow, because a
/// quietly short table is the failure mode the website's importer refuses.
/// </summary>
public static bool TryLoadFile(string path, out List<Entry> entries, out string warning, out string error)
{
entries = null;
warning = null;
error = null;
byte[] buffer;
try
{
buffer = File.ReadAllBytes(path);
}
catch (Exception e)
{
error = "cannot read " + path + ": " + e.Message;
return false;
}
return TryLoad(buffer, out entries, out warning, out error);
}
/// <summary>Reads an in-memory cliloc file. See <see cref="TryLoadFile"/>.</summary>
public static bool TryLoad(byte[] buffer, out List<Entry> entries, out string warning, out string error)
{
entries = null;
warning = null;
error = null;
bool compressedFirst = LooksCompressed(buffer);
List<Entry> primary;
string primaryError;
bool primaryComplete;
if (TryParse(buffer, compressedFirst, out primary, out primaryComplete, out primaryError) && primaryComplete)
{
entries = primary;
return true;
}
List<Entry> fallback;
string fallbackError;
bool fallbackComplete;
if (TryParse(buffer, !compressedFirst, out fallback, out fallbackComplete, out fallbackError) && fallbackComplete)
{
entries = fallback;
return true;
}
// Neither layout parsed to the end. Take whichever salvaged more rows and say so.
int primaryCount = primary == null ? 0 : primary.Count;
int fallbackCount = fallback == null ? 0 : fallback.Count;
if (primaryCount == 0 && fallbackCount == 0)
{
error = "as " + Label(compressedFirst) + ": " + primaryError
+ "; as " + Label(!compressedFirst) + ": " + fallbackError;
return false;
}
if (primaryCount >= fallbackCount)
{
entries = primary;
warning = "parsed partially as " + Label(compressedFirst) + ": " + primaryError
+ " (" + primaryCount + " entries salvaged)";
}
else
{
entries = fallback;
warning = "parsed partially as " + Label(!compressedFirst) + ": " + fallbackError
+ " (" + fallbackCount + " entries salvaged)";
}
return true;
}
private static string Label(bool compressed)
{
return compressed ? "compressed" : "uncompressed";
}
// ── Record layout ────────────────────────────────────────────────────────────────────
/// <summary>
/// Walks the plain record layout: a 4-byte and a 2-byte header, then repeating
/// [int32 number][byte flag][uint16 length][length bytes of UTF-8].
///
/// <paramref name="complete"/> distinguishes "parsed to the end of the file" from
/// "stopped early but salvaged rows", which is the distinction the caller needs and
/// an exception would destroy.
/// </summary>
private static bool TryParse(byte[] buffer, bool decompress, out List<Entry> entries, out bool complete, out string error)
{
entries = new List<Entry>();
complete = false;
error = null;
byte[] data;
if (decompress)
{
if (!TryDecompress(buffer, out data, out error))
return false;
}
else
{
data = buffer;
}
if (data.Length < 6)
{
error = "file is " + data.Length + " bytes, smaller than the 6-byte header";
return false;
}
int cursor = 6; // int32 version marker + int16 language marker
int lastNumber = -1;
while (cursor < data.Length)
{
int entryStart = cursor;
int remaining = data.Length - cursor;
if (remaining < 7)
{
error = "unexpected " + remaining + " trailing byte(s) at 0x" + entryStart.ToString("X")
+ " after entry #" + lastNumber + "; an entry header needs 7";
return true;
}
int number = ReadInt32(data, cursor);
byte flag = data[cursor + 4];
// Deliberately UNSIGNED. Read as Int16, a string of 32768 bytes or more comes back
// negative and corrupts every record after it.
int length = data[cursor + 5] | (data[cursor + 6] << 8);
cursor += 7;
if (length > data.Length - cursor)
{
error = "entry #" + number + " at 0x" + entryStart.ToString("X") + " declares length "
+ length + " but only " + (data.Length - cursor) + " byte(s) remain (parsed "
+ entries.Count + " so far)";
return true;
}
string text;
try
{
text = Encoding.UTF8.GetString(data, cursor, length);
}
catch (Exception e)
{
error = "entry #" + number + " at 0x" + entryStart.ToString("X") + " has " + length
+ " body bytes that are not valid UTF-8: " + e.Message;
return true;
}
cursor += length;
entries.Add(new Entry(number, flag, text));
lastNumber = number;
}
complete = true;
return true;
}
// ── Mythic stage 1: the XOR header and the move-to-front code ────────────────────────
/// <summary>
/// Reads the obfuscated decompressed length from the first DWORD. Public so a caller
/// can size a buffer before committing to the decode.
/// </summary>
public static uint PeekDecompressedLength(byte[] source)
{
if (source == null || source.Length < 4)
return 0;
return ReadUInt32(source, 0) ^ HeaderXorKey;
}
/// <summary>
/// Decompresses the Mythic container: strip the 4-byte length header, undo the
/// move-to-front coding, then run stage 2.
/// </summary>
public static bool TryDecompress(byte[] source, out byte[] output, out string error)
{
output = null;
error = null;
if (source == null || source.Length < 4)
{
error = "payload shorter than the 4-byte length header";
return false;
}
uint dataLength = ReadUInt32(source, 0) ^ HeaderXorKey;
// A wrong guess about the container makes this astronomically large, which is the
// cheapest possible rejection and must happen before any allocation.
if (dataLength == 0 || dataLength > int.MaxValue)
{
error = "implausible decompressed length " + dataLength + " — not the compressed layout";
return false;
}
var mtf = new byte[source.Length - 4];
MoveToFrontDecode(source, 4, mtf);
var destination = new byte[(int)dataLength];
int written;
if (!TryInternalDecompress(mtf, destination, out written, out error))
return false;
if (written != (int)dataLength)
{
error = "decompressed " + written + " bytes, header declared " + dataLength;
return false;
}
output = destination;
return true;
}
/// <summary>
/// Move-to-front decode. Each input byte is an index into a 256-symbol table; the
/// symbol found there is emitted and moved to the front.
/// </summary>
private static void MoveToFrontDecode(byte[] input, int offset, byte[] output)
{
var symbols = new byte[256];
for (int i = 0; i < 256; i++)
symbols[i] = (byte)i;
for (int i = 0; i < output.Length; i++)
{
int index = input[offset + i];
byte symbol = symbols[index];
output[i] = symbol;
for (int j = index; j > 0; j--)
symbols[j] = symbols[j - 1];
symbols[0] = symbol;
}
}
// ── Mythic stage 2 ───────────────────────────────────────────────────────────────────
/// <summary>
/// Turns the MTF-decoded payload back into the original bytes.
///
/// The payload is a 1024-byte frequency header (256 little-endian int32 symbol counts)
/// followed by the coded stream. The counts partition the stream into one run per
/// symbol; <c>cursor[]</c> holds each run's read position and <c>limit[]</c> its end,
/// and the walk emits a symbol, advances that symbol's run, and re-orders the symbol
/// table by the index it reads.
///
/// Every index derived from file content is checked. Upstream's equivalent is wrapped
/// in a blanket catch; here a malformed file is a <c>false</c> with a reason.
/// </summary>
private static bool TryInternalDecompress(byte[] input, byte[] destination, out int written, out string error)
{
written = 0;
error = null;
if (input.Length < FrequencyHeaderSize)
{
error = "payload (" + input.Length + " bytes) is smaller than the 1024-byte frequency header";
return false;
}
var counts = new int[256]; // symbol → number of occurrences
var cursor = new int[256]; // symbol → next unread position in its run
var limit = new int[256]; // symbol → one past the end of its run
int sum = 0;
for (int i = 0; i < 256; i++)
{
counts[i] = ReadInt32(input, i * 4);
if (counts[i] < 0)
{
error = "frequency header declares a negative count for symbol " + i;
return false;
}
sum += counts[i];
if (sum < 0)
{
error = "frequency header sums past int range at symbol " + i;
return false;
}
}
if (sum == 0)
{
written = 0;
return true;
}
if (destination.Length < sum)
{
error = "destination holds " + destination.Length + " bytes, payload needs " + sum;
return false;
}
int nonZeroCount = 0;
for (int i = 0; i < 256; i++)
{
if (counts[i] != 0)
nonZeroCount++;
}
// The coded stream must be long enough to hold one index per emitted byte.
if (input.Length - FrequencyHeaderSize < sum)
{
error = "coded stream holds " + (input.Length - FrequencyHeaderSize) + " bytes, frequency header claims " + sum;
return false;
}
var order = new byte[256];
FrequencyOrder(counts, order);
var symbolTable = new byte[256];
for (int i = 0; i < 256; i++)
symbolTable[i] = (byte)i;
for (int i = 0, m = 0; i < nonZeroCount; ++i)
{
byte symbol = order[i];
// m indexes the coded stream and comes from the file's own counts.
if (m < 0 || m >= input.Length - FrequencyHeaderSize)
{
error = "run table for symbol " + symbol + " starts at " + m + ", past the coded stream";
return false;
}
symbolTable[input[m + FrequencyHeaderSize]] = symbol;
cursor[symbol] = m + 1;
m += counts[symbol];
limit[symbol] = m;
}
byte val = symbolTable[0];
int count = 0;
int liveSymbols = nonZeroCount;
do
{
destination[count] = val;
if (cursor[val] < limit[val])
{
int at = cursor[val] + FrequencyHeaderSize;
if (at < FrequencyHeaderSize || at >= input.Length)
{
error = "run for symbol " + val + " reads at " + at + ", past the " + input.Length + "-byte payload";
return false;
}
byte index = input[at];
cursor[val]++;
if (index != 0)
{
ShiftLeft(symbolTable, index);
symbolTable[index] = val;
val = symbolTable[0];
}
}
else if (liveSymbols-- > 0)
{
ShiftLeft(symbolTable, liveSymbols);
val = symbolTable[0];
}
count++;
}
while (count < sum);
written = sum;
return true;
}
/// <summary>
/// Orders symbols by descending frequency: repeatedly take the largest remaining count
/// and record its symbol. Ties go to the lower symbol, because the scan keeps the first
/// strictly-greater value — matching upstream, and the tie-break is load-bearing.
/// </summary>
private static void FrequencyOrder(int[] counts, byte[] output)
{
var tmp = new int[256];
Array.Copy(counts, tmp, 256);
for (int i = 0; i < 256; i++)
{
int best = 0;
byte index = 0;
for (int j = 0; j < 256; j++)
{
if (tmp[j] > best)
{
index = (byte)j;
best = tmp[j];
}
}
if (best == 0)
break;
output[i] = index;
tmp[index] = 0;
}
}
/// <summary>Shifts <c>[1..element]</c> down one slot, dropping element 0.</summary>
private static void ShiftLeft(byte[] input, int element)
{
for (int i = 0; i < element; ++i)
input[i] = input[i + 1];
}
// ── Little-endian readers (BinaryPrimitives is not available on net48) ───────────────
private static int ReadInt32(byte[] b, int at)
{
return b[at] | (b[at + 1] << 8) | (b[at + 2] << 16) | (b[at + 3] << 24);
}
private static uint ReadUInt32(byte[] b, int at)
{
return (uint)(b[at] | (b[at + 1] << 8) | (b[at + 2] << 16) | (b[at + 3] << 24));
}
}
}