using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace Server.Custom
{
///
/// 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 Ultima.StringList implements only
/// the plain layout and throws Non-negative number required on every modern client's
/// file, which is also why the shard's own VendorSearch.GetItemName is already inert.
///
/// **Provenance.** Ported from UOFiddler's Ultima/Helpers/MythicDecompress.cs,
/// MoveToFront.cs and StringList.TryParse (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 Span<T>,
/// stackalloc, ArrayPool and BinaryPrimitives. 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 input[m + 1024] and input[firstVal + 1024] with
/// offsets derived from the file's own frequency header, inside a
/// try { } catch (Exception) { return false; }. 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 false instead.
///
/// Phase 0 uses this from to prove the port reproduces
/// UOFiddler's own output exactly. **Phase 2 promotes this file into
/// overlay/Scripts/Custom/Bridge/** — it lives in scaffolding only for as long as it
/// is a spike.
///
public static class BridgeMythicCliloc
{
/// The first DWORD of a compressed file is the decompressed length, XORed with this.
private const uint HeaderXorKey = 0x8E2C9A3D;
/// 256 little-endian int32 symbol frequencies precede the coded payload.
private const int FrequencyHeaderSize = 1024;
/// One decoded cliloc row. Mirrors Ultima.StringEntry's three fields.
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 ──────────────────────────────────────────────────────────────
///
/// True when the file looks like the Mythic container. The marker is the high byte of
/// the first DWORD being 0x8E — which is not a magic number in the file so much
/// as a consequence of : a plausible decompressed length is
/// small enough that its top byte is zero, so the XOR leaves 0x8E showing.
///
public static bool LooksCompressed(byte[] buffer)
{
return buffer != null && buffer.Length >= 4 && buffer[3] == 0x8E;
}
// ── The public entry point ───────────────────────────────────────────────────────────
///
/// 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. 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.
///
public static bool TryLoadFile(string path, out List 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);
}
/// Reads an in-memory cliloc file. See .
public static bool TryLoad(byte[] buffer, out List entries, out string warning, out string error)
{
entries = null;
warning = null;
error = null;
bool compressedFirst = LooksCompressed(buffer);
List primary;
string primaryError;
bool primaryComplete;
if (TryParse(buffer, compressedFirst, out primary, out primaryComplete, out primaryError) && primaryComplete)
{
entries = primary;
return true;
}
List 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 ────────────────────────────────────────────────────────────────────
///
/// 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].
///
/// 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.
///
private static bool TryParse(byte[] buffer, bool decompress, out List entries, out bool complete, out string error)
{
entries = new List();
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 ────────────────────────
///
/// Reads the obfuscated decompressed length from the first DWORD. Public so a caller
/// can size a buffer before committing to the decode.
///
public static uint PeekDecompressedLength(byte[] source)
{
if (source == null || source.Length < 4)
return 0;
return ReadUInt32(source, 0) ^ HeaderXorKey;
}
///
/// Decompresses the Mythic container: strip the 4-byte length header, undo the
/// move-to-front coding, then run stage 2.
///
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;
}
///
/// 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.
///
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 ───────────────────────────────────────────────────────────────────
///
/// 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; cursor[] holds each run's read position and limit[] 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 false with a reason.
///
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;
}
///
/// 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.
///
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;
}
}
/// Shifts [1..element] down one slot, dropping element 0.
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));
}
}
}