diff --git a/overlay/Scripts/Custom/Bridge/BridgeAssets.cs b/overlay/Scripts/Custom/Bridge/BridgeAssets.cs
index d809aa6..9bde8cb 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeAssets.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeAssets.cs
@@ -76,8 +76,14 @@ namespace Server.Custom.Bridge
/// Bump it whenever extraction changes what it produces from unchanged input. It is
/// the same rule spawnAtlasSource.js's `PARSER_VERSION` follows, and it applies
/// here more rather than less: this pipeline derives far more from far less.
+ ///
+ /// **2** — phase 4 (§4.3, §4.9). The catalogue now falls back to
+ /// AnimationFrame*.uop for bodies the legacy path has nothing for, which on a
+ /// stock client is 235 new sprites and two of them player-character bodies; and the
+ /// player-body set no longer carries ghost ids. Every client file is byte-identical
+ /// and the answer is different, which is precisely what this number exists to say.
///
- public const int EXTRACTOR_VERSION = 1;
+ public const int EXTRACTOR_VERSION = 2;
// ── the one slot (§3.2) ──────────────────────────────────────────────────────────────
@@ -325,6 +331,8 @@ namespace Server.Custom.Bridge
"anim3.idx", "anim3.mul",
"anim4.idx", "anim4.mul",
"anim5.idx", "anim5.mul",
+ "animationframe1.uop", "animationframe2.uop", "animationframe3.uop",
+ "animationframe4.uop", "animationframe6.uop",
"body.def", "bodyconv.def",
"hues.mul",
"verdata.mul"
@@ -425,12 +433,22 @@ namespace Server.Custom.Bridge
{
try
{
- return Files.GetFilePath(name);
+ string path = Files.GetFilePath(name);
+
+ if (path != null)
+ return path;
}
catch
{
- return null;
+ // Ultima's lookup reads the registry on Windows; a host where that throws still
+ // has the directories ServUO itself booted from, which is what the fallback uses.
}
+
+ // `Ultima.Files` has a fixed table of file names that predates UOP animations, so it
+ // answers null for every `AnimationFrame*.uop` however present they are (§4.3). Phase
+ // 4 added those to this list, so the fallback is what makes their size, mtime and hash
+ // reachable at all.
+ return BridgeUop.FindClientFile(name);
}
private static long ToUnixMs(DateTime utc)
diff --git a/overlay/Scripts/Custom/Bridge/BridgeCatalog.cs b/overlay/Scripts/Custom/Bridge/BridgeCatalog.cs
index 00b3ad0..20f19fd 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeCatalog.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeCatalog.cs
@@ -19,7 +19,9 @@ namespace Server.Custom.Bridge
/// listing and a character sheet render. Everything deeper — every action, every frame —
/// is the same addressing scheme at a deeper key, fetched on demand in a later phase; this
/// is the set that is worth importing before anything asks for it, because on this
- /// machine's client it is **787 sprites at about a kilobyte each**.
+ /// machine's client it is **1,022 sprites at about a kilobyte each** — 787 out of the
+ /// legacy `anim*.mul` files and, since phase 4, 235 more out of `AnimationFrame*.uop`,
+ /// which ServUO's vendored decoder never opens (§4.3, §4.9).
///
/// Two request kinds, which are §6's two stages for assets rather than for sources:
///
@@ -70,6 +72,17 @@ namespace Server.Custom.Bridge
/// from the library's reused stream buffer. That is 357 of the 1,144 bodies the library
/// claims on a stock client, and importing them would have written 357 duplicate
/// portraits whose subject depended on the order this walk happened to run in.
+ ///
+ /// ── **The UOP fallback, and why it cannot reintroduce that** ──
+ ///
+ /// Phase 4 added beneath the legacy reader: a body the vendored
+ /// path has nothing for is looked for in the UOP packages before it is reported absent.
+ /// That is where two of the six player-character bodies live — `Bodyconv.def` sends
+ /// gargoyles 666 and 667 to `anim5`, at an index past the end of `anim5.idx` — and 233
+ /// other bodies besides. It cannot produce a wrong picture the way a legacy sweep would,
+ /// because a UOP entry is addressed by the hash of a name that contains the body id and
+ /// the payload then declares that id again, which
+ /// checks.
///
public static class BridgeCatalog
{
@@ -103,6 +116,15 @@ namespace Server.Custom.Bridge
public byte[] Png;
public int Width;
public int Height;
+
+ ///
+ /// Which reader produced it: `legacy` for ServUO's vendored Animations over
+ /// anim*.mul, `uop` for phase 4's own reader over
+ /// AnimationFrame*.uop (§4.3, §4.9). On the wire so that an operator
+ /// looking at a wrong picture can tell which half of the extractor to doubt, and
+ /// so the acceptance walk can prove the fallback fired at all.
+ ///
+ public string Source;
}
private sealed class Catalog
@@ -243,6 +265,7 @@ namespace Server.Custom.Bridge
item.Append(",\"height\":").Append(sprite.Height.ToString(CultureInfo.InvariantCulture));
item.Append(",\"body\":").Append(sprite.Body.ToString(CultureInfo.InvariantCulture));
item.Append(",\"direction\":").Append(sprite.Direction.ToString(CultureInfo.InvariantCulture));
+ item.Append(",\"source\":\"").Append(sprite.Source).Append('"');
item.Append('}');
if (!page.TryAdd(item.ToString(), "b:" + body.ToString(CultureInfo.InvariantCulture)))
@@ -446,6 +469,7 @@ namespace Server.Custom.Bridge
item.Append(",\"height\":").Append(sprite.Height.ToString(CultureInfo.InvariantCulture));
item.Append(",\"body\":").Append(sprite.Body.ToString(CultureInfo.InvariantCulture));
item.Append(",\"direction\":").Append(sprite.Direction.ToString(CultureInfo.InvariantCulture));
+ item.Append(",\"source\":\"").Append(sprite.Source).Append('"');
item.Append(",\"png\":\"").Append(Convert.ToBase64String(sprite.Png)).Append("\"}");
return item.ToString();
@@ -474,6 +498,34 @@ namespace Server.Custom.Bridge
? BridgeConfig.AssetPlayerDirection
: BridgeConfig.AssetCreatureDirection;
+ // Legacy first, always. The vendored decoder is what 787 of this client's bodies come
+ // out of, it is what phase 3 measured, and the UOP packages hold a different and
+ // mostly disjoint set (measured: of the 244 bodies they carry, 8 also have legacy
+ // art). So this is a fallback rather than a choice, and no body changes reader while
+ // a client sits still.
+ Sprite sprite = ResolveLegacy(key, readers, body, direction)
+ ?? ResolveUop(key, readers, body, direction);
+
+ if (sprite == null)
+ return null;
+
+ lock (_sync)
+ {
+ if (!catalog.ByKey.ContainsKey(key))
+ {
+ catalog.ByKey[key] = sprite;
+ catalog.Order.Add(sprite);
+ }
+
+ return catalog.ByKey[key];
+ }
+ }
+
+ ///
+ /// ServUO's vendored Animations over anim*.mul, behind §4.5's validator.
+ ///
+ private static Sprite ResolveLegacy(string key, Readers readers, int body, int direction)
+ {
int fileType, at;
string reason;
@@ -505,11 +557,9 @@ namespace Server.Custom.Bridge
if (!reader.AnimationSane(index, at, 1, out reason))
return null;
- Sprite sprite;
-
try
{
- sprite = Decode(key, body, direction, fileType);
+ return Decode(key, body, direction, fileType);
}
catch (Exception e)
{
@@ -517,20 +567,93 @@ namespace Server.Custom.Bridge
body, e.GetType().Name, e.Message);
return null;
}
+ }
- if (sprite == null)
- return null;
+ ///
+ /// Phase 4's own reader over AnimationFrame*.uop (§4.3, §4.9), for the bodies
+ /// the legacy path has nothing for.
+ ///
+ /// On this machine's client that is **235 bodies** the catalogue could not reach
+ /// before, including the two gargoyle player bodies — `Bodyconv.def` sends 666 and 667
+ /// to `anim5`, at an index past the end of `anim5.idx`, and the art has been in
+ /// `AnimationFrame3.uop` all along.
+ ///
+ /// Nothing here can produce §4.8's failure. A legacy index is addressed by position,
+ /// so a wrong lookup is another creature's picture; a UOP entry is addressed by the
+ /// hash of a name carrying the body id, and the payload repeats that id in its own
+ /// header for to check. A miss is a miss.
+ ///
+ private static Sprite ResolveUop(string key, Readers readers, int body, int direction)
+ {
+ ulong hash = BridgeUop.HashOf(body, CatalogAction);
- lock (_sync)
+ byte[] payload = null;
+ string reason = null;
+
+ foreach (int n in BridgeUop.Packages)
{
- if (!catalog.ByKey.ContainsKey(key))
+ BridgeUop.Package package = readers.Package(n);
+
+ if (package == null || !package.Has(hash))
+ continue;
+
+ if (!package.TryRead(hash, out payload, out reason))
{
- catalog.ByKey[key] = sprite;
- catalog.Order.Add(sprite);
+ Console.WriteLine("[Bridge] catalogue: body {0} in {1}: {2}",
+ body, BridgeUop.PackageName(n), reason);
+ return null;
}
- return catalog.ByKey[key];
+ break;
}
+
+ if (payload == null)
+ return null;
+
+ BridgeUop.Group group;
+
+ if (!BridgeUop.Group.TryOpen(payload, body, out group, out reason))
+ {
+ Console.WriteLine("[Bridge] catalogue: body {0} uop: {1}", body, reason);
+ return null;
+ }
+
+ int frame = group.DirectionAt(direction);
+
+ if (frame < 0)
+ return null;
+
+ BridgeUop.Pixels pixels;
+ bool empty;
+
+ if (!group.TryDecode(frame, out pixels, out empty, out reason))
+ {
+ // A 0x0 frame is no art rather than damage — the vendored decoder returns early on
+ // exactly the same condition — so it is absent, silently. Anything else is a
+ // record this reader refused, and that is worth a line.
+ if (!empty)
+ Console.WriteLine("[Bridge] catalogue: body {0} uop: {1}", body, reason);
+
+ return null;
+ }
+
+ byte[] png = BridgePng.FromArgb1555(pixels.Argb1555, pixels.Width, pixels.Height);
+
+ if (png == null)
+ return null;
+
+ return new Sprite
+ {
+ Key = key,
+ Body = body,
+ Direction = direction,
+ FileType = 0,
+ Png = png,
+ Width = pixels.Width,
+ Height = pixels.Height,
+ Sha256 = Hash(png),
+ Source = "uop"
+ };
}
private static Sprite Decode(string key, int body, int direction, int fileType)
@@ -564,7 +687,8 @@ namespace Server.Custom.Bridge
Png = png,
Width = bitmap.Width,
Height = bitmap.Height,
- Sha256 = Hash(png)
+ Sha256 = Hash(png),
+ Source = "legacy"
};
}
@@ -674,21 +798,32 @@ namespace Server.Custom.Bridge
///
/// Asked of the shard, never hardcoded.
///
- /// Every registered race carries four body ids, and a shard that calls `RegisterRace`
- /// adds ids no table of ours could contain. Even on stock ServUO a hardcoded list
- /// would already be wrong in a way that is easy to miss: `RaceDefinitions.cs` passes
- /// the gargoyle's ghost bodies in the OPPOSITE order to the other two races.
+ /// Every registered race carries its living male and female body ids, and a shard that
+ /// calls `RegisterRace` adds ids no table of ours could contain — which is the whole
+ /// argument against a hardcoded list, and it was never hypothetical: stock ServUO's
+ /// own `RaceDefinitions.cs` passes the gargoyle's ghost bodies in the OPPOSITE order
+ /// to the other two races.
///
/// This set is the whole of what §5.1 gives direction 0 — head-on, facing the viewer,
/// because a character is a portrait and should look at you. Everything else takes
/// direction 1, the front three-quarter, because head-on is the least legible view of
/// a four-legged creature: a wolf seen from the front is a dark blob.
+ ///
+ /// **Ghost bodies are deliberately not in it** (§5.2, decided 2026-09-10 in phase 4).
+ /// A race declares four ids and two of them are its ghosts, and no UO client has art
+ /// for any of them: 402/403 and 694/695 read `lookup -1` in `anim.idx`, 607/608 read
+ /// `length 0` — the §4.8 shape, where the library hands back the previously-decoded
+ /// body's picture — and none of the six is in any `AnimationFrame*.uop`, which phase 4
+ /// established by claiming all 10,724 entries of the five packages with the one name
+ /// scheme. Listing them only advertised keys that cannot exist. A shard whose client
+ /// does ship ghost art still gets it: the body is catalogued like any other, at
+ /// direction 1 rather than 0.
///
///
/// Cached for the life of the process: `RegisterRace` runs at Configure time, before
/// anything on this plane can be asked a question, so the set cannot change under a
/// running shard. Rebuilding it per body would enumerate every race 2,047 times per
- /// scan to answer a question whose answer is twelve integers.
+ /// scan to answer a question whose answer is six integers.
///
private static HashSet _playerBodies;
@@ -710,8 +845,6 @@ namespace Server.Custom.Bridge
set.Add(race.MaleBody);
set.Add(race.FemaleBody);
- set.Add(race.MaleGhostBody);
- set.Add(race.FemaleGhostBody);
}
}
catch (Exception e)
@@ -829,12 +962,22 @@ namespace Server.Custom.Bridge
.Append(':').Append(BridgeConfig.AssetPlayerDirection)
.Append(':').Append(BridgeConfig.AssetCreatureDirection);
+ var paths = new List();
+
for (int fileType = 1; fileType <= 5; fileType++)
+ paths.Add(BridgeAssetValidator.AnimDataPath(fileType));
+
+ // Since phase 4 the catalogue's bytes depend on the UOP packages too — 235 of its
+ // bodies come out of them — so patching one has to change the catalogue id, exactly as
+ // patching an anim*.mul does. Leaving them out would let an operator replace a
+ // gargoyle and have an Update find nothing to do.
+ foreach (int n in BridgeUop.Packages)
+ paths.Add(BridgeUop.PackagePath(n));
+
+ foreach (string path in paths)
{
sb.Append('|');
- string path = BridgeAssetValidator.AnimDataPath(fileType);
-
if (path == null)
continue;
@@ -883,10 +1026,11 @@ namespace Server.Custom.Bridge
}
///
- /// The five anim files' index and record readers, opened for one reply and closed with
- /// it. Holding them across replies would keep handles on the operator's client files
- /// for as long as the cache lives, for no gain: opening five is microseconds and a
- /// page decodes hundreds of sprites through them.
+ /// The five anim files' index and record readers — and, since phase 4, the five UOP
+ /// packages beside them — opened for one reply and closed with it. Holding them across
+ /// replies would keep handles on the operator's client files for as long as the cache
+ /// lives, for no gain: opening them is microseconds and a page decodes hundreds of
+ /// sprites through them.
///
private sealed class Readers : IDisposable
{
@@ -896,6 +1040,9 @@ namespace Server.Custom.Bridge
private readonly long[] _length = new long[6];
private readonly bool[] _open = new bool[6];
+ private readonly Dictionary _packages =
+ new Dictionary();
+
public readonly long VerdataLength;
public Readers()
@@ -958,8 +1105,42 @@ namespace Server.Custom.Bridge
return fileType >= 1 && fileType <= 5 ? _reader[fileType] : null;
}
+ ///
+ /// One AnimationFrame*.uop, opened on first use. A package this client does
+ /// not ship is a null that is cached as one: the miss must not be re-resolved and
+ /// re-opened once per body across a 2,047-body walk.
+ ///
+ public BridgeUop.Package Package(int n)
+ {
+ BridgeUop.Package package;
+
+ if (_packages.TryGetValue(n, out package))
+ return package;
+
+ package = BridgeUop.Package.Open(BridgeUop.PackagePath(n));
+
+ _packages[n] = package;
+
+ return package;
+ }
+
public void Dispose()
{
+ foreach (var package in _packages.Values)
+ {
+ if (package == null)
+ continue;
+
+ try
+ {
+ package.Dispose();
+ }
+ catch
+ {
+ // Closing a read-only handle. Nothing useful is left to do.
+ }
+ }
+
for (int i = 1; i <= 5; i++)
{
if (_reader[i] == null)
diff --git a/overlay/Scripts/Custom/Bridge/BridgePng.cs b/overlay/Scripts/Custom/Bridge/BridgePng.cs
new file mode 100644
index 0000000..47b89e1
--- /dev/null
+++ b/overlay/Scripts/Custom/Bridge/BridgePng.cs
@@ -0,0 +1,231 @@
+using System;
+using System.IO;
+using System.IO.Compression;
+
+namespace Server.Custom.Bridge
+{
+ ///
+ /// **A PNG encoder that does not go through GDI+** (docs/link/v8.md §4.4, §4.9 — phase 4).
+ ///
+ /// decodes into a ushort[] of ARGB1555 rather than into a
+ /// Bitmap, which is the whole point of §4.4's note that the UOP reader is written
+ /// without System.Drawing: libgdiplus was archived in March 2025, and every line of
+ /// extraction that does not depend on it is a line that survives its absence. That leaves
+ /// the encode, and Bitmap.Save(…, ImageFormat.Png) is GDI+ too — so this is the
+ /// other half.
+ ///
+ /// It is deliberately the smallest thing that produces a correct file: 8-bit RGBA, one
+ /// IDAT, filter type 0 on every row. No interlacing, no palette, no colour-type choice, no
+ /// filter heuristics. A sprite is a few hundred pixels across and the bytes go straight
+ /// into a base64 field; the compression difference between this and a tuned encoder is a
+ /// rounding error against the wire, and every knob not turned is a way this cannot be
+ /// subtly wrong.
+ ///
+ /// Phase 3's BridgeCatalog.ToPng is left exactly as it is. It is measured, shipped,
+ /// and its input really is a Bitmap from the vendored decoder — a path that needs
+ /// GDI+ to produce the pixels in the first place, so encoding them without it buys nothing.
+ ///
+ public static class BridgePng
+ {
+ private static readonly byte[] Signature =
+ {
+ 0x89, (byte)'P', (byte)'N', (byte)'G', 0x0D, 0x0A, 0x1A, 0x0A
+ };
+
+ private static readonly uint[] CrcTable = BuildCrcTable();
+
+ private static readonly byte[] Empty = new byte[0];
+
+ ///
+ /// ARGB1555 to an RGBA8 PNG with a transparent background.
+ ///
+ /// The expansion is the same one BridgeCatalog.ToPng documents and for the same
+ /// reason: alpha bit clear is fully transparent, and each 5-bit channel is widened by
+ /// repeating its high bits — (c << 3) | (c >> 2), not a plain shift,
+ /// which would cap white at 248 and tint every sprite.
+ ///
+ public static byte[] FromArgb1555(ushort[] pixels, int width, int height)
+ {
+ if (pixels == null || width <= 0 || height <= 0)
+ return null;
+
+ if ((long)width * height > pixels.Length)
+ return null;
+
+ // One filter byte per row, then RGBA per pixel. This is the PNG "raw" stream, the
+ // thing that gets deflated. Bounded by the caller's dimension ceiling
+ // (BridgeAssetValidator.MaxArtDimension), so the arithmetic cannot overflow an int —
+ // the check is here anyway, because that ceiling lives in another file.
+ long size = (((long)width * 4) + 1) * height;
+
+ if (size > Int32.MaxValue / 2)
+ return null;
+
+ var raw = new byte[size];
+
+ int at = 0;
+
+ for (int y = 0; y < height; y++)
+ {
+ raw[at++] = 0; // filter: None
+
+ int row = y * width;
+
+ for (int x = 0; x < width; x++)
+ {
+ int p = pixels[row + x];
+
+ if ((p & 0x8000) == 0)
+ {
+ at += 4; // already zero: transparent black
+ continue;
+ }
+
+ int r = (p >> 10) & 0x1F;
+ int g = (p >> 5) & 0x1F;
+ int b = p & 0x1F;
+
+ raw[at++] = (byte)((r << 3) | (r >> 2));
+ raw[at++] = (byte)((g << 3) | (g >> 2));
+ raw[at++] = (byte)((b << 3) | (b >> 2));
+ raw[at++] = 0xFF;
+ }
+ }
+
+ using (var ms = new MemoryStream(raw.Length / 2))
+ {
+ ms.Write(Signature, 0, Signature.Length);
+
+ var header = new byte[13];
+
+ WriteBigEndian(header, 0, (uint)width);
+ WriteBigEndian(header, 4, (uint)height);
+
+ header[8] = 8; // bit depth
+ header[9] = 6; // colour type: truecolour with alpha
+ header[10] = 0; // compression: deflate
+ header[11] = 0; // filter method 0
+ header[12] = 0; // no interlace
+
+ WriteChunk(ms, "IHDR", header, 0, header.Length);
+
+ byte[] deflated = Zlib(raw);
+
+ WriteChunk(ms, "IDAT", deflated, 0, deflated.Length);
+ WriteChunk(ms, "IEND", Empty, 0, 0);
+
+ return ms.ToArray();
+ }
+ }
+
+ ///
+ /// A zlib stream around .NET Framework's raw-deflate-only DeflateStream: the
+ /// two-byte header PNG requires, the deflate data, and the adler32 trailer computed
+ /// here because nothing in the framework will do it. Written by hand for exactly the
+ /// same reason reads one by hand — net48 exposes deflate and
+ /// calls it zlib, and the two are not the same format.
+ ///
+ private static byte[] Zlib(byte[] data)
+ {
+ using (var ms = new MemoryStream(data.Length / 2))
+ {
+ // CMF 0x78 (deflate, 32K window) and FLG 0x9C (default level, no dictionary):
+ // 0x789C is the pair whose value is divisible by 31, which is the check a decoder
+ // applies.
+ ms.WriteByte(0x78);
+ ms.WriteByte(0x9C);
+
+ using (var deflate = new DeflateStream(ms, CompressionMode.Compress, true))
+ deflate.Write(data, 0, data.Length);
+
+ uint adler = Adler32(data);
+
+ ms.WriteByte((byte)(adler >> 24));
+ ms.WriteByte((byte)(adler >> 16));
+ ms.WriteByte((byte)(adler >> 8));
+ ms.WriteByte((byte)adler);
+
+ return ms.ToArray();
+ }
+ }
+
+ private static void WriteChunk(Stream to, string type, byte[] data, int offset, int length)
+ {
+ var head = new byte[8];
+
+ WriteBigEndian(head, 0, (uint)length);
+
+ head[4] = (byte)type[0];
+ head[5] = (byte)type[1];
+ head[6] = (byte)type[2];
+ head[7] = (byte)type[3];
+
+ to.Write(head, 0, head.Length);
+
+ if (length > 0)
+ to.Write(data, offset, length);
+
+ // The CRC covers the type and the data, and not the length.
+ uint crc = Crc32(head, 4, 4, 0xFFFFFFFF);
+
+ if (length > 0)
+ crc = Crc32(data, offset, length, crc);
+
+ crc ^= 0xFFFFFFFF;
+
+ var tail = new byte[4];
+
+ WriteBigEndian(tail, 0, crc);
+
+ to.Write(tail, 0, tail.Length);
+ }
+
+ private static void WriteBigEndian(byte[] into, int at, uint value)
+ {
+ into[at] = (byte)(value >> 24);
+ into[at + 1] = (byte)(value >> 16);
+ into[at + 2] = (byte)(value >> 8);
+ into[at + 3] = (byte)value;
+ }
+
+ private static uint[] BuildCrcTable()
+ {
+ var table = new uint[256];
+
+ for (uint n = 0; n < 256; n++)
+ {
+ uint c = n;
+
+ for (int k = 0; k < 8; k++)
+ c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1;
+
+ table[n] = c;
+ }
+
+ return table;
+ }
+
+ private static uint Crc32(byte[] data, int offset, int length, uint crc)
+ {
+ for (int i = 0; i < length; i++)
+ crc = CrcTable[(crc ^ data[offset + i]) & 0xFF] ^ (crc >> 8);
+
+ return crc;
+ }
+
+ private static uint Adler32(byte[] data)
+ {
+ const uint Mod = 65521;
+
+ uint a = 1, b = 0;
+
+ for (int i = 0; i < data.Length; i++)
+ {
+ a = (a + data[i]) % Mod;
+ b = (b + a) % Mod;
+ }
+
+ return (b << 16) | a;
+ }
+ }
+}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeUop.cs b/overlay/Scripts/Custom/Bridge/BridgeUop.cs
new file mode 100644
index 0000000..da7c41c
--- /dev/null
+++ b/overlay/Scripts/Custom/Bridge/BridgeUop.cs
@@ -0,0 +1,823 @@
+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
+{
+ ///
+ /// **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 Ultima.Animations reads legacy anim*.mul only — it
+ /// constructs its five FileIndexes with the four-argument constructor, which passes
+ /// uopFile: null, so AnimationFrame*.uop 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 BodyConverter.Convert and is
+ /// never guessed, because asking another anim*.mul 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**
+ /// (build/animationlegacyframe/000666/00.bin). 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 Ultima'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 System.Drawing, deliberately** ──
+ ///
+ /// §4.4 states it: libgdiplus was archived in March 2025, and the long-term argument for
+ /// moving extraction off System.Drawing is that a Linux shard depends on an
+ /// unmaintained library to see a sprite. This decoder writes ARGB1555 into a
+ /// ushort[] of its own and 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+.)
+ ///
+ public static class BridgeUop
+ {
+ /// 'MYP\0' — the Mythic package magic, as FileIndex reads it.
+ private const int PackageMagic = 0x50594D;
+
+ /// 'AMOU' — the animation payload's own magic, little-endian.
+ private const int PayloadMagic = 0x554F4D41;
+
+ /// Each frame record opens with its own palette: 0x100 ARGB1555 entries.
+ private const int PaletteBytes = 0x100 * 2;
+
+ /// The frame table's row width: group, frame id, two unknowns, pixel offset.
+ private const int FrameRowBytes = 16;
+
+ /// One block-chain record: offset, three lengths, hash, adler32, flag.
+ private const int BlockEntryBytes = 34;
+
+ /// The xor Frame applies to every run header, and so must this.
+ private const int DoubleXor = (0x200 << 22) | (0x200 << 12);
+
+ ///
+ /// 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.
+ ///
+ public const int MaxPayloadBytes = 64 * 1024 * 1024;
+
+ ///
+ /// 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.
+ ///
+ private const int MaxEntries = 1 << 20;
+
+ /// The five packages this client ships. There is no AnimationFrame5.uop.
+ private static readonly int[] PackageNumbers = { 1, 2, 3, 4, 6 };
+
+ public static IEnumerable Packages
+ {
+ get { return PackageNumbers; }
+ }
+
+ public static string PackageName(int n)
+ {
+ return "AnimationFrame" + n.ToString(CultureInfo.InvariantCulture) + ".uop";
+ }
+
+ ///
+ /// Where a UOP animation package lives.
+ ///
+ /// Ultima.Files.GetFilePath cannot answer this: its table of known client files
+ /// predates UOP animations and contains no AnimationFrame*.uop 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 — Files.RootDir first, then
+ /// Core.DataDirectories, 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".
+ ///
+ /// 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.
+ ///
+ public static string PackagePath(int n)
+ {
+ return FindClientFile(PackageName(n));
+ }
+
+ private static readonly object _pathSync = new object();
+
+ private static readonly Dictionary _paths =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Finds a client file Ultima.Files 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.
+ ///
+ 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 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 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;
+ }
+
+ ///
+ /// The name a body's action file is stored under, hashed the way the container indexes
+ /// it. Ultima.FileIndex.HashFileName 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// One opened AnimationFrame*.uop: 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.
+ ///
+ public sealed class Package : IDisposable
+ {
+ private readonly Dictionary _entries;
+ private readonly FileStream _stream;
+
+ public readonly string Path;
+
+ private Package(string path, FileStream stream, Dictionary entries)
+ {
+ Path = path;
+ _stream = stream;
+ _entries = entries;
+ }
+
+ public int Count
+ {
+ get { return _entries.Count; }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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();
+
+ 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);
+ }
+
+ ///
+ /// The bytes behind one entry, decompressed. False with a reason is the ordinary
+ /// answer for "this package does not hold it".
+ ///
+ 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;
+ }
+
+ ///
+ /// 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
+ /// DeflateStream 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.
+ ///
+ 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 ──────────────────────────────────────────────────────────────────────
+
+ /// One decoded frame: ARGB1555 in our own array, no Bitmap anywhere.
+ public sealed class Pixels
+ {
+ public int Width;
+ public int Height;
+ public int CenterX;
+ public int CenterY;
+ public ushort[] Argb1555;
+ }
+
+ ///
+ /// One action of one body — every direction of it, concatenated.
+ ///
+ /// The legacy files address a frame as index + action * 5 + direction; a UOP
+ /// group file holds the whole action in one record and the directions are equal-length
+ /// runs inside its frame table. So 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// Which frame of this action faces a given direction.
+ ///
+ /// Five directions share the action's frames equally, so direction *d* starts at
+ /// d * (FrameCount / 5). 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// Decodes one frame, bounding every read against the record and every write
+ /// against the bitmap.
+ ///
+ /// The run loop is Ultima.Frame's, with the two bounds it does not have.
+ /// Frame writes through a LockBits 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 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.
+ ///
+ 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;
+ }
+ }
+ }
+}