Asset Bridge phase 1 (docs/link/v8.md §16). Sidecar half: RunicGateway/link#41. Docs half: RunicGateway/docs#236. The transport for protocol 8, plus phase 0's validator promoted into the overlay and extended to animations — which is where the interesting part is. ## 357 of the 1,144 "decodable" bodies are wrong pictures, on a STOCK client Phase 0 measured the art path and left the animation half unbuilt. It has the same defect, and it is worse: `GetAnimation` decodes through `new MemoryStream(m_StreamBuffer, false)` — the whole shared buffer, not the `length` bytes just read into it — so a truncated or absent record does not even hit end-of-stream. It sails on into the previous animation's bytes. Measured directly, because no count could tell: | Decode body 320 (`lookup 22638982, length 0`) straight after… | Comes back | |---|---| | body 12, the dragon | the dragon, 176x167, identical hash | | body 34, the wolf | the wolf's dimensions, 35x34 | | body 400, the human male | the human, 27x63, identical hash | The catalogue is **787 bodies, not 1,144**. Importing the other 357 would have written duplicate creature portraits into the site showing whichever body the walk decoded before them. The record walk refused **0** real bodies on the stock client — the false-refusal measurement §4.5 says the boundary depends on. ## And four of the twelve player bodies, not six §5.2 listed the elf ghosts (607, 608) as decoding. Their index entry is `length 0`; what came back was the elf female at her exact dimensions, because 606 is what the walk decoded immediately before. Confirmed the same way — 607 after the dragon is the dragon. Phase 4's UOP decoder now covers eight ids rather than six. ## What is here - **`overlay/Scripts/Custom/Bridge/BridgeAssets.cs`** — the plane. Accepts on the Core thread, hands off to a dedicated asset worker, returns immediately. Three rules, all answering a specific failure: - **one slot**, second request answered `bridge.busy` (425). `Emit`'s queue is bounded in *lines*, so 10,000 queued 200 KB replies is 2 GB of shard memory; the bound that holds is flow control, on the side where the memory is. - **byte budgets** (`AssetBatchBytes`, 512 KiB) under the sidecar's new 1 MiB cap. The factor of two is load-bearing: a page always admits its first item, so it may overshoot by one, and the headroom is what makes that land on the wire. - **replies, never events** — no `reqId`, no answer. An uncorrelated frame is an event by definition, and §3.1 is why none of this may be one. - **`PageBuilder`** — one paging envelope (`more`/`cursor`/`cut`) for all five families that will page, defined before the first one needs it. `cut` matters: "short page" has three meanings and only `end` means finished. - **`assets.sources`** — stage 1 of the import gate, its first user. - **`BridgeAssetValidator.cs`** — promoted from `tools/`, plus `ResolveAnimation` (the never-sweep-file-types rule as code, with no loop and no fallback), `AnimationRecordSane` and the frame walk. - **`EXTRACTOR_VERSION`**, **`overlay.toml` protocol 7 → 8**, `AssetsEnabled`. ## Hashing had to come off the request path §6's gate is (size, mtime) first, hash only when those differ. The first call has nothing cached, so that still means hashing 1.06 GB — inside the sidecar's 10 s reply timeout it does not fit. So hashes are computed on their own thread (deliberately not the single-slot worker, which would answer every status poll `bridge.busy` for the whole pass) and the reply carries `hashing`/`complete`. Measured on the real rig: first call instant with `sha256: null`, second call **44 ms** with every hash present. ## Verified on the wire, not just compiled Real ServUO 57.4 + the real sidecar + the real client. `GET /assets/sources` → 200, `X-UOLink-Version: 8`, `imaging: {ok: true}`, and §4.6's diagnostic firing on a live client: `artDataFile: artlegacymul.uop`, with `art.mul` and `artidx.mul` both carrying `shadowedBy`. Live events kept flowing through the new capped reader with no warnings. Not exercised live: the disabled-plane 403 and the busy 425 (both unit-tested on the sidecar side; the shard halves are a config read and a lock). - [x] AI-assisted — Claude Code (Opus 5) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
752 lines
31 KiB
C#
752 lines
31 KiB
C#
using System;
|
||
using System.IO;
|
||
|
||
using Ultima;
|
||
|
||
namespace Server.Custom.Bridge
|
||
{
|
||
/// <summary>
|
||
/// **Validate before calling** (docs/link/v8.md §4.5) — the boundary between this protocol
|
||
/// and ServUO's vendored <c>Ultima</c> decoders. Phase 0 prototyped it in
|
||
/// <c>tools/scaffolding/BridgeAssetProbe.cs</c> 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 <c>m_StreamBuffer</c> that is
|
||
/// reused, only ever grown, and filled by a <c>stream.Read</c> 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 <c>lookup 0, length 0</c>.
|
||
///
|
||
/// 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.
|
||
/// </summary>
|
||
public static class BridgeAssetValidator
|
||
{
|
||
public enum Verdict
|
||
{
|
||
/// <summary>Nothing at this id, and the index says so honestly.</summary>
|
||
Absent,
|
||
|
||
/// <summary>The entry is self-consistent and inside its file.</summary>
|
||
Ok,
|
||
|
||
/// <summary>The entry claims something the file cannot support. Do not decode it.</summary>
|
||
Refused
|
||
}
|
||
|
||
/// <summary>Land tiles decode a fixed 44×44 diamond: 2 × (2+4+…+44) ushorts.</summary>
|
||
public const int LandRecordBytes = 2024;
|
||
|
||
/// <summary>
|
||
/// A ceiling on decoded art dimensions. `LoadStatic` allocates
|
||
/// <c>new Bitmap(width, height)</c> 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.
|
||
/// </summary>
|
||
public const int MaxArtDimension = 1024;
|
||
|
||
/// <summary>
|
||
/// Builds our own index over the same files, with the same constructor arguments
|
||
/// <c>Art</c> uses — including <c>hasExtra: false</c>, which is the whole reason the
|
||
/// art path is safe where the gump path is not (§4.1).
|
||
/// </summary>
|
||
public static FileIndex OpenArtIndex()
|
||
{
|
||
if (ArtDataPath() == null)
|
||
return null;
|
||
|
||
return new FileIndex("Artidx.mul", "Art.mul", "artLegacyMUL.uop", 0x10000, 4, ".tga", 0x13FDC, false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The file an art index entry's <c>lookup</c> is an offset **into** — which is not
|
||
/// <c>art.mul</c> 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. <c>FileIndex</c>'s UOP constructor ends with a bare
|
||
/// <c>MulPath = uopPath</c>: **when <c>artLegacyMUL.uop</c> exists it wins outright**,
|
||
/// and <c>art.mul</c> / <c>artidx.mul</c> are never opened at all. A validator that
|
||
/// bounds offsets against <c>art.mul</c> 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 <c>FileIndex</c>'s exactly, and anything that
|
||
/// needs the bytes behind an entry must ask this rather than assume.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Judges one index entry.
|
||
///
|
||
/// The check <c>FileIndex.Seek</c> is missing is the last one: it tests
|
||
/// <c>Stream.Length < e.lookup</c> — 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 <c>stream.Read</c> then returns a short count
|
||
/// that the decoders discard, leaving the previous asset's bytes in the shared buffer.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// `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.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Walks a static record's own row table the way <c>LoadStatic</c> will, and refuses
|
||
/// it if that walk would read outside the record.
|
||
///
|
||
/// This is the check with teeth. <c>LoadStatic</c>'s inner loop guards the write into
|
||
/// the bitmap (<c>xOffset > delta</c>, <c>xOffset + xRun > delta</c>) 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.
|
||
/// </summary>
|
||
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.
|
||
|
||
/// <summary>The palette every animation record opens with: 0x100 ushorts.</summary>
|
||
public const int AnimPaletteBytes = 0x100 * 2;
|
||
|
||
/// <summary>
|
||
/// A ceiling on an animation's declared frame count. <c>GetAnimation</c> does
|
||
/// <c>new int[frameCount]</c> straight from four bytes in the file, before it has
|
||
/// looked at anything else. Real actions are tens of frames.
|
||
/// </summary>
|
||
public const int MaxAnimFrames = 1024;
|
||
|
||
/// <summary>The xor <c>Frame</c> applies to every run header before decoding it.</summary>
|
||
private const int DoubleXor = (0x200 << 22) | (0x200 << 12);
|
||
|
||
/// <summary>
|
||
/// The <c>anim*.mul</c> an animation index entry's <c>lookup</c> is an offset into.
|
||
///
|
||
/// Unlike art (§4.6) there is no UOP precedence to get wrong here, and that is not
|
||
/// luck: <c>Animations</c> constructs its five <c>FileIndex</c>es with the four-argument
|
||
/// constructor, which passes <c>uopFile: null</c>. It never reads
|
||
/// <c>AnimationFrame*.uop</c> 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.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Builds our own index over one anim file, with the same constructor arguments
|
||
/// <c>Animations</c> uses — the entry lengths especially, since they decide how far
|
||
/// into the file an index runs.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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
|
||
/// <c>BodyConverter.Convert</c> 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 <paramref name="reason"/> set is the ordinary, expected answer
|
||
/// for a body this client has no art for — the caller reports absent, not an error.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// <c>Animations.GetFileIndex</c>'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.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Walks an animation record the way <c>GetAnimation</c> and <c>Frame</c> 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:
|
||
///
|
||
/// <c>GetAnimation</c> decodes through <c>new MemoryStream(m_StreamBuffer, false)</c> —
|
||
/// the whole shared buffer, not the <c>length</c> 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
|
||
/// <paramref name="length"/> rather than against the buffer is the entire point.
|
||
///
|
||
/// And <c>Frame</c>'s run loop is a *write* through a <c>LockBits</c> pointer whose
|
||
/// origin comes from two signed shorts in the file (<c>xCenter</c>, <c>yCenter</c>),
|
||
/// with no bound of any kind. <c>LoadStatic</c> at least guards its writes; this does
|
||
/// not, so the destination of every run is checked against the bitmap it locked.
|
||
///
|
||
/// <paramref name="maxFrames"/> is how many frames the caller will actually decode —
|
||
/// 1 for the catalogue's thumbnail (<c>FirstFrame: true</c>), 0 for all of them.
|
||
/// Checking frames nobody decodes would invent refusals, which §4.5 costs more than
|
||
/// it saves.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads a record's actual bytes so <see cref="StaticRecordSane"/> or
|
||
/// <see cref="AnimationRecordSane"/> can walk it.
|
||
///
|
||
/// Holds its own handles rather than borrowing the library's, because <c>FileIndex</c>
|
||
/// hands out the stream it decodes from and moving that stream's position underneath
|
||
/// the decoder would be its own bug. Opened <c>FileShare.ReadWrite</c> to match how
|
||
/// <c>FileIndex</c> opens the same files.
|
||
///
|
||
/// One reader serves one data file, so an animation sweep wants one per file type,
|
||
/// built from <see cref="AnimDataPath"/>.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// True when the record at <paramref name="at"/> is safe to hand to
|
||
/// <c>Art.GetStatic</c>. A record that cannot be read at all is reported sane —
|
||
/// <see cref="CheckEntry"/> has already judged the entry, and this must not
|
||
/// invent a second reason to refuse.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// True when the record at <paramref name="at"/> is safe to hand to
|
||
/// <c>Animations.GetAnimation</c>. <paramref name="maxFrames"/> is how many frames
|
||
/// the caller will decode — 1 for a <c>FirstFrame</c> call, 0 for all of them.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads one record into <see cref="_scratch"/>. Returns its length, 0 for a
|
||
/// failure (with <paramref name="reason"/> set), or -1 when there is nothing to
|
||
/// read at all — <see cref="CheckEntry"/> has already judged the entry, and this
|
||
/// must not invent a second reason to refuse.
|
||
/// </summary>
|
||
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();
|
||
}
|
||
}
|
||
}
|
||
}
|