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; } } }