Files
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeAssetValidator.cs
wtclaude a9bd18e48e feat(asset-bridge): the 73 bodies action 0 could not see, and the ceiling that makes looking safe (Phase 6)
The catalogue asked every body for action 0 and reported the rest absent. 73 of
this client's bodies have no art there and real art deeper — body 820's first
drawn action is 23, and it is a horse — so they rendered as text on the bestiary.
The catalogue now falls back to the first action that has art, and the key names
that action (`body/820/a23`). 1,022 -> 1,095 rows.

Walking the action axis is the one thing that can walk off the end of a body's
slots, and the slots after a body's band are the NEXT BODY'S. Measured here: one
action past the band, 643 of 795 legacy bodies return a fully validated picture
and 452 of those are byte-identical to body+1's action 0 (body 1 action 22 is an
ettin; body 3's is an imp, both confirmed by rendering them). Phase 0's validator
cannot catch that — the record is real — so the ceiling refuses the ADDRESS, in
ResolveAnimation where every caller already goes.

The ceiling is the index banding, never `Animations.GetAnimLength`: for a body
reaching file type 5 as id 34 that function answers 22 while the arithmetic gives
13, and the difference is nine actions of another creature's art.

A fetch serves only the key the catalogue chose for that body. `body/820/a0` and
`body/400/a2` come back `unsupported` with the chosen action alongside, never by
decoding what was asked for.

`EXTRACTOR_VERSION` 2 -> 3 (unchanged input, a different answer). Protocol stays
8 — `action` on a manifest/fetch row is additive.

Deep frame keys and the bulk-fill switch that §16 planned for this phase were
NOT built: the site displays still pictures, and a complete one-direction
animation set measures 174,453 frames / 281.5 MB against no consumer (docs
§11.2, org lead 2026-09-11).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 01:09:38 -05:00

864 lines
35 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 &lt; 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 &gt; delta</c>, <c>xOffset + xRun &gt; 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;
}
int actions = ActionsOf(translated, fileType);
if (action >= actions)
{
// §4.10, measured in phase 6: this is the never-sweep rule again, one axis over.
// A body's slots are contiguous and the next body's begin immediately after them,
// so `index + action * 5` past the ceiling addresses ANOTHER BODY'S action — a
// real record, at a real offset, that every check below passes. Measured on this
// client: of 795 legacy bodies, 643 return a fully validated picture one action
// past their band and **452 of those are byte-identical to body+1's action 0**.
// Body 1 action 22 is an ettin; body 3 action 22 is an imp. Nothing downstream
// can tell, which is why the refusal has to be here.
reason = "body " + body + " has " + actions + " actions in file type " + fileType
+ "; action " + action + " belongs to the next body";
return false;
}
index = AnimIndexOf(translated, fileType) + (action * 5) + direction;
return true;
}
/// <summary>
/// How many actions the index reserves for a body — the only safe ceiling, and it is
/// the banding rather than the library's own answer.
///
/// <c>Animations.GetAnimLength</c> exists and looks like the right source. It is not:
/// for a body reaching file type 5 as id 34 it answers **22** while
/// <see cref="AnimIndexOf"/> puts that body in the 65-slot band, which is **13**. The
/// two disagree on exactly one body of this client (reached by translation from body
/// 276), and taking the larger number is nine actions of somebody else's art. So the
/// count is derived from the same arithmetic that produces the offset, in the same
/// file, where the two cannot drift apart.
/// </summary>
public static bool ActionCount(int body, out int actions, out int fileType, out string reason)
{
reason = null;
actions = 0;
fileType = 0;
if (body <= 0)
{
reason = "body " + body + " is not addressable";
return false;
}
int translated = body;
int hue = 0;
try
{
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)
{
reason = "bodyconv sends body " + body + " to file type " + fileType
+ ", which this client does not have";
return false;
}
actions = ActionsOf(translated, fileType);
return true;
}
/// <summary>
/// The banding of <see cref="AnimIndexOf"/>, read as an action count: a body's slots
/// are five directions per action, so the band size divided by five is how many
/// actions it owns.
/// </summary>
private static int ActionsOf(int body, int fileType)
{
return SlotsOf(body, fileType) / 5;
}
/// <summary>
/// How many index slots <see cref="AnimIndexOf"/>'s arithmetic gives this body. The
/// bands are transcribed there and their sizes here, from the same source and in the
/// same order, because a ceiling that disagrees with an offset is worse than no
/// ceiling at all.
/// </summary>
private static int SlotsOf(int body, int fileType)
{
switch (fileType)
{
case 2:
return body < 200 ? 110 : 65;
case 3:
if (body < 300)
return 65;
return body < 400 ? 110 : 175;
case 5:
// Body 34's exclusion again — it is in the second band here, so it owns 13
// actions and not 22. This is the one body `GetAnimLength` is wrong about.
if (body < 200 && body != 34)
return 110;
return body < 400 ? 65 : 175;
default: // 1 and 4 share their banding
if (body < 200)
return 110;
return body < 400 ? 65 : 175;
}
}
/// <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();
}
}
}
}