using System.Text; using Minint.Core.Models; using Minint.Core.Services; namespace Minint.Infrastructure.Serialization; /// /// Self-implemented binary reader/writer for the .minint container format. /// All multi-byte integers are little-endian. Strings are UTF-8 with a 1-byte length prefix. /// public sealed class MinintSerializer : IMinintSerializer { private static readonly byte[] Signature = "MININT"u8.ToArray(); private const ushort CurrentVersion = 1; private const int ReservedBytes = 8; private const int MaxNameLength = 255; #region Write public void Write(Stream stream, MinintContainer container) { ArgumentNullException.ThrowIfNull(stream); ArgumentNullException.ThrowIfNull(container); using var w = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true); WriteHeader(w, container); foreach (var doc in container.Documents) WriteDocument(w, doc, container.Width, container.Height); } private static void WriteHeader(BinaryWriter w, MinintContainer c) { w.Write(Signature); w.Write(CurrentVersion); w.Write((uint)c.Width); w.Write((uint)c.Height); w.Write((uint)c.Documents.Count); w.Write(new byte[ReservedBytes]); } private static void WriteDocument(BinaryWriter w, MinintDocument doc, int width, int height) { WritePrefixedString(w, doc.Name); w.Write(doc.FrameDelayMs); w.Write((uint)doc.Palette.Count); foreach (var color in doc.Palette) { w.Write(color.R); w.Write(color.G); w.Write(color.B); w.Write(color.A); } w.Write((uint)doc.Layers.Count); int byteWidth = doc.IndexByteWidth; foreach (var layer in doc.Layers) WriteLayer(w, layer, byteWidth, width * height); } private static void WriteLayer(BinaryWriter w, MinintLayer layer, int byteWidth, int pixelCount) { WritePrefixedString(w, layer.Name); w.Write(layer.IsVisible ? (byte)1 : (byte)0); w.Write(layer.Opacity); if (layer.Pixels.Length != pixelCount) throw new InvalidOperationException( $"Layer '{layer.Name}' has {layer.Pixels.Length} pixels, expected {pixelCount}."); for (int i = 0; i < pixelCount; i++) WriteIndex(w, layer.Pixels[i], byteWidth); } private static void WriteIndex(BinaryWriter w, int index, int byteWidth) { switch (byteWidth) { case 1: w.Write((byte)index); break; case 2: w.Write((ushort)index); break; case 3: w.Write((byte)(index & 0xFF)); w.Write((byte)((index >> 8) & 0xFF)); w.Write((byte)((index >> 16) & 0xFF)); break; case 4: w.Write(index); break; } } private static void WritePrefixedString(BinaryWriter w, string value) { var bytes = Encoding.UTF8.GetBytes(value); if (bytes.Length > MaxNameLength) throw new InvalidOperationException( $"String '{value}' exceeds max length of {MaxNameLength} UTF-8 bytes."); w.Write((byte)bytes.Length); w.Write(bytes); } #endregion #region Read public MinintContainer Read(Stream stream) { ArgumentNullException.ThrowIfNull(stream); using var r = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true); var (width, height, docCount) = ReadHeader(r); var container = new MinintContainer(width, height); for (int i = 0; i < docCount; i++) container.Documents.Add(ReadDocument(r, width, height)); return container; } private static (int Width, int Height, int DocCount) ReadHeader(BinaryReader r) { byte[] sig = ReadExact(r, Signature.Length, "file signature"); if (!sig.AsSpan().SequenceEqual(Signature)) throw new InvalidDataException( "Invalid file signature. Expected 'MININT'."); ushort version = r.ReadUInt16(); if (version != CurrentVersion) throw new InvalidDataException( $"Unsupported format version {version}. Only version {CurrentVersion} is supported."); uint width = r.ReadUInt32(); uint height = r.ReadUInt32(); uint docCount = r.ReadUInt32(); if (width == 0 || height == 0) throw new InvalidDataException("Width and height must be at least 1."); if (width > 65_536 || height > 65_536) throw new InvalidDataException( $"Dimensions {width}x{height} exceed maximum supported size (65536)."); if (docCount == 0) throw new InvalidDataException("Container must have at least 1 document."); byte[] reserved = ReadExact(r, ReservedBytes, "reserved bytes"); for (int i = 0; i < reserved.Length; i++) { if (reserved[i] != 0) break; // non-zero reserved bytes: tolerated for forward compat } return ((int)width, (int)height, (int)docCount); } private static MinintDocument ReadDocument(BinaryReader r, int width, int height) { string name = ReadPrefixedString(r); uint frameDelay = r.ReadUInt32(); uint paletteCount = r.ReadUInt32(); if (paletteCount == 0) throw new InvalidDataException("Palette must have at least 1 color."); var palette = new List((int)paletteCount); for (uint i = 0; i < paletteCount; i++) { byte cr = r.ReadByte(); byte cg = r.ReadByte(); byte cb = r.ReadByte(); byte ca = r.ReadByte(); palette.Add(new RgbaColor(cr, cg, cb, ca)); } int byteWidth = GetIndexByteWidth((int)paletteCount); uint layerCount = r.ReadUInt32(); if (layerCount == 0) throw new InvalidDataException($"Document '{name}' must have at least 1 layer."); var layers = new List((int)layerCount); int pixelCount = width * height; for (uint i = 0; i < layerCount; i++) layers.Add(ReadLayer(r, byteWidth, pixelCount, (int)paletteCount)); return new MinintDocument(name, frameDelay, palette, layers); } private static MinintLayer ReadLayer(BinaryReader r, int byteWidth, int pixelCount, int paletteCount) { string name = ReadPrefixedString(r); byte visByte = r.ReadByte(); if (visByte > 1) throw new InvalidDataException( $"Layer '{name}': invalid visibility flag {visByte} (expected 0 or 1)."); bool isVisible = visByte == 1; byte opacity = r.ReadByte(); var pixels = new int[pixelCount]; for (int i = 0; i < pixelCount; i++) { int idx = ReadIndex(r, byteWidth); if (idx < 0 || idx >= paletteCount) throw new InvalidDataException( $"Layer '{name}': pixel index {idx} at position {i} is out of palette range [0, {paletteCount})."); pixels[i] = idx; } return new MinintLayer(name, isVisible, opacity, pixels); } private static int ReadIndex(BinaryReader r, int byteWidth) { return byteWidth switch { 1 => r.ReadByte(), 2 => r.ReadUInt16(), 3 => r.ReadByte() | (r.ReadByte() << 8) | (r.ReadByte() << 16), 4 => r.ReadInt32(), _ => throw new InvalidDataException($"Invalid index byte width: {byteWidth}") }; } private static string ReadPrefixedString(BinaryReader r) { byte len = r.ReadByte(); if (len == 0) return string.Empty; byte[] bytes = ReadExact(r, len, "string data"); return Encoding.UTF8.GetString(bytes); } /// /// Reads exactly bytes or throws on premature EOF. /// private static byte[] ReadExact(BinaryReader r, int count, string context) { byte[] buf = r.ReadBytes(count); if (buf.Length < count) throw new InvalidDataException( $"Unexpected end of stream while reading {context} (expected {count} bytes, got {buf.Length})."); return buf; } #endregion #region Helpers /// /// Same logic as , /// usable when only the palette count is known (during deserialization). /// public static int GetIndexByteWidth(int paletteCount) => paletteCount switch { <= 255 => 1, <= 65_535 => 2, <= 16_777_215 => 3, _ => 4 }; #endregion }