diff --git a/project/code/Images/GifDecoder.cs b/project/code/Images/GifDecoder.cs new file mode 100644 index 0000000..635e9f1 --- /dev/null +++ b/project/code/Images/GifDecoder.cs @@ -0,0 +1,349 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Numerics; +using System.Text; +using KeepersCompound.LGS; + +namespace KeepersCompound.Images; + +public class GifDecoder +{ + // TODO: I can drop all these structs and shit lol + + + private record Header + { + public string Signature; + public ushort LogicalScreenWidth; + public ushort LogicalScreenHeight; + public bool HasGlobalColorTable; + public byte BitsPerColorChannel; // Seemingly unused lol + public bool IsGlobalColorTableSorted; + public int GlobalColorTableSize; + public byte BackgroundColorIndex; + public float PixelAspectRatio; // Ratio of width over height + + public Header(BinaryReader reader) + { + Signature = reader.ReadNullString(6); + if (Signature != "GIF87a" && Signature != "GIF89a") + { + throw new InvalidDataException("File signature does not match GIF spec"); + } + LogicalScreenWidth = reader.ReadUInt16(); + LogicalScreenHeight = reader.ReadUInt16(); + var flags = reader.ReadByte(); + HasGlobalColorTable = ((flags >> 7) & 0x1) != 0; + // Godot.GD.Print($"HALLO WE HAVE GLOBAL TABLE? {HasGlobalColorTable}"); + BitsPerColorChannel = (byte)(((flags >> 4) & 0x7) + 1); + IsGlobalColorTableSorted = ((flags >> 3) & 0x1) != 0; + GlobalColorTableSize = (int)Math.Pow(2, (flags & 0x7) + 1); + // Godot.GD.Print($"Global colour size? {GlobalColorTableSize}"); + BackgroundColorIndex = reader.ReadByte(); + var rawAspectRatio = reader.ReadByte(); + if (rawAspectRatio == 0) + { + PixelAspectRatio = 1.0f; + } + else + { + PixelAspectRatio = (15 + rawAspectRatio) / 64.0f; + } + } + } + + private record GraphicsControl + { + public int TransparencyIndex; + + public GraphicsControl(BinaryReader reader) + { + var blockSize = reader.ReadByte(); + if (blockSize != 4) + { + throw new InvalidDataException($"Graphics Control block size should be 4. Found: {blockSize}"); + } + + // The only flag we care about is transparency + var flags = reader.ReadByte(); + var transparency = (flags & 0x1) != 0; + var delayTime = reader.ReadUInt16(); // I don't think thief uses any animated gifs so we'll ignore this :) + var rawTransparencyIndex = reader.ReadByte(); + TransparencyIndex = transparency ? rawTransparencyIndex : -1; + var terminator = reader.ReadByte(); + if (terminator != 0) + { + throw new InvalidDataException($"Graphics Control terminator should be 0. Found: {terminator}"); + } + } + } + + public record ImageData + { + public int Width; + public int Height; + private byte[] _colors; + private byte[] _pixelIndices; + private int _transparentIndex; + + public ImageData(BinaryReader reader, byte[] globalColors, int transparentIndex) + { + var x = reader.ReadUInt16(); + var y = reader.ReadUInt16(); + var width = reader.ReadUInt16(); + var height = reader.ReadUInt16(); + var flags = reader.ReadByte(); + var hasLocalColorTable = ((flags >> 7) & 0x1) != 0; + var interlaced = ((flags >> 6) & 0x1) != 0; // See appendix E for interlacing + var isLocalColorTableSorted = ((flags >> 5) & 0x1) != 0; + var sizeOfLocalColorTable = (byte)Math.Pow(2, (flags & 0x7) + 1); + byte[] colors; + if (hasLocalColorTable) + { + colors = ReadColorTable(reader, sizeOfLocalColorTable); + } + else + { + colors = globalColors; + } + + // Now is the fun part. All the lovely LZW encoded pixel data :) + var outIndex = 0; + var pixelIndices = new byte[width * height]; + + var minCodeSize = reader.ReadByte(); + var clearCode = 1 << minCodeSize; + var endCode = 1 + (1 << minCodeSize); + var table = new List(); + + void ResetTable() + { + table.Clear(); + var len = 1 << minCodeSize; + for (var i = 0; i < len; i++) + { + table.Add([(byte)i]); + } + table.Add([]); // clear code + table.Add([]); // end code + } + + ResetTable(); + + // Remember all this data is in blocks!!! + var compressedBytes = new List(); + var blockSize = reader.ReadByte(); + while (blockSize != 0) + { + var bytes = reader.ReadBytes(blockSize); + compressedBytes.AddRange(bytes); + blockSize = reader.ReadByte(); + } + // Godot.GD.Print($"End of image data: {reader.BaseStream.Position}"); + + using MemoryStream compressedStream = new(compressedBytes.ToArray()); + using BinaryReader compressedReader = new(compressedStream, Encoding.UTF8, false); + + var codeSize = minCodeSize + 1; + var codeInputByte = compressedReader.ReadByte(); + var codeInputBit = 0; + var previousCode = -1; + while (true) + { + // Codes are variable length so we need to manage the bytes + var code = 0; + var codeBit = 0; + while (codeBit < codeSize) + { + var codeBitsLeft = codeSize - codeBit; + var inputBitsLeft = 8 - codeInputBit; + if (inputBitsLeft == 0) + { + codeInputByte = compressedReader.ReadByte(); + codeInputBit = 0; + inputBitsLeft = 8; + } + var bitsToRead = Math.Min(inputBitsLeft, codeBitsLeft); + code += ((codeInputByte >> codeInputBit) & ((1 << bitsToRead) - 1)) << codeBit; + codeBit += bitsToRead; + codeInputBit += bitsToRead; + } + + // Match the code + var codeCount = table.Count; + if (code == clearCode) + { + ResetTable(); + codeSize = minCodeSize + 1; + previousCode = -1; + } + else if (code == endCode) + { + // Godot.GD.Print($"CompressedBytesLeft: {compressedReader.BaseStream.Length - compressedReader.BaseStream.Position}"); + break; + } + else if (code < codeCount) + { + // Write code W + var bytes = table[code]; + foreach (var b in bytes) + { + pixelIndices[outIndex++] = b; + } + // Add new code = previous code + first "symbol" of W + if (previousCode != -1) + { + var previousBytes = table[previousCode]; + var newCodeBytes = new byte[previousBytes.Length + 1]; + var newCodeBytesIdx = 0; + foreach (var b in previousBytes) + { + newCodeBytes[newCodeBytesIdx++] = b; + } + newCodeBytes[newCodeBytesIdx] = bytes[0]; + table.Add(newCodeBytes); + } + previousCode = code; + } + else if (code == codeCount) + { + // Add new code = previous code + first symbol of previous code + var previousBytes = table[previousCode]; + var bytes = new byte[previousBytes.Length + 1]; + var bytesIdx = 0; + foreach (var b in previousBytes) + { + bytes[bytesIdx++] = b; + } + bytes[bytesIdx] = previousBytes[0]; + table.Add(bytes); + // Write new code + foreach (var b in bytes) + { + pixelIndices[outIndex++] = b; + } + previousCode = code; + } + else + { + throw new InvalidDataException("Code out of range"); + } + + // Increase codesize :) + if (table.Count == (1 << codeSize)) + { + if (codeSize < 12) + { + codeSize += 1; + } + // else + // { + // // pretty sure this is an error + // throw new InvalidDataException("Code Size exceeding 12 bits"); + // } + } + } + + Width = width; + Height = height; + _colors = colors; + _pixelIndices = pixelIndices; + _transparentIndex = transparentIndex; + } + + public byte[] GetRgbaBytes() + { + var bytesIdx = 0; + var bytes = new byte[Width * Height * 4]; + foreach (var colorIndex in _pixelIndices) + { + bytes[bytesIdx++] = _colors[colorIndex * 3]; + bytes[bytesIdx++] = _colors[colorIndex * 3 + 1]; + bytes[bytesIdx++] = _colors[colorIndex * 3 + 2]; + // Fuck you LGS hardcoding the transparent index suck my balls + // bytes[bytesIdx++] = (byte)((colorIndex == _transparentIndex) ? 0 : 255); + bytes[bytesIdx++] = (byte)((colorIndex == 0) ? 0 : 255); + } + return bytes; + } + } + + Header _header; + byte[] _globalColors; + ImageData[] _images; + + public GifDecoder(string path) + { + if (!File.Exists(path)) + { + throw new FileNotFoundException(); + } + + // Godot.GD.Print($"Decoding image at: {path}"); + + using MemoryStream stream = new(File.ReadAllBytes(path)); + using BinaryReader reader = new(stream, Encoding.UTF8, false); + + _header = new Header(reader); + _globalColors = ReadColorTable(reader, _header.HasGlobalColorTable ? _header.GlobalColorTableSize : 0); + var images = new List(); + var transparentIndex = -1; + while (true) + { + var id = reader.ReadByte(); + var eof = false; + switch (id) + { + case 0x2C: // Image + // Godot.GD.Print("Adding new image!!"); + images.Add(new ImageData(reader, _globalColors, transparentIndex)); + transparentIndex = -1; + break; + case 0x21: // Extension + var extId = reader.ReadByte(); + if (extId == 0xF9) + { + var graphicsControl = new GraphicsControl(reader); + transparentIndex = graphicsControl.TransparencyIndex; + // Godot.GD.Print($"We set the transparent index: {transparentIndex} for {path}"); + } + else + { + // We don't support Comment (0xFE), Text (0x01), or Application (0xFF) extensions + throw new InvalidDataException($"Unknown or unsupported extension identifier in GIF file: {extId}"); + } + break; + case 0x3B: + eof = true; + break; + default: + throw new InvalidDataException($"Unknown block identifier in GIF file: {id} at {stream.Position}"); + } + + if (eof) + { + break; + } + } + + _images = images.ToArray(); + } + + public ImageData GetImage(int idx) + { + return _images[idx]; + } + + private static byte[] ReadColorTable(BinaryReader reader, int length) + { + var colors = new byte[length * 3]; + for (var i = 0; i < length; i++) + { + colors[i * 3] = reader.ReadByte(); + colors[i * 3 + 1] = reader.ReadByte(); + colors[i * 3 + 2] = reader.ReadByte(); + } + return colors; + } +} \ No newline at end of file diff --git a/project/code/LGS/Extensions.cs b/project/code/LGS/Extensions.cs index e4e7b83..976c391 100644 --- a/project/code/LGS/Extensions.cs +++ b/project/code/LGS/Extensions.cs @@ -1,5 +1,6 @@ using System.IO; using System.Numerics; +using System.Text; namespace KeepersCompound.LGS; @@ -14,4 +15,13 @@ public static class Extensions { return new Vector2(reader.ReadSingle(), reader.ReadSingle()); } + + // TODO: Go through and replace all usages of string reading with this + public static string ReadNullString(this BinaryReader reader, int length) + { + var tmpName = Encoding.UTF8.GetString(reader.ReadBytes(length)); + var idx = tmpName.IndexOf('\0'); + if (idx >= 0) tmpName = tmpName[..idx]; + return tmpName; + } } diff --git a/project/code/TMV/TextureLoader.Gif.cs b/project/code/TMV/TextureLoader.Gif.cs index 33dc1d1..080d80e 100644 --- a/project/code/TMV/TextureLoader.Gif.cs +++ b/project/code/TMV/TextureLoader.Gif.cs @@ -1,4 +1,5 @@ using Godot; +using KeepersCompound.Images; using SixLabors.ImageSharp.PixelFormats; namespace KeepersCompound.TMV; @@ -11,13 +12,19 @@ public partial class TextureLoader // - https://www.w3.org/Graphics/GIF/spec-gif89a.txt private static ImageTexture LoadGif(string path) { - using var gifImage = SixLabors.ImageSharp.Image.Load(path); - - var width = gifImage.Width; - var height = gifImage.Height; - var bytes = new byte[width * height * 4]; - gifImage.CopyPixelDataTo(bytes); - var image = Image.CreateFromData(width, height, false, Image.Format.Rgba8, bytes); + var gif = new GifDecoder(path); + var gifImage = gif.GetImage(0); + var bytes = gifImage.GetRgbaBytes(); + var image = Image.CreateFromData(gifImage.Width, gifImage.Height, false, Image.Format.Rgba8, bytes); return ImageTexture.CreateFromImage(image); + + // using var gifImage = SixLabors.ImageSharp.Image.Load(path); + + // var width = gifImage.Width; + // var height = gifImage.Height; + // var bytes = new byte[width * height * 4]; + // gifImage.CopyPixelDataTo(bytes); + // var image = Image.CreateFromData(width, height, false, Image.Format.Rgba8, bytes); + // return ImageTexture.CreateFromImage(image); } } \ No newline at end of file