Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
6fe7c36
Add ResxGen tool to generate typed C++ translation accessors
Mosch0512 May 19, 2026
677cbd9
Wire ResxGen into the build with English-as-key sample translations
Mosch0512 May 19, 2026
4daf218
Use I18N accessors for the skill editor save button and locale switch
Mosch0512 May 19, 2026
177e3b4
Preserve original case in the resx key slug
Mosch0512 May 19, 2026
5d33983
Migrate editor.json + metadata.json content to .resx for all 10 locales
Mosch0512 May 19, 2026
7ccbe69
Generate I18N::Format helper for placeholder substitution
Mosch0512 May 19, 2026
8e8b29a
Generate I18N locale registry and display-name lookup
Mosch0512 May 19, 2026
3058fd4
Migrate all editor call sites to typed I18N accessors
Mosch0512 May 19, 2026
e43661c
Resolve field display names via typed I18N pointers in descriptors
Mosch0512 May 19, 2026
635c7c5
Drive language picker and startup off the I18N namespace
Mosch0512 May 19, 2026
4a1fd53
Remove legacy Translator class and JSON translation files
Mosch0512 May 19, 2026
4f2a791
Add wide-char (wchar_t) group support to ResxGen
Mosch0512 May 20, 2026
7d49c96
Convert in-game text bmd files into the I18N::Game resx group
Mosch0512 May 20, 2026
0271340
Generate I18N::Game::Lookup(int) for legacy GlobalText integer indices
Mosch0512 May 20, 2026
3c75ac8
Round-trip Game resx identifiers through the C# slug rule
Mosch0512 May 20, 2026
3fa6023
Migrate GlobalText[N] call sites to typed I18N::Game accessors
Mosch0512 May 20, 2026
a58ac50
Remove legacy GlobalText system and Text_*.bmd files
Mosch0512 May 20, 2026
a38d65c
Add live language dropdown to the in-game options window
Mosch0512 May 20, 2026
763315e
Cover all bmd entries in I18N::Game and stabilize collision suffixes
Mosch0512 May 20, 2026
bdd177b
Use GameConfig.UILocale as the single source of truth for the active …
Mosch0512 May 20, 2026
ab3810c
Normalize %s -> %ls in wide-group format strings
Mosch0512 May 20, 2026
719a973
Machine-translate Game.{de,es,pt}.resx as a starter localization pass
Mosch0512 May 20, 2026
b1066ce
Machine-translate Game.{id,pl,ru,tl,uk,zh-TW}.resx
Mosch0512 May 20, 2026
01578a4
Fix option-window combo z-order and pull the window higher up
Mosch0512 May 20, 2026
2b93e40
Restrict resx slugs to ASCII and install .NET on the MinGW CI
Mosch0512 May 20, 2026
39ff384
Gate ClientLibrary AOT separately so Linux CI can still run ResxGen
Mosch0512 May 20, 2026
ec90367
Emit non-ASCII characters as \uHHHH in generated string literals
Mosch0512 May 20, 2026
050ac8b
Escape non-ASCII in option-window language dropdown labels
Mosch0512 May 20, 2026
e540c8b
Refresh widget labels and tooltips when switching language
Mosch0512 May 23, 2026
012a62a
Refresh vault and MUHelper-toggle tooltips on language switch
Mosch0512 May 24, 2026
ed7fc74
Move NPC dialog text to I18N::Dialog resx group
Mosch0512 May 24, 2026
9ba8e3d
Harden ResxGen generated Format and narrow-literal escapes
Mosch0512 May 24, 2026
57b6038
Restore std::lower_bound in generated Lookup
Mosch0512 May 24, 2026
884a4d0
Generate ApplyLocale as a data-driven slot table
Mosch0512 May 24, 2026
3cf2f1b
Refresh character-balloon tooltip on locale change
Mosch0512 May 24, 2026
8361566
Reject duplicate legacy_ids and add brace escape to Format
Mosch0512 May 24, 2026
19ad188
Remove legacy Dialog_*.bmd files
Mosch0512 May 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/mingw-build-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ jobs:
sudo apt-get update
sudo apt-get install -y mingw-w64 g++-mingw-w64-i686 cmake ninja-build

- name: Install .NET SDK 10
# Required for the ResxGen build-time tool that compiles the
# localization .resx files into the I18N C++ accessors.
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'

- name: Cache libjpeg-turbo (MinGW i686)
uses: actions/cache@v4
with:
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/mingw-build-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ jobs:
sudo apt-get update
sudo apt-get install -y mingw-w64 g++-mingw-w64-i686 cmake ninja-build wine wine32:i386

- name: Install .NET SDK 10
# Required for the ResxGen build-time tool that compiles the
# localization .resx files into the I18N C++ accessors that the
# rest of the codebase #include's. Without it the generator
# never runs and source files fail with "I18N/All.h: No such
# file or directory".
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'

- name: Initialise wine prefix
run: |
wineboot --init >/dev/null 2>&1 || true
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/mingw-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ jobs:
sudo apt-get update
sudo apt-get install -y mingw-w64 g++-mingw-w64-i686 cmake ninja-build

- name: Install .NET SDK 10
# Required for the ResxGen build-time tool that compiles the
# localization .resx files into the I18N C++ accessors.
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'

- name: Cache libjpeg-turbo (MinGW i686)
uses: actions/cache@v4
with:
Expand Down
111 changes: 111 additions & 0 deletions Tools/DialogImporter/BmdReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using System.Buffers.Binary;
using System.Text;

namespace MuMain.Tools.DialogImporter;

/// Reads and decrypts a Dialog_<lang>.bmd file into per-entry records.
///
/// Layout matches DIALOG_SCRIPT in src/source/Core/Globals/_struct.h:
/// char m_lpszText[MAX_LENGTH_DIALOG] (300 bytes)
/// int32 m_iNumAnswer (4 bytes)
/// int32 m_iLinkForAnswer[MAX_ANSWER_FOR_DIALOG] (10*4 = 40 bytes)
/// int32 m_iReturnForAnswer[MAX_ANSWER_FOR_DIALOG] (10*4 = 40 bytes)
/// char m_lpszAnswer[MAX_ANSWER_FOR_DIALOG][MAX_LENGTH_ANSWER] (10*64 = 640 bytes)
/// File contains MAX_DIALOG = 200 entries, total 200 * 1024 = 204800 bytes.
///
/// Bytes are XOR'd against the 3-byte BuxCode cycle (matches BuxConvert in
/// src/source/Core/Globals/_crypt.h).
internal static class BmdReader
{
public const int MaxDialog = 200;
public const int MaxAnswer = 10;

private const int TextSize = 300;
private const int AnswerSize = 64;
private const int EntrySize = TextSize + 4 + 4 * MaxAnswer + 4 * MaxAnswer + AnswerSize * MaxAnswer;

private static readonly byte[] BuxCode = [0xFC, 0xCF, 0xAB];

public static IReadOnlyList<DialogRecord> Read(string path, Encoding textEncoding)
{
var raw = File.ReadAllBytes(path);
if (raw.Length != EntrySize * MaxDialog)
{
throw new InvalidDataException(
$"{path}: expected {EntrySize * MaxDialog} bytes, got {raw.Length}");
}

var entries = new List<DialogRecord>(MaxDialog);
var entryBuf = new byte[EntrySize];
for (var i = 0; i < MaxDialog; i++)
{
Buffer.BlockCopy(raw, i * EntrySize, entryBuf, 0, EntrySize);
BuxDecode(entryBuf);
entries.Add(ParseEntry(entryBuf, textEncoding));
}
return entries;
}

private static void BuxDecode(byte[] buf)
{
for (var i = 0; i < buf.Length; i++)
{
buf[i] ^= BuxCode[i % BuxCode.Length];
}
}

private static DialogRecord ParseEntry(byte[] buf, Encoding textEncoding)
{
var off = 0;
var text = ReadCString(buf, off, TextSize, textEncoding);
off += TextSize;

// BMD ints are written as little-endian by the original toolchain;
// read them explicitly so the importer works on any host endianness.
var numAnswer = BinaryPrimitives.ReadInt32LittleEndian(buf.AsSpan(off));
off += 4;

var links = new int[MaxAnswer];
for (var i = 0; i < MaxAnswer; i++)
{
links[i] = BinaryPrimitives.ReadInt32LittleEndian(buf.AsSpan(off));
off += 4;
}

var returns = new int[MaxAnswer];
for (var i = 0; i < MaxAnswer; i++)
{
returns[i] = BinaryPrimitives.ReadInt32LittleEndian(buf.AsSpan(off));
off += 4;
}

var answers = new string[MaxAnswer];
for (var i = 0; i < MaxAnswer; i++)
{
answers[i] = ReadCString(buf, off, AnswerSize, textEncoding);
off += AnswerSize;
}

return new DialogRecord(text, numAnswer, links, returns, answers);
}

/// Reads a NUL-terminated string from a fixed-size slot using the
/// provided encoding. The bmd source format is a fixed-width char buffer
/// — the encoding is determined per-language by what the original tool
/// chain wrote (English/UTF-8, Portuguese/Spanish/Windows-1252, etc.).
private static string ReadCString(byte[] buf, int offset, int length, Encoding encoding)
{
var end = Array.IndexOf<byte>(buf, 0, offset, length);
if (end < 0) end = offset + length;
var slice = end - offset;
if (slice == 0) return string.Empty;
return encoding.GetString(buf, offset, slice);
}
}

internal sealed record DialogRecord(
string Text,
int NumAnswer,
int[] Links,
int[] Returns,
string[] Answers);
13 changes: 13 additions & 0 deletions Tools/DialogImporter/DialogImporter.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>MuMain.Tools.DialogImporter</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>

</Project>
169 changes: 169 additions & 0 deletions Tools/DialogImporter/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
using System.Text;

namespace MuMain.Tools.DialogImporter;

/// One-shot importer: turns Dialog_<lang>.bmd files into Tools/ResxGen-
/// compatible resx files and a language-agnostic dialog-branching table.
///
/// Usage:
/// DialogImporter \
/// --eng <Dialog_eng.bmd> \
/// --por <Dialog_por.bmd> \
/// --spn <Dialog_spn.bmd> \
/// --resx-out <src/Localization> \
/// --struct-header <src/source/GameLogic/Quests/DialogStructure.h> \
/// --struct-source <src/source/GameLogic/Quests/DialogStructure.cpp>
///
/// English is authoritative for structure (links, returns, numAnswer).
/// Portuguese and Spanish are read for their text only; they MUST agree
/// structurally — the importer aborts loudly otherwise so a structurally
/// mismatched fork can't silently corrupt the dialog graph.
internal static class Program
{
public static int Main(string[] args)
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

var opts = ParseArgs(args);
if (opts is null) return 2;

Console.WriteLine($"Reading {opts.EnglishBmd} (authoritative)");
var eng = BmdReader.Read(opts.EnglishBmd, EncodingFor("en"));

var translations = new Dictionary<string, IReadOnlyList<DialogRecord>>(StringComparer.Ordinal);
translations["en"] = eng;
foreach (var (locale, path) in opts.OtherSources)
{
Console.WriteLine($"Reading {path} ({locale})");
var entries = BmdReader.Read(path, EncodingFor(locale));
EnsureStructuralMatch(opts.EnglishBmd, eng, path, entries);
translations[locale] = entries;
}

Directory.CreateDirectory(opts.ResxOutDir);
foreach (var (locale, entries) in translations)
{
var path = Path.Combine(opts.ResxOutDir, $"Dialog.{locale}.resx");
var n = ResxWriter.Write(path, locale, entries);
Console.WriteLine($"Wrote {path} ({n} entries)");
}

EnsureParentDirectoryExists(opts.StructHeaderPath);
EnsureParentDirectoryExists(opts.StructSourcePath);
StructureWriter.Write(opts.StructHeaderPath, opts.StructSourcePath, eng);
Console.WriteLine($"Wrote {opts.StructHeaderPath}");
Console.WriteLine($"Wrote {opts.StructSourcePath}");

return 0;
}

/// Creates the parent directory of `path` if needed. Skips the call when
/// `path` is a bare filename (Path.GetDirectoryName returns null for that)
/// so we don't crash on ArgumentNullException.
private static void EnsureParentDirectoryExists(string path)
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
}

/// Maps a locale code to the encoding the original tool chain wrote the
/// bmd in. English files are ASCII / UTF-8 (no high-bit bytes to worry
/// about). Portuguese and Spanish were emitted as Windows-1252, which
/// covers all the accented Latin chars they need.
private static Encoding EncodingFor(string locale) => locale switch
{
"en" => new UTF8Encoding(false),
"pt" or "es" => Encoding.GetEncoding(1252),
_ => new UTF8Encoding(false),
};

private static void EnsureStructuralMatch(
string baseLabel,
IReadOnlyList<DialogRecord> baseEntries,
string otherLabel,
IReadOnlyList<DialogRecord> otherEntries)
{
if (baseEntries.Count != otherEntries.Count)
{
throw new InvalidDataException(
$"{otherLabel}: entry count {otherEntries.Count} != {baseLabel} count {baseEntries.Count}");
}
var diffs = 0;
for (var i = 0; i < baseEntries.Count; i++)
{
var b = baseEntries[i];
var o = otherEntries[i];
if (b.NumAnswer != o.NumAnswer)
{
Console.Error.WriteLine($" [structural diff @ {i}] numAnswer: base={b.NumAnswer} other={o.NumAnswer}");
diffs++;
continue;
}
for (var j = 0; j < b.NumAnswer; j++)
{
if (b.Links[j] != o.Links[j])
{
Console.Error.WriteLine($" [structural diff @ {i}] link[{j}]: base={b.Links[j]} other={o.Links[j]}");
diffs++;
}
if (b.Returns[j] != o.Returns[j])
{
Console.Error.WriteLine($" [structural diff @ {i}] return[{j}]: base={b.Returns[j]} other={o.Returns[j]}");
diffs++;
}
}
}
if (diffs > 0)
{
throw new InvalidDataException(
$"{otherLabel} disagrees structurally with {baseLabel} ({diffs} differences). " +
"Reconcile the source files before importing.");
}
}

private static Options? ParseArgs(string[] args)
{
string? eng = null;
var others = new List<(string Locale, string Path)>();
string? resxOut = null;
string? structHeader = null;
string? structSource = null;

for (var i = 0; i < args.Length; i++)
{
var key = args[i];
string Next() => i + 1 < args.Length ? args[++i] : throw new ArgumentException($"missing value for {key}");
switch (key)
{
case "--eng": eng = Next(); break;
case "--por": others.Add(("pt", Next())); break;
case "--spn": others.Add(("es", Next())); break;
case "--resx-out": resxOut = Next(); break;
case "--struct-header": structHeader = Next(); break;
case "--struct-source": structSource = Next(); break;
default:
Console.Error.WriteLine($"Unknown argument: {key}");
return null;
}
}

if (eng is null || resxOut is null || structHeader is null || structSource is null)
{
Console.Error.WriteLine("Required: --eng <path> --resx-out <dir> --struct-header <path> --struct-source <path>");
Console.Error.WriteLine("Optional: --por <path> --spn <path>");
return null;
}

return new Options(eng, others, resxOut, structHeader, structSource);
}

private sealed record Options(
string EnglishBmd,
IReadOnlyList<(string Locale, string Path)> OtherSources,
string ResxOutDir,
string StructHeaderPath,
string StructSourcePath);
}
Loading
Loading