using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Text;
using Ultima;
namespace Server.Custom.Bridge
{
///
/// **The UOP animation reader** (docs/link/v8.md §4.3, §4.9 — protocol 8, phase 4): the
/// second and last decoder this protocol writes rather than calls.
///
/// ServUO's vendored Ultima.Animations reads legacy anim*.mul only — it
/// constructs its five FileIndexes with the four-argument constructor, which passes
/// uopFile: null, so AnimationFrame*.uop is never opened. Everything a
/// modern client added there is invisible to it. This class opens those packages directly.
///
/// ── **Why this is not the never-sweep rule being broken** ──
///
/// §4.3's rule is that a body's file type comes from BodyConverter.Convert and is
/// never guessed, because asking another anim*.mul for an index it does not own
/// returns a decodable picture of something else — a giant spider on the gargoyle page.
/// That rule exists because a legacy index is addressed **by position**: nothing in the
/// file says which body a record belongs to.
///
/// A UOP package is addressed by the **hash of a name that contains the body id**
/// (build/animationlegacyframe/000666/00.bin). Looking in all five packages for one
/// hash is therefore not a sweep — a hit is proof of identity, not a coincidence of
/// position, and the payload repeats the body id in its own header for us to check against.
/// Measured on this machine's client: 10,724 entries across the five packages, every one
/// of them claimed by that name scheme, and **no hash appears in more than one package**.
///
/// ── **Validate as we go, because here we are the library** ──
///
/// §4.5's rule is "validate before calling", and it exists because Ultima's decoders
/// take their bounds from the file they are reading. Nothing about this code can be
/// validated from outside — it *is* the decode — so the same discipline appears as a bound
/// on every read: the block chain against the file length, an entry's record against the
/// file, the inflated length against the declared one, the frame table against the
/// payload, and every run header against **both** the record's remaining bytes and the
/// bitmap it is writing into. A record that fails any of them is reported absent and no
/// pixel of it is kept.
///
/// Measured the same way §4.5 was, which is the only measurement that says the boundary is
/// in the right place: across every UOP body on a stock client the walk refused **nothing**
/// that carries art, and the one body it does refuse (286) declares a 0×0 frame, which the
/// legacy decoder treats as absent too.
///
/// ── **No System.Drawing, deliberately** ──
///
/// §4.4 states it: libgdiplus was archived in March 2025, and the long-term argument for
/// moving extraction off System.Drawing is that a Linux shard depends on an
/// unmaintained library to see a sprite. This decoder writes ARGB1555 into a
/// ushort[] of its own and encodes that directly, so the
/// door stays open. (Phase 4 does not walk through it: the catalogue still refuses the
/// whole family when imaging is unavailable, because most of it genuinely needs GDI+.)
///
public static class BridgeUop
{
/// 'MYP\0' — the Mythic package magic, as FileIndex reads it.
private const int PackageMagic = 0x50594D;
/// 'AMOU' — the animation payload's own magic, little-endian.
private const int PayloadMagic = 0x554F4D41;
/// Each frame record opens with its own palette: 0x100 ARGB1555 entries.
private const int PaletteBytes = 0x100 * 2;
/// The frame table's row width: group, frame id, two unknowns, pixel offset.
private const int FrameRowBytes = 16;
/// One block-chain record: offset, three lengths, hash, adler32, flag.
private const int BlockEntryBytes = 34;
/// The xor Frame applies to every run header, and so must this.
private const int DoubleXor = (0x200 << 22) | (0x200 << 12);
///
/// A ceiling on a declared decompressed payload. One group file is a whole action for
/// one body across every direction; the largest on this machine's client is body
/// 1248's at 4.3 MB, so this is two orders of magnitude of headroom over real data and
/// still small enough that a corrupt length cannot ask for the host's memory.
///
public const int MaxPayloadBytes = 64 * 1024 * 1024;
///
/// A ceiling on the block chain. Five packages hold 10,724 entries between them; this
/// bounds a cyclic or corrupt chain into a refusal rather than a hang.
///
private const int MaxEntries = 1 << 20;
/// The five packages this client ships. There is no AnimationFrame5.uop.
private static readonly int[] PackageNumbers = { 1, 2, 3, 4, 6 };
public static IEnumerable Packages
{
get { return PackageNumbers; }
}
public static string PackageName(int n)
{
return "AnimationFrame" + n.ToString(CultureInfo.InvariantCulture) + ".uop";
}
///
/// Where a UOP animation package lives.
///
/// Ultima.Files.GetFilePath cannot answer this: its table of known client files
/// predates UOP animations and contains no AnimationFrame*.uop entry, so it
/// returns null for every one of them. So the lookup is done here, against the same
/// directories ServUO itself resolved at boot — Files.RootDir first, then
/// Core.DataDirectories, which §1 is built on.
///
/// The comparison is case-insensitive **by enumeration** rather than by trying one
/// spelling. On Windows either would work; on a Linux shard host the client directory
/// is case-sensitive and the file may be shipped as `AnimationFrame1.uop`,
/// `animationframe1.uop` or anything between, which is exactly the shape of bug that
/// presents as "the gargoyles import on my machine and not on the server".
///
/// is the general form, and `assets.sources` uses it for
/// the same reason: a file Ultima's table predates has to be found some other way.
///
public static string PackagePath(int n)
{
return FindClientFile(PackageName(n));
}
private static readonly object _pathSync = new object();
private static readonly Dictionary _paths =
new Dictionary(StringComparer.OrdinalIgnoreCase);
///
/// Finds a client file Ultima.Files has never heard of.
///
/// Only successful answers are cached: a file an operator copies in while the shard is
/// up should be found by the next import, and nothing here is hot enough for a
/// negative cache to be worth that.
///
public static string FindClientFile(string name)
{
if (String.IsNullOrEmpty(name))
return null;
lock (_pathSync)
{
string cached;
if (_paths.TryGetValue(name, out cached))
return cached;
}
foreach (string dir in Directories())
{
if (String.IsNullOrEmpty(dir))
continue;
try
{
if (!Directory.Exists(dir))
continue;
string direct = Path.Combine(dir, name);
string hit = File.Exists(direct) ? direct : null;
if (hit == null)
{
foreach (string found in Directory.GetFiles(dir))
{
if (String.Equals(Path.GetFileName(found), name,
StringComparison.OrdinalIgnoreCase))
{
hit = found;
break;
}
}
}
if (hit == null)
continue;
lock (_pathSync)
_paths[name] = hit;
return hit;
}
catch (Exception e)
{
Console.WriteLine("[Bridge] uop: cannot look in {0}: {1}", dir, e.Message);
}
}
return null;
}
private static IEnumerable Directories()
{
string root = null;
try
{
root = Files.RootDir;
}
catch
{
// Ultima's static initialiser reads the registry on Windows. A host where that
// throws still has Core.DataDirectories, which is the path ServUO actually booted
// from.
}
if (!String.IsNullOrEmpty(root))
yield return root;
List dirs = null;
try
{
dirs = Core.DataDirectories;
}
catch
{
// Same reasoning; an empty list is a real answer and the caller reports absent.
}
if (dirs == null)
yield break;
foreach (string dir in dirs)
yield return dir;
}
///
/// The name a body's action file is stored under, hashed the way the container indexes
/// it. Ultima.FileIndex.HashFileName is pure arithmetic over a string — no file
/// is touched and no decoder is entered — so this is the one place phase 4 leans on
/// the vendored code, and it leans on it precisely so that our lookup cannot disagree
/// with the container's own.
///
public static ulong HashOf(int body, int action)
{
string name = String.Format(CultureInfo.InvariantCulture,
"build/animationlegacyframe/{0:D6}/{1:D2}.bin", body, action);
return FileIndex.HashFileName(name);
}
// ── the container ────────────────────────────────────────────────────────────────────
private struct Entry
{
public long At;
public int CompressedLength;
public int DecompressedLength;
public short Flag;
}
///
/// One opened AnimationFrame*.uop: its entry table in memory, its bytes on
/// demand. Opening one is a single pass over the block chain — 10,724 entries across
/// all five on this client — and the handle is held for the life of a reply, exactly
/// like the legacy readers next to it.
///
public sealed class Package : IDisposable
{
private readonly Dictionary _entries;
private readonly FileStream _stream;
public readonly string Path;
private Package(string path, FileStream stream, Dictionary entries)
{
Path = path;
_stream = stream;
_entries = entries;
}
public int Count
{
get { return _entries.Count; }
}
///
/// Reads the block chain, refusing anything that does not fit inside the file.
/// Returns null — never throws — because a client that ships a truncated package
/// is an ordinary thing to survive, not an error to raise.
///
public static Package Open(string path)
{
if (String.IsNullOrEmpty(path))
return null;
FileStream stream = null;
try
{
stream = new FileStream(path, FileMode.Open, FileAccess.Read,
FileShare.ReadWrite);
long length = stream.Length;
var entries = new Dictionary();
using (var br = new BinaryReader(stream, Encoding.UTF8, true))
{
if (length < 28 || br.ReadInt32() != PackageMagic)
{
Console.WriteLine("[Bridge] uop: {0} is not a Mythic package", path);
stream.Dispose();
return null;
}
br.ReadInt32(); // version
br.ReadUInt32(); // signature
long nextBlock = br.ReadInt64();
br.ReadInt32(); // block capacity
br.ReadInt32(); // declared file count
while (nextBlock > 0)
{
// A block header is 12 bytes. Anything that does not leave room for
// one is a corrupt or cyclic chain, and this is where it stops.
if (nextBlock + 12 > length)
break;
stream.Seek(nextBlock, SeekOrigin.Begin);
int filesCount = br.ReadInt32();
long following = br.ReadInt64();
if (filesCount < 0
|| nextBlock + 12 + ((long)filesCount * BlockEntryBytes) > length)
{
break;
}
for (int i = 0; i < filesCount; i++)
{
long offset = br.ReadInt64();
int headerLength = br.ReadInt32();
int compressedLength = br.ReadInt32();
int decompressedLength = br.ReadInt32();
ulong hash = br.ReadUInt64();
br.ReadUInt32(); // adler32
short flag = br.ReadInt16();
if (offset <= 0 || headerLength < 0 || compressedLength <= 0)
continue;
if (decompressedLength <= 0 || decompressedLength > MaxPayloadBytes)
continue;
long at = offset + headerLength;
// The check FileIndex.Seek is missing, in the place it matters
// here too: that the record ENDS inside the file, not merely that
// it starts inside it (§4.5).
if (at < 0 || at + compressedLength > length)
continue;
if (entries.Count >= MaxEntries)
break;
// First writer wins. Nothing on this client produces a collision
// — measured: no hash appears in two packages, and none twice in
// one — and if a patched client ever did, taking the first is the
// answer that does not depend on chain order.
if (!entries.ContainsKey(hash))
entries[hash] = new Entry
{
At = at,
CompressedLength = compressedLength,
DecompressedLength = decompressedLength,
Flag = flag
};
}
if (following <= nextBlock)
break; // a chain that does not move forward is a loop
nextBlock = following;
}
}
return new Package(path, stream, entries);
}
catch (Exception e)
{
Console.WriteLine("[Bridge] uop: cannot open {0}: {1}: {2}",
path, e.GetType().Name, e.Message);
if (stream != null)
{
try
{
stream.Dispose();
}
catch
{
// Closing a read-only handle.
}
}
return null;
}
}
public bool Has(ulong hash)
{
return _entries.ContainsKey(hash);
}
///
/// The bytes behind one entry, decompressed. False with a reason is the ordinary
/// answer for "this package does not hold it".
///
public bool TryRead(ulong hash, out byte[] payload, out string reason)
{
payload = null;
reason = null;
Entry entry;
if (!_entries.TryGetValue(hash, out entry))
{
reason = "not in " + System.IO.Path.GetFileName(Path);
return false;
}
byte[] raw;
try
{
_stream.Seek(entry.At, SeekOrigin.Begin);
raw = new byte[entry.CompressedLength];
if (!Fill(_stream, raw, raw.Length))
{
// The §4.5 failure, in our own code this time: a short read that nobody
// checked is how the library ends up decoding the previous asset.
reason = "record is shorter than the index claims";
return false;
}
}
catch (Exception e)
{
reason = "read failed: " + e.GetType().Name;
return false;
}
if (entry.Flag != 1)
{
if (raw.Length != entry.DecompressedLength)
{
reason = "stored record is " + raw.Length + " bytes, not the declared "
+ entry.DecompressedLength;
return false;
}
payload = raw;
return true;
}
return TryInflate(raw, entry.DecompressedLength, out payload, out reason);
}
public void Dispose()
{
try
{
_stream.Dispose();
}
catch
{
// Closing a read-only handle. Nothing useful is left to do.
}
}
}
private static bool Fill(Stream stream, byte[] into, int count)
{
int read = 0;
while (read < count)
{
int n = stream.Read(into, read, count - read);
if (n <= 0)
return false;
read += n;
}
return true;
}
///
/// zlib, which .NET Framework 4.8 does not expose — only raw deflate. The two-byte
/// zlib header is checked and skipped rather than assumed, because handing a
/// DeflateStream a stream that is not deflate produces garbage as readily as an
/// exception, and the trailing adler32 is left to the length check below: a stream
/// that inflates to exactly the declared number of bytes did not silently truncate.
///
private static bool TryInflate(byte[] raw, int declared, out byte[] payload, out string reason)
{
payload = null;
reason = null;
if (raw.Length < 3)
{
reason = "compressed record is too short to be zlib";
return false;
}
int cmf = raw[0];
int flg = raw[1];
if ((cmf & 0x0F) != 8 || (((cmf << 8) + flg) % 31) != 0 || (flg & 0x20) != 0)
{
reason = "compressed record is not a zlib stream";
return false;
}
try
{
var output = new byte[declared];
using (var source = new MemoryStream(raw, 2, raw.Length - 2, false))
using (var inflate = new DeflateStream(source, CompressionMode.Decompress))
{
int read = 0;
while (read < declared)
{
int n = inflate.Read(output, read, declared - read);
if (n <= 0)
break;
read += n;
}
if (read != declared)
{
reason = "inflated " + read + " bytes, not the declared " + declared;
return false;
}
// One more byte would mean the record is longer than its own header says,
// which is a different file from the one we were promised.
if (inflate.ReadByte() != -1)
{
reason = "inflated past the declared " + declared + " bytes";
return false;
}
}
payload = output;
return true;
}
catch (Exception e)
{
reason = "inflate failed: " + e.GetType().Name;
return false;
}
}
// ── the payload ──────────────────────────────────────────────────────────────────────
/// One decoded frame: ARGB1555 in our own array, no Bitmap anywhere.
public sealed class Pixels
{
public int Width;
public int Height;
public int CenterX;
public int CenterY;
public ushort[] Argb1555;
}
///
/// One action of one body — every direction of it, concatenated.
///
/// The legacy files address a frame as index + action * 5 + direction; a UOP
/// group file holds the whole action in one record and the directions are equal-length
/// runs inside its frame table. So is where "direction 1" is
/// turned into a frame number, and it is integer division exactly as the reference
/// implementations do it — see the note there for the nine bodies where that matters.
///
public sealed class Group
{
private readonly byte[] _buf;
private readonly int _dataStart;
public readonly int FrameCount;
public readonly int Body;
private Group(byte[] buf, int body, int frameCount, int dataStart)
{
_buf = buf;
Body = body;
FrameCount = frameCount;
_dataStart = dataStart;
}
public static bool TryOpen(byte[] buf, int expectedBody, out Group group, out string reason)
{
group = null;
reason = null;
if (buf == null || buf.Length < 40)
{
reason = "payload is too short to carry a header";
return false;
}
if (BitConverter.ToInt32(buf, 0) != PayloadMagic)
{
reason = "payload is not an AMOU animation record";
return false;
}
int body = BitConverter.ToInt32(buf, 12);
// The container said which body this is, by the name it was stored under; the
// payload says it again. They agree on every record of this client, and the day
// they do not is the day something is being read that was not asked for.
if (body != expectedBody)
{
reason = "payload declares body " + body + ", not " + expectedBody;
return false;
}
int frameCount = BitConverter.ToInt32(buf, 32);
int dataStart = BitConverter.ToInt32(buf, 36);
if (frameCount <= 0 || frameCount > BridgeAssetValidator.MaxAnimFrames)
{
reason = "payload declares " + frameCount + " frames";
return false;
}
if (dataStart < 40 || dataStart > buf.Length)
{
reason = "frame table starts at " + dataStart + " of " + buf.Length;
return false;
}
if ((long)dataStart + ((long)frameCount * FrameRowBytes) > buf.Length)
{
reason = "frame table of " + frameCount + " rows runs past the record";
return false;
}
group = new Group(buf, body, frameCount, dataStart);
return true;
}
///
/// Which frame of this action faces a given direction.
///
/// Five directions share the action's frames equally, so direction *d* starts at
/// d * (FrameCount / 5). On nine of this client's 244 UOP bodies the frame
/// count is **not** a multiple of five (41, 42, 46…), and integer division then
/// lands a direction or so early in the run. That is what ClassicUO does, it is
/// the right trade, and the reason is §4.8's: the failure being guarded against is
/// a picture of the **wrong creature**, and this cannot produce one — the worst
/// case is the right creature at a slightly different angle, on nine bodies, where
/// refusing them instead would lose nine creatures outright.
///
public int DirectionAt(int direction)
{
int perDirection = FrameCount / 5;
if (perDirection <= 0)
return direction == 0 ? 0 : -1;
if (direction < 0 || direction > 4)
return -1;
int at = direction * perDirection;
return at < FrameCount ? at : -1;
}
///
/// Decodes one frame, bounding every read against the record and every write
/// against the bitmap.
///
/// The run loop is Ultima.Frame's, with the two bounds it does not have.
/// Frame writes through a LockBits pointer whose origin comes from
/// two signed shorts in the file and never checks where a run lands; here a run
/// that would leave the bitmap, or read past the record, refuses the frame. Across
/// every UOP body on a stock client that refuses nothing that carries art.
///
/// A 0×0 frame returns false with set: the legacy decoder
/// treats that as no art rather than as damage, and so must this, or body 286
/// would be logged as a defect on every scan.
///
public bool TryDecode(int index, out Pixels pixels, out bool empty, out string reason)
{
pixels = null;
empty = false;
reason = null;
if (index < 0 || index >= FrameCount)
{
reason = "frame " + index + " of " + FrameCount;
return false;
}
int row = _dataStart + (index * FrameRowBytes);
long at = (long)row + (uint)BitConverter.ToInt32(_buf, row + 12);
if (at < 0 || at + PaletteBytes + 8 > _buf.Length)
{
reason = "frame " + index + " points outside the record";
return false;
}
int pixelAt = (int)at;
int centerX = BitConverter.ToInt16(_buf, pixelAt + PaletteBytes);
int centerY = BitConverter.ToInt16(_buf, pixelAt + PaletteBytes + 2);
int width = BitConverter.ToUInt16(_buf, pixelAt + PaletteBytes + 4);
int height = BitConverter.ToUInt16(_buf, pixelAt + PaletteBytes + 6);
if (width <= 0 || height <= 0)
{
empty = true;
reason = "frame " + index + " is " + width + "x" + height;
return false;
}
if (width > BridgeAssetValidator.MaxArtDimension
|| height > BridgeAssetValidator.MaxArtDimension)
{
reason = "frame " + index + " declares " + width + "x" + height;
return false;
}
var palette = new ushort[0x100];
for (int i = 0; i < palette.Length; i++)
{
// The library's own xor: the stored entry has its alpha bit clear and every
// palette colour is opaque. A pixel no run covers stays zero, which is how a
// sprite keeps its transparent background.
palette[i] = (ushort)(BitConverter.ToUInt16(_buf, pixelAt + (i * 2)) ^ 0x8000);
}
var canvas = new ushort[width * height];
int p = pixelAt + PaletteBytes + 8;
int xBase = centerX - 0x200;
int yBase = (centerY + height) - 0x200;
while (true)
{
if (p + 4 > _buf.Length)
{
reason = "frame " + index + " has no terminator inside the record";
return false;
}
int header = BitConverter.ToInt32(_buf, p);
p += 4;
if (header == 0x7FFF7FFF)
break;
header ^= DoubleXor;
int x = ((header >> 22) & 0x3FF) + xBase;
int y = ((header >> 12) & 0x3FF) + yBase;
int run = header & 0xFFF;
if (run == 0)
continue;
if (p + run > _buf.Length)
{
reason = "frame " + index + " has a run past the end of the record";
return false;
}
if (y < 0 || y >= height || x < 0 || x + run > width)
{
reason = "frame " + index + " has a run at " + x + "," + y + " of "
+ run + " outside " + width + "x" + height;
return false;
}
int cursor = (y * width) + x;
for (int i = 0; i < run; i++)
canvas[cursor + i] = palette[_buf[p + i]];
p += run;
}
pixels = new Pixels
{
Width = width,
Height = height,
CenterX = centerX,
CenterY = centerY,
Argb1555 = canvas
};
return true;
}
}
}
}