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