ServUO's vendored `Ultima.Animations` reads legacy `anim*.mul` only -- it builds its
five FileIndexes with the constructor that passes `uopFile: null` -- so everything a
modern client moved into `AnimationFrame*.uop` is invisible to it. This adds the one
reader docs/link/v8.md 4.3 reserved for phase 4, and wires it in as a fallback beneath
the legacy path.
What it actually recovers is not what the plan expected, and the difference was
measured before any of this was written:
- Of the EIGHT player-character bodies 4.8 assigned to this phase, only TWO are in
the client at all: gargoyles 666 and 667, in AnimationFrame3.uop. The six ghost
bodies (human 402/403, elf 607/608, gargoyle 694/695) are in no package. The five
packages hold 10,724 entries between them and the
`build/animationlegacyframe/%06d/%02d.bin` name scheme claims every one, so there
is no other naming they could be hiding under.
- The same fallback reaches 233 further bodies the catalogue had nothing for, so the
working set goes from 787 to 1,022 (57 Monster, 26 Animal, 97 Equipment, 50
unlisted, 3 Human, 2 Sea). The catalogue was already 366 Equipment bodies before
this, so its character does not change.
Decided with the org lead before building: the fallback applies to every body rather
than to player bodies alone; ghost ids leave the player-body set entirely (no client
has art for any of them, and listing them only advertised keys that cannot exist); the
UOP path gets its own PNG encoder rather than Bitmap.Save; and a host without
libgdiplus keeps the flat NO_IMAGING refusal rather than serving a partial catalogue.
Three things about the reader:
- It is not the never-sweep rule being broken. That rule exists because a legacy
index is addressed by POSITION, so asking the wrong file returns a giant spider
that decodes cleanly. A UOP entry is addressed by the hash of a name containing the
body id, and the payload declares that id again for `Group.TryOpen` to check, so a
hit is proof of identity. Measured: no hash appears in two packages.
- Validate-as-we-go replaces validate-before-calling, because here we ARE the
library: the block chain is bounded against the file, the record against the file,
the inflated length against the declared one, the frame table against the payload,
and every run header against both the record's remaining bytes and the bitmap it
writes into. Measured the way 4.5 was -- across every UOP body on a stock client it
refuses nothing that carries art. The one body it refuses (286) declares a 0x0
frame, which the vendored decoder treats as absent too.
- No System.Drawing anywhere in it, which is what 4.4 promised: the decode fills a
ushort[] of ARGB1555 and BridgePng encodes that directly (zlib around net48's
raw-deflate-only DeflateStream, CRC32, one IDAT, filter 0).
EXTRACTOR_VERSION 1 -> 2: every client file is byte-identical and the answer is
different, which is exactly what that number exists to say. The UOP packages join
`assets.sources` and the catalogue id, so patching one is drift; `Ultima.Files` cannot
resolve them (its table predates UOP animations) so BridgeUop.FindClientFile does it,
case-insensitively by enumeration for Linux hosts. Manifest and fetch rows carry a new
`source` field (`legacy` / `uop`).
Protocol stays 8 -- no message shape changed, only fields added.
Measured on the live rig (real sidecar, real ServUO, this machine's client):
1,022 rows in ONE page, 1,409 ms cold; six player bodies, all six with art for the
first time (400/401/605/606 legacy, 666/667 uop), all at direction 0; 1,016 at
direction 1; the six ghost bodies absent; 45 duplicate-hash groups of which exactly one
is new, bodies 1531/1532, two distinct records whose first frames match -- legitimate,
and provable only because each payload declares its own body id. The gargoyles were
rendered and looked at, because 4.3's whole point is that this failure mode produces
confident, wrong pictures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
824 lines
33 KiB
C#
824 lines
33 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Globalization;
|
||
using System.IO;
|
||
using System.IO.Compression;
|
||
using System.Text;
|
||
|
||
using Ultima;
|
||
|
||
namespace Server.Custom.Bridge
|
||
{
|
||
/// <summary>
|
||
/// **The UOP animation reader** (docs/link/v8.md §4.3, §4.9 — protocol 8, phase 4): the
|
||
/// second and last decoder this protocol writes rather than calls.
|
||
///
|
||
/// ServUO's vendored <c>Ultima.Animations</c> reads legacy <c>anim*.mul</c> only — it
|
||
/// constructs its five <c>FileIndex</c>es with the four-argument constructor, which passes
|
||
/// <c>uopFile: null</c>, so <c>AnimationFrame*.uop</c> is never opened. Everything a
|
||
/// modern client added there is invisible to it. This class opens those packages directly.
|
||
///
|
||
/// ── **Why this is not the never-sweep rule being broken** ──
|
||
///
|
||
/// §4.3's rule is that a body's file type comes from <c>BodyConverter.Convert</c> and is
|
||
/// never guessed, because asking another <c>anim*.mul</c> for an index it does not own
|
||
/// returns a decodable picture of something else — a giant spider on the gargoyle page.
|
||
/// That rule exists because a legacy index is addressed **by position**: nothing in the
|
||
/// file says which body a record belongs to.
|
||
///
|
||
/// A UOP package is addressed by the **hash of a name that contains the body id**
|
||
/// (<c>build/animationlegacyframe/000666/00.bin</c>). Looking in all five packages for one
|
||
/// hash is therefore not a sweep — a hit is proof of identity, not a coincidence of
|
||
/// position, and the payload repeats the body id in its own header for us to check against.
|
||
/// Measured on this machine's client: 10,724 entries across the five packages, every one
|
||
/// of them claimed by that name scheme, and **no hash appears in more than one package**.
|
||
///
|
||
/// ── **Validate as we go, because here we are the library** ──
|
||
///
|
||
/// §4.5's rule is "validate before calling", and it exists because <c>Ultima</c>'s decoders
|
||
/// take their bounds from the file they are reading. Nothing about this code can be
|
||
/// validated from outside — it *is* the decode — so the same discipline appears as a bound
|
||
/// on every read: the block chain against the file length, an entry's record against the
|
||
/// file, the inflated length against the declared one, the frame table against the
|
||
/// payload, and every run header against **both** the record's remaining bytes and the
|
||
/// bitmap it is writing into. A record that fails any of them is reported absent and no
|
||
/// pixel of it is kept.
|
||
///
|
||
/// Measured the same way §4.5 was, which is the only measurement that says the boundary is
|
||
/// in the right place: across every UOP body on a stock client the walk refused **nothing**
|
||
/// that carries art, and the one body it does refuse (286) declares a 0×0 frame, which the
|
||
/// legacy decoder treats as absent too.
|
||
///
|
||
/// ── **No <c>System.Drawing</c>, deliberately** ──
|
||
///
|
||
/// §4.4 states it: libgdiplus was archived in March 2025, and the long-term argument for
|
||
/// moving extraction off <c>System.Drawing</c> is that a Linux shard depends on an
|
||
/// unmaintained library to see a sprite. This decoder writes ARGB1555 into a
|
||
/// <c>ushort[]</c> of its own and <see cref="BridgePng"/> encodes that directly, so the
|
||
/// door stays open. (Phase 4 does not walk through it: the catalogue still refuses the
|
||
/// whole family when imaging is unavailable, because most of it genuinely needs GDI+.)
|
||
/// </summary>
|
||
public static class BridgeUop
|
||
{
|
||
/// <summary>'MYP\0' — the Mythic package magic, as <c>FileIndex</c> reads it.</summary>
|
||
private const int PackageMagic = 0x50594D;
|
||
|
||
/// <summary>'AMOU' — the animation payload's own magic, little-endian.</summary>
|
||
private const int PayloadMagic = 0x554F4D41;
|
||
|
||
/// <summary>Each frame record opens with its own palette: 0x100 ARGB1555 entries.</summary>
|
||
private const int PaletteBytes = 0x100 * 2;
|
||
|
||
/// <summary>The frame table's row width: group, frame id, two unknowns, pixel offset.</summary>
|
||
private const int FrameRowBytes = 16;
|
||
|
||
/// <summary>One block-chain record: offset, three lengths, hash, adler32, flag.</summary>
|
||
private const int BlockEntryBytes = 34;
|
||
|
||
/// <summary>The xor <c>Frame</c> applies to every run header, and so must this.</summary>
|
||
private const int DoubleXor = (0x200 << 22) | (0x200 << 12);
|
||
|
||
/// <summary>
|
||
/// A ceiling on a declared decompressed payload. One group file is a whole action for
|
||
/// one body across every direction; the largest on this machine's client is body
|
||
/// 1248's at 4.3 MB, so this is two orders of magnitude of headroom over real data and
|
||
/// still small enough that a corrupt length cannot ask for the host's memory.
|
||
/// </summary>
|
||
public const int MaxPayloadBytes = 64 * 1024 * 1024;
|
||
|
||
/// <summary>
|
||
/// A ceiling on the block chain. Five packages hold 10,724 entries between them; this
|
||
/// bounds a cyclic or corrupt chain into a refusal rather than a hang.
|
||
/// </summary>
|
||
private const int MaxEntries = 1 << 20;
|
||
|
||
/// <summary>The five packages this client ships. There is no AnimationFrame5.uop.</summary>
|
||
private static readonly int[] PackageNumbers = { 1, 2, 3, 4, 6 };
|
||
|
||
public static IEnumerable<int> Packages
|
||
{
|
||
get { return PackageNumbers; }
|
||
}
|
||
|
||
public static string PackageName(int n)
|
||
{
|
||
return "AnimationFrame" + n.ToString(CultureInfo.InvariantCulture) + ".uop";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Where a UOP animation package lives.
|
||
///
|
||
/// <c>Ultima.Files.GetFilePath</c> cannot answer this: its table of known client files
|
||
/// predates UOP animations and contains no <c>AnimationFrame*.uop</c> entry, so it
|
||
/// returns null for every one of them. So the lookup is done here, against the same
|
||
/// directories ServUO itself resolved at boot — <c>Files.RootDir</c> first, then
|
||
/// <c>Core.DataDirectories</c>, which §1 is built on.
|
||
///
|
||
/// The comparison is case-insensitive **by enumeration** rather than by trying one
|
||
/// spelling. On Windows either would work; on a Linux shard host the client directory
|
||
/// is case-sensitive and the file may be shipped as `AnimationFrame1.uop`,
|
||
/// `animationframe1.uop` or anything between, which is exactly the shape of bug that
|
||
/// presents as "the gargoyles import on my machine and not on the server".
|
||
///
|
||
/// <see cref="FindClientFile"/> is the general form, and `assets.sources` uses it for
|
||
/// the same reason: a file Ultima's table predates has to be found some other way.
|
||
/// </summary>
|
||
public static string PackagePath(int n)
|
||
{
|
||
return FindClientFile(PackageName(n));
|
||
}
|
||
|
||
private static readonly object _pathSync = new object();
|
||
|
||
private static readonly Dictionary<string, string> _paths =
|
||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||
|
||
/// <summary>
|
||
/// Finds a client file <c>Ultima.Files</c> has never heard of.
|
||
///
|
||
/// Only successful answers are cached: a file an operator copies in while the shard is
|
||
/// up should be found by the next import, and nothing here is hot enough for a
|
||
/// negative cache to be worth that.
|
||
/// </summary>
|
||
public static string FindClientFile(string name)
|
||
{
|
||
if (String.IsNullOrEmpty(name))
|
||
return null;
|
||
|
||
lock (_pathSync)
|
||
{
|
||
string cached;
|
||
|
||
if (_paths.TryGetValue(name, out cached))
|
||
return cached;
|
||
}
|
||
|
||
foreach (string dir in Directories())
|
||
{
|
||
if (String.IsNullOrEmpty(dir))
|
||
continue;
|
||
|
||
try
|
||
{
|
||
if (!Directory.Exists(dir))
|
||
continue;
|
||
|
||
string direct = Path.Combine(dir, name);
|
||
string hit = File.Exists(direct) ? direct : null;
|
||
|
||
if (hit == null)
|
||
{
|
||
foreach (string found in Directory.GetFiles(dir))
|
||
{
|
||
if (String.Equals(Path.GetFileName(found), name,
|
||
StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
hit = found;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hit == null)
|
||
continue;
|
||
|
||
lock (_pathSync)
|
||
_paths[name] = hit;
|
||
|
||
return hit;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Console.WriteLine("[Bridge] uop: cannot look in {0}: {1}", dir, e.Message);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static IEnumerable<string> Directories()
|
||
{
|
||
string root = null;
|
||
|
||
try
|
||
{
|
||
root = Files.RootDir;
|
||
}
|
||
catch
|
||
{
|
||
// Ultima's static initialiser reads the registry on Windows. A host where that
|
||
// throws still has Core.DataDirectories, which is the path ServUO actually booted
|
||
// from.
|
||
}
|
||
|
||
if (!String.IsNullOrEmpty(root))
|
||
yield return root;
|
||
|
||
List<string> dirs = null;
|
||
|
||
try
|
||
{
|
||
dirs = Core.DataDirectories;
|
||
}
|
||
catch
|
||
{
|
||
// Same reasoning; an empty list is a real answer and the caller reports absent.
|
||
}
|
||
|
||
if (dirs == null)
|
||
yield break;
|
||
|
||
foreach (string dir in dirs)
|
||
yield return dir;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The name a body's action file is stored under, hashed the way the container indexes
|
||
/// it. <c>Ultima.FileIndex.HashFileName</c> is pure arithmetic over a string — no file
|
||
/// is touched and no decoder is entered — so this is the one place phase 4 leans on
|
||
/// the vendored code, and it leans on it precisely so that our lookup cannot disagree
|
||
/// with the container's own.
|
||
/// </summary>
|
||
public static ulong HashOf(int body, int action)
|
||
{
|
||
string name = String.Format(CultureInfo.InvariantCulture,
|
||
"build/animationlegacyframe/{0:D6}/{1:D2}.bin", body, action);
|
||
|
||
return FileIndex.HashFileName(name);
|
||
}
|
||
|
||
// ── the container ────────────────────────────────────────────────────────────────────
|
||
|
||
private struct Entry
|
||
{
|
||
public long At;
|
||
public int CompressedLength;
|
||
public int DecompressedLength;
|
||
public short Flag;
|
||
}
|
||
|
||
/// <summary>
|
||
/// One opened <c>AnimationFrame*.uop</c>: its entry table in memory, its bytes on
|
||
/// demand. Opening one is a single pass over the block chain — 10,724 entries across
|
||
/// all five on this client — and the handle is held for the life of a reply, exactly
|
||
/// like the legacy readers next to it.
|
||
/// </summary>
|
||
public sealed class Package : IDisposable
|
||
{
|
||
private readonly Dictionary<ulong, Entry> _entries;
|
||
private readonly FileStream _stream;
|
||
|
||
public readonly string Path;
|
||
|
||
private Package(string path, FileStream stream, Dictionary<ulong, Entry> entries)
|
||
{
|
||
Path = path;
|
||
_stream = stream;
|
||
_entries = entries;
|
||
}
|
||
|
||
public int Count
|
||
{
|
||
get { return _entries.Count; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads the block chain, refusing anything that does not fit inside the file.
|
||
/// Returns null — never throws — because a client that ships a truncated package
|
||
/// is an ordinary thing to survive, not an error to raise.
|
||
/// </summary>
|
||
public static Package Open(string path)
|
||
{
|
||
if (String.IsNullOrEmpty(path))
|
||
return null;
|
||
|
||
FileStream stream = null;
|
||
|
||
try
|
||
{
|
||
stream = new FileStream(path, FileMode.Open, FileAccess.Read,
|
||
FileShare.ReadWrite);
|
||
|
||
long length = stream.Length;
|
||
|
||
var entries = new Dictionary<ulong, Entry>();
|
||
|
||
using (var br = new BinaryReader(stream, Encoding.UTF8, true))
|
||
{
|
||
if (length < 28 || br.ReadInt32() != PackageMagic)
|
||
{
|
||
Console.WriteLine("[Bridge] uop: {0} is not a Mythic package", path);
|
||
stream.Dispose();
|
||
return null;
|
||
}
|
||
|
||
br.ReadInt32(); // version
|
||
br.ReadUInt32(); // signature
|
||
|
||
long nextBlock = br.ReadInt64();
|
||
|
||
br.ReadInt32(); // block capacity
|
||
br.ReadInt32(); // declared file count
|
||
|
||
while (nextBlock > 0)
|
||
{
|
||
// A block header is 12 bytes. Anything that does not leave room for
|
||
// one is a corrupt or cyclic chain, and this is where it stops.
|
||
if (nextBlock + 12 > length)
|
||
break;
|
||
|
||
stream.Seek(nextBlock, SeekOrigin.Begin);
|
||
|
||
int filesCount = br.ReadInt32();
|
||
long following = br.ReadInt64();
|
||
|
||
if (filesCount < 0
|
||
|| nextBlock + 12 + ((long)filesCount * BlockEntryBytes) > length)
|
||
{
|
||
break;
|
||
}
|
||
|
||
for (int i = 0; i < filesCount; i++)
|
||
{
|
||
long offset = br.ReadInt64();
|
||
int headerLength = br.ReadInt32();
|
||
int compressedLength = br.ReadInt32();
|
||
int decompressedLength = br.ReadInt32();
|
||
ulong hash = br.ReadUInt64();
|
||
|
||
br.ReadUInt32(); // adler32
|
||
|
||
short flag = br.ReadInt16();
|
||
|
||
if (offset <= 0 || headerLength < 0 || compressedLength <= 0)
|
||
continue;
|
||
|
||
if (decompressedLength <= 0 || decompressedLength > MaxPayloadBytes)
|
||
continue;
|
||
|
||
long at = offset + headerLength;
|
||
|
||
// The check FileIndex.Seek is missing, in the place it matters
|
||
// here too: that the record ENDS inside the file, not merely that
|
||
// it starts inside it (§4.5).
|
||
if (at < 0 || at + compressedLength > length)
|
||
continue;
|
||
|
||
if (entries.Count >= MaxEntries)
|
||
break;
|
||
|
||
// First writer wins. Nothing on this client produces a collision
|
||
// — measured: no hash appears in two packages, and none twice in
|
||
// one — and if a patched client ever did, taking the first is the
|
||
// answer that does not depend on chain order.
|
||
if (!entries.ContainsKey(hash))
|
||
entries[hash] = new Entry
|
||
{
|
||
At = at,
|
||
CompressedLength = compressedLength,
|
||
DecompressedLength = decompressedLength,
|
||
Flag = flag
|
||
};
|
||
}
|
||
|
||
if (following <= nextBlock)
|
||
break; // a chain that does not move forward is a loop
|
||
|
||
nextBlock = following;
|
||
}
|
||
}
|
||
|
||
return new Package(path, stream, entries);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Console.WriteLine("[Bridge] uop: cannot open {0}: {1}: {2}",
|
||
path, e.GetType().Name, e.Message);
|
||
|
||
if (stream != null)
|
||
{
|
||
try
|
||
{
|
||
stream.Dispose();
|
||
}
|
||
catch
|
||
{
|
||
// Closing a read-only handle.
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|
||
|
||
public bool Has(ulong hash)
|
||
{
|
||
return _entries.ContainsKey(hash);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The bytes behind one entry, decompressed. False with a reason is the ordinary
|
||
/// answer for "this package does not hold it".
|
||
/// </summary>
|
||
public bool TryRead(ulong hash, out byte[] payload, out string reason)
|
||
{
|
||
payload = null;
|
||
reason = null;
|
||
|
||
Entry entry;
|
||
|
||
if (!_entries.TryGetValue(hash, out entry))
|
||
{
|
||
reason = "not in " + System.IO.Path.GetFileName(Path);
|
||
return false;
|
||
}
|
||
|
||
byte[] raw;
|
||
|
||
try
|
||
{
|
||
_stream.Seek(entry.At, SeekOrigin.Begin);
|
||
|
||
raw = new byte[entry.CompressedLength];
|
||
|
||
if (!Fill(_stream, raw, raw.Length))
|
||
{
|
||
// The §4.5 failure, in our own code this time: a short read that nobody
|
||
// checked is how the library ends up decoding the previous asset.
|
||
reason = "record is shorter than the index claims";
|
||
return false;
|
||
}
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
reason = "read failed: " + e.GetType().Name;
|
||
return false;
|
||
}
|
||
|
||
if (entry.Flag != 1)
|
||
{
|
||
if (raw.Length != entry.DecompressedLength)
|
||
{
|
||
reason = "stored record is " + raw.Length + " bytes, not the declared "
|
||
+ entry.DecompressedLength;
|
||
return false;
|
||
}
|
||
|
||
payload = raw;
|
||
return true;
|
||
}
|
||
|
||
return TryInflate(raw, entry.DecompressedLength, out payload, out reason);
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
try
|
||
{
|
||
_stream.Dispose();
|
||
}
|
||
catch
|
||
{
|
||
// Closing a read-only handle. Nothing useful is left to do.
|
||
}
|
||
}
|
||
}
|
||
|
||
private static bool Fill(Stream stream, byte[] into, int count)
|
||
{
|
||
int read = 0;
|
||
|
||
while (read < count)
|
||
{
|
||
int n = stream.Read(into, read, count - read);
|
||
|
||
if (n <= 0)
|
||
return false;
|
||
|
||
read += n;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// zlib, which .NET Framework 4.8 does not expose — only raw deflate. The two-byte
|
||
/// zlib header is checked and skipped rather than assumed, because handing a
|
||
/// <c>DeflateStream</c> a stream that is not deflate produces garbage as readily as an
|
||
/// exception, and the trailing adler32 is left to the length check below: a stream
|
||
/// that inflates to exactly the declared number of bytes did not silently truncate.
|
||
/// </summary>
|
||
private static bool TryInflate(byte[] raw, int declared, out byte[] payload, out string reason)
|
||
{
|
||
payload = null;
|
||
reason = null;
|
||
|
||
if (raw.Length < 3)
|
||
{
|
||
reason = "compressed record is too short to be zlib";
|
||
return false;
|
||
}
|
||
|
||
int cmf = raw[0];
|
||
int flg = raw[1];
|
||
|
||
if ((cmf & 0x0F) != 8 || (((cmf << 8) + flg) % 31) != 0 || (flg & 0x20) != 0)
|
||
{
|
||
reason = "compressed record is not a zlib stream";
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
var output = new byte[declared];
|
||
|
||
using (var source = new MemoryStream(raw, 2, raw.Length - 2, false))
|
||
using (var inflate = new DeflateStream(source, CompressionMode.Decompress))
|
||
{
|
||
int read = 0;
|
||
|
||
while (read < declared)
|
||
{
|
||
int n = inflate.Read(output, read, declared - read);
|
||
|
||
if (n <= 0)
|
||
break;
|
||
|
||
read += n;
|
||
}
|
||
|
||
if (read != declared)
|
||
{
|
||
reason = "inflated " + read + " bytes, not the declared " + declared;
|
||
return false;
|
||
}
|
||
|
||
// One more byte would mean the record is longer than its own header says,
|
||
// which is a different file from the one we were promised.
|
||
if (inflate.ReadByte() != -1)
|
||
{
|
||
reason = "inflated past the declared " + declared + " bytes";
|
||
return false;
|
||
}
|
||
}
|
||
|
||
payload = output;
|
||
return true;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
reason = "inflate failed: " + e.GetType().Name;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ── the payload ──────────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>One decoded frame: ARGB1555 in our own array, no <c>Bitmap</c> anywhere.</summary>
|
||
public sealed class Pixels
|
||
{
|
||
public int Width;
|
||
public int Height;
|
||
public int CenterX;
|
||
public int CenterY;
|
||
public ushort[] Argb1555;
|
||
}
|
||
|
||
/// <summary>
|
||
/// One action of one body — every direction of it, concatenated.
|
||
///
|
||
/// The legacy files address a frame as <c>index + action * 5 + direction</c>; a UOP
|
||
/// group file holds the whole action in one record and the directions are equal-length
|
||
/// runs inside its frame table. So <see cref="DirectionAt"/> is where "direction 1" is
|
||
/// turned into a frame number, and it is integer division exactly as the reference
|
||
/// implementations do it — see the note there for the nine bodies where that matters.
|
||
/// </summary>
|
||
public sealed class Group
|
||
{
|
||
private readonly byte[] _buf;
|
||
private readonly int _dataStart;
|
||
|
||
public readonly int FrameCount;
|
||
public readonly int Body;
|
||
|
||
private Group(byte[] buf, int body, int frameCount, int dataStart)
|
||
{
|
||
_buf = buf;
|
||
Body = body;
|
||
FrameCount = frameCount;
|
||
_dataStart = dataStart;
|
||
}
|
||
|
||
public static bool TryOpen(byte[] buf, int expectedBody, out Group group, out string reason)
|
||
{
|
||
group = null;
|
||
reason = null;
|
||
|
||
if (buf == null || buf.Length < 40)
|
||
{
|
||
reason = "payload is too short to carry a header";
|
||
return false;
|
||
}
|
||
|
||
if (BitConverter.ToInt32(buf, 0) != PayloadMagic)
|
||
{
|
||
reason = "payload is not an AMOU animation record";
|
||
return false;
|
||
}
|
||
|
||
int body = BitConverter.ToInt32(buf, 12);
|
||
|
||
// The container said which body this is, by the name it was stored under; the
|
||
// payload says it again. They agree on every record of this client, and the day
|
||
// they do not is the day something is being read that was not asked for.
|
||
if (body != expectedBody)
|
||
{
|
||
reason = "payload declares body " + body + ", not " + expectedBody;
|
||
return false;
|
||
}
|
||
|
||
int frameCount = BitConverter.ToInt32(buf, 32);
|
||
int dataStart = BitConverter.ToInt32(buf, 36);
|
||
|
||
if (frameCount <= 0 || frameCount > BridgeAssetValidator.MaxAnimFrames)
|
||
{
|
||
reason = "payload declares " + frameCount + " frames";
|
||
return false;
|
||
}
|
||
|
||
if (dataStart < 40 || dataStart > buf.Length)
|
||
{
|
||
reason = "frame table starts at " + dataStart + " of " + buf.Length;
|
||
return false;
|
||
}
|
||
|
||
if ((long)dataStart + ((long)frameCount * FrameRowBytes) > buf.Length)
|
||
{
|
||
reason = "frame table of " + frameCount + " rows runs past the record";
|
||
return false;
|
||
}
|
||
|
||
group = new Group(buf, body, frameCount, dataStart);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Which frame of this action faces a given direction.
|
||
///
|
||
/// Five directions share the action's frames equally, so direction *d* starts at
|
||
/// <c>d * (FrameCount / 5)</c>. On nine of this client's 244 UOP bodies the frame
|
||
/// count is **not** a multiple of five (41, 42, 46…), and integer division then
|
||
/// lands a direction or so early in the run. That is what ClassicUO does, it is
|
||
/// the right trade, and the reason is §4.8's: the failure being guarded against is
|
||
/// a picture of the **wrong creature**, and this cannot produce one — the worst
|
||
/// case is the right creature at a slightly different angle, on nine bodies, where
|
||
/// refusing them instead would lose nine creatures outright.
|
||
/// </summary>
|
||
public int DirectionAt(int direction)
|
||
{
|
||
int perDirection = FrameCount / 5;
|
||
|
||
if (perDirection <= 0)
|
||
return direction == 0 ? 0 : -1;
|
||
|
||
if (direction < 0 || direction > 4)
|
||
return -1;
|
||
|
||
int at = direction * perDirection;
|
||
|
||
return at < FrameCount ? at : -1;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Decodes one frame, bounding every read against the record and every write
|
||
/// against the bitmap.
|
||
///
|
||
/// The run loop is <c>Ultima.Frame</c>'s, with the two bounds it does not have.
|
||
/// <c>Frame</c> writes through a <c>LockBits</c> pointer whose origin comes from
|
||
/// two signed shorts in the file and never checks where a run lands; here a run
|
||
/// that would leave the bitmap, or read past the record, refuses the frame. Across
|
||
/// every UOP body on a stock client that refuses nothing that carries art.
|
||
///
|
||
/// A 0×0 frame returns false with <paramref name="empty"/> set: the legacy decoder
|
||
/// treats that as no art rather than as damage, and so must this, or body 286
|
||
/// would be logged as a defect on every scan.
|
||
/// </summary>
|
||
public bool TryDecode(int index, out Pixels pixels, out bool empty, out string reason)
|
||
{
|
||
pixels = null;
|
||
empty = false;
|
||
reason = null;
|
||
|
||
if (index < 0 || index >= FrameCount)
|
||
{
|
||
reason = "frame " + index + " of " + FrameCount;
|
||
return false;
|
||
}
|
||
|
||
int row = _dataStart + (index * FrameRowBytes);
|
||
|
||
long at = (long)row + (uint)BitConverter.ToInt32(_buf, row + 12);
|
||
|
||
if (at < 0 || at + PaletteBytes + 8 > _buf.Length)
|
||
{
|
||
reason = "frame " + index + " points outside the record";
|
||
return false;
|
||
}
|
||
|
||
int pixelAt = (int)at;
|
||
|
||
int centerX = BitConverter.ToInt16(_buf, pixelAt + PaletteBytes);
|
||
int centerY = BitConverter.ToInt16(_buf, pixelAt + PaletteBytes + 2);
|
||
int width = BitConverter.ToUInt16(_buf, pixelAt + PaletteBytes + 4);
|
||
int height = BitConverter.ToUInt16(_buf, pixelAt + PaletteBytes + 6);
|
||
|
||
if (width <= 0 || height <= 0)
|
||
{
|
||
empty = true;
|
||
reason = "frame " + index + " is " + width + "x" + height;
|
||
return false;
|
||
}
|
||
|
||
if (width > BridgeAssetValidator.MaxArtDimension
|
||
|| height > BridgeAssetValidator.MaxArtDimension)
|
||
{
|
||
reason = "frame " + index + " declares " + width + "x" + height;
|
||
return false;
|
||
}
|
||
|
||
var palette = new ushort[0x100];
|
||
|
||
for (int i = 0; i < palette.Length; i++)
|
||
{
|
||
// The library's own xor: the stored entry has its alpha bit clear and every
|
||
// palette colour is opaque. A pixel no run covers stays zero, which is how a
|
||
// sprite keeps its transparent background.
|
||
palette[i] = (ushort)(BitConverter.ToUInt16(_buf, pixelAt + (i * 2)) ^ 0x8000);
|
||
}
|
||
|
||
var canvas = new ushort[width * height];
|
||
|
||
int p = pixelAt + PaletteBytes + 8;
|
||
|
||
int xBase = centerX - 0x200;
|
||
int yBase = (centerY + height) - 0x200;
|
||
|
||
while (true)
|
||
{
|
||
if (p + 4 > _buf.Length)
|
||
{
|
||
reason = "frame " + index + " has no terminator inside the record";
|
||
return false;
|
||
}
|
||
|
||
int header = BitConverter.ToInt32(_buf, p);
|
||
p += 4;
|
||
|
||
if (header == 0x7FFF7FFF)
|
||
break;
|
||
|
||
header ^= DoubleXor;
|
||
|
||
int x = ((header >> 22) & 0x3FF) + xBase;
|
||
int y = ((header >> 12) & 0x3FF) + yBase;
|
||
int run = header & 0xFFF;
|
||
|
||
if (run == 0)
|
||
continue;
|
||
|
||
if (p + run > _buf.Length)
|
||
{
|
||
reason = "frame " + index + " has a run past the end of the record";
|
||
return false;
|
||
}
|
||
|
||
if (y < 0 || y >= height || x < 0 || x + run > width)
|
||
{
|
||
reason = "frame " + index + " has a run at " + x + "," + y + " of "
|
||
+ run + " outside " + width + "x" + height;
|
||
return false;
|
||
}
|
||
|
||
int cursor = (y * width) + x;
|
||
|
||
for (int i = 0; i < run; i++)
|
||
canvas[cursor + i] = palette[_buf[p + i]];
|
||
|
||
p += run;
|
||
}
|
||
|
||
pixels = new Pixels
|
||
{
|
||
Width = width,
|
||
Height = height,
|
||
CenterX = centerX,
|
||
CenterY = centerY,
|
||
Argb1555 = canvas
|
||
};
|
||
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
}
|