using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Text; #if WPF using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; #endif namespace codelanguage; public class CraftIconFile : CraftFile { public class IconState { internal Action? FrameChanged; public string Name { get; set; } = ""; public int Width { get; set; } = 32; public int Height { get; set; } = 32; private int _frameCount = 1; private int _dirCount = 1; public int FrameCount { get => _frameCount; set => SetFrameCount(value); } public int DirCount { get => _dirCount; set => SetDirCount(value); } public int Delay { get; set; } = 1; public bool Movement { get; set; } public List Frames { get; set; } = new(); private void SetFrameCount(int requested) { int newCount = Math.Max(1, requested); if (_frameCount == newCount) return; int oldCount = _frameCount; int dirs = Math.Max(1, _dirCount); int newTotal = dirs * newCount; var resized = new List(newTotal); for (int i = 0; i < newTotal; i++) resized.Add(Array.Empty()); for (int dir = 0; dir < dirs; dir++) { for (int frame = 0; frame < Math.Min(oldCount, newCount); frame++) { int oldIndex = dir * oldCount + frame; if (oldIndex >= 0 && oldIndex < Frames.Count) resized[dir * newCount + frame] = Frames[oldIndex]; } } Frames = resized; _frameCount = newCount; FrameChanged?.Invoke(); } private void SetDirCount(int requested) { int newDirs = Math.Max(1, requested); if (_dirCount == newDirs) return; int oldDirs = _dirCount; int framesPerDir = Math.Max(1, _frameCount); int newTotal = newDirs * framesPerDir; var resized = new List(newTotal); for (int i = 0; i < newTotal; i++) resized.Add(Array.Empty()); for (int dir = 0; dir < Math.Min(oldDirs, newDirs) && dir < oldDirs; dir++) { for (int frame = 0; frame < framesPerDir; frame++) { int oldIndex = dir * framesPerDir + frame; if (oldIndex >= 0 && oldIndex < Frames.Count) resized[dir * framesPerDir + frame] = Frames[oldIndex]; } } Frames = resized; _dirCount = newDirs; FrameChanged?.Invoke(); } public byte[]? GetFrame(int dir, int frame) { int idx = dir * FrameCount + frame; return idx >= 0 && idx < Frames.Count ? Frames[idx] : null; } public void SetFrame(int dir, int frame, byte[] data) { int idx = dir * FrameCount + frame; while (Frames.Count <= idx) Frames.Add(Array.Empty()); Frames[idx] = data; FrameChanged?.Invoke(); } public int TotalFrames => DirCount * FrameCount; } public List States { get; set; } = new(); public int Version { get; set; } = 400; public int IconWidth { get; set; } = 32; public int IconHeight { get; set; } = 32; private byte[]? _sourcePng; private bool _modified = false; public string? LastError { get; set; } /// Fired when any CraftIconFile is saved to disk. Provides the file path. public static event Action? FileChanged; [Conditional("DEBUG")] private static void DebugLog(string message) => Console.Error.WriteLine(message); public CraftIconFile(string path) : base(path) { } public override bool Load() { try { if (!File.Exists(Path)) { LastError = "File does not exist."; return false; } _sourcePng = File.ReadAllBytes(Path); DebugLog($"Load: read {_sourcePng.Length} bytes from {Path}"); if (_sourcePng.Length == 0) { LastError = "File is empty."; return false; } if (!HasPngSignature(_sourcePng)) { LastError = "File is not a valid PNG."; return false; } States.Clear(); string? description = ReadDescriptionChunk(_sourcePng); DebugLog($"Load: description = {(description == null ? "null" : $"\"{description.Replace("\r\n", "\\r\\n").Replace("\n", "\\n")}\"")}"); if (!string.IsNullOrEmpty(description)) ParseDescription(description); AttachStateChangeHooks(); NormalizeStateDimensions(); DebugLog($"Load: after ParseDescription, States.Count = {States.Count}"); ExtractFrames(); LastModified = File.GetLastWriteTime(Path); _modified = false; LastError = null; return true; } catch (Exception ex) { LastError = $"Error loading file: {ex.Message}"; DebugLog($"Load exception: {ex.Message}"); return false; } } public override bool Save() { try { AttachStateChangeHooks(); DebugLog($"Save: _modified={_modified}, States.Count={States.Count}, _sourcePng={(null != _sourcePng)}"); if (_modified && States.Count > 0) { var newPng = RebuildPng(); if (newPng == null) { LastError ??= "Failed to rebuild DMI image."; return false; } WriteAllBytesAtomically(Path, newPng); _sourcePng = newPng; LastModified = File.GetLastWriteTime(Path); _modified = false; FileChanged?.Invoke(Path); return true; } if (_sourcePng != null) { WriteAllBytesAtomically(Path, _sourcePng); LastModified = File.GetLastWriteTime(Path); FileChanged?.Invoke(Path); return true; } return false; } catch (Exception ex) { DebugLog($"Save exception: {ex.Message}"); LastError ??= $"Error saving file: {ex.Message}"; return false; } } public bool IsModified => _modified; public void MarkModified() { _modified = true; DebugLog($"MarkModified called - file now marked as modified"); } private byte[]? RebuildPng() { try { if (States.Count == 0) { DebugLog("RebuildPng: no states"); return null; } int sheetWidth = IconWidth * States.Max(s => s.FrameCount); int sheetHeight = IconHeight * States.Sum(s => s.DirCount); DebugLog($"RebuildPng: sheet {sheetWidth}x{sheetHeight}"); var sheet = new System.Drawing.Bitmap(sheetWidth, sheetHeight); using var sheetG = System.Drawing.Graphics.FromImage(sheet); sheetG.Clear(System.Drawing.Color.Transparent); int yOffset = 0; foreach (var state in States) { for (int dir = 0; dir < state.DirCount; dir++) { for (int frame = 0; frame < state.FrameCount; frame++) { var frameData = state.GetFrame(dir, frame); if (frameData != null && frameData.Length > 8) { try { using var ms = new MemoryStream(frameData); using var frameBmp = new System.Drawing.Bitmap(ms); int sx = frame * IconWidth; int sy = yOffset + dir * IconHeight; sheetG.DrawImage(frameBmp, sx, sy, IconWidth, IconHeight); } catch (Exception ex) { DebugLog($"RebuildPng draw error: {ex.Message}"); } } } } yOffset += IconHeight * state.DirCount; } using var outMs = new MemoryStream(); sheet.Save(outMs, System.Drawing.Imaging.ImageFormat.Png); var pngBytes = outMs.ToArray(); DebugLog($"RebuildPng: raw PNG = {pngBytes.Length} bytes"); var description = BuildDescription(); DebugLog($"RebuildPng: description = [{description}]"); var finalPng = InjectDescription(pngBytes, description); DebugLog($"RebuildPng: final PNG = {(null == finalPng ? "null" : finalPng.Length.ToString() + " bytes")}"); return finalPng; } catch (Exception ex) { DebugLog($"RebuildPng exception: {ex.Message}"); return null; } } private string BuildDescription() { var sb = new StringBuilder(); sb.AppendLine($"version = {Version / 100.0:F1}"); sb.AppendLine($"width = {IconWidth}"); sb.AppendLine($"height = {IconHeight}"); foreach (var state in States) { sb.AppendLine($"state = \"{state.Name}\""); if (state.DirCount > 1) sb.AppendLine($"\tdirs = {state.DirCount}"); if (state.FrameCount > 1) sb.AppendLine($"\tframes = {state.FrameCount}"); if (state.Delay > 1) sb.AppendLine($"\tdelay = {state.Delay}"); if (state.Movement) sb.AppendLine("\tmovement = 1"); } return sb.ToString(); } private byte[]? InjectDescription(byte[] png, string description) { try { var descriptionBytes = Encoding.UTF8.GetBytes(description); var chunk = CreateTextChunk("Description", descriptionBytes); if (chunk == null) { DebugLog("InjectDescription: CreateTextChunk returned null"); return png; } var result = new MemoryStream(); result.Write(png, 0, 8); int i = 8; bool injected = false; while (i + 12 <= png.Length) { int length = (png[i] << 24) | (png[i + 1] << 16) | (png[i + 2] << 8) | png[i + 3]; string chunkType = Encoding.ASCII.GetString(png, i + 4, 4); if (chunkType == "IHDR" && !injected) { result.Write(png, i, 12 + length); result.Write(chunk, 0, chunk.Length); injected = true; DebugLog($"InjectDescription: injected after IHDR, i={i}, length={length}, chunk.Length={chunk.Length}"); i += 12 + length; continue; } if (chunkType == "IDAT" || chunkType == "IEND") { result.Write(png, i, 12 + length); i += 12 + length; continue; } result.Write(png, i, 12 + length); i += 12 + length; } var finalBytes = result.ToArray(); DebugLog($"InjectDescription: result size={finalBytes.Length}, injected={injected}"); return finalBytes; } catch (Exception ex) { DebugLog($"InjectDescription exception: {ex.Message}"); return png; } } private void WriteAllBytesAtomically(string path, byte[] bytes) { string? directory = System.IO.Path.GetDirectoryName(path); if (string.IsNullOrWhiteSpace(directory)) directory = "."; string temporaryPath = System.IO.Path.Combine(directory, $".{System.IO.Path.GetFileName(path)}.{Guid.NewGuid():n}.tmp"); try { File.WriteAllBytes(temporaryPath, bytes); File.Move(temporaryPath, path, true); } catch { try { if (File.Exists(temporaryPath)) File.Delete(temporaryPath); } catch { } throw; } } private byte[]? CreateTextChunk(string keyword, byte[] text) { try { var sb = new StringBuilder(); sb.Append(keyword); sb.Append((char)0); var keywordBytes = Encoding.ASCII.GetBytes(sb.ToString()); DebugLog($"CTC: step1 keywordBytes.Length={keywordBytes.Length}, text.Length={text.Length}"); var data = new byte[keywordBytes.Length + text.Length]; DebugLog($"CTC: step2 data.Length={data.Length}"); keywordBytes.CopyTo(data, 0); DebugLog($"CTC: step3 keywordBytes.CopyTo done"); text.CopyTo(data, keywordBytes.Length); DebugLog($"CTC: step4 text.CopyTo done"); var lengthBytes = new byte[] { (byte)((data.Length >> 24) & 0xFF), (byte)((data.Length >> 16) & 0xFF), (byte)((data.Length >> 8) & 0xFF), (byte)(data.Length & 0xFF) }; DebugLog($"CTC: step5 lengthBytes done"); var chunkType = Encoding.ASCII.GetBytes("tEXt"); DebugLog($"CTC: step6 chunkType.Length={chunkType.Length}"); var crcData = new byte[chunkType.Length + data.Length]; DebugLog($"CTC: step7 crcData.Length={crcData.Length}"); chunkType.CopyTo(crcData, 0); DebugLog($"CTC: step8 chunkType.CopyTo crcData done"); data.CopyTo(crcData, chunkType.Length); DebugLog($"CTC: step9 data.CopyTo crcData done"); uint crc = Crc32(chunkType, data); DebugLog($"CTC: step10 crc=0x{crc:X8}"); var crcBytes = new byte[] { (byte)((crc >> 24) & 0xFF), (byte)((crc >> 16) & 0xFF), (byte)((crc >> 8) & 0xFF), (byte)(crc & 0xFF) }; DebugLog($"CTC: step11 crcBytes done"); var chunk = new byte[lengthBytes.Length + chunkType.Length + data.Length + crcBytes.Length]; DebugLog($"CTC: step12 chunk.Length={chunk.Length}"); lengthBytes.CopyTo(chunk, 0); chunkType.CopyTo(chunk, 4); data.CopyTo(chunk, 8); crcBytes.CopyTo(chunk, 8 + data.Length); DebugLog($"CTC: step13 done"); return chunk; } catch (Exception ex) { DebugLog($"CTC exception: {ex.GetType().Name}: {ex.Message}"); LastError ??= $"Failed to encode DMI chunk: {ex.Message}"; return null; } } private static uint Crc32(byte[] chunkType, byte[] data) { uint crc = 0xFFFFFFFF; foreach (byte b in chunkType) crc = Crc32Update(crc, b); foreach (byte b in data) crc = Crc32Update(crc, b); return ~crc; } private static uint Crc32Update(uint crc, byte b) { uint[] table = GenerateCrc32Table(); return (crc >> 8) ^ table[(crc ^ b) & 0xFF]; } private static uint[]? _crcTable; private static uint[] GenerateCrc32Table() { if (_crcTable != null) return _crcTable; _crcTable = new uint[256]; for (uint i = 0; i < 256; i++) { uint c = i; for (int j = 0; j < 8; j++) c = (c & 1) != 0 ? (0xEDB88320u ^ (c >> 1)) : (c >> 1); _crcTable[i] = c; } return _crcTable; } public IconState AddState(string name, int width, int height) { if (width < 1 || height < 1) throw new ArgumentOutOfRangeException(null, "Icon dimensions must be at least 1 x 1."); // A .cpi file has one global sprite size. Apply the requested size to that // file-level dimension, then attach it to every state instead of allowing // per-frame/per-state dimensional drift. if (States.Count == 0 || IconWidth < 1 || IconHeight < 1) { IconWidth = width; IconHeight = height; } else if (width != IconWidth || height != IconHeight) { throw new ArgumentException("Icon state dimensions must match the DMI file's global sprite dimensions."); } var state = new IconState { Name = name, Width = IconWidth, Height = IconHeight, DirCount = 1, FrameCount = 1 }; RebuildFrames(state); state.FrameChanged = MarkModified; States.Add(state); _modified = true; return state; } public IconState AddState(string name) => AddState(name, Math.Max(1, IconWidth), Math.Max(1, IconHeight)); public IconState AddState(IconState source, string? name = null) { if (source == null) throw new ArgumentNullException(nameof(source)); var copied = AddState(string.IsNullOrWhiteSpace(name) ? source.Name + "_copy" : name.Trim(), source.Width, source.Height); copied.DirCount = Math.Max(1, source.DirCount); copied.FrameCount = Math.Max(1, source.FrameCount); copied.Delay = Math.Max(1, source.Delay); for (int dir = 0; dir < copied.DirCount; dir++) { for (int frame = 0; frame < copied.FrameCount; frame++) { var sourceFrame = source.GetFrame(dir, frame); copied.SetFrame(dir, frame, sourceFrame != null && sourceFrame.Length > 0 ? [.. sourceFrame] : Array.Empty()); } } return copied; } private void NormalizeStateDimensions() { if (IconWidth < 1) IconWidth = 32; if (IconHeight < 1) IconHeight = 32; foreach (var state in States) { state.Width = IconWidth; state.Height = IconHeight; if (state.DirCount < 1) state.DirCount = 1; if (state.FrameCount < 1) state.FrameCount = 1; if (state.Frames.Count < state.DirCount * state.FrameCount) RebuildFrames(state); } } public IconState? GetState(string name) => States.FirstOrDefault(s => s.Name == name); private void ExtractFrames() { #if WPF if (_sourcePng == null || States.Count == 0) return; BitmapSource? sheet = null; try { var ms = new MemoryStream(_sourcePng); var decoder = BitmapFrame.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad); sheet = decoder; } catch { return; } int yOffset = 0; foreach (var state in States) { state.Frames.Clear(); for (int dir = 0; dir < state.DirCount; dir++) { for (int frame = 0; frame < state.FrameCount; frame++) { byte[]? png = null; try { int sx = frame * state.Width; int sy = yOffset + dir * state.Height; if (sx + state.Width <= sheet.PixelWidth && sy + state.Height <= sheet.PixelHeight) { var crop = new CroppedBitmap(sheet, new Int32Rect(sx, sy, state.Width, state.Height)); var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(crop)); using var outMs = new MemoryStream(); encoder.Save(outMs); png = outMs.ToArray(); } } catch { png = null; } state.Frames.Add(png ?? Array.Empty()); } } yOffset += state.Height * state.DirCount; } #endif } private static bool HasPngSignature(byte[] bytes) => bytes.Length >= 8 && bytes.AsSpan(0, 8).SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }); private static string? ReadDescriptionChunk(byte[] png) { int offset = 8; while (offset + 12 <= png.Length) { int length = ReadBigEndianInt32(png, offset); offset += 4; if (length < 0 || offset + 8 + length > png.Length) return null; string chunkType = Encoding.ASCII.GetString(png, offset, 4); offset += 4; var data = png.AsSpan(offset, length).ToArray(); offset += length + 4; if (chunkType == "tEXt") { int separator = Array.IndexOf(data, (byte)0); if (separator >= 0 && Encoding.ASCII.GetString(data, 0, separator) == "Description") return Encoding.Latin1.GetString(data, separator + 1, data.Length - separator - 1); } else if (chunkType == "zTXt") { int separator = Array.IndexOf(data, (byte)0); if (separator >= 0 && separator + 2 <= data.Length && Encoding.ASCII.GetString(data, 0, separator) == "Description" && data[separator + 1] == 0) { try { using var input = new MemoryStream(data, separator + 2, data.Length - separator - 2); using var zlib = new ZLibStream(input, CompressionMode.Decompress); using var output = new MemoryStream(); zlib.CopyTo(output); return Encoding.UTF8.GetString(output.ToArray()); } catch { // Some exporters omit an encoding/Compression payload variance. Try raw UTF-8. try { return Encoding.UTF8.GetString(data, separator + 1, data.Length - separator - 1); } catch { return null; } } } } else if (chunkType == "iTXt") { int separator = Array.IndexOf(data, (byte)0); if (separator >= 0 && Encoding.ASCII.GetString(data, 0, separator) == "Description") { int position = separator + 1; if (position + 2 > data.Length) return null; bool compressed = data[position] == 1; position += 2; // compression flag and method int languageEnd = Array.IndexOf(data, (byte)0, position); if (languageEnd >= 0) { position = languageEnd + 1; int translatedEnd = Array.IndexOf(data, (byte)0, position); if (translatedEnd >= 0) { position = translatedEnd + 1; if (!compressed) return Encoding.UTF8.GetString(data, position, data.Length - position); try { using var input = new MemoryStream(data, position, data.Length - position); using var zlib = new ZLibStream(input, CompressionMode.Decompress); using var output = new MemoryStream(); zlib.CopyTo(output); return Encoding.UTF8.GetString(output.ToArray()); } catch { return null; } } } } } if (chunkType == "IEND") break; } return null; } private void ParseDescription(string description) { int width = 32; int height = 32; IconState? current = null; foreach (var rawLine in description.Split('\n')) { string line = rawLine.Trim(); if (string.IsNullOrEmpty(line)) continue; if (line.StartsWith("version", StringComparison.OrdinalIgnoreCase)) { if (TryReadDouble(line, out var version)) Version = Math.Max(1, (int)Math.Round(version * 100)); } else if (line.StartsWith("width", StringComparison.OrdinalIgnoreCase) || line.StartsWith("size-width", StringComparison.OrdinalIgnoreCase)) { if (TryReadDouble(line, out var value)) width = Math.Max(1, (int)value); IconWidth = width; } else if (line.StartsWith("height", StringComparison.OrdinalIgnoreCase) || line.StartsWith("size-height", StringComparison.OrdinalIgnoreCase)) { if (TryReadDouble(line, out var value)) height = Math.Max(1, (int)value); IconHeight = height; } else if (line.StartsWith("state", StringComparison.OrdinalIgnoreCase)) { string name = ReadStateName(line) ?? ""; current = AddState(name, width, height); } else if (current != null && line.StartsWith("dirs", StringComparison.OrdinalIgnoreCase)) { int dirs = TryReadDouble(line, out var value) ? Math.Max(1, (int)value) : 1; current.DirCount = dirs; RebuildFrames(current); } else if (current != null && line.StartsWith("frames", StringComparison.OrdinalIgnoreCase)) { int frames = TryReadDouble(line, out var value) ? Math.Max(1, (int)value) : 1; current.FrameCount = frames; RebuildFrames(current); } else if (current != null && line.StartsWith("delay", StringComparison.OrdinalIgnoreCase)) { int delay = TryReadDouble(line, out var value) ? Math.Max(1, (int)value) : 1; current.Delay = delay; } else if (current != null && line.StartsWith("movement", StringComparison.OrdinalIgnoreCase)) { current.Movement = TryReadDouble(line, out var value) && value != 0; } } // BYOND DMI sprite geometry is global. A malformed description cannot leave // one state with independently-sized slices. NormalizeStateDimensions(); } private static string? ReadStateName(string line) { int equals = line.IndexOf('='); if (equals < 0) return string.IsNullOrEmpty(line) ? null : line; return ReadQuotedValue(line) ?? line[(equals + 1)..].Trim().Trim('"').Trim(); } public static void RebuildFrames(IconState state) { int total = state.DirCount * state.FrameCount; state.Frames = new List(Enumerable.Repeat(Array.Empty(), total)); } private static bool TryReadDouble(string line, out double value) { value = 0; int equals = line.IndexOf('='); return equals >= 0 && double.TryParse(line[(equals + 1)..].Trim().Trim('"'), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out value); } private static string? ReadQuotedValue(string line) { int first = line.IndexOf('"'); if (first < 0) return null; int last = line.IndexOf('"', first + 1); return last > first ? line[(first + 1)..last] : null; } private static int ReadBigEndianInt32(byte[] bytes, int offset) => (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]; private void AttachStateChangeHooks() { foreach (var state in States) { state.FrameChanged = MarkModified; } } }