From 6f3ed7f8a434ab906f62eef9839c2d5f5f5f7e58 Mon Sep 17 00:00:00 2001 From: Yokimitsuro Date: Fri, 14 Aug 2026 11:41:40 +0200 Subject: [PATCH 1/7] Add KH1 BINL text editor --- OpenKh.Kh1/Kh1Binl.cs | 330 ++++++++++++++++++ OpenKh.Kh1/Kh1TextTable.cs | 240 +++++++++++++ OpenKh.Tests/Kh1/Kh1TextTableTests.cs | 140 ++++++++ OpenKh.Tools.Kh1TextEditor/App.xaml | 7 + OpenKh.Tools.Kh1TextEditor/App.xaml.cs | 8 + .../OpenKh.Tools.Kh1TextEditor.csproj | 20 ++ .../ViewModels/TextEntryViewModel.cs | 46 +++ .../Views/MainWindow.xaml | 97 +++++ .../Views/MainWindow.xaml.cs | 213 +++++++++++ OpenKh.sln | 11 + docs/tool/GUI.Kh1TextEditor/index.md | 24 ++ docs/tool/index.md | 1 + 12 files changed, 1137 insertions(+) create mode 100644 OpenKh.Kh1/Kh1Binl.cs create mode 100644 OpenKh.Kh1/Kh1TextTable.cs create mode 100644 OpenKh.Tests/Kh1/Kh1TextTableTests.cs create mode 100644 OpenKh.Tools.Kh1TextEditor/App.xaml create mode 100644 OpenKh.Tools.Kh1TextEditor/App.xaml.cs create mode 100644 OpenKh.Tools.Kh1TextEditor/OpenKh.Tools.Kh1TextEditor.csproj create mode 100644 OpenKh.Tools.Kh1TextEditor/ViewModels/TextEntryViewModel.cs create mode 100644 OpenKh.Tools.Kh1TextEditor/Views/MainWindow.xaml create mode 100644 OpenKh.Tools.Kh1TextEditor/Views/MainWindow.xaml.cs create mode 100644 docs/tool/GUI.Kh1TextEditor/index.md diff --git a/OpenKh.Kh1/Kh1Binl.cs b/OpenKh.Kh1/Kh1Binl.cs new file mode 100644 index 000000000..3f6040ba2 --- /dev/null +++ b/OpenKh.Kh1/Kh1Binl.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace OpenKh.Kh1 +{ + /// + /// Preserving editor for remastered KH1 EvMsg *.binl files. + /// Unknown bytecode and command parameters are kept byte-for-byte. + /// + public sealed class Kh1Binl + { + public sealed class TextEntry + { + private readonly Kh1TextTable _table; + + internal TextEntry(int index, int offset, int length, byte[] bytes, Kh1TextTable table) + { + Index = index; + Offset = offset; + OriginalLength = length; + _table = table; + Text = DecodeBody(bytes); + } + + public int Index { get; } + public int Offset { get; } + public int OriginalLength { get; } + public string Text { get; set; } + + internal byte[] EncodeBody() + { + var output = new List(); + var plainText = new StringBuilder(); + + void FlushPlainText() + { + if (plainText.Length == 0) + return; + output.AddRange(_table.Encode(plainText.ToString())); + plainText.Clear(); + } + + for (var position = 0; position < Text.Length;) + { + if (Text[position] == '\r' || Text[position] == '\n') + { + FlushPlainText(); + if (Text[position] == '\r' && position + 1 < Text.Length && Text[position + 1] == '\n') + position++; + output.Add(0x02); + position++; + continue; + } + + if (TryReadCommandToken(Text, position, out var command, out var tokenLength)) + { + FlushPlainText(); + ValidateCommand(command); + output.AddRange(command); + position += tokenLength; + continue; + } + + plainText.Append(Text[position]); + position++; + } + + FlushPlainText(); + ValidateBody(output); + return output.ToArray(); + } + + private string DecodeBody(byte[] bytes) + { + var output = new StringBuilder(); + for (var offset = 0; offset < bytes.Length;) + { + var opcode = bytes[offset]; + if (opcode == 0x01) + { + output.Append(' '); + offset++; + } + else if (opcode == 0x02) + { + output.AppendLine(); + offset++; + } + else if (opcode <= 0x0E) + { + var length = GetInstructionLength(opcode); + if (offset + length > bytes.Length) + length = bytes.Length - offset; + output.Append("{cmd:") + .Append(string.Join(" ", bytes.AsSpan(offset, length).ToArray().Select(x => x.ToString("X2")))) + .Append('}'); + offset += length; + } + else + { + var nextControl = offset + 1; + while (nextControl < bytes.Length && bytes[nextControl] > 0x0E) + nextControl++; + output.Append(_table.Decode(bytes.AsSpan(offset, nextControl - offset))); + offset = nextControl; + } + } + + return output.ToString(); + } + + private static bool TryReadCommandToken( + string text, + int offset, + out byte[] command, + out int tokenLength) + { + command = null; + tokenLength = 0; + if (!text.AsSpan(offset).StartsWith("{cmd:".AsSpan(), StringComparison.OrdinalIgnoreCase)) + return false; + + var closeBrace = text.IndexOf('}', offset + 5); + if (closeBrace < 0) + throw new InvalidDataException($"Command token at position {offset + 1} has no closing brace."); + + var value = text.Substring(offset + 5, closeBrace - offset - 5) + .Replace(" ", string.Empty); + if (value.Length == 0 || (value.Length & 1) != 0 || !value.All(Uri.IsHexDigit)) + throw new InvalidDataException($"Invalid command token at position {offset + 1}."); + + command = Convert.FromHexString(value); + tokenLength = closeBrace - offset + 1; + return true; + } + + private static void ValidateCommand(byte[] command) + { + if (command.Length == 0 || command[0] > 0x0E) + throw new InvalidDataException("A {cmd:...} token must begin with a BINL opcode from 00 to 0E."); + if (command.Length != GetInstructionLength(command[0])) + throw new InvalidDataException( + $"BINL command {command[0]:X2} must contain {GetInstructionLength(command[0])} byte(s)."); + } + + private static void ValidateBody(IReadOnlyList bytes) + { + for (var offset = 0; offset < bytes.Count;) + { + var opcode = bytes[offset]; + var length = opcode <= 0x0E ? GetInstructionLength(opcode) : 1; + if (offset + length > bytes.Count) + throw new InvalidDataException($"Truncated BINL command {opcode:X2} in edited text."); + if (opcode == 0x05 || opcode == 0x06 || opcode == 0x0A) + throw new InvalidDataException( + $"Structural BINL command {opcode:X2} cannot be inserted into editable text."); + offset += length; + } + } + } + + private readonly byte[] _source; + private readonly int _contentLength; + + private Kh1Binl(byte[] source, int contentLength, IReadOnlyList entries) + { + _source = source; + _contentLength = contentLength; + Entries = entries; + Language = Encoding.ASCII.GetString(source, 5, 2); + } + + public string Language { get; } + public IReadOnlyList Entries { get; } + + public static bool IsValid(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + if (!stream.CanSeek || stream.Length < 12) + return false; + + var oldPosition = stream.Position; + Span magic = stackalloc byte[5]; + var read = stream.Read(magic); + stream.Position = oldPosition; + return read == magic.Length && magic.SequenceEqual("EvMsg"u8); + } + + public static Kh1Binl Read(Stream stream, Kh1TextTable table) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentNullException.ThrowIfNull(table); + if (!IsValid(stream)) + throw new InvalidDataException("The file is not a KH1 EvMsg BINL file."); + + stream.Position = 0; + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + var source = buffer.ToArray(); + var contentLength = source.Length; + while (contentLength > 11 && source[contentLength - 1] == 0xCD) + contentLength--; + + var recordOffsets = FindRecordOffsets(source, contentLength); + var entries = new List(); + for (var recordIndex = 0; recordIndex < recordOffsets.Count; recordIndex++) + { + var recordStart = recordOffsets[recordIndex]; + var recordEnd = recordIndex + 1 < recordOffsets.Count + ? recordOffsets[recordIndex + 1] + : contentLength; + if (TryFindBody(source, recordStart + 4, recordEnd, out var bodyStart, out var bodyEnd)) + { + entries.Add(new TextEntry( + entries.Count, + bodyStart, + bodyEnd - bodyStart, + source.AsSpan(bodyStart, bodyEnd - bodyStart).ToArray(), + table)); + } + } + + if (entries.Count == 0) + throw new InvalidDataException("No editable text entries were found in the BINL file."); + + return new Kh1Binl(source, contentLength, entries); + } + + public static Kh1Binl Read(Stream stream) => Read(stream, Kh1TextTable.Default); + + public static Kh1Binl Read(string fileName, Kh1TextTable table) + { + using var stream = File.OpenRead(fileName); + return Read(stream, table); + } + + public static Kh1Binl Read(string fileName) => Read(fileName, Kh1TextTable.Default); + + public void Write(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + + var replacements = Entries + .Select(x => new { Entry = x, Bytes = x.EncodeBody() }) + .ToList(); + + var cursor = 0; + foreach (var replacement in replacements) + { + stream.Write(_source, cursor, replacement.Entry.Offset - cursor); + stream.Write(replacement.Bytes); + cursor = replacement.Entry.Offset + replacement.Entry.OriginalLength; + } + + stream.Write(_source, cursor, _contentLength - cursor); + while ((stream.Position & 0x0F) != 0) + stream.WriteByte(0xCD); + } + + private static List FindRecordOffsets(byte[] source, int contentLength) + { + var result = new List(); + for (var offset = 11; offset < contentLength;) + { + var opcode = source[offset]; + var length = opcode <= 0x0E ? GetInstructionLength(opcode) : 1; + if (offset + length > contentLength) + throw new InvalidDataException($"Truncated BINL instruction at 0x{offset:X}."); + if (opcode == 0x0A) + result.Add(offset); + offset += length; + } + + return result; + } + + private static bool TryFindBody( + byte[] source, + int start, + int end, + out int bodyStart, + out int bodyEnd) + { + bodyStart = -1; + bodyEnd = -1; + var pendingWhitespace = -1; + for (var offset = start; offset < end;) + { + var opcode = source[offset]; + var length = opcode <= 0x0E ? GetInstructionLength(opcode) : 1; + if (offset + length > end) + return false; + + if (bodyStart < 0) + { + if ((opcode == 0x01 || opcode == 0x02) && pendingWhitespace < 0) + pendingWhitespace = offset; + else if (opcode > 0x0E) + bodyStart = pendingWhitespace >= 0 ? pendingWhitespace : offset; + } + else if (opcode == 0x05 || opcode == 0x06) + { + bodyEnd = offset; + return bodyEnd > bodyStart; + } + + offset += length; + } + + if (bodyStart >= 0) + { + bodyEnd = end; + return true; + } + + return false; + } + + private static int GetInstructionLength(byte opcode) => opcode switch + { + 0x05 or 0x06 or 0x07 => 3, + 0x0A or 0x0B or 0x0D => 4, + 0x0C or 0x0E => 2, + _ => 1, + }; + } +} diff --git a/OpenKh.Kh1/Kh1TextTable.cs b/OpenKh.Kh1/Kh1TextTable.cs new file mode 100644 index 000000000..d53941ce1 --- /dev/null +++ b/OpenKh.Kh1/Kh1TextTable.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +namespace OpenKh.Kh1 +{ + /// + /// Reads the text-table format commonly used to describe the KH1 message encoding. + /// + public sealed class Kh1TextTable + { + private sealed class Entry + { + public byte[] Bytes { get; init; } + public string Text { get; init; } + } + + private readonly List _decodeEntries; + private readonly List _encodeEntries; + private readonly HashSet _ambiguousText; + + /// + /// Built-in international KH1 encoding used by the remastered BINL files. + /// Update when the game's character table changes. + /// + public static Kh1TextTable Default { get; } = CreateDefault(); + + private Kh1TextTable(IEnumerable entries) + { + var entryList = entries.ToList(); + _decodeEntries = entryList + .OrderByDescending(x => x.Bytes.Length) + .ToList(); + + _ambiguousText = entryList + .GroupBy(x => x.Text, StringComparer.Ordinal) + .Where(x => x.Count() > 1) + .Select(x => x.Key) + .ToHashSet(StringComparer.Ordinal); + + _encodeEntries = entryList + .Where(x => !_ambiguousText.Contains(x.Text)) + .OrderByDescending(x => x.Text.Length) + .ToList(); + } + + public static Kh1TextTable Read(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + + var entries = new List(); + var byteKeys = new HashSet(StringComparer.Ordinal); + using var reader = new StreamReader(stream, new UTF8Encoding(false, true), true, 1024, true); + var lineNumber = 0; + while (reader.ReadLine() is string line) + { + lineNumber++; + if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#')) + continue; + + var separator = line.IndexOf('='); + if (separator <= 0) + throw new InvalidDataException($"Invalid TBL entry at line {lineNumber}. Expected HEX=text."); + + var hex = line.Substring(0, separator).Trim().Replace(" ", string.Empty); + var text = line.Substring(separator + 1); + if (hex.Length == 0 || (hex.Length & 1) != 0 || !hex.All(Uri.IsHexDigit)) + throw new InvalidDataException($"Invalid hexadecimal key at TBL line {lineNumber}."); + if (text.Length == 0) + throw new InvalidDataException($"Empty text value at TBL line {lineNumber} is not supported."); + + var bytes = Convert.FromHexString(hex); + var byteKey = Convert.ToHexString(bytes); + if (!byteKeys.Add(byteKey)) + throw new InvalidDataException($"Duplicate byte key {byteKey} at TBL line {lineNumber}."); + + entries.Add(new Entry { Bytes = bytes, Text = text }); + } + + if (entries.Count == 0) + throw new InvalidDataException("The TBL file contains no entries."); + + return new Kh1TextTable(entries); + } + + public static Kh1TextTable Read(string fileName) + { + using var stream = File.OpenRead(fileName); + return Read(stream); + } + + private static Kh1TextTable CreateDefault() + { + var entries = new List(); + void Add(byte value, string text) => + entries.Add(new Entry { Bytes = new[] { value }, Text = text }); + + Add(0x00, "{eol}"); + Add(0x01, " "); + Add(0x02, "{lf}"); + Add(0x20, "—"); + + for (var value = 0; value < 10; value++) + Add((byte)(0x21 + value), value.ToString(CultureInfo.InvariantCulture)); + for (var value = 0; value < 26; value++) + { + Add((byte)(0x2B + value), ((char)('A' + value)).ToString()); + Add((byte)(0x45 + value), ((char)('a' + value)).ToString()); + } + + foreach (var (value, text) in new (byte, string)[] + { + (0x5F, "!"), (0x60, "?"), (0x61, "&"), (0x62, "%"), + (0x63, "+"), (0x64, "{-}"), (0x65, "{mX}"), (0x66, "/"), + (0x67, "*"), (0x68, "."), (0x69, ","), (0x6A, "・"), + (0x6B, ":"), (0x6C, ";"), (0x6D, "…"), (0x6E, "-"), + (0x6F, "ー"), (0x70, "~"), (0x71, "'"), (0x72, "\""), + (0x73, "{゛b}"), (0x74, "("), (0x75, ")"), (0x76, "["), + (0x77, "]"), (0x78, "<"), (0x79, ">"), (0x7A, "★"), + (0x7B, "☆"), (0x7C, "↑"), (0x7D, "↓"), (0x7E, "→"), + (0x7F, "←"), (0x80, "●"), (0x81, "■"), + (0x82, "{iPotion}"), (0x83, "{iTent}"), (0x84, "{iGem}"), + (0x85, "{iAbility}"), (0x86, "{iKey}"), (0x87, "{iStaff}"), + (0x88, "{iShield}"), (0x89, "{iRing}"), (0x8A, "{iHat}"), + (0x8B, "{iMickey}"), (0x8C, "○"), (0x8D, "×"), + (0x8E, "△"), (0x8F, "□"), (0x90, "▲"), (0x91, "▼"), + (0x92, "►"), (0x93, "◄"), (0xA9, "®"), + (0xC4, "{III}"), (0xC5, "{VII}"), (0xC6, "{VIII}"), + (0xC7, "{X}"), (0xC8, "Œ"), (0xC9, "œ"), + (0xCA, "¡"), (0xCB, "¿"), (0xCC, "À"), (0xCD, "Á"), + (0xCE, "Â"), (0xCF, "Ä"), (0xD0, "Ç"), (0xD1, "È"), + (0xD2, "É"), (0xD3, "Ê"), (0xD4, "Ë"), (0xD5, "Ì"), + (0xD6, "Í"), (0xD7, "Î"), (0xD8, "Ï"), (0xD9, "Ñ"), + (0xDA, "Ò"), (0xDB, "Ó"), (0xDC, "Ô"), (0xDD, "Ö"), + (0xDE, "Ù"), (0xDF, "Ú"), (0xE0, "Û"), (0xE1, "Ü"), + (0xE2, "ß"), (0xE3, "à"), (0xE4, "á"), (0xE5, "â"), + (0xE6, "ä"), (0xE7, "ç"), (0xE8, "è"), (0xE9, "é"), + (0xEA, "ê"), (0xEB, "ë"), (0xEC, "ì"), (0xED, "í"), + (0xEE, "î"), (0xEF, "ï"), (0xF0, "ñ"), (0xF1, "ò"), + (0xF2, "ó"), (0xF3, "ô"), (0xF4, "ö"), (0xF5, "ù"), + (0xF6, "ú"), (0xF7, "û"), (0xF8, "ü"), (0xF9, "°"), + (0xFA, "{---}"), (0xFB, "》"), (0xFC, "《"), + }) + Add(value, text); + + return new Kh1TextTable(entries); + } + + public string Decode(ReadOnlySpan data) + { + var result = new StringBuilder(); + for (var offset = 0; offset < data.Length;) + { + var entry = FindDecodeEntry(data.Slice(offset)); + if (entry == null) + { + result.AppendFormat(CultureInfo.InvariantCulture, "{{0x{0:X2}}}", data[offset]); + offset++; + } + else + { + if (_ambiguousText.Contains(entry.Text)) + result.Append("{0x").Append(Convert.ToHexString(entry.Bytes)).Append('}'); + else + result.Append(entry.Text); + offset += entry.Bytes.Length; + } + } + + return result.ToString(); + } + + public byte[] Encode(string text) + { + ArgumentNullException.ThrowIfNull(text); + + using var output = new MemoryStream(); + for (var offset = 0; offset < text.Length;) + { + if (TryReadRawByteToken(text, offset, out var rawBytes, out var tokenLength)) + { + output.Write(rawBytes); + offset += tokenLength; + continue; + } + + var entry = _encodeEntries.FirstOrDefault(x => + text.AsSpan(offset).StartsWith(x.Text.AsSpan(), StringComparison.Ordinal)); + if (entry == null) + { + var display = char.ConvertToUtf32(text, offset); + throw new InvalidDataException( + $"Text at position {offset + 1} cannot be encoded by the selected TBL (U+{display:X4})."); + } + + output.Write(entry.Bytes); + offset += entry.Text.Length; + } + + return output.ToArray(); + } + + private Entry FindDecodeEntry(ReadOnlySpan data) + { + foreach (var entry in _decodeEntries) + { + if (data.StartsWith(entry.Bytes)) + return entry; + } + return null; + } + + private static bool TryReadRawByteToken( + string text, + int offset, + out byte[] bytes, + out int tokenLength) + { + bytes = null; + tokenLength = 0; + if (!text.AsSpan(offset).StartsWith("{0x".AsSpan(), StringComparison.OrdinalIgnoreCase)) + return false; + + var closeBrace = text.IndexOf('}', offset + 3); + if (closeBrace < 0) + return false; + + var hex = text.Substring(offset + 3, closeBrace - offset - 3); + if (hex.Length == 0 || (hex.Length & 1) != 0 || !hex.All(Uri.IsHexDigit)) + return false; + + bytes = Convert.FromHexString(hex); + tokenLength = closeBrace - offset + 1; + return true; + } + } +} diff --git a/OpenKh.Tests/Kh1/Kh1TextTableTests.cs b/OpenKh.Tests/Kh1/Kh1TextTableTests.cs new file mode 100644 index 000000000..a65f77574 --- /dev/null +++ b/OpenKh.Tests/Kh1/Kh1TextTableTests.cs @@ -0,0 +1,140 @@ +using OpenKh.Kh1; +using System.IO; +using System.Text; +using Xunit; + +namespace OpenKh.Tests.Kh1 +{ + public class Kh1TextTableTests + { + private const string TableText = + "00={eol}\n" + + "01= \n" + + "02={lf}\n" + + "2B=A\n" + + "2C=B\n" + + "2D=C\n" + + "45=a\n" + + "68=.\n" + + "94={icon}\n" + + "95={icon}\n"; + + [Fact] + public void SpaceIs01AndZeroIsEol() + { + var table = ReadTable(); + + Assert.Equal(new byte[] { 0x01 }, table.Encode(" ")); + Assert.Equal(new byte[] { 0x00 }, table.Encode("{eol}")); + Assert.Equal(" ", table.Decode(new byte[] { 0x01 })); + Assert.Equal("{eol}", table.Decode(new byte[] { 0x00 })); + } + + [Fact] + public void DefaultTableContainsTheInternationalKh1Encoding() + { + var table = Kh1TextTable.Default; + + Assert.Equal(new byte[] { 0x01 }, table.Encode(" ")); + Assert.Equal(new byte[] { 0xCB, 0x3B, 0x59, 0xE9, 0x60 }, table.Encode("¿Qué?")); + Assert.Equal("ÁÉÍÓÚ áéíóú ñ", table.Decode(new byte[] + { + 0xCD, 0xD2, 0xD6, 0xDB, 0xDF, 0x01, + 0xE4, 0xE9, 0xED, 0xF2, 0xF6, 0x01, 0xF0, + })); + } + + [Fact] + public void DefaultTableRoundTripsEveryByte() + { + var table = Kh1TextTable.Default; + + for (var value = 0; value <= byte.MaxValue; value++) + { + var original = new[] { (byte)value }; + Assert.Equal(original, table.Encode(table.Decode(original))); + } + } + + [Fact] + public void AmbiguousTextUsesRawTokensForLosslessRoundTrip() + { + var table = ReadTable(); + + var decoded = table.Decode(new byte[] { 0x94, 0x95 }); + + Assert.Equal("{0x94}{0x95}", decoded); + Assert.Equal(new byte[] { 0x94, 0x95 }, table.Encode(decoded)); + } + + [Fact] + public void BinlRoundTripsCommandsPaddingAndFalseRecordMarkers() + { + var table = ReadTable(); + var original = CreateBinl(); + using var input = new MemoryStream(original); + + var binl = Kh1Binl.Read(input, table); + + Assert.Equal("SP", binl.Language); + Assert.Equal(2, binl.Entries.Count); + Assert.Equal("A B\r\nC.", binl.Entries[0].Text); + Assert.Equal("a{cmd:0C 04}A.", binl.Entries[1].Text); + + using var output = new MemoryStream(); + binl.Write(output); + Assert.Equal(original, output.ToArray()); + } + + [Fact] + public void BinlCanGrowAndBeReadAgain() + { + var table = ReadTable(); + using var input = new MemoryStream(CreateBinl()); + var binl = Kh1Binl.Read(input, table); + binl.Entries[0].Text = "A B A\nC."; + + using var output = new MemoryStream(); + binl.Write(output); + Assert.Equal(0, output.Length % 16); + + output.Position = 0; + var reopened = Kh1Binl.Read(output, table); + Assert.Equal("A B A\r\nC.", reopened.Entries[0].Text); + Assert.Equal(binl.Entries.Count, reopened.Entries.Count); + } + + private static Kh1TextTable ReadTable() + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(TableText)); + return Kh1TextTable.Read(stream); + } + + private static byte[] CreateBinl() + { + using var stream = new MemoryStream(); + stream.Write(Encoding.ASCII.GetBytes("EvMsgSP")); + stream.Write(new byte[] { 0x02, 0x00, 0x00, 0x00, 0x04 }); + + stream.Write(new byte[] + { + 0x0A, 0x00, 0x00, 0x00, + 0x07, 0x0C, 0x00, + 0x2B, 0x01, 0x2C, 0x02, 0x2D, 0x68, + 0x05, 0x6E, 0x00, + 0x0D, 0x01, 0x0A, 0x00, + 0x00, + }); + stream.Write(new byte[] + { + 0x0A, 0x00, 0x00, 0x00, + 0x07, 0x0C, 0x00, + 0x45, 0x0C, 0x04, 0x2B, 0x68, + 0x06, 0x3C, 0x00, 0x00, 0x08, + }); + while ((stream.Length & 0x0F) != 0) + stream.WriteByte(0xCD); + return stream.ToArray(); + } + } +} diff --git a/OpenKh.Tools.Kh1TextEditor/App.xaml b/OpenKh.Tools.Kh1TextEditor/App.xaml new file mode 100644 index 000000000..7f08851b9 --- /dev/null +++ b/OpenKh.Tools.Kh1TextEditor/App.xaml @@ -0,0 +1,7 @@ + + + + diff --git a/OpenKh.Tools.Kh1TextEditor/App.xaml.cs b/OpenKh.Tools.Kh1TextEditor/App.xaml.cs new file mode 100644 index 000000000..c4d9050fe --- /dev/null +++ b/OpenKh.Tools.Kh1TextEditor/App.xaml.cs @@ -0,0 +1,8 @@ +using System.Windows; + +namespace OpenKh.Tools.Kh1TextEditor +{ + public partial class App : Application + { + } +} diff --git a/OpenKh.Tools.Kh1TextEditor/OpenKh.Tools.Kh1TextEditor.csproj b/OpenKh.Tools.Kh1TextEditor/OpenKh.Tools.Kh1TextEditor.csproj new file mode 100644 index 000000000..d160c2ef6 --- /dev/null +++ b/OpenKh.Tools.Kh1TextEditor/OpenKh.Tools.Kh1TextEditor.csproj @@ -0,0 +1,20 @@ + + + + WinExe + net8.0-windows + latest + true + KH1 Text editor + KH1 Text editor - OpenKH + OpenKH contributors + OpenKH + Copyright (C) OpenKH + https://github.com/OpenKH/OpenKh + + + + + + + diff --git a/OpenKh.Tools.Kh1TextEditor/ViewModels/TextEntryViewModel.cs b/OpenKh.Tools.Kh1TextEditor/ViewModels/TextEntryViewModel.cs new file mode 100644 index 000000000..437b2efa5 --- /dev/null +++ b/OpenKh.Tools.Kh1TextEditor/ViewModels/TextEntryViewModel.cs @@ -0,0 +1,46 @@ +using OpenKh.Kh1; +using System; +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace OpenKh.Tools.Kh1TextEditor.ViewModels +{ + public sealed class TextEntryViewModel : INotifyPropertyChanged + { + private string _text; + + public TextEntryViewModel(Kh1Binl.TextEntry entry) + { + Entry = entry; + _text = entry.Text; + OriginalText = entry.Text; + } + + public Kh1Binl.TextEntry Entry { get; } + public int Index => Entry.Index; + public string Number => $"#{Index + 1:D3}"; + public string Offset => $"0x{Entry.Offset:X6}"; + public string OriginalText { get; } + public bool IsModified => !string.Equals(OriginalText, Text, StringComparison.Ordinal); + public string Preview => Text.Replace("\r", string.Empty).Replace("\n", " ↵ "); + + public string Text + { + get => _text; + set + { + if (string.Equals(_text, value, StringComparison.Ordinal)) + return; + _text = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(Preview)); + OnPropertyChanged(nameof(IsModified)); + } + } + + public event PropertyChangedEventHandler PropertyChanged; + + private void OnPropertyChanged([CallerMemberName] string propertyName = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/OpenKh.Tools.Kh1TextEditor/Views/MainWindow.xaml b/OpenKh.Tools.Kh1TextEditor/Views/MainWindow.xaml new file mode 100644 index 000000000..3adcb6637 --- /dev/null +++ b/OpenKh.Tools.Kh1TextEditor/Views/MainWindow.xaml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Space = 01 · New line = 02 · {eol} = 00. Keep {cmd:...} formatting tokens intact. + + + + + + diff --git a/OpenKh.Tools.Kh1TextEditor/Views/MainWindow.xaml.cs b/OpenKh.Tools.Kh1TextEditor/Views/MainWindow.xaml.cs new file mode 100644 index 000000000..682c7a09a --- /dev/null +++ b/OpenKh.Tools.Kh1TextEditor/Views/MainWindow.xaml.cs @@ -0,0 +1,213 @@ +using Microsoft.Win32; +using OpenKh.Kh1; +using OpenKh.Tools.Kh1TextEditor.ViewModels; +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Data; +using System.Windows.Input; + +namespace OpenKh.Tools.Kh1TextEditor.Views +{ + public partial class MainWindow : Window, INotifyPropertyChanged + { + private Kh1Binl _binl; + private string _binlFileName; + private TextEntryViewModel _selectedEntry; + private bool _isDirty; + + public MainWindow() + { + InitializeComponent(); + DataContext = this; + EntriesView = CollectionViewSource.GetDefaultView(Entries); + EntriesView.Filter = FilterEntry; + + var arguments = Environment.GetCommandLineArgs().Skip(1).ToArray(); + _binlFileName = arguments.FirstOrDefault(x => + string.Equals(Path.GetExtension(x), ".binl", StringComparison.OrdinalIgnoreCase)); + if (_binlFileName != null) + LoadFiles(); + else + UpdateStatus(); + } + + public ObservableCollection Entries { get; } = new(); + public ICollectionView EntriesView { get; } + + public TextEntryViewModel SelectedEntry + { + get => _selectedEntry; + set + { + _selectedEntry = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasSelection)); + } + } + + public bool HasSelection => SelectedEntry != null; + + private string _statusText; + public string StatusText + { + get => _statusText; + private set + { + _statusText = value; + OnPropertyChanged(); + } + } + + public event PropertyChangedEventHandler PropertyChanged; + + private void OpenBinl_Executed(object sender, ExecutedRoutedEventArgs e) + { + var dialog = new OpenFileDialog + { + Filter = "KH1 remastered messages (*.binl)|*.binl|All files (*.*)|*.*", + Title = "Open KH1 BINL", + }; + if (dialog.ShowDialog(this) != true) + return; + + if (!ConfirmDiscardChanges()) + return; + _binlFileName = dialog.FileName; + LoadFiles(); + } + + private void LoadFiles() + { + try + { + _binl = Kh1Binl.Read(_binlFileName); + + foreach (var oldEntry in Entries) + oldEntry.PropertyChanged -= Entry_PropertyChanged; + Entries.Clear(); + foreach (var entry in _binl.Entries.Select(x => new TextEntryViewModel(x))) + { + entry.PropertyChanged += Entry_PropertyChanged; + Entries.Add(entry); + } + + SelectedEntry = Entries.FirstOrDefault(); + _isDirty = false; + Title = $"{Path.GetFileName(_binlFileName)} | KH1 Text editor - OpenKH"; + UpdateStatus(); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, "Unable to open file", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private void Save_Executed(object sender, ExecutedRoutedEventArgs e) + { + if (_binl == null) + return; + SaveFile(_binlFileName); + } + + private void SaveAs_Click(object sender, RoutedEventArgs e) + { + if (_binl == null) + return; + var dialog = new SaveFileDialog + { + Filter = "KH1 remastered messages (*.binl)|*.binl|All files (*.*)|*.*", + FileName = Path.GetFileName(_binlFileName), + Title = "Save KH1 BINL as", + }; + if (dialog.ShowDialog(this) == true) + SaveFile(dialog.FileName); + } + + private void SaveFile(string fileName) + { + try + { + foreach (var viewModel in Entries) + viewModel.Entry.Text = viewModel.Text; + + using var memory = new MemoryStream(); + _binl.Write(memory); + File.WriteAllBytes(fileName, memory.ToArray()); + _binlFileName = fileName; + _isDirty = false; + Title = $"{Path.GetFileName(_binlFileName)} | KH1 Text editor - OpenKH"; + UpdateStatus(); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, "Unable to save file", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private void Entry_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(TextEntryViewModel.Text)) + { + _isDirty = true; + UpdateStatus(); + } + } + + private void SearchBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e) => + EntriesView.Refresh(); + + private bool FilterEntry(object item) + { + if (item is not TextEntryViewModel entry || string.IsNullOrWhiteSpace(SearchBox?.Text)) + return true; + return entry.Text.Contains(SearchBox.Text, StringComparison.CurrentCultureIgnoreCase) || + entry.Number.Contains(SearchBox.Text, StringComparison.OrdinalIgnoreCase) || + entry.Offset.Contains(SearchBox.Text, StringComparison.OrdinalIgnoreCase); + } + + private void About_Click(object sender, RoutedEventArgs e) + { + MessageBox.Show( + this, + "KH1 Text editor - OpenKH\n\nEdits remastered EvMsg BINL text using the built-in KH1 encoding.", + "About KH1 Text editor", + MessageBoxButton.OK, + MessageBoxImage.Information); + } + + private void Exit_Click(object sender, RoutedEventArgs e) => Close(); + + private void Window_Closing(object sender, CancelEventArgs e) + { + if (!ConfirmDiscardChanges()) + e.Cancel = true; + } + + private bool ConfirmDiscardChanges() + { + if (!_isDirty) + return true; + return MessageBox.Show( + this, + "There are unsaved changes. Discard them?", + "KH1 Text editor", + MessageBoxButton.YesNo, + MessageBoxImage.Warning) == MessageBoxResult.Yes; + } + + private void UpdateStatus() + { + var messageName = string.IsNullOrEmpty(_binlFileName) ? "no BINL" : Path.GetFileName(_binlFileName); + StatusText = $"{messageName} · built-in KH1 encoding · {Entries.Count} text entries" + + (_isDirty ? " · modified" : string.Empty); + } + + private void OnPropertyChanged([CallerMemberName] string propertyName = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/OpenKh.sln b/OpenKh.sln index d031d1f30..b79aa86cd 100644 --- a/OpenKh.sln +++ b/OpenKh.sln @@ -55,6 +55,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenKh.Engine", "OpenKh.Eng EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenKh.Tools.Kh2TextEditor", "OpenKh.Tools.Kh2TextEditor\OpenKh.Tools.Kh2TextEditor.csproj", "{3A619AFC-1FC1-4653-9AE9-F6C31B598A9B}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenKh.Tools.Kh1TextEditor", "OpenKh.Tools.Kh1TextEditor\OpenKh.Tools.Kh1TextEditor.csproj", "{7CB5BB76-6E0B-4C9E-8212-C8BD91F898E2}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenKh.Game", "OpenKh.Game\OpenKh.Game.csproj", "{D9B50EAF-4718-43C2-B34D-0E045D56B08E}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenKh.Tools.Kh2BattleEditor", "OpenKh.Tools.Kh2BattleEditor\OpenKh.Tools.Kh2BattleEditor.csproj", "{67810138-DACB-4A93-87FC-647BF73672AC}" @@ -942,6 +944,14 @@ Global {E9CAA582-1823-4B42-8218-F4B7B2C788D3}.Debug|Any CPU.Build.0 = Debug|Any CPU {E9CAA582-1823-4B42-8218-F4B7B2C788D3}.Release|Any CPU.ActiveCfg = Release|Any CPU {E9CAA582-1823-4B42-8218-F4B7B2C788D3}.Release|Any CPU.Build.0 = Release|Any CPU + {7CB5BB76-6E0B-4C9E-8212-C8BD91F898E2}..NET Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7CB5BB76-6E0B-4C9E-8212-C8BD91F898E2}..NET Debug|Any CPU.Build.0 = Debug|Any CPU + {7CB5BB76-6E0B-4C9E-8212-C8BD91F898E2}..NET Release|Any CPU.ActiveCfg = Release|Any CPU + {7CB5BB76-6E0B-4C9E-8212-C8BD91F898E2}..NET Release|Any CPU.Build.0 = Release|Any CPU + {7CB5BB76-6E0B-4C9E-8212-C8BD91F898E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7CB5BB76-6E0B-4C9E-8212-C8BD91F898E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7CB5BB76-6E0B-4C9E-8212-C8BD91F898E2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7CB5BB76-6E0B-4C9E-8212-C8BD91F898E2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1039,6 +1049,7 @@ Global {A8DAC079-E416-4574-9B54-19EED185D368} = {9C52D787-9819-4B89-989B-EEA6FEB20731} {D58EEC5B-962C-4888-953B-4A443015E45D} = {402B2669-D594-4DFA-965B-02A92626ADC6} {E9CAA582-1823-4B42-8218-F4B7B2C788D3} = {0FB7CD6A-EE31-467D-A590-267DCD028618} + {7CB5BB76-6E0B-4C9E-8212-C8BD91F898E2} = {402B2669-D594-4DFA-965B-02A92626ADC6} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {1DF3960B-4FE5-4E56-92D4-2FC0A912E823} diff --git a/docs/tool/GUI.Kh1TextEditor/index.md b/docs/tool/GUI.Kh1TextEditor/index.md new file mode 100644 index 000000000..c497426b1 --- /dev/null +++ b/docs/tool/GUI.Kh1TextEditor/index.md @@ -0,0 +1,24 @@ +# KH1 Text Editor + +`OpenKh.Tools.Kh1TextEditor` edits event-message text from Kingdom Hearts 1.5 +ReMIX `*.binl` files. The KH1 character table is compiled into the tool, so no +external `*.tbl` file is required. + +## Usage + +1. Open the extracted `*.binl` file with **File > Open BINL**. +2. Search for an entry, edit its text, and use **Save as** to keep the original + file as a backup. + +The built-in KH1 table uses `01` for a space, `02` for a line break, and `00` +for `{eol}`. A line break typed in the editor is saved as `02`. + +The table is maintained in `OpenKh.Kh1/Kh1TextTable.cs`. Update +`CreateDefault()` there when a character mapping needs to change. + +Tokens formatted as `{cmd:...}` are BINL control commands. Keep them intact +unless you understand the command bytecode. Unknown or ambiguous table values +are shown losslessly as `{0xNN}`. + +The editor preserves all data outside the editable text ranges and restores the +file's 16-byte alignment with `CD` padding when saving. diff --git a/docs/tool/index.md b/docs/tool/index.md index 73c955a0f..f89df97c6 100644 --- a/docs/tool/index.md +++ b/docs/tool/index.md @@ -58,3 +58,4 @@ If you have never used the command line before or have used it very little, fear | File | Tool name |------|----------- |[HD assets](../common/hdassets.md)| OpenKh.Command.HdAssets +|KH1 `*.binl` event messages| [OpenKh.Tools.Kh1TextEditor](./GUI.Kh1TextEditor/index.md) From cb87e61e682998642122e1015434c3a00fcd1830 Mon Sep 17 00:00:00 2001 From: Yokimitsuro Date: Fri, 14 Aug 2026 15:25:20 +0200 Subject: [PATCH 2/7] Extend KH1 text editor with folder and KMB support --- OpenKh.Kh1/Kh1Binl.cs | 30 +- OpenKh.Kh1/Kh1Kmb.cs | 178 +++++++++++ OpenKh.Kh1/Kh1MessageV361.cs | 253 +++++++++++++++ OpenKh.Tests/Kh1/Kh1TextTableTests.cs | 120 +++++++ .../Models/LoadedDocument.cs | 118 +++++++ .../Models/TextOccurrence.cs | 32 ++ .../ViewModels/TextEntryViewModel.cs | 58 +++- .../Views/MainWindow.xaml | 51 +-- .../Views/MainWindow.xaml.cs | 300 ++++++++++++++---- docs/tool/GUI.Kh1TextEditor/index.md | 25 +- docs/tool/index.md | 2 +- 11 files changed, 1050 insertions(+), 117 deletions(-) create mode 100644 OpenKh.Kh1/Kh1Kmb.cs create mode 100644 OpenKh.Kh1/Kh1MessageV361.cs create mode 100644 OpenKh.Tools.Kh1TextEditor/Models/LoadedDocument.cs create mode 100644 OpenKh.Tools.Kh1TextEditor/Models/TextOccurrence.cs diff --git a/OpenKh.Kh1/Kh1Binl.cs b/OpenKh.Kh1/Kh1Binl.cs index 3f6040ba2..9d0a3ad62 100644 --- a/OpenKh.Kh1/Kh1Binl.cs +++ b/OpenKh.Kh1/Kh1Binl.cs @@ -15,6 +15,7 @@ public sealed class Kh1Binl public sealed class TextEntry { private readonly Kh1TextTable _table; + private readonly byte[] _originalBytes; internal TextEntry(int index, int offset, int length, byte[] bytes, Kh1TextTable table) { @@ -22,16 +23,27 @@ internal TextEntry(int index, int offset, int length, byte[] bytes, Kh1TextTable Offset = offset; OriginalLength = length; _table = table; + _originalBytes = bytes; Text = DecodeBody(bytes); + OriginalText = Text; } public int Index { get; } public int Offset { get; } public int OriginalLength { get; } + public string OriginalText { get; } public string Text { get; set; } + public bool IsModified => !string.Equals(OriginalText, Text, StringComparison.Ordinal); + internal bool ContainsStructuralCommands => + Text.Contains("{cmd:05", StringComparison.OrdinalIgnoreCase) || + Text.Contains("{cmd:06", StringComparison.OrdinalIgnoreCase) || + Text.Contains("{cmd:0A", StringComparison.OrdinalIgnoreCase); internal byte[] EncodeBody() { + if (!IsModified) + return _originalBytes; + var output = new List(); var plainText = new StringBuilder(); @@ -214,18 +226,17 @@ public static Kh1Binl Read(Stream stream, Kh1TextTable table) : contentLength; if (TryFindBody(source, recordStart + 4, recordEnd, out var bodyStart, out var bodyEnd)) { - entries.Add(new TextEntry( + var entry = new TextEntry( entries.Count, bodyStart, bodyEnd - bodyStart, source.AsSpan(bodyStart, bodyEnd - bodyStart).ToArray(), - table)); + table); + if (!entry.ContainsStructuralCommands) + entries.Add(entry); } } - if (entries.Count == 0) - throw new InvalidDataException("No editable text entries were found in the BINL file."); - return new Kh1Binl(source, contentLength, entries); } @@ -310,12 +321,9 @@ private static bool TryFindBody( offset += length; } - if (bodyStart >= 0) - { - bodyEnd = end; - return true; - } - + // Records without a 05/06 terminator contain presentation data rather + // than an editable dialogue body. Keeping them out of the entry list + // avoids exposing command parameters as black-square characters. return false; } diff --git a/OpenKh.Kh1/Kh1Kmb.cs b/OpenKh.Kh1/Kh1Kmb.cs new file mode 100644 index 000000000..68ffb0965 --- /dev/null +++ b/OpenKh.Kh1/Kh1Kmb.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace OpenKh.Kh1 +{ + /// + /// Preserving editor for remastered KH1 menu-message *.kmb files. + /// + public sealed class Kh1Kmb + { + public sealed class TextEntry + { + private readonly Kh1TextTable _table; + private readonly byte[] _originalBytes; + + internal TextEntry(int index, int offset, byte[] bytes, Kh1TextTable table) + { + Index = index; + Offset = offset; + OriginalLength = bytes.Length; + _originalBytes = bytes; + _table = table; + Text = Decode(bytes, table); + OriginalText = Text; + } + + public int Index { get; } + public int Offset { get; } + public int OriginalLength { get; } + public string OriginalText { get; } + public string Text { get; set; } + public bool IsModified => !string.Equals(OriginalText, Text, StringComparison.Ordinal); + + internal byte[] Encode() + { + if (!IsModified) + return _originalBytes; + + using var output = new MemoryStream(); + var plainText = new StringBuilder(); + + void FlushPlainText() + { + if (plainText.Length == 0) + return; + output.Write(_table.Encode(plainText.ToString())); + plainText.Clear(); + } + + for (var position = 0; position < Text.Length; position++) + { + if (Text[position] != '\r' && Text[position] != '\n') + { + plainText.Append(Text[position]); + continue; + } + + FlushPlainText(); + if (Text[position] == '\r' && position + 1 < Text.Length && Text[position + 1] == '\n') + position++; + output.WriteByte(0x02); + } + + FlushPlainText(); + var bytes = output.ToArray(); + if (bytes.Contains((byte)0x00)) + throw new InvalidDataException("KMB text cannot contain {eol}; byte 00 terminates the entry."); + return bytes; + } + + private static string Decode(byte[] bytes, Kh1TextTable table) + { + var output = new StringBuilder(); + var textStart = 0; + for (var offset = 0; offset < bytes.Length; offset++) + { + if (bytes[offset] != 0x02) + continue; + + if (offset > textStart) + output.Append(table.Decode(bytes.AsSpan(textStart, offset - textStart))); + output.AppendLine(); + textStart = offset + 1; + } + + if (textStart < bytes.Length) + output.Append(table.Decode(bytes.AsSpan(textStart))); + return output.ToString(); + } + } + + private readonly byte[] _source; + private readonly bool _usesCdPadding; + + private Kh1Kmb(byte[] source, IReadOnlyList entries) + { + _source = source; + _usesCdPadding = source.Length > 0 && source[source.Length - 1] == 0xCD; + Entries = entries; + } + + public IReadOnlyList Entries { get; } + + public static Kh1Kmb Read(Stream stream, Kh1TextTable table) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentNullException.ThrowIfNull(table); + + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + var source = buffer.ToArray(); + if (source.Length < sizeof(uint)) + throw new InvalidDataException("The file is too small to be a KH1 KMB file."); + + var count = BitConverter.ToUInt32(source, 0); + if (count > int.MaxValue || count > source.Length - sizeof(uint)) + throw new InvalidDataException("The KMB entry count is invalid."); + + var entries = new List((int)count); + var offset = sizeof(uint); + for (var index = 0; index < count; index++) + { + var end = Array.IndexOf(source, (byte)0x00, offset); + if (end < 0) + throw new InvalidDataException($"KMB entry #{index + 1} has no 00 terminator."); + + entries.Add(new TextEntry( + index, + offset, + source.AsSpan(offset, end - offset).ToArray(), + table)); + offset = end + 1; + } + + return new Kh1Kmb(source, entries); + } + + public static Kh1Kmb Read(Stream stream) => Read(stream, Kh1TextTable.Default); + + public static Kh1Kmb Read(string fileName, Kh1TextTable table) + { + using var stream = File.OpenRead(fileName); + return Read(stream, table); + } + + public static Kh1Kmb Read(string fileName) => Read(fileName, Kh1TextTable.Default); + + public void Write(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + + if (!Entries.Any(x => x.IsModified)) + { + stream.Write(_source); + return; + } + + stream.Write(_source, 0, sizeof(uint)); + foreach (var entry in Entries) + { + stream.Write(entry.Encode()); + stream.WriteByte(0x00); + } + + var minimumLength = stream.Position + (_usesCdPadding ? 1 : 0); + var targetLength = Math.Max(_source.Length, Align16(minimumLength)); + if (_usesCdPadding) + stream.WriteByte(0x00); + while (stream.Position < targetLength) + stream.WriteByte(_usesCdPadding ? (byte)0xCD : (byte)0x00); + } + + private static long Align16(long value) => (value + 0x0F) & ~0x0F; + } +} diff --git a/OpenKh.Kh1/Kh1MessageV361.cs b/OpenKh.Kh1/Kh1MessageV361.cs new file mode 100644 index 000000000..67b2b5c8d --- /dev/null +++ b/OpenKh.Kh1/Kh1MessageV361.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace OpenKh.Kh1 +{ + /// + /// Preserving editor for the remastered KH1 "Message v361" BINL format. + /// + public sealed class Kh1MessageV361 + { + private static readonly byte[] Magic = Encoding.ASCII.GetBytes("Message v361"); + + public sealed class TextEntry + { + private readonly Kh1TextTable _table; + private readonly byte[] _originalBytes; + + internal TextEntry(int index, int offset, byte[] bytes, Kh1TextTable table) + { + Index = index; + Offset = offset; + OriginalLength = bytes.Length; + _originalBytes = bytes; + _table = table; + Text = Decode(bytes, table); + OriginalText = Text; + } + + public int Index { get; } + public int Offset { get; } + public int OriginalLength { get; } + public string OriginalText { get; } + public string Text { get; set; } + public bool IsModified => !string.Equals(OriginalText, Text, StringComparison.Ordinal); + + internal byte[] Encode() + { + if (!IsModified) + return _originalBytes; + + using var output = new MemoryStream(); + var plainText = new StringBuilder(); + void FlushPlainText() + { + if (plainText.Length == 0) + return; + output.Write(_table.Encode(plainText.ToString())); + plainText.Clear(); + } + + for (var position = 0; position < Text.Length; position++) + { + if (Text[position] != '\r' && Text[position] != '\n') + { + plainText.Append(Text[position]); + continue; + } + + FlushPlainText(); + if (Text[position] == '\r' && position + 1 < Text.Length && Text[position + 1] == '\n') + position++; + output.WriteByte(0x02); + } + + FlushPlainText(); + var bytes = output.ToArray(); + if (bytes.Contains((byte)0x00)) + throw new InvalidDataException("Message v361 text cannot contain {eol}; byte 00 terminates the entry."); + return bytes; + } + + private static string Decode(byte[] bytes, Kh1TextTable table) + { + var output = new StringBuilder(); + var textStart = 0; + for (var offset = 0; offset < bytes.Length; offset++) + { + if (bytes[offset] != 0x02) + continue; + if (offset > textStart) + output.Append(table.Decode(bytes.AsSpan(textStart, offset - textStart))); + output.AppendLine(); + textStart = offset + 1; + } + if (textStart < bytes.Length) + output.Append(table.Decode(bytes.AsSpan(textStart))); + return output.ToString(); + } + } + + private readonly byte[] _source; + private readonly int _offsetTableOffset; + private readonly int _textOffset; + private readonly int _offsetCount; + private readonly bool _hasTrailingSentinel; + private readonly bool _usesCdPadding; + + private Kh1MessageV361( + byte[] source, + int offsetTableOffset, + int textOffset, + int offsetCount, + bool hasTrailingSentinel, + IReadOnlyList entries) + { + _source = source; + _offsetTableOffset = offsetTableOffset; + _textOffset = textOffset; + _offsetCount = offsetCount; + _hasTrailingSentinel = hasTrailingSentinel; + _usesCdPadding = source.Length > 0 && source[source.Length - 1] == 0xCD; + Entries = entries; + } + + public IReadOnlyList Entries { get; } + + public static bool IsValid(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + if (!stream.CanSeek || stream.Length < 0x20) + return false; + var oldPosition = stream.Position; + Span magic = stackalloc byte[12]; + var read = stream.Read(magic); + stream.Position = oldPosition; + return read == magic.Length && magic.SequenceEqual(Magic); + } + + public static Kh1MessageV361 Read(Stream stream, Kh1TextTable table) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentNullException.ThrowIfNull(table); + if (!IsValid(stream)) + throw new InvalidDataException("The file is not a KH1 Message v361 BINL file."); + + stream.Position = 0; + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + var source = buffer.ToArray(); + var count = BitConverter.ToUInt32(source, 0x0C); + var offsetTableOffset = checked((int)BitConverter.ToUInt32(source, 0x10)); + var textOffset = checked((int)BitConverter.ToUInt32(source, 0x14)); + var offsetTableLength = checked((int)BitConverter.ToUInt32(source, 0x18)); + var textLength = checked((int)BitConverter.ToUInt32(source, 0x1C)); + + var offsetCount = offsetTableLength / sizeof(ushort); + if (count == 0 || count > int.MaxValue || + offsetTableOffset < 0x20 || + (offsetTableLength & 1) != 0 || + (offsetCount != count && offsetCount != count + 1) || + textOffset != offsetTableOffset + offsetTableLength || + textLength < 1 || textOffset + textLength > source.Length || + source[textOffset + textLength - 1] != 0x00) + throw new InvalidDataException("The Message v361 header is invalid."); + + var entries = new List((int)count); + var offsets = new ushort[offsetCount]; + for (var index = 0; index < offsetCount; index++) + { + offsets[index] = BitConverter.ToUInt16(source, offsetTableOffset + index * sizeof(ushort)); + if ((index == 0 && offsets[index] != 0) || + (index > 0 && offsets[index] < offsets[index - 1]) || + offsets[index] >= textLength) + throw new InvalidDataException("The Message v361 offset table is invalid."); + } + + var hasTrailingSentinel = offsetCount > count || + (offsets[count - 1] < textLength - 1 && + source[textOffset + textLength - 2] == 0x00); + var entriesEnd = textLength - (hasTrailingSentinel ? 1 : 0); + if (offsetCount > count && offsets[count] != entriesEnd) + throw new InvalidDataException("The Message v361 final offset is invalid."); + for (var index = 0; index < count; index++) + { + var start = offsets[index]; + var end = index + 1 < count ? offsets[index + 1] : entriesEnd; + var length = end - start; + if (length == 0 || source[textOffset + end - 1] != 0x00) + throw new InvalidDataException($"Message v361 entry #{index + 1} has no 00 terminator."); + entries.Add(new TextEntry( + index, + textOffset + start, + source.AsSpan(textOffset + start, length - 1).ToArray(), + table)); + } + + return new Kh1MessageV361( + source, + offsetTableOffset, + textOffset, + offsetCount, + hasTrailingSentinel, + entries); + } + + public static Kh1MessageV361 Read(Stream stream) => Read(stream, Kh1TextTable.Default); + + public static Kh1MessageV361 Read(string fileName, Kh1TextTable table) + { + using var stream = File.OpenRead(fileName); + return Read(stream, table); + } + + public static Kh1MessageV361 Read(string fileName) => Read(fileName, Kh1TextTable.Default); + + public void Write(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + if (!Entries.Any(x => x.IsModified)) + { + stream.Write(_source); + return; + } + + var encoded = Entries.Select(x => x.Encode()).ToList(); + var entriesLength = encoded.Sum(x => x.Length + 1); + var textLength = entriesLength + (_hasTrailingSentinel ? 1 : 0); + if (textLength > ushort.MaxValue) + throw new InvalidDataException("The edited Message v361 text block exceeds 65535 bytes."); + + var header = _source.AsSpan(0, _textOffset).ToArray(); + BitConverter.GetBytes(textLength).CopyTo(header, 0x1C); + var currentOffset = 0; + for (var index = 0; index < encoded.Count; index++) + { + BitConverter.GetBytes((ushort)currentOffset) + .CopyTo(header, _offsetTableOffset + index * sizeof(ushort)); + currentOffset += encoded[index].Length + 1; + } + if (_offsetCount > encoded.Count) + BitConverter.GetBytes((ushort)currentOffset) + .CopyTo(header, _offsetTableOffset + encoded.Count * sizeof(ushort)); + + stream.Write(header); + foreach (var entry in encoded) + { + stream.Write(entry); + stream.WriteByte(0x00); + } + if (_hasTrailingSentinel) + stream.WriteByte(0x00); + + var targetLength = Math.Max(_source.Length, Align16(stream.Position)); + while (stream.Position < targetLength) + stream.WriteByte(_usesCdPadding ? (byte)0xCD : (byte)0x00); + } + + private static long Align16(long value) => (value + 0x0F) & ~0x0F; + } +} diff --git a/OpenKh.Tests/Kh1/Kh1TextTableTests.cs b/OpenKh.Tests/Kh1/Kh1TextTableTests.cs index a65f77574..1f43a49dc 100644 --- a/OpenKh.Tests/Kh1/Kh1TextTableTests.cs +++ b/OpenKh.Tests/Kh1/Kh1TextTableTests.cs @@ -1,4 +1,5 @@ using OpenKh.Kh1; +using System; using System.IO; using System.Text; using Xunit; @@ -104,6 +105,97 @@ public void BinlCanGrowAndBeReadAgain() Assert.Equal(binl.Entries.Count, reopened.Entries.Count); } + [Fact] + public void BinlIgnoresPresentationRecordsWithoutATextTerminator() + { + var table = ReadTable(); + var original = CreateBinl(); + using var stream = new MemoryStream(); + stream.Write(original, 0, 11); + stream.Write(new byte[] + { + 0x0A, 0x00, 0x00, 0x00, + 0x07, 0x0C, 0x00, + 0x0B, 0x00, 0x04, 0x00, + 0x81, 0x81, 0x81, + }); + while ((stream.Length & 0x0F) != 0) + stream.WriteByte(0xCD); + stream.Position = 0; + + var binl = Kh1Binl.Read(stream, table); + Assert.Empty(binl.Entries); + + using var output = new MemoryStream(); + binl.Write(output); + Assert.Equal(stream.ToArray(), output.ToArray()); + } + + [Fact] + public void KmbRoundTripsEntriesAndZeroPadding() + { + var table = ReadTable(); + var original = CreateKmb(0x00); + using var input = new MemoryStream(original); + + var kmb = Kh1Kmb.Read(input, table); + + Assert.Equal(3, kmb.Entries.Count); + Assert.Equal("A B", kmb.Entries[0].Text); + Assert.Equal("a\r\nA", kmb.Entries[1].Text); + Assert.Equal("{0x0F}", kmb.Entries[2].Text); + + using var output = new MemoryStream(); + kmb.Write(output); + Assert.Equal(original, output.ToArray()); + } + + [Theory] + [InlineData(0x00)] + [InlineData(0xCD)] + public void KmbCanGrowAndBeReadAgain(byte padding) + { + var table = ReadTable(); + using var input = new MemoryStream(CreateKmb(padding)); + var kmb = Kh1Kmb.Read(input, table); + kmb.Entries[0].Text = "A B A B A B A B A B"; + + using var output = new MemoryStream(); + kmb.Write(output); + Assert.Equal(0, output.Length % 16); + + output.Position = 0; + var reopened = Kh1Kmb.Read(output, table); + Assert.Equal("A B A B A B A B A B", reopened.Entries[0].Text); + Assert.Equal(3, reopened.Entries.Count); + } + + [Fact] + public void MessageV361RoundTripsAndCanGrow() + { + var table = ReadTable(); + var original = CreateMessageV361(); + using var input = new MemoryStream(original); + var message = Kh1MessageV361.Read(input, table); + + Assert.Equal(2, message.Entries.Count); + Assert.Equal("A", message.Entries[0].Text); + Assert.Equal("a\r\nA", message.Entries[1].Text); + + using var unchanged = new MemoryStream(); + message.Write(unchanged); + Assert.Equal(original, unchanged.ToArray()); + + message.Entries[0].Text = "A B A B A B A B A B"; + using var edited = new MemoryStream(); + message.Write(edited); + Assert.Equal(0, edited.Length % 16); + edited.Position = 0; + var reopened = Kh1MessageV361.Read(edited, table); + Assert.Equal("A B A B A B A B A B", reopened.Entries[0].Text); + Assert.Equal("a\r\nA", reopened.Entries[1].Text); + } + private static Kh1TextTable ReadTable() { using var stream = new MemoryStream(Encoding.UTF8.GetBytes(TableText)); @@ -136,5 +228,33 @@ private static byte[] CreateBinl() stream.WriteByte(0xCD); return stream.ToArray(); } + + private static byte[] CreateKmb(byte padding) + { + using var stream = new MemoryStream(); + stream.Write(BitConverter.GetBytes(3)); + stream.Write(new byte[] { 0x2B, 0x01, 0x2C, 0x00 }); + stream.Write(new byte[] { 0x45, 0x02, 0x2B, 0x00 }); + stream.Write(new byte[] { 0x0F, 0x00 }); + while ((stream.Length & 0x0F) != 0) + stream.WriteByte(padding); + return stream.ToArray(); + } + + private static byte[] CreateMessageV361() + { + using var stream = new MemoryStream(); + stream.Write(Encoding.ASCII.GetBytes("Message v361")); + stream.Write(BitConverter.GetBytes(2)); + stream.Write(BitConverter.GetBytes(0x20)); + stream.Write(BitConverter.GetBytes(0x26)); + stream.Write(BitConverter.GetBytes(0x06)); + stream.Write(BitConverter.GetBytes(0x07)); + stream.Write(new byte[] { 0x00, 0x00, 0x02, 0x00, 0x06, 0x00 }); + stream.Write(new byte[] { 0x2B, 0x00, 0x45, 0x02, 0x2B, 0x00, 0x00 }); + while ((stream.Length & 0x0F) != 0) + stream.WriteByte(0xCD); + return stream.ToArray(); + } } } diff --git a/OpenKh.Tools.Kh1TextEditor/Models/LoadedDocument.cs b/OpenKh.Tools.Kh1TextEditor/Models/LoadedDocument.cs new file mode 100644 index 000000000..48aac1d05 --- /dev/null +++ b/OpenKh.Tools.Kh1TextEditor/Models/LoadedDocument.cs @@ -0,0 +1,118 @@ +using OpenKh.Kh1; +using System; +using System.Collections.Generic; +using System.IO; + +namespace OpenKh.Tools.Kh1TextEditor.Models +{ + internal sealed class LoadedDocument + { + private readonly Action _write; + + private LoadedDocument(string fileName, string relativePath, string format, Action write) + { + FileName = fileName; + RelativePath = relativePath; + Format = format; + _write = write; + } + + public string FileName { get; } + public string RelativePath { get; } + public string Format { get; } + public List Entries { get; } = new(); + + public static LoadedDocument Read(string fileName, string rootPath) + { + var extension = Path.GetExtension(fileName); + var relativePath = Directory.Exists(rootPath) + ? Path.GetRelativePath(rootPath, fileName) + : Path.GetFileName(fileName); + + if (string.Equals(extension, ".binl", StringComparison.OrdinalIgnoreCase)) + { + using var stream = File.OpenRead(fileName); + if (Kh1Binl.IsValid(stream)) + { + var binl = Kh1Binl.Read(stream); + var document = new LoadedDocument(fileName, relativePath, "BINL", binl.Write); + foreach (var item in binl.Entries) + { + var entry = item; + document.Entries.Add(new TextOccurrence( + document, + entry.Index, + entry.Offset, + () => entry.Text, + value => entry.Text = value)); + } + return document; + } + + if (Kh1MessageV361.IsValid(stream)) + { + var message = Kh1MessageV361.Read(stream); + var document = new LoadedDocument(fileName, relativePath, "BINL-v361", message.Write); + foreach (var item in message.Entries) + { + var entry = item; + document.Entries.Add(new TextOccurrence( + document, + entry.Index, + entry.Offset, + () => entry.Text, + value => entry.Text = value)); + } + return document; + } + + return null; + } + + if (string.Equals(extension, ".kmb", StringComparison.OrdinalIgnoreCase)) + { + var kmb = Kh1Kmb.Read(fileName); + var document = new LoadedDocument(fileName, relativePath, "KMB", kmb.Write); + foreach (var item in kmb.Entries) + { + var entry = item; + document.Entries.Add(new TextOccurrence( + document, + entry.Index, + entry.Offset, + () => entry.Text, + value => entry.Text = value)); + } + return document; + } + + throw new InvalidDataException("Only KH1 BINL and KMB files are supported."); + } + + public byte[] BuildFile() + { + using var output = new MemoryStream(); + _write(output); + return output.ToArray(); + } + + public static void WriteFile(string fileName, byte[] data) + { + var directory = Path.GetDirectoryName(Path.GetFullPath(fileName)); + Directory.CreateDirectory(directory); + var temporaryFile = Path.Combine( + directory, + $".{Path.GetFileName(fileName)}.{Guid.NewGuid():N}.tmp"); + try + { + File.WriteAllBytes(temporaryFile, data); + File.Move(temporaryFile, fileName, true); + } + finally + { + if (File.Exists(temporaryFile)) + File.Delete(temporaryFile); + } + } + } +} diff --git a/OpenKh.Tools.Kh1TextEditor/Models/TextOccurrence.cs b/OpenKh.Tools.Kh1TextEditor/Models/TextOccurrence.cs new file mode 100644 index 000000000..f7fc1a609 --- /dev/null +++ b/OpenKh.Tools.Kh1TextEditor/Models/TextOccurrence.cs @@ -0,0 +1,32 @@ +using System; + +namespace OpenKh.Tools.Kh1TextEditor.Models +{ + internal sealed class TextOccurrence + { + private readonly Func _getText; + private readonly Action _setText; + + public TextOccurrence( + LoadedDocument document, + int index, + int offset, + Func getText, + Action setText) + { + Document = document; + Index = index; + Offset = offset; + _getText = getText; + _setText = setText; + } + + public LoadedDocument Document { get; } + public int Index { get; } + public int Offset { get; } + public string Text => _getText(); + public string Location => $"{Document.RelativePath} #{Index + 1:D3} 0x{Offset:X6}"; + + public void SetText(string text) => _setText(text); + } +} diff --git a/OpenKh.Tools.Kh1TextEditor/ViewModels/TextEntryViewModel.cs b/OpenKh.Tools.Kh1TextEditor/ViewModels/TextEntryViewModel.cs index 437b2efa5..135296f84 100644 --- a/OpenKh.Tools.Kh1TextEditor/ViewModels/TextEntryViewModel.cs +++ b/OpenKh.Tools.Kh1TextEditor/ViewModels/TextEntryViewModel.cs @@ -1,28 +1,43 @@ -using OpenKh.Kh1; +using OpenKh.Tools.Kh1TextEditor.Models; using System; +using System.Collections.Generic; using System.ComponentModel; +using System.Linq; using System.Runtime.CompilerServices; namespace OpenKh.Tools.Kh1TextEditor.ViewModels { public sealed class TextEntryViewModel : INotifyPropertyChanged { + private readonly IReadOnlyList _occurrences; + private string _locations; + private string _originalText; private string _text; - public TextEntryViewModel(Kh1Binl.TextEntry entry) + internal TextEntryViewModel(int index, IGrouping group) { - Entry = entry; - _text = entry.Text; - OriginalText = entry.Text; + Index = index; + _occurrences = group.ToList(); + _text = group.Key; + _originalText = group.Key; + } - public Kh1Binl.TextEntry Entry { get; } - public int Index => Entry.Index; - public string Number => $"#{Index + 1:D3}"; - public string Offset => $"0x{Entry.Offset:X6}"; - public string OriginalText { get; } - public bool IsModified => !string.Equals(OriginalText, Text, StringComparison.Ordinal); + public int Index { get; } + public string Number => $"#{Index + 1:D4}"; + public string OriginalText => _originalText; + public bool IsModified => !string.Equals(_originalText, Text, StringComparison.Ordinal); public string Preview => Text.Replace("\r", string.Empty).Replace("\n", " ↵ "); + public int OccurrenceCount => _occurrences.Count; + public int FileCount => _occurrences.Select(x => x.Document).Distinct().Count(); + public string Occurrences => OccurrenceCount == 1 + ? "1 occurrence" + : $"{OccurrenceCount} occurrences in {FileCount} file(s)"; + public string Formats => string.Join("/", _occurrences.Select(x => x.Document.Format).Distinct()); + public string Locations => _locations ??= BuildLocations(); + internal IEnumerable Documents => _occurrences.Select(x => x.Document).Distinct(); + internal bool ContainsLocation(string search) => _occurrences.Any(x => + x.Document.RelativePath.Contains(search, StringComparison.CurrentCultureIgnoreCase)); public string Text { @@ -40,6 +55,27 @@ public string Text public event PropertyChangedEventHandler PropertyChanged; + internal void Apply() + { + foreach (var occurrence in _occurrences) + occurrence.SetText(Text); + } + + internal void AcceptChanges() + { + _originalText = Text; + OnPropertyChanged(nameof(OriginalText)); + OnPropertyChanged(nameof(IsModified)); + } + + private string BuildLocations() + { + var locations = _occurrences.Take(200).Select(x => x.Location).ToList(); + if (_occurrences.Count > locations.Count) + locations.Add($"... and {_occurrences.Count - locations.Count} more occurrences"); + return string.Join(Environment.NewLine, locations); + } + private void OnPropertyChanged([CallerMemberName] string propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } diff --git a/OpenKh.Tools.Kh1TextEditor/Views/MainWindow.xaml b/OpenKh.Tools.Kh1TextEditor/Views/MainWindow.xaml index 3adcb6637..37f9408b4 100644 --- a/OpenKh.Tools.Kh1TextEditor/Views/MainWindow.xaml +++ b/OpenKh.Tools.Kh1TextEditor/Views/MainWindow.xaml @@ -1,11 +1,11 @@ - + @@ -16,10 +16,11 @@ - + + - + @@ -36,9 +37,9 @@ - + - + @@ -47,22 +48,25 @@ - + + ScrollViewer.HorizontalScrollBarVisibility="Disabled" + VirtualizingPanel.IsVirtualizing="True" + VirtualizingPanel.VirtualizationMode="Recycling"> - + - - + + + - - + + + @@ -76,19 +80,26 @@ + - + + - + + + + - Space = 01 · New line = 02 · {eol} = 00. Keep {cmd:...} formatting tokens intact. + Space = 01 · New line = 02 · {eol} = 00. In folder mode, an edit updates every identical occurrence shown above. Keep {cmd:...} and {0xNN} tokens intact. diff --git a/OpenKh.Tools.Kh1TextEditor/Views/MainWindow.xaml.cs b/OpenKh.Tools.Kh1TextEditor/Views/MainWindow.xaml.cs index 682c7a09a..3d78a7776 100644 --- a/OpenKh.Tools.Kh1TextEditor/Views/MainWindow.xaml.cs +++ b/OpenKh.Tools.Kh1TextEditor/Views/MainWindow.xaml.cs @@ -1,12 +1,13 @@ using Microsoft.Win32; -using OpenKh.Kh1; +using OpenKh.Tools.Kh1TextEditor.Models; using OpenKh.Tools.Kh1TextEditor.ViewModels; using System; -using System.Collections.ObjectModel; +using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.Linq; using System.Runtime.CompilerServices; +using System.Threading.Tasks; using System.Windows; using System.Windows.Data; using System.Windows.Input; @@ -15,29 +16,49 @@ namespace OpenKh.Tools.Kh1TextEditor.Views { public partial class MainWindow : Window, INotifyPropertyChanged { - private Kh1Binl _binl; - private string _binlFileName; + private sealed class LoadResult + { + public List Documents { get; } = new(); + public List Errors { get; } = new(); + } + + private List _documents = new(); + private List _entries = new(); + private ICollectionView _entriesView; private TextEntryViewModel _selectedEntry; + private string _sourcePath; + private string _statusText; + private bool _isFolder; private bool _isDirty; + private bool _isBusy; public MainWindow() { InitializeComponent(); DataContext = this; - EntriesView = CollectionViewSource.GetDefaultView(Entries); - EntriesView.Filter = FilterEntry; + SetEntries(new List()); + Loaded += MainWindow_Loaded; + } - var arguments = Environment.GetCommandLineArgs().Skip(1).ToArray(); - _binlFileName = arguments.FirstOrDefault(x => - string.Equals(Path.GetExtension(x), ".binl", StringComparison.OrdinalIgnoreCase)); - if (_binlFileName != null) - LoadFiles(); - else - UpdateStatus(); + public List Entries + { + get => _entries; + private set + { + _entries = value; + OnPropertyChanged(); + } } - public ObservableCollection Entries { get; } = new(); - public ICollectionView EntriesView { get; } + public ICollectionView EntriesView + { + get => _entriesView; + private set + { + _entriesView = value; + OnPropertyChanged(); + } + } public TextEntryViewModel SelectedEntry { @@ -47,12 +68,27 @@ public TextEntryViewModel SelectedEntry _selectedEntry = value; OnPropertyChanged(); OnPropertyChanged(nameof(HasSelection)); + OnPropertyChanged(nameof(CanEdit)); } } public bool HasSelection => SelectedEntry != null; + public bool CanEdit => HasSelection && !IsBusy; + public bool CanSaveAs => _documents.Count == 1 && !_isFolder && !IsBusy; + + public bool IsBusy + { + get => _isBusy; + private set + { + _isBusy = value; + Mouse.OverrideCursor = value ? Cursors.Wait : null; + OnPropertyChanged(); + OnPropertyChanged(nameof(CanEdit)); + OnPropertyChanged(nameof(CanSaveAs)); + } + } - private string _statusText; public string StatusText { get => _statusText; @@ -65,116 +101,244 @@ private set public event PropertyChangedEventHandler PropertyChanged; - private void OpenBinl_Executed(object sender, ExecutedRoutedEventArgs e) + private async void MainWindow_Loaded(object sender, RoutedEventArgs e) + { + var argument = Environment.GetCommandLineArgs().Skip(1).FirstOrDefault(x => + Directory.Exists(x) || IsSupportedFile(x)); + if (argument != null) + await LoadPathAsync(argument); + else + UpdateStatus(); + } + + private async void OpenFile_Executed(object sender, ExecutedRoutedEventArgs e) { var dialog = new OpenFileDialog { - Filter = "KH1 remastered messages (*.binl)|*.binl|All files (*.*)|*.*", - Title = "Open KH1 BINL", + Filter = "KH1 remastered text (*.binl;*.kmb)|*.binl;*.kmb|BINL files (*.binl)|*.binl|KMB files (*.kmb)|*.kmb|All files (*.*)|*.*", + Title = "Open KH1 text file", }; - if (dialog.ShowDialog(this) != true) - return; + if (dialog.ShowDialog(this) == true && ConfirmDiscardChanges()) + await LoadPathAsync(dialog.FileName); + } - if (!ConfirmDiscardChanges()) - return; - _binlFileName = dialog.FileName; - LoadFiles(); + private async void OpenFolder_Click(object sender, RoutedEventArgs e) + { + var dialog = new OpenFolderDialog + { + Title = "Open KH1 remastered folder", + Multiselect = false, + }; + if (dialog.ShowDialog(this) == true && ConfirmDiscardChanges()) + await LoadPathAsync(dialog.FolderName); } - private void LoadFiles() + private async Task LoadPathAsync(string path) { try { - _binl = Kh1Binl.Read(_binlFileName); + IsBusy = true; + StatusText = Directory.Exists(path) + ? "Scanning BINL and KMB files..." + : $"Opening {Path.GetFileName(path)}..."; - foreach (var oldEntry in Entries) - oldEntry.PropertyChanged -= Entry_PropertyChanged; - Entries.Clear(); - foreach (var entry in _binl.Entries.Select(x => new TextEntryViewModel(x))) + var loaded = await Task.Run(() => { - entry.PropertyChanged += Entry_PropertyChanged; - Entries.Add(entry); - } + var documents = LoadDocuments(path); + var groups = documents.Documents + .SelectMany(x => x.Entries) + .GroupBy(x => x.Text, StringComparer.Ordinal) + .Select((group, index) => new TextEntryViewModel(index, group)) + .ToList(); + return (Documents: documents, Groups: groups); + }); + var result = loaded.Documents; + if (result.Documents.Count == 0) + throw new InvalidDataException("No readable BINL or KMB files were found."); + _documents = result.Documents; + _sourcePath = path; + _isFolder = Directory.Exists(path); + SetEntries(loaded.Groups); SelectedEntry = Entries.FirstOrDefault(); _isDirty = false; - Title = $"{Path.GetFileName(_binlFileName)} | KH1 Text editor - OpenKH"; + Title = $"{Path.GetFileName(path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))} | KH1 Text editor - OpenKH"; + OnPropertyChanged(nameof(CanSaveAs)); UpdateStatus(); + + if (result.Errors.Count > 0) + { + var details = string.Join(Environment.NewLine, result.Errors.Take(10)); + if (result.Errors.Count > 10) + details += $"{Environment.NewLine}... and {result.Errors.Count - 10} more files."; + MessageBox.Show( + this, + $"{result.Errors.Count} file(s) could not be read:{Environment.NewLine}{Environment.NewLine}{details}", + "KH1 Text editor", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } } catch (Exception ex) { - MessageBox.Show(this, ex.Message, "Unable to open file", MessageBoxButton.OK, MessageBoxImage.Error); + MessageBox.Show(this, ex.Message, "Unable to open source", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + IsBusy = false; + UpdateStatus(); } } - private void Save_Executed(object sender, ExecutedRoutedEventArgs e) + private static LoadResult LoadDocuments(string path) { - if (_binl == null) - return; - SaveFile(_binlFileName); + var result = new LoadResult(); + var isFolder = Directory.Exists(path); + var files = isFolder + ? Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories) + .Where(IsSupportedFile) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + : new[] { path }.AsEnumerable(); + + foreach (var fileName in files) + { + try + { + var document = LoadedDocument.Read(fileName, path); + if (document != null) + result.Documents.Add(document); + } + catch (Exception ex) + { + if (!isFolder) + throw; + result.Errors.Add($"{Path.GetRelativePath(path, fileName)}: {ex.Message}"); + } + } + return result; } - private void SaveAs_Click(object sender, RoutedEventArgs e) + private async void Save_Executed(object sender, ExecutedRoutedEventArgs e) => + await SaveChangesAsync(null); + + private async void SaveAs_Click(object sender, RoutedEventArgs e) { - if (_binl == null) + if (!CanSaveAs) return; + + var document = _documents[0]; var dialog = new SaveFileDialog { - Filter = "KH1 remastered messages (*.binl)|*.binl|All files (*.*)|*.*", - FileName = Path.GetFileName(_binlFileName), - Title = "Save KH1 BINL as", + Filter = string.Equals(document.Format, "KMB", StringComparison.Ordinal) + ? "KMB files (*.kmb)|*.kmb|All files (*.*)|*.*" + : "BINL files (*.binl)|*.binl|All files (*.*)|*.*", + FileName = Path.GetFileName(document.FileName), + Title = "Save KH1 text file as", }; if (dialog.ShowDialog(this) == true) - SaveFile(dialog.FileName); + await SaveChangesAsync(dialog.FileName); } - private void SaveFile(string fileName) + private async Task SaveChangesAsync(string saveAsFileName) { + if (_documents.Count == 0 || IsBusy) + return; + + var modifiedGroups = Entries.Where(x => x.IsModified).ToList(); + if (modifiedGroups.Count == 0 && saveAsFileName == null) + return; + try { - foreach (var viewModel in Entries) - viewModel.Entry.Text = viewModel.Text; + IsBusy = true; + StatusText = "Encoding and validating changes..."; + foreach (var group in modifiedGroups) + group.Apply(); + + var affectedDocuments = saveAsFileName != null + ? _documents + : modifiedGroups.SelectMany(x => x.Documents).Distinct().ToList(); + var output = await Task.Run(() => affectedDocuments + .Select(x => new { Document = x, Data = x.BuildFile() }) + .ToList()); - using var memory = new MemoryStream(); - _binl.Write(memory); - File.WriteAllBytes(fileName, memory.ToArray()); - _binlFileName = fileName; + StatusText = "Writing files..."; + await Task.Run(() => + { + foreach (var item in output) + { + var target = saveAsFileName ?? item.Document.FileName; + LoadedDocument.WriteFile(target, item.Data); + } + }); + + foreach (var group in modifiedGroups) + group.AcceptChanges(); _isDirty = false; - Title = $"{Path.GetFileName(_binlFileName)} | KH1 Text editor - OpenKH"; UpdateStatus(); + + await LoadPathAsync(saveAsFileName ?? _sourcePath); } catch (Exception ex) { - MessageBox.Show(this, ex.Message, "Unable to save file", MessageBoxButton.OK, MessageBoxImage.Error); + MessageBox.Show(this, ex.Message, "Unable to save changes", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + IsBusy = false; + UpdateStatus(); } } + private void SetEntries(List entries) + { + foreach (var oldEntry in Entries) + oldEntry.PropertyChanged -= Entry_PropertyChanged; + Entries = entries; + foreach (var entry in Entries) + entry.PropertyChanged += Entry_PropertyChanged; + + EntriesView = CollectionViewSource.GetDefaultView(Entries); + EntriesView.Filter = FilterEntry; + } + private void Entry_PropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(TextEntryViewModel.Text)) { - _isDirty = true; + _isDirty = Entries.Any(x => x.IsModified); UpdateStatus(); } } private void SearchBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e) => - EntriesView.Refresh(); + EntriesView?.Refresh(); private bool FilterEntry(object item) { if (item is not TextEntryViewModel entry || string.IsNullOrWhiteSpace(SearchBox?.Text)) return true; - return entry.Text.Contains(SearchBox.Text, StringComparison.CurrentCultureIgnoreCase) || - entry.Number.Contains(SearchBox.Text, StringComparison.OrdinalIgnoreCase) || - entry.Offset.Contains(SearchBox.Text, StringComparison.OrdinalIgnoreCase); + var search = SearchBox.Text; + return entry.Text.Contains(search, StringComparison.CurrentCultureIgnoreCase) || + entry.Number.Contains(search, StringComparison.OrdinalIgnoreCase) || + entry.Formats.Contains(search, StringComparison.OrdinalIgnoreCase) || + entry.ContainsLocation(search); + } + + private static bool IsSupportedFile(string path) + { + if (!File.Exists(path)) + return false; + var extension = Path.GetExtension(path); + return string.Equals(extension, ".binl", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".kmb", StringComparison.OrdinalIgnoreCase); } private void About_Click(object sender, RoutedEventArgs e) { MessageBox.Show( this, - "KH1 Text editor - OpenKH\n\nEdits remastered EvMsg BINL text using the built-in KH1 encoding.", + "KH1 Text editor - OpenKH\n\nEdits remastered BINL and KMB text. Folder mode groups identical text and updates every occurrence.", "About KH1 Text editor", MessageBoxButton.OK, MessageBoxImage.Information); @@ -202,9 +366,17 @@ private bool ConfirmDiscardChanges() private void UpdateStatus() { - var messageName = string.IsNullOrEmpty(_binlFileName) ? "no BINL" : Path.GetFileName(_binlFileName); - StatusText = $"{messageName} · built-in KH1 encoding · {Entries.Count} text entries" + - (_isDirty ? " · modified" : string.Empty); + if (IsBusy) + return; + if (_documents.Count == 0) + { + StatusText = "Open a BINL/KMB file or a remastered folder."; + return; + } + + var occurrenceCount = _documents.Sum(x => x.Entries.Count); + StatusText = $"{_documents.Count:N0} file(s) · {Entries.Count:N0} unique text(s) · {occurrenceCount:N0} occurrence(s)" + + (_isDirty ? $" · {Entries.Count(x => x.IsModified):N0} modified group(s)" : string.Empty); } private void OnPropertyChanged([CallerMemberName] string propertyName = null) => diff --git a/docs/tool/GUI.Kh1TextEditor/index.md b/docs/tool/GUI.Kh1TextEditor/index.md index c497426b1..3d7a65d9d 100644 --- a/docs/tool/GUI.Kh1TextEditor/index.md +++ b/docs/tool/GUI.Kh1TextEditor/index.md @@ -1,14 +1,18 @@ # KH1 Text Editor -`OpenKh.Tools.Kh1TextEditor` edits event-message text from Kingdom Hearts 1.5 -ReMIX `*.binl` files. The KH1 character table is compiled into the tool, so no +`OpenKh.Tools.Kh1TextEditor` edits text from Kingdom Hearts 1.5 ReMIX `*.binl` +and `*.kmb` files. The KH1 character table is compiled into the tool, so no external `*.tbl` file is required. ## Usage -1. Open the extracted `*.binl` file with **File > Open BINL**. -2. Search for an entry, edit its text, and use **Save as** to keep the original - file as a backup. +1. Open one extracted `*.binl` or `*.kmb` file with **File > Open BINL/KMB**, + or open the extracted `remastered` folder with **Open remastered folder**. +2. Search for an entry and edit its text. When folder mode finds the exact same + text in several files, it groups the occurrences into one entry and saves the + edit to every listed location. +3. Use **Save** to update the affected files. **Save as** is available in + single-file mode. The built-in KH1 table uses `01` for a space, `02` for a line break, and `00` for `{eol}`. A line break typed in the editor is saved as `02`. @@ -16,9 +20,10 @@ for `{eol}`. A line break typed in the editor is saved as `02`. The table is maintained in `OpenKh.Kh1/Kh1TextTable.cs`. Update `CreateDefault()` there when a character mapping needs to change. -Tokens formatted as `{cmd:...}` are BINL control commands. Keep them intact -unless you understand the command bytecode. Unknown or ambiguous table values -are shown losslessly as `{0xNN}`. +Tokens formatted as `{cmd:...}` are EvMsg BINL control commands. Keep them +intact unless you understand the command bytecode. Unknown or ambiguous table +values are shown losslessly as `{0xNN}`. -The editor preserves all data outside the editable text ranges and restores the -file's 16-byte alignment with `CD` padding when saving. +The editor supports EvMsg BINL, `Message v361` BINL, and KMB message tables. +Unrecognized BINL data such as offset tables is ignored in folder mode. Data +outside editable text ranges and the file's padding style are preserved. diff --git a/docs/tool/index.md b/docs/tool/index.md index f89df97c6..ce622d19e 100644 --- a/docs/tool/index.md +++ b/docs/tool/index.md @@ -58,4 +58,4 @@ If you have never used the command line before or have used it very little, fear | File | Tool name |------|----------- |[HD assets](../common/hdassets.md)| OpenKh.Command.HdAssets -|KH1 `*.binl` event messages| [OpenKh.Tools.Kh1TextEditor](./GUI.Kh1TextEditor/index.md) +|KH1 remastered `*.binl` and `*.kmb` text| [OpenKh.Tools.Kh1TextEditor](./GUI.Kh1TextEditor/index.md) From ba933416826965e3b118a1c370f2ed6b05fdd266 Mon Sep 17 00:00:00 2001 From: Yokimitsuro Date: Fri, 14 Aug 2026 15:32:16 +0200 Subject: [PATCH 3/7] Add language filter to KH1 folder mode --- .../Views/LanguageSelectionWindow.xaml | 22 +++++++ .../Views/LanguageSelectionWindow.xaml.cs | 54 ++++++++++++++++++ .../Views/MainWindow.xaml | 2 +- .../Views/MainWindow.xaml.cs | 57 +++++++++++++++---- docs/tool/GUI.Kh1TextEditor/index.md | 7 ++- 5 files changed, 128 insertions(+), 14 deletions(-) create mode 100644 OpenKh.Tools.Kh1TextEditor/Views/LanguageSelectionWindow.xaml create mode 100644 OpenKh.Tools.Kh1TextEditor/Views/LanguageSelectionWindow.xaml.cs diff --git a/OpenKh.Tools.Kh1TextEditor/Views/LanguageSelectionWindow.xaml b/OpenKh.Tools.Kh1TextEditor/Views/LanguageSelectionWindow.xaml new file mode 100644 index 000000000..58d0ef18c --- /dev/null +++ b/OpenKh.Tools.Kh1TextEditor/Views/LanguageSelectionWindow.xaml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + +