using System; using System.IO; using Ultima; namespace Server.Custom.Bridge { /// /// **Validate before calling** (docs/link/v8.md §4.5) — the boundary between this protocol /// and ServUO's vendored Ultima decoders. Phase 0 prototyped it in /// tools/scaffolding/BridgeAssetProbe.cs and measured it both ways; phase 1 promoted /// it here, into the overlay, and extended it to animations. /// /// The principle: `Ultima`'s decoders take their bounds from the file they are reading, so /// the extractor must decide whether a record is worth handing over *before* handing it /// over. Every check below is against the index entry and the record header — cheap, and /// enough to turn an uncatchable corrupted-state exception into a skipped asset. /// /// **The failure this exists for is a wrong picture, not a crash.** `LoadStatic`, /// `LoadLand` and `GetAnimation` all decode out of a shared m_StreamBuffer that is /// reused, only ever grown, and filled by a stream.Read whose return value is /// discarded. A record that is short, absent or out of bounds therefore renders **whatever /// the previously-decoded asset left behind**, reports success, and is undetectable by /// anything downstream. On the stock client on the machine phase 0 ran on that is 22,102 /// ids whose index entry reads lookup 0, length 0. /// /// It cannot be complete and does not claim to be. It closes the shapes that reading the /// source showed are reachable. What says the boundary is in the right place is the second /// measurement rather than the first: against a client patched 21 ways it refused all eight /// record-level defects, and against the **stock** client it refused **nothing** across /// 49,151 statics and 16,384 land tiles. A checker that refuses real art would be worse /// than no checker. /// public static class BridgeAssetValidator { public enum Verdict { /// Nothing at this id, and the index says so honestly. Absent, /// The entry is self-consistent and inside its file. Ok, /// The entry claims something the file cannot support. Do not decode it. Refused } /// Land tiles decode a fixed 44×44 diamond: 2 × (2+4+…+44) ushorts. public const int LandRecordBytes = 2024; /// /// A ceiling on decoded art dimensions. `LoadStatic` allocates /// new Bitmap(width, height) straight from two ushorts in the record, so a /// corrupt header asks for up to 65535×65535 — an 8 GB allocation, from a file. Real /// art is a couple of hundred pixels at most. /// public const int MaxArtDimension = 1024; /// /// Builds our own index over the same files, with the same constructor arguments /// Art uses — including hasExtra: false, which is the whole reason the /// art path is safe where the gump path is not (§4.1). /// public static FileIndex OpenArtIndex() { if (ArtDataPath() == null) return null; return new FileIndex("Artidx.mul", "Art.mul", "artLegacyMUL.uop", 0x10000, 4, ".tga", 0x13FDC, false); } /// /// The file an art index entry's lookup is an offset **into** — which is not /// art.mul on any current client. /// /// This cost a whole probe run to learn and it is the single most important thing /// phase 1 must not get wrong. FileIndex's UOP constructor ends with a bare /// MulPath = uopPath: **when artLegacyMUL.uop exists it wins outright**, /// and art.mul / artidx.mul are never opened at all. A validator that /// bounds offsets against art.mul while the index holds UOP offsets is not /// merely approximate, it is nonsense — the first run of this probe refused 34,299 /// perfectly good statics for "declaring 10533x2085" because it was reading UOP /// offsets into the wrong file. /// /// So the resolution order here mirrors FileIndex's exactly, and anything that /// needs the bytes behind an entry must ask this rather than assume. /// public static string ArtDataPath() { var uop = Files.GetFilePath("artlegacymul.uop"); if (uop != null) return uop; return Files.GetFilePath("art.mul"); } public static long MulLength(string path) { if (path == null) return 0; try { return new FileInfo(path).Length; } catch { return 0; } } /// /// Judges one index entry. /// /// The check FileIndex.Seek is missing is the last one: it tests /// Stream.Length < e.lookup — that the record *starts* inside the file — and /// never that it *ends* inside it. A record that begins two bytes before EOF and /// declares a length of 4,000 passes, and stream.Read then returns a short count /// that the decoders discard, leaving the previous asset's bytes in the shared buffer. /// public static Verdict CheckEntry(FileIndex index, int at, long mulLength, long verdataLength, out string reason) { reason = null; if (index == null || index.Index == null || at < 0 || at >= index.Index.Length) { reason = "index " + at + " out of range"; return Verdict.Absent; } Entry3D e = index.Index[at]; if (e.lookup < 0) { reason = "lookup " + e.lookup; return Verdict.Absent; } bool patched = (e.length & (1 << 31)) != 0; int length = e.length & 0x7FFFFFFF; if (!patched && e.length < 0) { reason = "length " + e.length; return Verdict.Absent; } if (length == 0) { reason = "lookup " + e.lookup + ", length 0"; return Verdict.Absent; } long ceiling = patched ? verdataLength : mulLength; if (ceiling <= 0) { reason = (patched ? "verdata.mul" : "the art data file") + " has no length"; return Verdict.Refused; } if (e.lookup >= ceiling) { reason = "lookup " + e.lookup + " past the end of " + (patched ? "verdata.mul" : "the mul") + " (" + ceiling + ")"; return Verdict.Refused; } // The missing check. A short read is silent, and its consequence is the PREVIOUS // asset's picture served under this id. if (e.lookup + (long)length > ceiling) { reason = "record runs " + (e.lookup + (long)length - ceiling) + " bytes past the end of " + (patched ? "verdata.mul" : "the mul"); return Verdict.Refused; } return Verdict.Ok; } /// /// `LoadLand` reads 2,024 bytes regardless of the declared length, so a shorter record /// reads past the end of a buffer sized from that length. /// public static bool LandLengthSane(FileIndex index, int at, out string reason) { reason = null; if (index == null || index.Index == null || at < 0 || at >= index.Index.Length) return true; int length = index.Index[at].length & 0x7FFFFFFF; if (length > 0 && length < LandRecordBytes) { reason = "land record is " + length + " bytes; LoadLand always reads " + LandRecordBytes; return false; } return true; } /// /// Walks a static record's own row table the way LoadStatic will, and refuses /// it if that walk would read outside the record. /// /// This is the check with teeth. LoadStatic's inner loop guards the write into /// the bitmap (xOffset > delta, xOffset + xRun > delta) and does /// nothing at all about the read cursor, which advances until it happens to find a /// zero pair — potentially far outside a pinned array. Simulating the same walk with /// a bound is the cheapest way to know whether handing the id over is safe. /// public static bool StaticRecordSane(byte[] record, int length, out string reason) { reason = null; if (length < 8) { reason = "record is " + length + " bytes; a static header needs 8"; return false; } int words = length / 2; int width = ReadUInt16(record, 4); int height = ReadUInt16(record, 6); // LoadStatic returns null for these rather than misbehaving, so it is not a refusal. if (width <= 0 || height <= 0) return true; if (width > MaxArtDimension || height > MaxArtDimension) { reason = "declares " + width + "x" + height + ", past the " + MaxArtDimension + "px ceiling"; return false; } // The row-lookup table: height ushorts starting at word 4. if (4 + height > words) { reason = "row table (" + height + " entries) does not fit in a " + length + "-byte record"; return false; } int start = height + 4; for (int y = 0; y < height; y++) { int cursor = start + ReadUInt16(record, (4 + y) * 2); while (true) { // Two ushorts for the run header, and they must both be inside the record. if (cursor < 0 || cursor + 1 >= words) { reason = "row " + y + " reads at word " + cursor + ", past the record's " + words; return false; } int xOffset = ReadUInt16(record, cursor * 2); int xRun = ReadUInt16(record, (cursor + 1) * 2); cursor += 2; if (xOffset + xRun == 0) break; // LoadStatic stops the row here, so the read cursor stops with it. if (xOffset > width || xOffset + xRun > width) break; if (cursor + xRun > words) { reason = "row " + y + " declares a " + xRun + "-pixel run running past the record"; return false; } cursor += xRun; } } return true; } // ── animations (phase 1) ───────────────────────────────────────────────────────────── // // Phase 0 measured the art path and left this half unbuilt, and then proved it was // needed: the patched client's verdata entry for body 34 points past verdata.mul's own // end, and the wolf still "decoded" — counted among the 1,144 successes while rendering // something else entirely. `GetAnimation` has every weakness `LoadStatic` has and one // more, because the buffer it decodes from is longer than the record it read. /// The palette every animation record opens with: 0x100 ushorts. public const int AnimPaletteBytes = 0x100 * 2; /// /// A ceiling on an animation's declared frame count. GetAnimation does /// new int[frameCount] straight from four bytes in the file, before it has /// looked at anything else. Real actions are tens of frames. /// public const int MaxAnimFrames = 1024; /// The xor Frame applies to every run header before decoding it. private const int DoubleXor = (0x200 << 22) | (0x200 << 12); /// /// The anim*.mul an animation index entry's lookup is an offset into. /// /// Unlike art (§4.6) there is no UOP precedence to get wrong here, and that is not /// luck: Animations constructs its five FileIndexes with the four-argument /// constructor, which passes uopFile: null. It never reads /// AnimationFrame*.uop at all — which is the same fact that leaves six of the /// twelve player-character bodies undecodable until §4.3's reader lands in phase 4. /// public static string AnimDataPath(int fileType) { switch (fileType) { case 1: return Files.GetFilePath("anim.mul"); case 2: return Files.GetFilePath("anim2.mul"); case 3: return Files.GetFilePath("anim3.mul"); case 4: return Files.GetFilePath("anim4.mul"); case 5: return Files.GetFilePath("anim5.mul"); default: return null; } } /// /// Builds our own index over one anim file, with the same constructor arguments /// Animations uses — the entry lengths especially, since they decide how far /// into the file an index runs. /// public static FileIndex OpenAnimIndex(int fileType) { if (AnimDataPath(fileType) == null) return null; switch (fileType) { case 1: return new FileIndex("Anim.idx", "Anim.mul", 0x40000, 6); case 2: return new FileIndex("Anim2.idx", "Anim2.mul", 0x10000, -1); case 3: return new FileIndex("Anim3.idx", "Anim3.mul", 0x20000, -1); case 4: return new FileIndex("Anim4.idx", "Anim4.mul", 0x20000, -1); case 5: return new FileIndex("Anim5.idx", "Anim5.mul", 0x20000, -1); default: return null; } } /// /// Where a body's animation actually lives: which anim file, and which index in it. /// /// **This is the never-sweep-file-types rule, written as code** (§4.3). It asks /// BodyConverter.Convert once, takes its answer, and if that answer leads /// nowhere it reports nowhere. There is deliberately no loop here and no fallback, /// because asking the *other* anim files for an index they do not own does not fail — /// it returns 175 decodable action/direction combinations of **a giant spider** for /// gargoyle 666, and misaligned colour fragments for the other two. Every one of those /// reads reports success, and nothing downstream can tell them from art. /// /// A false return with set is the ordinary, expected answer /// for a body this client has no art for — the caller reports absent, not an error. /// public static bool ResolveAnimation( int body, int action, int direction, out int fileType, out int index, out string reason) { reason = null; fileType = 0; index = -1; if (body <= 0 || action < 0) { reason = "body " + body + " action " + action + " is not addressable"; return false; } // Directions 5-7 are the client mirroring 1-3, and `Frame` decodes them through its // flip branch — different pointer arithmetic, which nothing below has checked. // §5.1 fixed this protocol at direction 0 or 1, so refusing the rest costs nothing // and keeps the validator honest about what it has actually verified. if (direction < 0 || direction > 4) { reason = "direction " + direction + " is mirrored; this protocol reads 0-4 only"; return false; } int translated = body; int hue = 0; try { // Exactly what GetAnimation(..., preserveHue: false, ...) does first. Animations.Translate(ref translated, ref hue); fileType = BodyConverter.Convert(ref translated); } catch (Exception e) { reason = "body.def/bodyconv.def lookup failed: " + e.GetType().Name; return false; } if (AnimDataPath(fileType) == null) { // Gargoyle 666 lands here: Bodyconv.def maps it to anim5, and this client has no // anim5. Absent is the correct answer and the ONLY safe one. reason = "bodyconv sends body " + body + " to file type " + fileType + ", which this client does not have"; return false; } index = AnimIndexOf(translated, fileType) + (action * 5) + direction; return true; } /// /// Animations.GetFileIndex's own arithmetic, which is private. The banding is /// per file type and the boundaries differ between them, so this is transcribed rather /// than generalised — an index that disagrees with the library's by one is a picture /// of the wrong creature, validated. /// private static int AnimIndexOf(int body, int fileType) { switch (fileType) { case 2: return body < 200 ? body * 110 : 22000 + ((body - 200) * 65); case 3: if (body < 300) return body * 65; return body < 400 ? 33000 + ((body - 300) * 110) : 35000 + ((body - 400) * 175); case 5: // "looks strange, though it works" — the library's own comment. Body 34 is // excluded from the first band here and nowhere else. if (body < 200 && body != 34) return body * 110; return body < 400 ? 22000 + ((body - 200) * 65) : 35000 + ((body - 400) * 175); default: // 1 and 4 share their banding if (body < 200) return body * 110; return body < 400 ? 22000 + ((body - 200) * 65) : 35000 + ((body - 400) * 175); } } /// /// Walks an animation record the way GetAnimation and Frame will, and /// refuses it if that walk would read outside the record or write outside the bitmap. /// /// Two things make this stricter than the static walk, and both come from the library: /// /// GetAnimation decodes through new MemoryStream(m_StreamBuffer, false) — /// the whole shared buffer, not the length bytes it just read into it. So a /// truncated record does not hit end-of-stream and throw; the reader sails on into the /// **previous** animation's bytes and returns a plausible frame. Bounding against /// rather than against the buffer is the entire point. /// /// And Frame's run loop is a *write* through a LockBits pointer whose /// origin comes from two signed shorts in the file (xCenter, yCenter), /// with no bound of any kind. LoadStatic at least guards its writes; this does /// not, so the destination of every run is checked against the bitmap it locked. /// /// is how many frames the caller will actually decode — /// 1 for the catalogue's thumbnail (FirstFrame: true), 0 for all of them. /// Checking frames nobody decodes would invent refusals, which §4.5 costs more than /// it saves. /// public static bool AnimationRecordSane(byte[] record, int length, int maxFrames, out string reason) { reason = null; if (length < AnimPaletteBytes + 4) { reason = "record is " + length + " bytes; an animation needs " + (AnimPaletteBytes + 4) + " for its palette and frame count"; return false; } int start = AnimPaletteBytes; int frameCount = ReadInt32(record, start); if (frameCount <= 0) { reason = "declares " + frameCount + " frames"; return false; } if (frameCount > MaxAnimFrames) { reason = "declares " + frameCount + " frames, past the " + MaxAnimFrames + " ceiling"; return false; } // The lookup table is read in full whatever FirstFrame says, so it is bounded in full. long tableEnd = (long)start + 4 + ((long)frameCount * 4); if (tableEnd > length) { reason = "frame table (" + frameCount + " entries) does not fit in a " + length + "-byte record"; return false; } int check = maxFrames > 0 && maxFrames < frameCount ? maxFrames : frameCount; for (int i = 0; i < check; i++) { int at = start + ReadInt32(record, start + 4 + (i * 4)); if (!FrameSane(record, length, at, i, out reason)) return false; } return true; } private static bool FrameSane(byte[] record, int length, int at, int frame, out string reason) { reason = null; if (at < 0 || at + 8 > length) { reason = "frame " + frame + " starts at " + at + ", outside the " + length + "-byte record"; return false; } int xCenter = ReadInt16(record, at); int yCenter = ReadInt16(record, at + 2); int width = ReadUInt16(record, at + 4); int height = ReadUInt16(record, at + 6); // Frame's constructor returns before locking anything for these, so they are empty // rather than dangerous — and an empty frame is a real thing in this format. if (width == 0 || height == 0) return true; if (width > MaxArtDimension || height > MaxArtDimension) { reason = "frame " + frame + " declares " + width + "x" + height + ", past the " + MaxArtDimension + "px ceiling"; return false; } // Settings.PixelFormat is 16bpp and GDI+ pads each scanline to four bytes, so a row // is `delta` ushorts wide and the locked region is height*delta of them. This is the // same `bd.Stride >> 1` Frame computes. int delta = (((width * 2) + 3) & ~3) >> 1; long pixels = (long)height * delta; long origin = (xCenter - 0x200) + ((long)((yCenter + height) - 0x200) * delta); int cursor = at + 8; while (true) { if (cursor + 4 > length) { reason = "frame " + frame + " runs off the end of the record looking for its terminator"; return false; } int header = ReadInt32(record, cursor); cursor += 4; if (header == 0x7FFF7FFF) break; header ^= DoubleXor; long dy = (header >> 12) & 0x3FF; long dx = (header >> 22) & 0x3FF; int run = header & 0xFFF; long first = origin + (dy * delta) + dx; if (first < 0 || first + run > pixels) { reason = "frame " + frame + " writes pixels " + first + ".." + (first + run) + " outside its own " + pixels + "-pixel bitmap"; return false; } // One palette byte per pixel, read straight out of the record. if (cursor + run > length) { reason = "frame " + frame + " declares a " + run + "-pixel run running past the record"; return false; } cursor += run; } return true; } private static int ReadUInt16(byte[] b, int at) { return b[at] | (b[at + 1] << 8); } private static int ReadInt16(byte[] b, int at) { return (short)(b[at] | (b[at + 1] << 8)); } private static int ReadInt32(byte[] b, int at) { return b[at] | (b[at + 1] << 8) | (b[at + 2] << 16) | (b[at + 3] << 24); } /// /// Reads a record's actual bytes so or /// can walk it. /// /// Holds its own handles rather than borrowing the library's, because FileIndex /// hands out the stream it decodes from and moving that stream's position underneath /// the decoder would be its own bug. Opened FileShare.ReadWrite to match how /// FileIndex opens the same files. /// /// One reader serves one data file, so an animation sweep wants one per file type, /// built from . /// public sealed class RecordReader : IDisposable { private readonly FileStream _mul; private readonly FileStream _verdata; private byte[] _scratch = new byte[64 * 1024]; public RecordReader(string mulPath, string verdataPath) { _mul = Open(mulPath); _verdata = Open(verdataPath); } private static FileStream Open(string path) { if (path == null || !File.Exists(path)) return null; try { return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); } catch { return null; } } /// /// True when the record at is safe to hand to /// Art.GetStatic. A record that cannot be read at all is reported sane — /// has already judged the entry, and this must not /// invent a second reason to refuse. /// public bool StaticSane(FileIndex index, int at, out string reason) { int length = ReadRecord(index, at, out reason); if (length < 0) return true; if (length == 0) return false; return StaticRecordSane(_scratch, length, out reason); } /// /// True when the record at is safe to hand to /// Animations.GetAnimation. is how many frames /// the caller will decode — 1 for a FirstFrame call, 0 for all of them. /// public bool AnimationSane(FileIndex index, int at, int maxFrames, out string reason) { int length = ReadRecord(index, at, out reason); if (length < 0) return true; if (length == 0) return false; return AnimationRecordSane(_scratch, length, maxFrames, out reason); } /// /// Reads one record into . Returns its length, 0 for a /// failure (with set), or -1 when there is nothing to /// read at all — has already judged the entry, and this /// must not invent a second reason to refuse. /// private int ReadRecord(FileIndex index, int at, out string reason) { reason = null; if (index == null || index.Index == null || at < 0 || at >= index.Index.Length) return -1; Entry3D e = index.Index[at]; bool patched = (e.length & (1 << 31)) != 0; int length = e.length & 0x7FFFFFFF; var stream = patched ? _verdata : _mul; if (stream == null || length <= 0 || e.lookup < 0) return -1; if (_scratch.Length < length) _scratch = new byte[length]; int read; try { stream.Seek(e.lookup, SeekOrigin.Begin); read = stream.Read(_scratch, 0, length); } catch (Exception ex) { reason = "cannot read the record: " + ex.GetType().Name; return 0; } // The short read the decoders discard. Refusing here is the whole point: the // library would decode whatever the shared buffer happened to hold. if (read < length) { reason = "short read — " + read + " of " + length + " bytes available"; return 0; } return length; } public void Dispose() { if (_mul != null) _mul.Dispose(); if (_verdata != null) _verdata.Dispose(); } } } }