Files
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgePng.cs
Claude efbd45685c feat(asset-bridge): the UOP animation reader, and 235 bodies the legacy path cannot see (Phase 4)
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
2026-09-11 04:55:11 -05:00

232 lines
8.2 KiB
C#

using System;
using System.IO;
using System.IO.Compression;
namespace Server.Custom.Bridge
{
/// <summary>
/// **A PNG encoder that does not go through GDI+** (docs/link/v8.md §4.4, §4.9 — phase 4).
///
/// <see cref="BridgeUop"/> decodes into a <c>ushort[]</c> of ARGB1555 rather than into a
/// <c>Bitmap</c>, which is the whole point of §4.4's note that the UOP reader is written
/// without <c>System.Drawing</c>: 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 <c>Bitmap.Save(…, ImageFormat.Png)</c> 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 <c>BridgeCatalog.ToPng</c> is left exactly as it is. It is measured, shipped,
/// and its input really is a <c>Bitmap</c> from the vendored decoder — a path that needs
/// GDI+ to produce the pixels in the first place, so encoding them without it buys nothing.
/// </summary>
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];
/// <summary>
/// ARGB1555 to an RGBA8 PNG with a transparent background.
///
/// The expansion is the same one <c>BridgeCatalog.ToPng</c> 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>(c &lt;&lt; 3) | (c &gt;&gt; 2)</c>, not a plain shift,
/// which would cap white at 248 and tint every sprite.
/// </summary>
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();
}
}
/// <summary>
/// A zlib stream around .NET Framework's raw-deflate-only <c>DeflateStream</c>: 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 <see cref="BridgeUop"/> reads one by hand — net48 exposes deflate and
/// calls it zlib, and the two are not the same format.
/// </summary>
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;
}
}
}