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
864 lines
35 KiB
C#
864 lines
35 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;
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|
||
}
|
||
}
|