// Cliloc export — converts a modern client's COMPRESSED Cliloc.enu into the // plain format the website can read (docs/website/CLILOCS.md). // // Why this exists at all: every current UO client ships its cliloc files in the // compressed "Mythic" format — the first DWORD's high byte is 0x8E — and the // plain layout the website parses is what those files looked like before that // change. Decompressing is a bit-level inverse-BWT coder that the site has no // business carrying at runtime, and ServUO's own bundled `Ultima.StringList` // cannot read it either (which is why `VendorSearch.GetItemName` is already // inert on such a shard, and why the shard cannot supply names instead). // // So the conversion happens ONCE, here, against a decompressor that already // exists and is maintained: UOFiddler's `Ultima.dll`. // // ── Why reflection rather than a project reference ──────────────────────── // // UOFiddler ships as net10.0. Referencing it from a project built by an older // SDK fails at COMPILE time with CS1705 ("uses System.Runtime 10.0 which has a // higher version than referenced assembly"). Loading it reflectively moves that // question to run time, where `RollForward: LatestMajor` answers it — so this // builds on whatever SDK an operator happens to have and runs on the newest // runtime installed. // // ── Why not StringList.SaveStringList ──────────────────────────────────── // // It looks like exactly the right method and it is not: it RE-COMPRESSES on // save, because its purpose is round-tripping a file back into the client. The // output is byte-identical to the compressed input. The plain records below are // written by hand for that reason. // // Usage: // dotnet run -- [--tsv] // // Nothing produced by this tool is committed. See docs/website/CLILOCS.md. using System; using System.Collections; using System.IO; using System.Reflection; using System.Text; internal static class Program { private static int Main(string[] args) { if (args.Length < 3) { Console.Error.WriteLine("usage: clilocexport [--tsv]"); Console.Error.WriteLine(" Ultima.dll ships with UOFiddler (https://github.com/polserver/UOFiddler)."); return 2; } var (ultimaDll, input, output) = (args[0], args[1], args[2]); var asTsv = Array.IndexOf(args, "--tsv") >= 0; // The language code only names the file when StringList resolves the path // itself; here the path is explicit, so it is cosmetic. var language = Path.GetExtension(input).TrimStart('.'); if (string.IsNullOrWhiteSpace(language)) language = "enu"; var assembly = Assembly.LoadFrom(Path.GetFullPath(ultimaDll)); var stringListType = assembly.GetType("Ultima.StringList") ?? throw new InvalidOperationException("Ultima.StringList not found — is that really UOFiddler's Ultima.dll?"); // (language, path, decompress). `decompress: true` is the whole point; // the loader falls back to a plain read on its own if the file turns out // not to be compressed, so an already-converted file passes through. var ctor = stringListType.GetConstructor(new[] { typeof(string), typeof(string), typeof(bool) }) ?? throw new InvalidOperationException("Unexpected Ultima.StringList API — this tool targets UOFiddler 4.21+."); var stringList = ctor.Invoke(new object[] { language, Path.GetFullPath(input), true }); // A partial parse is reported rather than thrown. Surfacing it matters: // the output would otherwise be a quietly short table, which is exactly // the failure mode the website's parser refuses to import. var warning = stringListType.GetProperty("LoadWarning")?.GetValue(stringList) as string; if (!string.IsNullOrWhiteSpace(warning)) Console.Error.WriteLine("warning: " + warning); var entries = (IEnumerable)stringListType.GetProperty("Entries")!.GetValue(stringList)!; var entryType = assembly.GetType("Ultima.StringEntry")!; var numberProp = entryType.GetProperty("Number")!; var textProp = entryType.GetProperty("Text")!; var flagProp = entryType.GetProperty("Flag")!; int written = 0, skipped = 0, maxBytes = 0; if (asTsv) { using var writer = new StreamWriter(output, false, new UTF8Encoding(false)); foreach (var entry in entries) { var number = (int)numberProp.GetValue(entry)!; var text = (string?)textProp.GetValue(entry) ?? ""; maxBytes = Math.Max(maxBytes, Encoding.UTF8.GetByteCount(text)); // A tab or newline inside a cliloc string would break the row. // Neither occurs in real tables, but silently emitting a broken // file is worse than collapsing the whitespace. writer.WriteLine($"{number}\t{text.Replace('\t', ' ').Replace('\r', ' ').Replace('\n', ' ')}"); written++; } } else { using var stream = new FileStream(output, FileMode.Create, FileAccess.Write); using var binary = new BinaryWriter(stream); binary.Write(2); // int32 — the plain-format version marker binary.Write((short)1); // int16 — language marker foreach (var entry in entries) { var number = (int)numberProp.GetValue(entry)!; var text = (string?)textProp.GetValue(entry) ?? ""; var flag = Convert.ToByte(Convert.ToInt32(flagProp.GetValue(entry))); var utf8 = Encoding.UTF8.GetBytes(text); maxBytes = Math.Max(maxBytes, utf8.Length); // The length field is 16 bits. Real tables peak around 12 KB, so // this has never fired — but writing a truncated length would // corrupt every record after it, so an oversize entry is dropped // and counted instead. if (utf8.Length > ushort.MaxValue) { skipped++; continue; } binary.Write(number); binary.Write(flag); binary.Write((ushort)utf8.Length); binary.Write(utf8); written++; } } Console.WriteLine($"wrote {written} entries to {output} (maxTextBytes={maxBytes}, skippedOversize={skipped})"); if (written == 0) { Console.Error.WriteLine("no entries were written — is that a cliloc file?"); return 1; } return 0; } }