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; } } } }