using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; namespace pk3DS.Core; public class TextFile { // Text Formatting Config private const ushort KEY_BASE = 0x7C89; private const ushort KEY_ADVANCE = 0x2983; private const ushort KEY_VARIABLE = 0x0010; private const ushort KEY_TERMINATOR = 0x0000; private const ushort KEY_TEXTRETURN = 0xBE00; private const ushort KEY_TEXTCLEAR = 0xBE01; private const ushort KEY_TEXTWAIT = 0xBE02; private const ushort KEY_TEXTNULL = 0xBDFF; private static readonly byte[] emptyTextFile = [0x01, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, ]; private GameConfig Config { get; } public bool SETEMPTYTEXT { get; set; } public bool RemapChars { get; set; } public TextFile(GameConfig config, byte[] data = null, bool remapChars = false) { Data = (byte[])(data ?? emptyTextFile).Clone(); if (InitialKey != 0) throw new Exception("Invalid initial key! Not 0?"); if (SectionDataOffset + TotalLength != Data.Length || TextSections != 1) throw new Exception("Invalid Text File"); if (SectionLength != TotalLength) throw new Exception("Section size and overall size do not match."); Config = config; RemapChars = remapChars; } private ushort TextSections { get => BitConverter.ToUInt16(Data, 0x0); set => BitConverter.GetBytes(value).CopyTo(Data, 0x0); } // Always 0x0001 private ushort LineCount { get => BitConverter.ToUInt16(Data, 0x2); set => BitConverter.GetBytes(value).CopyTo(Data, 0x2); } private uint TotalLength { get => BitConverter.ToUInt32(Data, 0x4); set => BitConverter.GetBytes(value).CopyTo(Data, 0x4); } private uint InitialKey { get => BitConverter.ToUInt32(Data, 0x8); set => BitConverter.GetBytes(value).CopyTo(Data, 0x8); } // Always 0x00000000 private uint SectionDataOffset { get => BitConverter.ToUInt32(Data, 0xC); set => BitConverter.GetBytes(value).CopyTo(Data, 0xC); } // Always 0x0010 private uint SectionLength { get => BitConverter.ToUInt32(Data, (int)SectionDataOffset); set => BitConverter.GetBytes(value).CopyTo(Data, SectionDataOffset); } private LineInfo[] LineOffsets { get { LineInfo[] result = new LineInfo[LineCount]; int sdo = (int)SectionDataOffset; for (int i = 0; i < result.Length; i++) { result[i] = new LineInfo { Offset = BitConverter.ToInt32(Data, (i * 8) + sdo + 4) + sdo, Length = BitConverter.ToInt16(Data, (i * 8) + sdo + 8), }; } return result; } set { if (value == null) return; int sdo = (int)SectionDataOffset; for (int i = 0; i < value.Length; i++) { BitConverter.GetBytes(value[i].Offset).CopyTo(Data, (i * 8) + sdo + 4); BitConverter.GetBytes(value[i].Length).CopyTo(Data, (i * 8) + sdo + 8); } } } private class LineInfo { public int Offset, Length; } public byte[] this[int index] { get { ushort key = GetLineKey(index); var line = LineOffsets[index]; byte[] EncryptedLineData = new byte[line.Length * 2]; Array.Copy(Data, line.Offset, EncryptedLineData, 0, EncryptedLineData.Length); return CryptLineData(EncryptedLineData, key); } } private static ushort GetLineKey(int index) { ushort key = KEY_BASE; for (int i = 0; i < index; i++) key += KEY_ADVANCE; return key; } public byte[][] LineData { get { ushort key = KEY_BASE; byte[][] result = new byte[LineCount][]; LineInfo[] lines = LineOffsets; for (int i = 0; i < lines.Length; i++) { byte[] EncryptedLineData = new byte[lines[i].Length * 2]; Array.Copy(Data, lines[i].Offset, EncryptedLineData, 0, EncryptedLineData.Length); result[i] = CryptLineData(EncryptedLineData, key); key += KEY_ADVANCE; } return result; } set { // rebuild LineInfo LineInfo[] lines = new LineInfo[value.Length]; int bytesUsed = 0; for (int i = 0; i < lines.Length; i++) { lines[i] = new LineInfo { Offset = 4 + (8 * value.Length) + bytesUsed, Length = value[i].Length / 2 }; bytesUsed += value[i].Length; } // Apply Line Data int sdo = (int)SectionDataOffset; Array.Resize(ref Data, sdo + 4 + (8 * value.Length) + bytesUsed); LineOffsets = lines; value.SelectMany(i => i).ToArray().CopyTo(Data, Data.Length - bytesUsed); TotalLength = SectionLength = (uint)(Data.Length - sdo); LineCount = (ushort)value.Length; } } public string[] Lines { get => Array.ConvertAll(LineData, z => GetLineString(Config, z)); set => LineData = ConvertLinesToData(value); } private byte[][] ConvertLinesToData(string[] value) { value ??= []; ushort key = KEY_BASE; // Get Line Data byte[][] lineData = new byte[value.Length][]; for (int i = 0; i < value.Length; i++) { string text = (value[i] ?? "").Trim(); if (text.Length == 0 && SETEMPTYTEXT) text = $"[~ {i}]"; byte[] DecryptedLineData = GetLineData(Config, text); lineData[i] = CryptLineData(DecryptedLineData, key); if (lineData[i].Length % 4 == 2) Array.Resize(ref lineData[i], lineData[i].Length + 2); key += KEY_ADVANCE; } return lineData; } public byte[] Data; private static byte[] CryptLineData(byte[] data, ushort key) { byte[] result = new byte[data.Length]; for (int i = 0; i < result.Length; i += 2) { BitConverter.GetBytes((ushort)(BitConverter.ToUInt16(data, i) ^ key)).CopyTo(result, i); key = (ushort)(key << 3 | key >> 13); } return result; } private byte[] GetLineData(GameConfig config, string line) { if (line == null) return new byte[2]; var ms = new MemoryStream(); using var bw = new BinaryWriter(ms); int i = 0; while (i < line.Length) { ushort val = line[i++]; val = TryRemapChar(val); switch (val) { // Variable case '[': { // grab the string int bracket = line.IndexOf(']', i); if (bracket < 0) throw new ArgumentException("Variable text is not capped properly: " + line); string varText = line[i..bracket]; var varValues = GetVariableValues(config, varText); foreach (ushort v in varValues) bw.Write(v); i += 1 + varText.Length; break; } // Escaped Formatting case '\\': { var escapeValues = GetEscapeValues(line[i++]); foreach (ushort v in escapeValues) bw.Write(v); break; } default: bw.Write(val); break; } } bw.Write(KEY_TERMINATOR); // cap the line off return ms.ToArray(); } private ushort TryRemapChar(ushort val) { if (!RemapChars) return val; return val switch { 0x202F => 0xE07F, // nbsp 0x2026 => 0xE08D, // … 0x2642 => 0xE08E, // ♂ 0x2640 => 0xE08F, // ♀ _ => val, }; } private ushort TryUnmapChar(ushort val) { if (!RemapChars) return val; return val switch { 0xE07F => 0x202F, // nbsp 0xE08D => 0x2026, // … 0xE08E => 0x2642, // ♂ 0xE08F => 0x2640, // ♀ _ => val, }; } private string GetLineString(GameConfig config, byte[] data) { var s = new StringBuilder(); int i = 0; while (i < data.Length) { ushort val = BitConverter.ToUInt16(data, i); if (val == KEY_TERMINATOR) break; i += 2; switch (val) { case KEY_VARIABLE: s.Append(GetVariableString(config, data, ref i)); break; case '\n': s.Append(@"\n"); break; case '\\': s.Append(@"\\"); break; case '[': s.Append(@"\["); break; default: s.Append((char)TryUnmapChar(val)); break; } } return s.ToString(); // Shouldn't get hit if the string is properly terminated. } private static string GetVariableString(GameConfig config, byte[] data, ref int i) { var s = new StringBuilder(); ushort count = BitConverter.ToUInt16(data, i); i += 2; ushort variable = BitConverter.ToUInt16(data, i); i += 2; switch (variable) { case KEY_TEXTRETURN: // "Waitbutton then scroll text; \r" return "\\r"; case KEY_TEXTCLEAR: // "Waitbutton then clear text;; \c" return "\\c"; case KEY_TEXTWAIT: // Dramatic pause for a text line. New! ushort time = BitConverter.ToUInt16(data, i); i += 2; return $"[WAIT {time}]"; case KEY_TEXTNULL: // Empty Text line? Includes linenum so maybe for betatest finding used-unused lines? ushort line = BitConverter.ToUInt16(data, i); i += 2; return $"[~ {line}]"; } string varName = GetVariableString(config, variable); s.Append("[VAR ").Append(varName); if (count > 1) { s.Append('('); while (count > 1) { ushort arg = BitConverter.ToUInt16(data, i); i += 2; s.Append(arg.ToString("X4")); if (--count == 1) break; s.Append(','); } s.Append(')'); } s.Append(']'); return s.ToString(); } private static List GetEscapeValues(char esc) { var vals = new List(); switch (esc) { case 'n': vals.Add('\n'); return vals; case '\\': vals.Add('\\'); return vals; case '[': vals.Add('['); return vals; case 'r': vals.AddRange([KEY_VARIABLE, 1, KEY_TEXTRETURN]); return vals; case 'c': vals.AddRange([KEY_VARIABLE, 1, KEY_TEXTCLEAR]); return vals; default: throw new Exception("Invalid terminated line: \\" + esc); } } private static List GetVariableValues(GameConfig config, string variable) { string[] split = variable.Split(' '); if (split.Length < 2) throw new ArgumentException("Incorrectly formatted variable text: " + variable); var vals = new List { KEY_VARIABLE }; switch (split[0]) { case "~": // Blank Text Line Variable (No text set - debug/quality testing variable?) vals.Add(1); vals.Add(KEY_TEXTNULL); vals.Add(Convert.ToUInt16(split[1])); break; case "WAIT": // Event pause Variable. vals.Add(1); vals.Add(KEY_TEXTWAIT); vals.Add(Convert.ToUInt16(split[1])); break; case "VAR": // Text Variable vals.AddRange(GetVariableParameters(config, split[1])); break; default: throw new Exception("Unknown variable method type: " + variable); } return vals; } private static List GetVariableParameters(GameConfig config, string text) { var vals = new List(); int bracket = text.IndexOf('('); bool noArgs = bracket < 0; string variable = noArgs ? text : text[..bracket]; ushort varVal = GetVariableNumber(config, variable); if (!noArgs) { string[] args = text.Substring(bracket + 1, text.Length - bracket - 2).Split(','); vals.Add((ushort)(1 + args.Length)); vals.Add(varVal); vals.AddRange(args.Select(t => Convert.ToUInt16(t, 16))); } else { vals.Add(1); vals.Add(varVal); } return vals; } private static ushort GetVariableNumber(GameConfig config, string variable) { var v = config.GetVariableCode(variable); if (v != null) return (ushort)v.Code; try { return Convert.ToUInt16(variable, 16); } catch { throw new ArgumentException("Variable parse error: " + variable); } } private static string GetVariableString(GameConfig config, ushort variable) { var v = config.GetVariableName(variable); return v == null ? variable.ToString("X4") : v.Name; } // Exposed Methods public static string[] GetStrings(GameConfig config, byte[] data, bool remapChars = false) { TextFile t; try { t = new TextFile(config, data, remapChars); } catch { return []; } return t.Lines; } public static byte[] GetBytes(GameConfig config, string[] lines, bool remapChars = false) { return new TextFile(config, remapChars: remapChars) { Lines = lines }.Data; } }