From cc5669a00a9f011f7530e7831890c351265d71cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=8B=D1=82=D0=BA=D0=BE=D0=B2=20=D0=A0=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD?= Date: Sun, 29 Mar 2026 15:41:42 +0300 Subject: [PATCH] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Serialization/MinintSerializer.cs | 267 ++++++++++++++++++ Minint/Program.cs | 139 ++++++++- 2 files changed, 399 insertions(+), 7 deletions(-) create mode 100644 Minint.Infrastructure/Serialization/MinintSerializer.cs diff --git a/Minint.Infrastructure/Serialization/MinintSerializer.cs b/Minint.Infrastructure/Serialization/MinintSerializer.cs new file mode 100644 index 0000000..17a31a0 --- /dev/null +++ b/Minint.Infrastructure/Serialization/MinintSerializer.cs @@ -0,0 +1,267 @@ +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 +} diff --git a/Minint/Program.cs b/Minint/Program.cs index 3b4b281..5acf56e 100644 --- a/Minint/Program.cs +++ b/Minint/Program.cs @@ -1,21 +1,146 @@ -using Avalonia; +using Avalonia; using System; +using System.Diagnostics; +using System.IO; +using Minint.Core.Models; +using Minint.Infrastructure.Serialization; namespace Minint; sealed class Program { - // Initialization code. Don't use any Avalonia, third-party APIs or any - // SynchronizationContext-reliant code before AppMain is called: things aren't initialized - // yet and stuff might break. [STAThread] - public static void Main(string[] args) => BuildAvaloniaApp() - .StartWithClassicDesktopLifetime(args); + public static void Main(string[] args) + { + // TODO: remove --test-roundtrip branch after Stage 3 verification + if (args.Length > 0 && args[0] == "--test-roundtrip") + { + RunRoundTripTest(); + return; + } + + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } - // Avalonia configuration, don't remove; also used by visual designer. public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UsePlatformDetect() .WithInterFont() .LogToTrace(); + + /// + /// TODO: temporary round-trip test — remove after Stage 3 verification. + /// Creates a container, serializes to .minint, deserializes, and verifies equality. + /// + private static void RunRoundTripTest() + { + Console.WriteLine("=== Minint Round-Trip Test ===\n"); + + var container = new MinintContainer(8, 4); + + var doc1 = container.AddNewDocument("Frame 1"); + doc1.FrameDelayMs = 200; + doc1.Palette.Add(new RgbaColor(255, 0, 0, 255)); // idx 1 = red + doc1.Palette.Add(new RgbaColor(0, 255, 0, 255)); // idx 2 = green + doc1.Palette.Add(new RgbaColor(0, 0, 255, 128)); // idx 3 = semi-transparent blue + var layer1 = doc1.Layers[0]; + for (int i = 0; i < layer1.Pixels.Length; i++) + layer1.Pixels[i] = i % 4; // cycle 0,1,2,3 + + doc1.Layers.Add(new MinintLayer("Overlay", container.PixelCount)); + var layer2 = doc1.Layers[1]; + layer2.Opacity = 128; + layer2.Pixels[0] = 3; + layer2.Pixels[5] = 2; + + var doc2 = container.AddNewDocument("Frame 2"); + doc2.FrameDelayMs = 150; + doc2.Palette.Add(new RgbaColor(255, 255, 0, 255)); // idx 1 = yellow + var layer3 = doc2.Layers[0]; + for (int i = 0; i < layer3.Pixels.Length; i++) + layer3.Pixels[i] = i % 2; + + Console.WriteLine($"Original: {container.Width}x{container.Height}, {container.Documents.Count} docs"); + Console.WriteLine($" Doc1: palette={doc1.Palette.Count} colors, layers={doc1.Layers.Count}, indexWidth={doc1.IndexByteWidth}"); + Console.WriteLine($" Doc2: palette={doc2.Palette.Count} colors, layers={doc2.Layers.Count}, indexWidth={doc2.IndexByteWidth}"); + + var serializer = new MinintSerializer(); + + using var ms = new MemoryStream(); + serializer.Write(ms, container); + byte[] data = ms.ToArray(); + Console.WriteLine($"\nSerialized: {data.Length} bytes"); + Console.WriteLine($" Signature: {System.Text.Encoding.ASCII.GetString(data, 0, 6)}"); + + ms.Position = 0; + var loaded = serializer.Read(ms); + + Assert(loaded.Width == container.Width, "Width mismatch"); + Assert(loaded.Height == container.Height, "Height mismatch"); + Assert(loaded.Documents.Count == container.Documents.Count, "Document count mismatch"); + + for (int d = 0; d < container.Documents.Count; d++) + { + var orig = container.Documents[d]; + var copy = loaded.Documents[d]; + Assert(copy.Name == orig.Name, $"Doc[{d}] name mismatch"); + Assert(copy.FrameDelayMs == orig.FrameDelayMs, $"Doc[{d}] frameDelay mismatch"); + Assert(copy.Palette.Count == orig.Palette.Count, $"Doc[{d}] palette count mismatch"); + + for (int c = 0; c < orig.Palette.Count; c++) + Assert(copy.Palette[c] == orig.Palette[c], $"Doc[{d}] palette[{c}] mismatch"); + + Assert(copy.Layers.Count == orig.Layers.Count, $"Doc[{d}] layer count mismatch"); + + for (int l = 0; l < orig.Layers.Count; l++) + { + var oLayer = orig.Layers[l]; + var cLayer = copy.Layers[l]; + Assert(cLayer.Name == oLayer.Name, $"Doc[{d}].Layer[{l}] name mismatch"); + Assert(cLayer.IsVisible == oLayer.IsVisible, $"Doc[{d}].Layer[{l}] visibility mismatch"); + Assert(cLayer.Opacity == oLayer.Opacity, $"Doc[{d}].Layer[{l}] opacity mismatch"); + Assert(cLayer.Pixels.Length == oLayer.Pixels.Length, $"Doc[{d}].Layer[{l}] pixel count mismatch"); + + for (int p = 0; p < oLayer.Pixels.Length; p++) + Assert(cLayer.Pixels[p] == oLayer.Pixels[p], + $"Doc[{d}].Layer[{l}].Pixels[{p}] mismatch: expected {oLayer.Pixels[p]}, got {cLayer.Pixels[p]}"); + } + } + + Console.WriteLine("\n✓ All assertions passed — round-trip is correct!"); + + // Test invalid signature + Console.Write("\nTest: invalid signature... "); + data[0] = (byte)'X'; + try + { + serializer.Read(new MemoryStream(data)); + Console.WriteLine("FAIL (no exception)"); + } + catch (InvalidDataException) + { + Console.WriteLine("OK (InvalidDataException)"); + } + data[0] = (byte)'M'; // restore + + // Test truncated stream + Console.Write("Test: truncated stream... "); + try + { + serializer.Read(new MemoryStream(data, 0, 10)); + Console.WriteLine("FAIL (no exception)"); + } + catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException) + { + Console.WriteLine($"OK ({ex.GetType().Name})"); + } + + Console.WriteLine("\n=== All tests passed ==="); + } + + private static void Assert(bool condition, string message) + { + if (!condition) + throw new Exception($"Assertion failed: {message}"); + } }