Этап 3
This commit is contained in:
267
Minint.Infrastructure/Serialization/MinintSerializer.cs
Normal file
267
Minint.Infrastructure/Serialization/MinintSerializer.cs
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Minint.Core.Models;
|
||||||
|
using Minint.Core.Services;
|
||||||
|
|
||||||
|
namespace Minint.Infrastructure.Serialization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<RgbaColor>((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<MinintLayer>((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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads exactly <paramref name="count"/> bytes or throws on premature EOF.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Same logic as <see cref="MinintDocument.IndexByteWidth"/>,
|
||||||
|
/// usable when only the palette count is known (during deserialization).
|
||||||
|
/// </summary>
|
||||||
|
public static int GetIndexByteWidth(int paletteCount) => paletteCount switch
|
||||||
|
{
|
||||||
|
<= 255 => 1,
|
||||||
|
<= 65_535 => 2,
|
||||||
|
<= 16_777_215 => 3,
|
||||||
|
_ => 4
|
||||||
|
};
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -1,21 +1,146 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using Minint.Core.Models;
|
||||||
|
using Minint.Infrastructure.Serialization;
|
||||||
|
|
||||||
namespace Minint;
|
namespace Minint;
|
||||||
|
|
||||||
sealed class Program
|
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]
|
[STAThread]
|
||||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
public static void Main(string[] args)
|
||||||
.StartWithClassicDesktopLifetime(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()
|
public static AppBuilder BuildAvaloniaApp()
|
||||||
=> AppBuilder.Configure<App>()
|
=> AppBuilder.Configure<App>()
|
||||||
.UsePlatformDetect()
|
.UsePlatformDetect()
|
||||||
.WithInterFont()
|
.WithInterFont()
|
||||||
.LogToTrace();
|
.LogToTrace();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TODO: temporary round-trip test — remove after Stage 3 verification.
|
||||||
|
/// Creates a container, serializes to .minint, deserializes, and verifies equality.
|
||||||
|
/// </summary>
|
||||||
|
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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user