diff --git a/Launcher/WiiCompiled.Setup.Common/RuntimeConfiguration.cs b/Launcher/WiiCompiled.Setup.Common/RuntimeConfiguration.cs
index 9a4f88a..ec7e72c 100644
--- a/Launcher/WiiCompiled.Setup.Common/RuntimeConfiguration.cs
+++ b/Launcher/WiiCompiled.Setup.Common/RuntimeConfiguration.cs
@@ -1,4 +1,7 @@
using System.Text;
+using Tomlyn;
+using Tomlyn.Parsing;
+using Tomlyn.Syntax;
namespace WiiCompiled.Setup.Common;
@@ -17,12 +20,6 @@ public static class RuntimeConfiguration
{
public const string ConfigFileName = "Config.toml";
- ///
- /// Ordinal, matching the runtime parser/writer (runtime_config.h) exactly. Case-insensitive
- /// matching here would let setup rewrite a [Paths]/Dvd_Root spelling the runtime ignores.
- ///
- private const StringComparison KeyComparison = StringComparison.Ordinal;
-
/// The per-user configuration an ordinary (non-portable) installation shares.
public static string ApplicationDataConfigPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
@@ -75,9 +72,11 @@ public static class RuntimeConfiguration
public static void RemoveDvdRootIfOwned(string configPath, string dvdRoot) =>
RemovePathIfOwned(configPath, "dvd_root", dvdRoot);
- /// The raw stored text of a [paths] key, exactly as the file holds it.
+ /// The stored string of a [paths] key, exactly as the file holds it.
public static string? GetPath(string configPath, string key) =>
- TryUnquoteToml(GetRawValue(configPath, "paths", key) ?? "", out var value) ? value : null;
+ TryLoad(configPath) is { } document && Find(document, ["paths"], key)?.Item.Value is StringValueSyntax value
+ ? value.Value!
+ : null;
///
/// A [paths] value as the runtime will actually use it. The runtime resolves every relative
@@ -99,9 +98,19 @@ public static class RuntimeConfiguration
private static void SetPath(string configPath, string key, string value)
{
- var lines = ReadLinesOrDefault(configPath);
- SetSectionValue(lines, "paths", key, QuoteToml(FormatPathValue(configPath, value)));
- WriteLines(configPath, lines);
+ var document = Parse(File.Exists(configPath) ? File.ReadAllText(configPath) : DefaultConfig);
+ var section = new[] { "paths" };
+ var newValue = new StringValueSyntax(FormatPathValue(configPath, value));
+ if (Find(document, section, key) is { } existing)
+ {
+ LastToken(newValue).TrailingTrivia = LastToken(existing.Item.Value!).TrailingTrivia;
+ existing.Item.Value = newValue;
+ }
+ else
+ {
+ Append(document, section, new KeyValueSyntax(key, newValue));
+ }
+ Save(configPath, document);
}
///
@@ -123,176 +132,104 @@ public static class RuntimeConfiguration
private static void RemovePathIfOwned(string configPath, string key, string expectedValue)
{
- if (!File.Exists(configPath)) return;
- var lines = File.ReadAllLines(configPath).ToList();
- var inPaths = false;
- var changed = false;
- for (var index = 0; index < lines.Count; index++)
- {
- var trimmed = RemoveComment(lines[index]).Trim();
- if (IsSection(trimmed))
- {
- inPaths = trimmed.Equals("[paths]", KeyComparison);
- continue;
- }
- if (!inPaths || trimmed.Length == 0) continue;
- var equals = trimmed.IndexOf('=');
- if (equals < 0 || !trimmed[..equals].Trim().Equals(key, KeyComparison))
- continue;
- if (!TryUnquoteToml(trimmed[(equals + 1)..].Trim(), out var configured)) return;
- // A portable installation stores this relative, so ownership is decided on the path the
- // runtime would actually resolve, not on the stored text.
- if (!ResolveAgainstConfig(configPath, configured)
- .Equals(Path.GetFullPath(expectedValue), StringComparison.OrdinalIgnoreCase))
- return;
- lines.RemoveAt(index);
- changed = true;
- break;
- }
- if (changed) WriteLines(configPath, lines);
+ if (TryLoad(configPath) is not { } document ||
+ Find(document, ["paths"], key) is not { } entry ||
+ entry.Item.Value is not StringValueSyntax configured)
+ return;
+ // A portable installation stores this relative, so ownership is decided on the path the
+ // runtime would actually resolve, not on the stored text.
+ if (!ResolveAgainstConfig(configPath, configured.Value!)
+ .Equals(Path.GetFullPath(expectedValue), StringComparison.OrdinalIgnoreCase))
+ return;
+ var previous = entry.Previous?.EndOfLineToken ?? entry.Table?.EndOfLineToken;
+ if (previous is not null && entry.Item.EndOfLineToken?.TrailingTrivia is { } trivia)
+ (previous.TrailingTrivia ??= []).AddRange(trivia);
+ entry.Items.RemoveChild(entry.Item);
+ Save(configPath, document);
}
- /// The raw TOML literal stored for a key, or null when the section or key is absent.
- public static string? GetRawValue(string configPath, string section, string key)
+ private const string DefaultConfig = "# WiiCompiled user configuration\n\n";
+
+ private sealed record Entry(
+ KeyValueSyntax Item, SyntaxList Items, TableSyntax? Table, KeyValueSyntax? Previous);
+
+ private static DocumentSyntax Parse(string text) => SyntaxParser.ParseStrict(text);
+
+ private static DocumentSyntax? TryLoad(string configPath)
{
if (!File.Exists(configPath)) return null;
- var header = $"[{section}]";
- var inSection = false;
- foreach (var line in File.ReadLines(configPath))
+ try { return Parse(File.ReadAllText(configPath)); }
+ catch (TomlException) { return null; }
+ }
+
+ /// Re-parses the edited tree before replacing the file, so an invalid edit never reaches disk.
+ private static void Save(string configPath, DocumentSyntax document)
+ {
+ var text = document.ToString();
+ Parse(text);
+ FileSystemUtilities.WriteAtomic(configPath, new UTF8Encoding(false).GetBytes(text));
+ }
+
+ private static IEnumerable Tables(DocumentSyntax document) =>
+ document.Tables.OfType();
+
+ private static string[] Parts(KeySyntax key) =>
+ [Part(key.Key!), .. key.DotKeys.Select(dotted => Part(dotted.Key!))];
+
+ private static string Part(BareKeyOrStringValueSyntax part) =>
+ part is StringValueSyntax quoted ? quoted.Value! : ((BareKeySyntax)part).Key!.Text!;
+
+ /// Matches by TOML meaning, so "dvd_root" and dvd_root are the same key.
+ private static Entry? Find(DocumentSyntax document, string[] section, string key)
+ {
+ string[] wanted = [.. section, key];
+ var scopes = Tables(document)
+ .Select(table => (Table: (TableSyntax?)table, Prefix: Parts(table.Name!), Items: table.Items))
+ .Prepend((Table: null, Prefix: [], Items: document.KeyValues));
+ foreach (var (table, prefix, items) in scopes)
{
- var trimmed = RemoveComment(line).Trim();
- if (IsSection(trimmed))
+ KeyValueSyntax? previous = null;
+ foreach (var item in items)
{
- inSection = trimmed.Equals(header, KeyComparison);
- continue;
+ if (prefix.Concat(Parts(item.Key!)).SequenceEqual(wanted))
+ return new Entry(item, items, table, previous);
+ previous = item;
}
- if (!inSection || trimmed.Length == 0) continue;
- var equals = trimmed.IndexOf('=');
- if (equals < 0 || !trimmed[..equals].Trim().Equals(key, KeyComparison)) continue;
- return trimmed[(equals + 1)..].Trim();
}
return null;
}
- ///
- /// Replaces one key in one section, preserving comments, ordering, and every unrelated setting -
- /// this file belongs to the user and to the in-game settings bar as much as to setup. A
- /// commented-out key is deliberately not matched, matching the runtime's own writer.
- ///
- private static void SetSectionValue(List lines, string section, string key, string value)
+ private static void Append(DocumentSyntax document, string[] section, KeyValueSyntax item)
{
- var header = $"[{section}]";
- var sectionStart = -1;
- var sectionEnd = lines.Count;
- for (var index = 0; index < lines.Count; index++)
+ var text = document.ToString();
+ var newline = text.Contains("\r\n") ? "\r\n" : "\n";
+ var table = Tables(document).FirstOrDefault(t => Parts(t.Name!).SequenceEqual(section));
+ if (table is null)
{
- var trimmed = RemoveComment(lines[index]).Trim();
- if (!IsSection(trimmed)) continue;
- if (sectionStart >= 0)
- {
- sectionEnd = index;
- break;
- }
- if (trimmed.Equals(header, KeyComparison)) sectionStart = index;
- }
- if (sectionStart < 0)
- {
- if (lines.Count > 0 && lines[^1].Length != 0) lines.Add("");
- lines.Add(header);
- lines.Add($"{key} = {value}");
- return;
+ var name = new KeySyntax(section[0]);
+ foreach (var part in section.Skip(1)) name.DotKeys.Add(new DottedKeyItemSyntax(part));
+ table = new TableSyntax(name) { EndOfLineToken = NewLine(newline) };
+ var blankLine = text.EndsWith("\n\n") || text.EndsWith("\n\r\n");
+ var gap = text.Length == 0 ? 0 : !text.EndsWith('\n') ? 2 : blankLine ? 0 : 1;
+ for (var i = 0; i < gap; i++) (table.LeadingTrivia ??= []).Add(new SyntaxTrivia(TokenKind.NewLine, newline));
+ document.Tables.Add(table);
}
- for (var index = sectionStart + 1; index < sectionEnd; index++)
- {
- var trimmed = RemoveComment(lines[index]).Trim();
- if (trimmed.Length == 0) continue;
- var equals = trimmed.IndexOf('=');
- if (equals >= 0 && trimmed[..equals].Trim().Equals(key, KeyComparison))
- {
- lines[index] = $"{key} = {value}";
- return;
- }
- }
-
- // Append after the section's last real line, not after the blank line that separates it from
- // the next header, so a generated file stays readable for the user who also edits it by hand.
- var insertAt = sectionEnd;
- while (insertAt > sectionStart + 1 && lines[insertAt - 1].Trim().Length == 0) insertAt--;
- lines.Insert(insertAt, $"{key} = {value}");
+ // Blank lines and comments after the last entry stay after the new one.
+ var last = table.Items.LastOrDefault();
+ var before = last is null ? table.EndOfLineToken ??= NewLine(newline) : last.EndOfLineToken ??= NewLine(newline);
+ item.EndOfLineToken = NewLine(newline);
+ item.EndOfLineToken.TrailingTrivia = before.TrailingTrivia;
+ before.TrailingTrivia = null;
+ table.Items.Add(item);
}
- private static List ReadLinesOrDefault(string configPath) =>
- File.Exists(configPath) ? File.ReadAllLines(configPath).ToList() : DefaultConfigLines();
+ private static SyntaxToken NewLine(string text) => new(TokenKind.NewLine, text);
- private static void WriteLines(string configPath, List lines) =>
- FileSystemUtilities.WriteAtomic(configPath,
- Encoding.UTF8.GetBytes(string.Join(Environment.NewLine, lines) + Environment.NewLine));
-
- private static List DefaultConfigLines() =>
- [
- "# WiiCompiled user configuration",
- "",
- "[paths]"
- ];
-
- private static bool IsSection(string value) =>
- value.Length >= 2 && value[0] == '[' && value[^1] == ']';
-
- ///
- /// Line-for-line port of RemoveComment in runtime_config.h, including its backslash
- /// escaping inside basic strings: without it a Windows path ending in \\" reads as
- /// re-opening the quote, and the two implementations disagree about which # starts a comment.
- ///
- private static string RemoveComment(string line)
+ private static SyntaxToken LastToken(SyntaxNode node)
{
- var inSingle = false;
- var inDouble = false;
- var escaped = false;
- for (var index = 0; index < line.Length; index++)
- {
- var character = line[index];
- if (inDouble && character == '\\' && !escaped)
- {
- escaped = true;
- continue;
- }
- if (character == '\'' && !inDouble) inSingle = !inSingle;
- else if (character == '"' && !inSingle && !escaped) inDouble = !inDouble;
- else if (character == '#' && !inSingle && !inDouble) return line[..index];
- escaped = false;
- }
- return line;
- }
-
- private static string QuoteToml(string value) =>
- "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
-
- public static bool TryUnquoteToml(string value, out string result)
- {
- result = "";
- if (value.Length < 2) return false;
- // Literal strings carry no escapes; the runtime's parser treats them the same way.
- if (value[0] == '\'' && value[^1] == '\'')
- {
- result = value[1..^1];
- return true;
- }
- if (value[0] != '"' || value[^1] != '"') return false;
- var builder = new StringBuilder(value.Length - 2);
- for (var index = 1; index < value.Length - 1; index++)
- {
- if (value[index] == '\\' && index + 1 < value.Length - 1)
- {
- var escaped = value[++index];
- builder.Append(escaped switch { '\\' => '\\', '"' => '"', _ => escaped });
- }
- else
- {
- builder.Append(value[index]);
- }
- }
- result = builder.ToString();
- return true;
+ while (node is not SyntaxToken)
+ node = node.GetChild(node.ChildrenCount - 1)!;
+ return (SyntaxToken)node;
}
}
diff --git a/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj b/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj
index 3991fc5..c8bb35e 100644
--- a/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj
+++ b/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj
@@ -11,4 +11,7 @@
embedded
en
+
+
+
diff --git a/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs b/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs
index 284310a..292afd9 100644
--- a/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs
+++ b/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs
@@ -82,6 +82,7 @@ internal static class BuildRunner
{
startInfo.ArgumentList.Add("--sysroot"); startInfo.ArgumentList.Add(sysroot);
}
+ startInfo.ArgumentList.Add("--setup-version"); startInfo.ArgumentList.Add(ProductInfo.Version);
using var process = new Process { StartInfo = startInfo };
var window = new BuildProgressWindow(reporter, InstallStages.Build, start: 6, end: 96);
diff --git a/Launcher/WiiCompiled.Setup.Windows/SelfTests.cs b/Launcher/WiiCompiled.Setup.Windows/SelfTests.cs
index 21956a7..5d74747 100644
--- a/Launcher/WiiCompiled.Setup.Windows/SelfTests.cs
+++ b/Launcher/WiiCompiled.Setup.Windows/SelfTests.cs
@@ -1,5 +1,6 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
+using Tomlyn;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
@@ -51,6 +52,7 @@ internal static class SelfTests
RuntimeConfiguration.SetRetroRewindRoot, RuntimeConfiguration.RemoveRetroRewindRootIfOwned),
failures);
Test("Runtime configuration key matching", TestRuntimeConfigurationKeyMatching, failures);
+ Test("Runtime configuration quoted keys", TestRuntimeConfigurationQuotedKeys, failures);
Test("Install location validation", TestInstallLocationValidation, failures);
Test("Command line contract", TestCommandLineContract, failures);
Test("Portable root discovery", TestPortableRootDiscovery, failures);
@@ -229,6 +231,70 @@ internal static class SelfTests
}
}
+ ///
+ /// Quoted and bare spellings of a key or table are the same TOML key, so rewriting replaces the
+ /// entry in place, keeps everything else byte-identical, and never touches an unparseable file.
+ ///
+ private static void TestRuntimeConfigurationQuotedKeys()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "mkwc-tomlquoted-" + Guid.NewGuid().ToString("N"));
+ var config = Path.Combine(root, RuntimeConfiguration.ConfigFileName);
+ var owned = Path.Combine(root, "Owned Location");
+ var fullOwned = Path.GetFullPath(owned);
+ try
+ {
+ Directory.CreateDirectory(owned);
+ foreach (var header in new[] { "[\"paths\"]", "[ 'paths' ]", "[paths]" })
+ foreach (var key in new[] { "\"dvd_root\"", "'dvd_root'", "\"dvd\\u005Froot\"", "dvd_root" })
+ {
+ File.WriteAllText(config,
+ $"# top\r\n[video]\r\nwidescreen = true # keep\r\n\r\n{header}\r\n {key}=\"D:\\\\Old\" # note\r\nother = 1\r\n");
+ RuntimeConfiguration.SetDvdRoot(config, owned);
+ var text = File.ReadAllText(config);
+ var expected = $"# top\r\n[video]\r\nwidescreen = true # keep\r\n\r\n{header}\r\n {key}=" +
+ $"\"{fullOwned.Replace("\\", "\\\\")}\" # note\r\nother = 1\r\n";
+ if (text != expected)
+ throw new Exception($"{header} {key} was not replaced in place:\r\n{text}");
+ if (RuntimeConfiguration.GetResolvedPath(config, "dvd_root") != fullOwned)
+ throw new Exception($"The replaced {key} could not be read back.");
+ }
+
+ File.WriteAllText(config, "[paths]\r\nother = 1\r\n\r\n# next\r\n[video]\r\nx = 2");
+ RuntimeConfiguration.SetDvdRoot(config, owned);
+ var appended = File.ReadAllText(config);
+ if (!appended.StartsWith("[paths]\r\nother = 1\r\ndvd_root = ", StringComparison.Ordinal) ||
+ !appended.EndsWith("\r\n\r\n# next\r\n[video]\r\nx = 2", StringComparison.Ordinal))
+ throw new Exception("A missing key was not appended to its section:\r\n" + appended);
+
+ File.WriteAllText(config, "dvd_root = \"D:\\\\Root\"\r\n[video]\r\nx = 2\r\n");
+ RuntimeConfiguration.SetDvdRoot(config, owned);
+ var created = File.ReadAllText(config);
+ if (!created.StartsWith("dvd_root = \"D:\\\\Root\"\r\n[video]\r\nx = 2\r\n\r\n[paths]\r\n", StringComparison.Ordinal) ||
+ RuntimeConfiguration.GetResolvedPath(config, "dvd_root") != fullOwned)
+ throw new Exception("A missing section was not created without touching a same-named root key:\r\n" + created);
+
+ File.Delete(config);
+ RuntimeConfiguration.SetDvdRoot(config, owned);
+ if (RuntimeConfiguration.GetResolvedPath(config, "dvd_root") != fullOwned)
+ throw new Exception("A missing config file was not created.");
+
+ const string broken = "[paths]\r\ndvd_root = 1\r\ndvd_root = 2\r\n";
+ File.WriteAllText(config, broken);
+ try
+ {
+ RuntimeConfiguration.SetDvdRoot(config, owned);
+ throw new Exception("An unparseable config was rewritten.");
+ }
+ catch (TomlException) { }
+ if (File.ReadAllText(config) != broken)
+ throw new Exception("An unparseable config was modified.");
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
+ }
+
///
/// One location rule set covers both an installation directory and a portable root, so the
/// rejections a portable install performs are the ones an ordinary install performs too.
diff --git a/Launcher/local-build-macos.command b/Launcher/local-build-macos.command
index 4c238fd..7a01e64 100755
--- a/Launcher/local-build-macos.command
+++ b/Launcher/local-build-macos.command
@@ -27,12 +27,15 @@ Usage: local-build-macos.command --output-dir DIR [options]
--cmake PATH --ninja PATH Override build tools
--dotnet PATH Override dotnet
--translator-bin PATH Use a self-contained Translator.Cli executable
+ --setup-version VERSION Version recorded in the runtime log (default: in
+ Launcher/Directory.Build.props when present)
EOF
}
workspace="$default_workspace"; profile=base; output_dir=""; base_output_dir=""
game=""; nodtool=""; retro_root=""; retro_wfc=""; skip_retro_wfc=0; force_clean=0
parallel=0; cmake_bin=cmake; ninja_bin=ninja; dotnet_bin=dotnet; translator_bin=""
+setup_version=""
while (($#)); do
case "$1" in
--workspace) workspace=${2:-}; shift 2 ;;
@@ -50,6 +53,7 @@ while (($#)); do
--ninja) ninja_bin=${2:-}; shift 2 ;;
--dotnet) dotnet_bin=${2:-}; shift 2 ;;
--translator-bin) translator_bin=${2:-}; shift 2 ;;
+ --setup-version) setup_version=${2:-}; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) fail "unknown option: $1" ;;
esac
@@ -60,6 +64,10 @@ macos_arch=$(uname -m)
case "$macos_arch" in arm64|x86_64) ;; *) fail "unsupported macOS architecture: $macos_arch" ;; esac
macos_deployment_target=12.0
workspace=$(cd "$workspace" && pwd)
+if [[ -z "$setup_version" && -f "$workspace/Launcher/Directory.Build.props" ]]; then
+ setup_version=$(sed -nE 's/.*([^<]+)<\/Version>.*/\1/p' "$workspace/Launcher/Directory.Build.props" | head -n 1)
+fi
+[[ "$setup_version" =~ ^[0-9A-Za-z.+-]*$ ]] || fail '--setup-version contains unsupported characters'
[[ -n "$output_dir" ]] || fail '--output-dir is required'
case "$profile" in base|retro-rewind|both) ;; *) fail '--profile must be base, retro-rewind, or both' ;; esac
builds_retro=0; [[ "$profile" != base ]] && builds_retro=1
@@ -136,7 +144,7 @@ step emit-build-shards 'Preparing native build shards'; translator "${args[@]}"
step configure-native 'Configuring the native toolchain'
# Use Aurora's pinned SDL3 source on macOS. A system SDL3 can be older than
# Aurora's required API even when find_package() succeeds.
-"$cmake_bin" -S "$workspace/runtime" -B "$native_build" -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_MAKE_PROGRAM="$ninja_bin" -DCMAKE_OSX_ARCHITECTURES="$macos_arch" -DCMAKE_OSX_DEPLOYMENT_TARGET="$macos_deployment_target" -DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs" -DAURORA_SDL3_PROVIDER=vendor
+"$cmake_bin" -S "$workspace/runtime" -B "$native_build" -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_MAKE_PROGRAM="$ninja_bin" -DCMAKE_OSX_ARCHITECTURES="$macos_arch" -DCMAKE_OSX_DEPLOYMENT_TARGET="$macos_deployment_target" -DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs" -DAURORA_SDL3_PROVIDER=vendor -DMKW_SETUP_VERSION="$setup_version"
targets=(); [[ "$profile" != retro-rewind ]] && targets+=(WiiCompiled); [[ "$profile" != base ]] && targets+=(RetroRewind)
step compile "Compiling ${targets[*]} locally"; "$cmake_bin" --build "$native_build" --target "${targets[@]}" --parallel "$global_jobs"
if [[ "$profile" != retro-rewind ]]; then "$script_dir/macos/publish-app.command" --build-dir "$native_build" --product WiiCompiled --output-dir "${base_output_dir:-$output_dir}" --architecture "$macos_arch" --minimum-system-version "$macos_deployment_target"; fi
diff --git a/Launcher/local-build.sh b/Launcher/local-build.sh
index f48ae9b..cdc81eb 100755
--- a/Launcher/local-build.sh
+++ b/Launcher/local-build.sh
@@ -66,6 +66,7 @@ translator_bin_override=""
fuse_ld_override=""
native_prebuilt_dir=""
sysroot=""
+setup_version=""
usage() {
cat <<'EOF'
@@ -90,6 +91,8 @@ Usage: local-build.sh --output-dir DIR [options]
skips compiling aurora-main from source entirely
--sysroot PATH Passed to CMake as -DCMAKE_SYSROOT: where the compiler resolves
standard headers/startup files
+ --setup-version VERSION Version recorded in the runtime log (default: in
+ Launcher/Directory.Build.props when present)
EOF
}
@@ -114,11 +117,17 @@ while [[ $# -gt 0 ]]; do
--translator-bin) translator_bin_override=$2; shift 2 ;;
--native-prebuilt-dir) native_prebuilt_dir=$2; shift 2 ;;
--sysroot) sysroot=$2; shift 2 ;;
+ --setup-version) setup_version=$2; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) fail "unknown argument: $1" ;;
esac
done
+if [[ -z "$setup_version" && -f "$workspace/Launcher/Directory.Build.props" ]]; then
+ setup_version=$(sed -nE 's/.*([^<]+)<\/Version>.*/\1/p' "$workspace/Launcher/Directory.Build.props" | head -n 1)
+fi
+[[ "$setup_version" =~ ^[0-9A-Za-z.+-]*$ ]] || fail "--setup-version contains unsupported characters"
+
[[ -n "$output_dir" ]] || { usage; fail "--output-dir is required"; }
case "$profile" in
base|retro-rewind|both) ;;
@@ -418,7 +427,8 @@ configure_args=(-S "$workspace/runtime" -B "$build" -G Ninja
-DCMAKE_BUILD_TYPE=Release
-DCMAKE_C_COMPILER="$cc_bin" -DCMAKE_CXX_COMPILER="$cxx_bin"
-DCMAKE_MAKE_PROGRAM="$ninja_bin"
- -DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs")
+ -DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs"
+ -DMKW_SETUP_VERSION="$setup_version")
# CMAKE_C_COMPILER/CXX_COMPILER stay stable across AppImage runs on their own (they're exactly
# what's passed via -D above, and AppRun points --cc/--cxx at a symlink it re-targets at the
# current mount every launch - see build-appimage.sh). CMAKE_AR/RANLIB/LINKER/ASM_COMPILER do NOT
diff --git a/Launcher/macos/build-setup-pkg.command b/Launcher/macos/build-setup-pkg.command
index f2ae572..cf84b9e 100755
--- a/Launcher/macos/build-setup-pkg.command
+++ b/Launcher/macos/build-setup-pkg.command
@@ -151,6 +151,7 @@ for source in aurora-main projects runtime translator; do
copy_clean "$workspace/$source" "$resources/workspace/$source"
done
mkdir -p "$resources/workspace/Launcher/macos"
+copy_clean "$workspace/Launcher/Directory.Build.props" "$resources/workspace/Launcher/Directory.Build.props"
copy_clean "$workspace/Launcher/local-build-macos.command" "$resources/workspace/Launcher/local-build-macos.command"
copy_clean "$workspace/Launcher/macos/extract-disc.command" "$resources/workspace/Launcher/macos/extract-disc.command"
copy_clean "$workspace/Launcher/macos/publish-app.command" "$resources/workspace/Launcher/macos/publish-app.command"
diff --git a/aurora-main/cmake/AuroraSDL3Patches.cmake b/aurora-main/cmake/AuroraSDL3Patches.cmake
index cea23c1..e92eb2f 100644
--- a/aurora-main/cmake/AuroraSDL3Patches.cmake
+++ b/aurora-main/cmake/AuroraSDL3Patches.cmake
@@ -32,6 +32,52 @@ function(aurora_sdl3_apply_patches sdl_source_dir)
if (NOT EXISTS "${_wii}")
message(FATAL_ERROR "aurora: SDL3 source tree at ${sdl_source_dir} has no SDL_hidapi_wii.c")
endif ()
+ set(_rumble "${sdl_source_dir}/src/joystick/hidapi/SDL_hidapi_rumble.c")
+ if (NOT EXISTS "${_rumble}")
+ message(FATAL_ERROR "aurora: SDL3 source tree at ${sdl_source_dir} has no SDL_hidapi_rumble.c")
+ endif ()
+ set(_hidapi "${sdl_source_dir}/src/joystick/hidapi/SDL_hidapijoystick.c")
+ if (NOT EXISTS "${_hidapi}")
+ message(FATAL_ERROR "aurora: SDL3 source tree at ${sdl_source_dir} has no SDL_hidapijoystick.c")
+ endif ()
+
+ # Backport of SDL 71f4af732 (drop once SDL includes it): rumble writes must not hold dev_lock.
+ _aurora_sdl3_replace("${_rumble}" "HIDAPI rumble writes no longer hold the device lock"
+[==[ if (request) {
+ SDL_LockMutex(request->device->dev_lock);
+ if (request->device->dev) {
+#ifdef DEBUG_RUMBLE
+ HIDAPI_DumpPacket("Rumble packet: size = %d", request->data, request->size);
+#endif
+ SDL_hid_write(request->device->dev, request->data, request->size);
+ }
+ SDL_UnlockMutex(request->device->dev_lock);
+]==]
+[==[ if (request) {
+ if (request->device->dev) {
+#ifdef DEBUG_RUMBLE
+ HIDAPI_DumpPacket("Rumble packet: size = %d", request->data, request->size);
+#endif
+ SDL_hid_write(request->device->dev, request->data, request->size);
+ }
+]==])
+
+ # Without the lock, closing the handle must wait for in-flight rumble writes.
+ _aurora_sdl3_replace("${_hidapi}" "HIDAPI device close waits for pending rumble"
+[==[ device->driver->FreeDevice(device);
+ device->driver = NULL;
+
+ SDL_LockMutex(device->dev_lock);
+]==]
+[==[ device->driver->FreeDevice(device);
+ device->driver = NULL;
+
+ while (SDL_GetAtomicInt(&device->rumble_pending) > 0) {
+ SDL_Delay(10);
+ }
+
+ SDL_LockMutex(device->dev_lock);
+]==])
# Wii Remote: rebuild the joystick in place after an extension change.
#
@@ -156,6 +202,181 @@ static void HandleGamepadControllerButtonData(SDL_DriverWii_Context *ctx, SDL_Jo
PostClassicStickLinear(ctx->timestamp, joystick, &ctx->m_StickCalibrationData[2], SDL_GAMEPAD_AXIS_RIGHTX, rx, 32);
PostClassicStickLinear(ctx->timestamp, joystick, &ctx->m_StickCalibrationData[3], SDL_GAMEPAD_AXIS_RIGHTY, ry, 32);
]==])
+
+ # Wii Remote: decode the accelerometer with the factory calibration from the EEPROM.
+ _aurora_sdl3_replace("${_wii}" "Wii Remote accelerometer calibration (context fields)"
+[==[ StickCalibrationData m_StickCalibrationData[6];
+} SDL_DriverWii_Context;
+]==]
+[==[ StickCalibrationData m_StickCalibrationData[6];
+
+ float m_fAccelZeroG[3];
+ float m_fAccelPerG[3];
+} SDL_DriverWii_Context;
+]==])
+ _aurora_sdl3_replace("${_wii}" "Wii Remote accelerometer calibration (memory space parameter)"
+[==[static bool ReadRegister(SDL_DriverWii_Context *ctx, Uint32 address, int size, bool sync)
+{
+ Uint8 readRequest[7];
+
+ readRequest[0] = k_eWiiOutputReportIDs_ReadMemory;
+ readRequest[1] = (Uint8)(0x04 | (Uint8)ctx->m_bRumbleActive);
+]==]
+[==[static bool ReadMemory(SDL_DriverWii_Context *ctx, Uint8 addressSpace, Uint32 address, int size, bool sync);
+
+static bool ReadRegister(SDL_DriverWii_Context *ctx, Uint32 address, int size, bool sync)
+{
+ return ReadMemory(ctx, 0x04, address, size, sync);
+}
+
+static bool ReadMemory(SDL_DriverWii_Context *ctx, Uint8 addressSpace, Uint32 address, int size, bool sync)
+{
+ Uint8 readRequest[7];
+
+ readRequest[0] = k_eWiiOutputReportIDs_ReadMemory;
+ readRequest[1] = (Uint8)(addressSpace | (Uint8)ctx->m_bRumbleActive);
+]==])
+ _aurora_sdl3_replace("${_wii}" "Wii Remote accelerometer calibration (EEPROM read)"
+[==[static bool HIDAPI_DriverWii_InitDevice(SDL_HIDAPI_Device *device)
+{
+]==]
+[==[// Nominal decode: zero-g at 0x200, 100 units per g
+#define WII_ACCEL_NOMINAL_ZERO_G 512.0f
+#define WII_ACCEL_NOMINAL_PER_G 100.0f
+
+// EEPROM calibration at 0x16, copy at 0x20: zero-g XYZ, 1 g XYZ, LSBs of both, checksum
+#define WII_ACCEL_CALIBRATION_SIZE 10
+#define WII_ADDRESS_SPACE_EEPROM 0x00
+
+static bool ReadAccelCalibrationBlock(SDL_DriverWii_Context *ctx, Uint8 address, Uint8 *block)
+{
+ if (!ReadMemory(ctx, WII_ADDRESS_SPACE_EEPROM, address, WII_ACCEL_CALIBRATION_SIZE, true)) {
+ return false;
+ }
+ if (ctx->m_rgucReadBuffer[5] != address ||
+ ctx->m_rgucReadBuffer[3] != ((WII_ACCEL_CALIBRATION_SIZE - 1) << 4)) {
+ SDL_SetError("Unexpected EEPROM read response");
+ return false;
+ }
+ SDL_memcpy(block, ctx->m_rgucReadBuffer + 6, WII_ACCEL_CALIBRATION_SIZE);
+ return true;
+}
+
+static bool ParseAccelCalibration(SDL_DriverWii_Context *ctx, const Uint8 *block)
+{
+ int zero[3];
+ int perG[3];
+ Uint8 checksum = 0x55;
+ int i;
+
+ for (i = 0; i < WII_ACCEL_CALIBRATION_SIZE - 1; ++i) {
+ checksum = (Uint8)(checksum + block[i]);
+ }
+ if (checksum != block[WII_ACCEL_CALIBRATION_SIZE - 1]) {
+ SDL_SetError("Bad checksum");
+ return false;
+ }
+
+ for (i = 0; i < 3; ++i) {
+ int shift = 4 - 2 * i;
+ int oneG = (block[4 + i] << 2) | ((block[7] >> shift) & 0x03);
+
+ zero[i] = (block[i] << 2) | ((block[3] >> shift) & 0x03);
+ perG[i] = oneG - zero[i];
+
+ // Sanity limits around the nominal values
+ if (zero[i] < 384 || zero[i] > 640 || perG[i] < 60 || perG[i] > 160) {
+ SDL_SetError("Implausible values");
+ return false;
+ }
+ }
+
+ for (i = 0; i < 3; ++i) {
+ ctx->m_fAccelZeroG[i] = (float)zero[i];
+ ctx->m_fAccelPerG[i] = (float)perG[i];
+ }
+ return true;
+}
+
+static void ReadAccelCalibration(SDL_DriverWii_Context *ctx)
+{
+ static const Uint8 addresses[] = { 0x16, 0x20 };
+ Uint8 block[WII_ACCEL_CALIBRATION_SIZE];
+ int i;
+
+ for (i = 0; i < SDL_arraysize(addresses); ++i) {
+ if (ReadAccelCalibrationBlock(ctx, addresses[i], block) &&
+ ParseAccelCalibration(ctx, block)) {
+ SDL_LogInfo(SDL_LOG_CATEGORY_INPUT, "HIDAPI Wii: Accelerometer calibration from EEPROM 0x%02X: zero %d/%d/%d, %d/%d/%d units per g",
+ addresses[i],
+ (int)ctx->m_fAccelZeroG[0], (int)ctx->m_fAccelZeroG[1], (int)ctx->m_fAccelZeroG[2],
+ (int)ctx->m_fAccelPerG[0], (int)ctx->m_fAccelPerG[1], (int)ctx->m_fAccelPerG[2]);
+ return;
+ }
+ SDL_LogInfo(SDL_LOG_CATEGORY_INPUT, "HIDAPI Wii: Accelerometer calibration at EEPROM 0x%02X unusable: %s", addresses[i], SDL_GetError());
+ }
+ SDL_LogInfo(SDL_LOG_CATEGORY_INPUT, "HIDAPI Wii: Using nominal accelerometer calibration");
+}
+
+static bool HIDAPI_DriverWii_InitDevice(SDL_HIDAPI_Device *device)
+{
+]==])
+ _aurora_sdl3_replace("${_wii}" "Wii Remote accelerometer calibration (nominal defaults)"
+[==[ ctx->device = device;
+ device->context = ctx;
+]==]
+[==[ ctx->device = device;
+ device->context = ctx;
+ for (int i = 0; i < 3; ++i) {
+ ctx->m_fAccelZeroG[i] = WII_ACCEL_NOMINAL_ZERO_G;
+ ctx->m_fAccelPerG[i] = WII_ACCEL_NOMINAL_PER_G;
+ }
+]==])
+ _aurora_sdl3_replace("${_wii}" "Wii Remote accelerometer calibration (read at init)"
+[==[ ctx->m_eExtensionControllerType = ReadExtensionControllerType(device);
+]==]
+[==[ ctx->m_eExtensionControllerType = ReadExtensionControllerType(device);
+ ReadAccelCalibration(ctx);
+]==])
+ # Reports without accelerometer bytes (status, memory) decoded as garbage.
+ _aurora_sdl3_replace("${_wii}" "Wii Remote accelerometer guard"
+[==[ }
+
+ x = ((data->rgucAccelerometer[0] << 2) | ((data->rgucBaseButtons[0] >> 5) & 0x03))]==]
+[==[ }
+
+ if (!data->hasAccelerometer) {
+ return;
+ }
+
+ x = ((data->rgucAccelerometer[0] << 2) | ((data->rgucBaseButtons[0] >> 5) & 0x03))]==])
+ _aurora_sdl3_replace("${_wii}" "Wii Remote accelerometer calibration (nominal constant)"
+[==[const WiiButtonData *data)
+{
+ const float ACCEL_RES_PER_G = 100.0f;
+ Sint16 x, y, z;
+]==]
+[==[const WiiButtonData *data)
+{
+ Sint16 x, y, z;
+]==])
+ _aurora_sdl3_replace("${_wii}" "Wii Remote accelerometer calibration (decode)"
+[==[ x = ((data->rgucAccelerometer[0] << 2) | ((data->rgucBaseButtons[0] >> 5) & 0x03)) - 0x200;
+ y = ((data->rgucAccelerometer[1] << 2) | ((data->rgucBaseButtons[1] >> 4) & 0x02)) - 0x200;
+ z = ((data->rgucAccelerometer[2] << 2) | ((data->rgucBaseButtons[1] >> 5) & 0x02)) - 0x200;
+
+ values[0] = -((float)x / ACCEL_RES_PER_G) * SDL_STANDARD_GRAVITY;
+ values[1] = ((float)z / ACCEL_RES_PER_G) * SDL_STANDARD_GRAVITY;
+ values[2] = ((float)y / ACCEL_RES_PER_G) * SDL_STANDARD_GRAVITY;
+]==]
+[==[ x = ((data->rgucAccelerometer[0] << 2) | ((data->rgucBaseButtons[0] >> 5) & 0x03));
+ y = ((data->rgucAccelerometer[1] << 2) | ((data->rgucBaseButtons[1] >> 4) & 0x02));
+ z = ((data->rgucAccelerometer[2] << 2) | ((data->rgucBaseButtons[1] >> 5) & 0x02));
+
+ values[0] = -(((float)x - ctx->m_fAccelZeroG[0]) / ctx->m_fAccelPerG[0]) * SDL_STANDARD_GRAVITY;
+ values[1] = (((float)z - ctx->m_fAccelZeroG[2]) / ctx->m_fAccelPerG[2]) * SDL_STANDARD_GRAVITY;
+ values[2] = (((float)y - ctx->m_fAccelZeroG[1]) / ctx->m_fAccelPerG[1]) * SDL_STANDARD_GRAVITY;
+]==])
endfunction()
# Script mode (FetchContent PATCH_COMMAND).
diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt
index 6410b0b..73bf4d6 100644
--- a/runtime/CMakeLists.txt
+++ b/runtime/CMakeLists.txt
@@ -56,6 +56,8 @@ if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64)$" AND
message(FATAL_ERROR "MKW_X86_CPU_PROFILE must be v2 or v3 (got '${MKW_X86_CPU_PROFILE}')")
endif()
+set(MKW_SETUP_VERSION "" CACHE STRING "WiiCompiled version recorded in the runtime log")
+
option(MKW_PLATFORM_TESTS_ONLY "Build host-platform tests without graphics dependencies or game assets" OFF)
include("${CMAKE_CURRENT_LIST_DIR}/cmake/HostLibraries.cmake")
if(MKW_PLATFORM_TESTS_ONLY)
diff --git a/runtime/cmake/PublicProducts.cmake b/runtime/cmake/PublicProducts.cmake
index 14f2500..7b8b327 100644
--- a/runtime/cmake/PublicProducts.cmake
+++ b/runtime/cmake/PublicProducts.cmake
@@ -80,6 +80,9 @@ target_compile_features(mkw_runtime_common PRIVATE cxx_std_20)
target_compile_definitions(mkw_runtime_common PRIVATE
SDL_MAIN_HANDLED
_DISABLE_STRING_ANNOTATION _DISABLE_VECTOR_ANNOTATION)
+if(MKW_SETUP_VERSION)
+ target_compile_definitions(mkw_runtime_common PRIVATE MKW_SETUP_VERSION="${MKW_SETUP_VERSION}")
+endif()
target_link_libraries(mkw_runtime_common PRIVATE
aurora::gx aurora::pad aurora::si aurora::vi aurora::mtx)
target_link_libraries(mkw_runtime_common PRIVATE mkw_platform mkw::pugixml mkw::toml11 mkw::cryptopp mkw::mbedtls)
diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h
index 2204f70..5d0a60a 100644
--- a/runtime/include/runtime_config.h
+++ b/runtime/include/runtime_config.h
@@ -138,28 +138,6 @@ inline std::string Trim(std::string_view text) {
return std::string(text.substr(begin, end - begin));
}
-inline std::string RemoveComment(std::string_view line) {
- bool inSingle = false;
- bool inDouble = false;
- bool escaped = false;
- for (size_t i = 0; i < line.size(); ++i) {
- const char ch = line[i];
- if (inDouble && ch == '\\' && !escaped) {
- escaped = true;
- continue;
- }
- if (ch == '\'' && !inDouble) {
- inSingle = !inSingle;
- } else if (ch == '"' && !inSingle && !escaped) {
- inDouble = !inDouble;
- } else if (ch == '#' && !inSingle && !inDouble) {
- return std::string(line.substr(0, i));
- }
- escaped = false;
- }
- return std::string(line);
-}
-
inline bool IsSupportedResolutionMultiplier(float value) {
static constexpr std::array values{0.0f, 1.0f, 1.5f, 2.0f, 3.0f, 4.0f, 6.0f, 8.0f};
return std::find(values.begin(), values.end(), value) != values.end();
@@ -546,68 +524,50 @@ inline const std::optional& ControllerButton(size_t index) {
return index < Get().controllerButtons.size() ? Get().controllerButtons[index] : empty;
}
-// Update one TOML value without discarding comments, unrelated settings, or
-// user-specific paths. This is used by the in-game F10 settings bar.
+// Sets section.key (section may be a dotted table path) to a TOML literal, keeping the rest of the
+// document's keys, order and comments. Returns nullopt when the document or value does not parse.
+inline std::optional UpdateSettingText(const std::string& text, std::string_view section,
+ std::string_view key, std::string_view value) {
+ try {
+ auto root = toml::parse_str(text);
+ auto literal = toml::parse_str("v = " + std::string(value));
+ auto* table = &root;
+ std::istringstream path{std::string(section)};
+ for (std::string part; std::getline(path, part, '.');) {
+ auto& parent = table->as_table();
+ if (!parent.contains(part)) {
+ parent[part] = toml::ordered_value(toml::ordered_table{});
+ }
+ table = &parent.at(part);
+ if (!table->is_table()) {
+ return std::nullopt;
+ }
+ }
+ if (table->as_table_fmt().fmt == toml::table_format::implicit) {
+ table->as_table_fmt().fmt = toml::table_format::multiline;
+ }
+ auto& slot = table->as_table()[std::string(key)];
+ auto& updated = literal.as_table().at("v");
+ updated.comments() = slot.comments();
+ slot = std::move(updated);
+ return toml::format(root);
+ } catch (const std::exception&) {
+ return std::nullopt;
+ }
+}
+
+// Update one TOML value without discarding unrelated settings or user-specific paths. This is
+// used by the in-game F10 settings bar. An unparseable file is left untouched.
inline bool WriteSetting(std::string_view section, std::string_view key, std::string_view value) {
const auto path = ResolveConfigPath();
- std::ifstream input(path);
- std::vector lines;
- std::string line;
- while (std::getline(input, line)) {
- if (!line.empty() && line.back() == '\r') {
- line.pop_back();
- }
- lines.push_back(std::move(line));
+ std::ostringstream existing;
+ if (std::ifstream input(path, std::ios::binary); input) {
+ existing << input.rdbuf();
}
-
- const std::string normalizedSection = Trim(section);
- const std::string normalizedKey = Trim(key);
- size_t sectionStart = lines.size();
- size_t sectionEnd = lines.size();
- for (size_t i = 0; i < lines.size(); ++i) {
- const std::string trimmed = Trim(RemoveComment(lines[i]));
- if (trimmed.size() >= 2 && trimmed.front() == '[' && trimmed.back() == ']') {
- const std::string found = Trim(std::string_view(trimmed).substr(1, trimmed.size() - 2));
- if (sectionStart != lines.size()) {
- sectionEnd = i;
- break;
- }
- if (found == normalizedSection) {
- sectionStart = i;
- }
- }
- }
-
- const std::string replacement = normalizedKey + " = " + std::string(value);
- if (sectionStart == lines.size()) {
- if (!lines.empty() && !lines.back().empty()) {
- lines.emplace_back();
- }
- lines.emplace_back("[" + normalizedSection + "]");
- lines.push_back(replacement);
- } else {
- bool replaced = false;
- for (size_t i = sectionStart + 1; i < sectionEnd; ++i) {
- const std::string uncommented = Trim(RemoveComment(lines[i]));
- const size_t equals = uncommented.find('=');
- if (equals != std::string::npos && Trim(std::string_view(uncommented).substr(0, equals)) == normalizedKey) {
- lines[i] = replacement;
- replaced = true;
- break;
- }
- }
- if (!replaced) {
- // Append after the section's last real line rather than after the blank line that
- // separates it from the next header: this file is edited by hand and by the host
- // installer as well, and a key parked below the separator reads as if it belonged to
- // the next section. The host writer (Launcher/WiiCompiled.Setup/RuntimeConfiguration.cs)
- // applies exactly this rule.
- size_t insertAt = sectionEnd;
- while (insertAt > sectionStart + 1 && Trim(lines[insertAt - 1]).empty()) {
- --insertAt;
- }
- lines.insert(lines.begin() + static_cast(insertAt), replacement);
- }
+ const auto updated = UpdateSettingText(existing.str(), section, key, value);
+ if (!updated) {
+ std::cerr << "[runtime-config] Not updating invalid " << PathToUtf8(path) << std::endl;
+ return false;
}
std::error_code ec;
@@ -619,9 +579,7 @@ inline bool WriteSetting(std::string_view section, std::string_view key, std::st
std::cerr << "[runtime-config] Unable to write " << PathToUtf8(path) << std::endl;
return false;
}
- for (const auto& outputLine : lines) {
- output << outputLine << '\n';
- }
+ output << *updated;
return static_cast(output);
}
diff --git a/runtime/include/wii_remote_input.h b/runtime/include/wii_remote_input.h
index 986ec64..1c21fdb 100644
--- a/runtime/include/wii_remote_input.h
+++ b/runtime/include/wii_remote_input.h
@@ -73,15 +73,8 @@ bool ReadKpadSample(uint32_t chan, KpadSample& sample);
// no remote or SDL has not delivered a sample yet.
bool ReadAccelDebug(uint32_t chan, float sdlG[3], float kpadAcc[3]);
-// Accelerometer zero-point calibration. SDL's Wii driver tries to read the
-// remote's factory calibration block, but over Bluetooth that read often times
-// out ("Using fallback accelerometer calibration" in console.log) and it falls
-// back to a nominal 0x200 zero point; a real remote then carries a per-axis
-// bias of up to ~0.2 g, which held sideways as a wheel is a permanent steering
-// offset.
-// StartAccelCalibration expects the remote lying still with the buttons up;
-// Poll() then collects samples for about a second and stores the difference to
-// the ideal (0, 1, 0) g in Config.toml.
+// Residual zero-point trim on top of SDL's factory-calibrated decode. Expects the
+// remote lying still, buttons up; Poll() averages about a second and stores it.
void StartAccelCalibration(uint32_t chan);
// Forgets the stored correction.
void ClearAccelCalibration();
diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp
index 1597d20..b3784c2 100644
--- a/runtime/src/main.cpp
+++ b/runtime/src/main.cpp
@@ -378,10 +378,15 @@ void AttachParentConsoleForDiagnostics() {
void AttachParentConsoleForDiagnostics() {}
#endif
-// The setup writes build-fingerprint.json beside every product executable; its SetupVersion is
-// the only version identity the runtime has (products are compiled locally, so nothing is baked
-// into the binary). Surface it at the top of the transcript so every attached log self-identifies.
+// The macOS and Linux local builds bake the setup version in; the Windows setup instead writes it
+// to build-fingerprint.json beside the executable. Surfaced at the top of the transcript so every
+// attached log self-identifies.
std::string ReadInstalledSetupVersion() {
+#if defined(MKW_SETUP_VERSION)
+ if (constexpr std::string_view builtIn = MKW_SETUP_VERSION; !builtIn.empty()) {
+ return std::string(builtIn);
+ }
+#endif
const auto directory = RuntimeConfigFile::ExecutableDirectory();
if (!directory) {
return {};
diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp
index 78c32bf..e0c7f73 100644
--- a/runtime/src/settings_overlay.cpp
+++ b/runtime/src/settings_overlay.cpp
@@ -309,9 +309,8 @@ void DrawWiiRemoteAccelerometer(uint32_t port) {
} else {
ImGui::TextDisabled("No accelerometer data yet.");
}
- // SDL's read of the remote's calibration block often times out over Bluetooth
- // and it falls back to a nominal zero point, leaving a small per-axis bias;
- // measured here with the remote at rest.
+ // Residual per-axis bias left after SDL's factory-calibrated decode, measured
+ // here with the remote at rest.
if (WiiRemoteInput::IsAccelCalibrating()) {
ImGui::ProgressBar(WiiRemoteInput::AccelCalibrationProgress(), ImVec2(220.0f, 0.0f), "Hold still...");
} else if (ImGui::Button("Calibrate (remote lying flat, buttons up)")) {
@@ -333,7 +332,7 @@ void DrawWiiRemoteAccelerometer(uint32_t port) {
const std::array offset = RuntimeConfigFile::WiiAccelOffset();
ImGui::TextDisabled("Stored offset: x %+.3f y %+.3f z %+.3f g", offset[0], offset[1], offset[2]);
} else {
- ImGui::TextDisabled("Not calibrated (using SDL's zero point; see console.log for \"fallback accelerometer calibration\").");
+ ImGui::TextDisabled("No trim stored.");
}
}
diff --git a/runtime/src/wii_remote_input.cpp b/runtime/src/wii_remote_input.cpp
index 84b7ede..13dcf56 100644
--- a/runtime/src/wii_remote_input.cpp
+++ b/runtime/src/wii_remote_input.cpp
@@ -102,10 +102,10 @@ bool g_accelOffsetLoaded = false;
// sample may stray from the first one before the run is declared "moved".
constexpr int kCalibrationSamples = 90;
constexpr float kCalibrationMaxDeviationG = 0.15f;
-// |mean| outside this range means the remote was not at rest or the
-// accelerometer is far off its nominal scale; either way the offset is useless.
-constexpr float kCalibrationMinGravityG = 0.8f;
-constexpr float kCalibrationMaxGravityG = 1.2f;
+// A resting remote reads about 1 g and lies within 20 degrees of face up.
+constexpr double kCalibrationMinGravityG = 0.8;
+constexpr double kCalibrationMaxGravityG = 1.2;
+constexpr double kCalibrationMinTiltCosine = 0.9396926;
struct AccelCalibration {
bool active = false;
@@ -320,7 +320,8 @@ void StepAccelCalibration() {
std::array mean{};
for (int i = 0; i < 3; ++i) mean[i] = g_calibration.sum[i] / g_calibration.count;
const double length = std::sqrt(mean[0] * mean[0] + mean[1] * mean[1] + mean[2] * mean[2]);
- if (length < kCalibrationMinGravityG || length > kCalibrationMaxGravityG || mean[1] < 0.5) {
+ if (length < kCalibrationMinGravityG || length > kCalibrationMaxGravityG ||
+ mean[1] / length < kCalibrationMinTiltCosine) {
FinishAccelCalibration("Failed: the remote was not resting flat with the buttons up.");
return;
}
diff --git a/runtime/tests/runtime_config_tests.cpp b/runtime/tests/runtime_config_tests.cpp
index 337d40a..4883a1e 100644
--- a/runtime/tests/runtime_config_tests.cpp
+++ b/runtime/tests/runtime_config_tests.cpp
@@ -1,7 +1,9 @@
#include "runtime_config.h"
#include
+#include
#include
+#include
namespace {
bool Require(bool value, const char* message) {
@@ -11,6 +13,24 @@ bool Require(bool value, const char* message) {
}
return true;
}
+std::optional Update(const std::string& original, const char* section, const char* key,
+ const char* value, std::string* text = nullptr) {
+ const auto updated = RuntimeConfigFile::UpdateSettingText(original, section, key, value);
+ if (!updated) return std::nullopt;
+ if (text) *text = *updated;
+ return toml::parse_str(*updated);
+}
+bool SetsFps(const std::string& original, const char* message) {
+ const auto document = Update(original, "video", "show_fps", "true");
+ return Require(document && document->at("video").at("show_fps").as_boolean() &&
+ document->at("video").as_table().size() == 1,
+ message);
+}
+bool SetsOffset(const std::string& original, const char* message) {
+ const auto document = Update(original, "controller.wii_accel_offsets", "x", "0.5");
+ return Require(document && document->at("controller").at("wii_accel_offsets").at("x").as_floating() == 0.5,
+ message);
+}
} // namespace
int main() {
@@ -31,5 +51,49 @@ int main() {
const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "invalid.toml");
if (!Require(!config.metalFxSpatialUpscaling, "invalid MetalFX setting was accepted")) return 1;
}
+ if (!SetsFps("[video]\nshow_fps = false\n", "bare key was not replaced")) return 1;
+ if (!SetsFps("[video]\n\"show_fps\" = false\n", "basic-quoted key was not replaced")) return 1;
+ if (!SetsFps("[video]\n'show_fps'=false\n", "literal-quoted key was not replaced")) return 1;
+ if (!SetsFps("[video]\n\"show\\u005Ffps\" = false\n", "escaped key was not replaced")) return 1;
+ if (!SetsFps("[\"video\"]\n show_fps = false\n", "quoted table header was not matched")) return 1;
+ if (!SetsFps("[video]\nshow_fps = [\n 1,\n]\n", "multi-line value was not replaced")) return 1;
+ if (!SetsFps("[video]\n", "missing key was not created")) return 1;
+ if (!SetsFps("", "missing table was not created")) return 1;
+ {
+ std::string text;
+ const auto document = Update("# top\nfoo = 1\n\n[video]\nzzz = 2 # keep\nshow_fps = false # note\naaa = 3\n\n"
+ "[audio]\nshow_fps = 1\n",
+ "video", "show_fps", "true", &text);
+ if (!Require(document && document->at("foo").as_integer() == 1 &&
+ document->at("audio").at("show_fps").as_integer() == 1 &&
+ document->at("video").at("zzz").as_integer() == 2 &&
+ document->at("video").at("aaa").as_integer() == 3 &&
+ document->at("video").at("show_fps").as_boolean(),
+ "unrelated settings were not preserved")) return 1;
+ const size_t zzz = text.find("zzz"), fps = text.find("show_fps"), aaa = text.find("aaa");
+ if (!Require(text.find("# top") != std::string::npos && text.find("# keep") != std::string::npos &&
+ text.find("# note") != std::string::npos && zzz < fps && fps < aaa,
+ "comments or key order were not preserved")) return 1;
+ }
+ if (!Require(!RuntimeConfigFile::UpdateSettingText("[video]\nshow_fps = 1\nshow_fps = 2\n", "video", "show_fps", "true"),
+ "a document with a duplicate key was rewritten")) return 1;
+ if (!Require(!RuntimeConfigFile::UpdateSettingText("[video\nshow_fps = 1\n", "video", "show_fps", "true"),
+ "an unparseable document was rewritten")) return 1;
+ if (!SetsOffset("[controller]\nrumble = true\n\n[controller.wii_accel_offsets]\nx = 0.1\ny = 2\n",
+ "key in an existing nested table was not replaced")) return 1;
+ if (!SetsOffset("[controller.wii_accel_offsets]\ny = 2\n", "key was not added to a nested table")) return 1;
+ if (!SetsOffset("[controller]\nrumble = true\n", "missing nested table was not created")) return 1;
+ if (!SetsOffset("[controller.\"wii_accel_offsets\"]\nx = 0.1\n", "quoted nested header was not matched")) return 1;
+ if (!SetsOffset("[controller]\nwii_accel_offsets.y = 2\n", "dotted-key table was not extended")) return 1;
+ if (!SetsOffset("controller.wii_accel_offsets.y = 2\n", "root-level dotted table was not extended")) return 1;
+ if (!Require(!RuntimeConfigFile::UpdateSettingText("controller = \"custom\"\n", "controller.wii_accel_offsets",
+ "x", "0.5"),
+ "a scalar was replaced by a table")) return 1;
+ {
+ const auto document = Update("[video.advanced]\nfoo = 1\n", "video", "show_fps", "true");
+ if (!Require(document && document->at("video").at("show_fps").as_boolean() &&
+ document->at("video").at("advanced").at("foo").as_integer() == 1,
+ "key was not added to an implicit table")) return 1;
+ }
return 0;
}
diff --git a/translator/src/Translator.Core/Disassembly/PpcDisassembler.cs b/translator/src/Translator.Core/Disassembly/PpcDisassembler.cs
index 413db4b..48f5440 100644
--- a/translator/src/Translator.Core/Disassembly/PpcDisassembler.cs
+++ b/translator/src/Translator.Core/Disassembly/PpcDisassembler.cs
@@ -230,6 +230,11 @@ public sealed class PpcDisassembler : IDisposable
{
foreach (var target in ins.BranchTargets)
{
+ // A conditional call to another function returns to its fallthrough.
+ if (ins.IsCall && IsKnownExternalFunctionEntry(target))
+ {
+ continue;
+ }
localControlFlowTargets.Add(target);
if (!reachable.Contains(target))
{
diff --git a/translator/src/Translator.Core/Lifting/PpcLifter.cs b/translator/src/Translator.Core/Lifting/PpcLifter.cs
index c7ffe57..bb57f86 100644
--- a/translator/src/Translator.Core/Lifting/PpcLifter.cs
+++ b/translator/src/Translator.Core/Lifting/PpcLifter.cs
@@ -690,10 +690,11 @@ public sealed partial class PpcLifter
var rawInstr = ReadRawInstruction(ins);
var bo = (int)((rawInstr >> 21) & 0x1F);
var bi = (int)((rawInstr >> 16) & 0x1F);
- var fallthrough = $"0x{ins.EndAddress:X8}";
- var targetLabel = mnemonic == "bcctr"
- ? $"indirect_ctr_{ins.Address:X8}"
- : $"call_ctr_{ins.Address:X8}_{ins.EndAddress:X8}";
+ var linked = mnemonic == "bcctrl";
+ var targetLabel = linked
+ ? $"call_ctr_{ins.Address:X8}_{ins.EndAddress:X8}"
+ : $"indirect_ctr_{ins.Address:X8}";
+ var fallthrough = linked ? LinkedFallthroughLabel(ins) : $"0x{ins.EndAddress:X8}";
return new[] { new IrBranch("raw", targetLabel, fallthrough, BuildBoConditionExpression(bo, bi, allowCtr: false)) };
}
@@ -1943,6 +1944,15 @@ public sealed partial class PpcLifter
};
case "bltl":
+ case "bgtl":
+ case "beql":
+ case "bsol":
+ case "bgel":
+ case "blel":
+ case "bnel":
+ case "bnsl":
+ case "bdnzl":
+ case "bdzl":
case "bcl":
{
var rawInstr = ReadRawInstruction(ins);
@@ -1978,14 +1988,13 @@ public sealed partial class PpcLifter
var rawInstr = ReadRawInstruction(ins);
var bo = (int)((rawInstr >> 21) & 0x1F);
var bi = (int)((rawInstr >> 16) & 0x1F);
- var fallthrough = $"0x{ins.EndAddress:X8}";
var targetLabel = $"call_lr_{ins.Address:X8}_{ins.EndAddress:X8}";
var instructions = new List();
if ((bo & 0x04) == 0)
{
instructions.Add(new IrBinary("ctr", IrValue.Register("ctr"), IrValue.Imm(-1), "add"));
}
- instructions.Add(new IrBranch("raw", targetLabel, fallthrough, BuildBoConditionExpression(bo, bi, allowCtr: true)));
+ instructions.Add(new IrBranch("raw", targetLabel, LinkedFallthroughLabel(ins), BuildBoConditionExpression(bo, bi, allowCtr: true)));
return instructions;
}
@@ -2004,10 +2013,9 @@ public sealed partial class PpcLifter
crField = NormalizeRegister(crOp.Name);
}
- var fallthrough = $"0x{ins.EndAddress:X8}";
var targetLabel = $"call_lr_{ins.Address:X8}_{ins.EndAddress:X8}";
var cond = mnemonic.Replace("lrl", string.Empty, StringComparison.OrdinalIgnoreCase);
- return new[] { new IrBranch(cond, targetLabel, fallthrough, crField) };
+ return new[] { new IrBranch(cond, targetLabel, LinkedFallthroughLabel(ins), crField) };
}
case "bne":
@@ -3033,6 +3041,10 @@ private static string TargetLabel(PpcInstruction ins, HashSet validAddress
return $"0x{target:X8}";
}
+ // Not-taken path of a linked conditional branch: LR still receives the return address.
+ private static string LinkedFallthroughLabel(PpcInstruction ins) =>
+ $"link_branch_{ins.EndAddress:X8}_{ins.EndAddress:X8}";
+
private static string BuildBoConditionExpression(int bo, int bi, bool allowCtr)
{
var crFieldIdx = bi / 4;
diff --git a/translator/src/Translator.Core/Translation/FunctionTranslator.cs b/translator/src/Translator.Core/Translation/FunctionTranslator.cs
index 34bb2aa..86ab11a 100644
--- a/translator/src/Translator.Core/Translation/FunctionTranslator.cs
+++ b/translator/src/Translator.Core/Translation/FunctionTranslator.cs
@@ -420,7 +420,7 @@ public sealed class FunctionTranslator
continue;
}
- if (TryBuildSyntheticBlock(label, out var synth))
+ if (TryBuildSyntheticBlock(label, existingLabels, out var synth))
{
irBlocks.Add(synth);
existingLabels.Add(label);
@@ -839,7 +839,7 @@ public sealed class FunctionTranslator
return rewritten;
}
- private static bool TryBuildSyntheticBlock(string label, out IrBasicBlock block)
+ private static bool TryBuildSyntheticBlock(string label, IReadOnlySet localLabels, out IrBasicBlock block)
{
const string IndirectCtrPrefix = "indirect_ctr_";
const string CallCtrPrefix = "call_ctr_";
@@ -883,11 +883,18 @@ public sealed class FunctionTranslator
if (TryParseLinkBranchLabel(label, LinkBranchPrefix, out fallthrough, out var target))
{
- block = new IrBasicBlock(label, new IrInstruction[]
- {
- new IrAssign("lr", IrValue.Imm((int)fallthrough)),
- new IrJump($"0x{target:X8}")
- });
+ block = new IrBasicBlock(label, target == fallthrough || localLabels.Contains($"0x{target:X8}")
+ ? new IrInstruction[]
+ {
+ new IrAssign("lr", IrValue.Imm((int)fallthrough)),
+ new IrJump($"0x{target:X8}")
+ }
+ : new IrInstruction[]
+ {
+ new IrAssign("lr", IrValue.Imm((int)fallthrough)),
+ new IrCall(string.Empty, $"0x{target:X8}", BuildAbiCallArgs()),
+ new IrJump($"0x{fallthrough:X8}")
+ });
return true;
}
diff --git a/translator/tests/Translator.Tests/FunctionTranslatorCoverageTests.cs b/translator/tests/Translator.Tests/FunctionTranslatorCoverageTests.cs
index f7d09e2..9f2f229 100644
--- a/translator/tests/Translator.Tests/FunctionTranslatorCoverageTests.cs
+++ b/translator/tests/Translator.Tests/FunctionTranslatorCoverageTests.cs
@@ -45,6 +45,37 @@ public class FunctionTranslatorCoverageTests
Assert.Equal(TimeSpan.Zero, discovery.Metrics.CodeGen);
}
+ [Theory]
+ [InlineData(0x4D820021u)] // beqlrl
+ [InlineData(0x4D820421u)] // bcctrl, BO=12 BI=2
+ public void Discover_LinkedConditionalRegisterBranchUpdatesLrWhenNotTaken(uint branchWord)
+ {
+ var memory = new byte[MemoryLayout.RamSize];
+ WriteWord(memory, 0x80001000, branchWord);
+ WriteWord(memory, 0x80001004, 0x7C6802A6); // mflr r3
+ WriteWord(memory, 0x80001008, 0x4E800020); // blr
+
+ var image = new ProgramImage(
+ memory,
+ AddressRange.FromStartAndSize(0x80001000, 0x1004),
+ AddressRange.FromStartAndSize(0x80001000, 0x1004),
+ default,
+ "test");
+ var discovery = new FunctionTranslator(image).Discover(
+ 0x80001000,
+ TranslationOptions.Default with { MaxBytes = 0x20, AllowUnsupportedInstructions = true });
+
+ var blocks = discovery.Ssa.Function.Blocks;
+ var branch = Assert.Single(blocks.SelectMany(b => b.Instructions).OfType());
+ Assert.Equal("link_branch_80001004_80001004", branch.FalseLabel);
+
+ var notTaken = Assert.Single(blocks, b => b.Label == branch.FalseLabel);
+ var lrAssign = Assert.Single(notTaken.Instructions.OfType());
+ Assert.StartsWith("lr", lrAssign.Destination, StringComparison.Ordinal);
+ Assert.Equal(unchecked((int)0x80001004u), lrAssign.Value.Constant);
+ Assert.Equal("0x80001004", Assert.IsType(notTaken.Instructions[^1]).TargetLabel);
+ }
+
[Fact]
public void Translate_PreservesFallthroughAfterDirectLinkBranch()
{
@@ -354,18 +385,20 @@ public class FunctionTranslatorCoverageTests
Assert.Equal("f13", abiArgs[^1].RegisterName);
}
+ private static readonly HashSet NoLocalLabels = new();
+
[Fact]
public void TryBuildSyntheticBlock_CoversIndirectAndCallForms()
{
- var indirectArgs = new object?[] { "indirect_ctr_80001234", null! };
+ var indirectArgs = new object?[] { "indirect_ctr_80001234", NoLocalLabels, null! };
Assert.True(InvokePrivate("TryBuildSyntheticBlock", indirectArgs));
- var indirect = Assert.IsType(indirectArgs[1]);
+ var indirect = Assert.IsType(indirectArgs[2]);
Assert.Equal("indirect_ctr_80001234", indirect.Label);
Assert.Equal("ctr", Assert.IsType(Assert.Single(indirect.Instructions)).Target.RegisterName);
- var callCtrArgs = new object?[] { "call_ctr_80001000_80001004", null! };
+ var callCtrArgs = new object?[] { "call_ctr_80001000_80001004", NoLocalLabels, null! };
Assert.True(InvokePrivate("TryBuildSyntheticBlock", callCtrArgs));
- var callCtr = Assert.IsType(callCtrArgs[1]);
+ var callCtr = Assert.IsType(callCtrArgs[2]);
Assert.Collection(
callCtr.Instructions,
ins =>
@@ -382,9 +415,9 @@ public class FunctionTranslatorCoverageTests
},
ins => Assert.Equal("0x80001004", Assert.IsType(ins).TargetLabel));
- var callLrArgs = new object?[] { "call_lr_80002000_80002004", null! };
+ var callLrArgs = new object?[] { "call_lr_80002000_80002004", NoLocalLabels, null! };
Assert.True(InvokePrivate("TryBuildSyntheticBlock", callLrArgs));
- var callLr = Assert.IsType(callLrArgs[1]);
+ var callLr = Assert.IsType(callLrArgs[2]);
Assert.Collection(
callLr.Instructions,
ins =>
@@ -407,9 +440,39 @@ public class FunctionTranslatorCoverageTests
},
ins => Assert.Equal("0x80002004", Assert.IsType(ins).TargetLabel));
- var absoluteArgs = new object?[] { "0x800D7FCC", null! };
+ var notTakenArgs = new object?[] { "link_branch_80002004_80002004", NoLocalLabels, null! };
+ Assert.True(InvokePrivate("TryBuildSyntheticBlock", notTakenArgs));
+ var notTaken = Assert.IsType(notTakenArgs[2]);
+ Assert.Collection(
+ notTaken.Instructions,
+ ins =>
+ {
+ var assign = Assert.IsType(ins);
+ Assert.Equal("lr", assign.Destination);
+ Assert.Equal(unchecked((int)0x80002004u), assign.Value.Constant);
+ },
+ ins => Assert.Equal("0x80002004", Assert.IsType(ins).TargetLabel));
+
+ var linkedCallArgs = new object?[] { "link_branch_80002004_800D7FCC", NoLocalLabels, null! };
+ Assert.True(InvokePrivate("TryBuildSyntheticBlock", linkedCallArgs));
+ var linkedCall = Assert.IsType(linkedCallArgs[2]);
+ Assert.Collection(
+ linkedCall.Instructions,
+ ins => Assert.Equal(unchecked((int)0x80002004u), Assert.IsType(ins).Value.Constant),
+ ins => Assert.Equal("0x800D7FCC", Assert.IsType(ins).Target),
+ ins => Assert.Equal("0x80002004", Assert.IsType(ins).TargetLabel));
+
+ var localLinkedArgs = new object?[] { "link_branch_80002004_80002000", new HashSet { "0x80002000" }, null! };
+ Assert.True(InvokePrivate("TryBuildSyntheticBlock", localLinkedArgs));
+ var localLinked = Assert.IsType(localLinkedArgs[2]);
+ Assert.Collection(
+ localLinked.Instructions,
+ ins => Assert.Equal(unchecked((int)0x80002004u), Assert.IsType(ins).Value.Constant),
+ ins => Assert.Equal("0x80002000", Assert.IsType(ins).TargetLabel));
+
+ var absoluteArgs = new object?[] { "0x800D7FCC", NoLocalLabels, null! };
Assert.True(InvokePrivate("TryBuildSyntheticBlock", absoluteArgs));
- var absolute = Assert.IsType(absoluteArgs[1]);
+ var absolute = Assert.IsType(absoluteArgs[2]);
Assert.Equal("0x800D7FCC", absolute.Label);
Assert.Collection(
absolute.Instructions,
@@ -421,9 +484,9 @@ public class FunctionTranslatorCoverageTests
},
ins => Assert.Null(Assert.IsType(ins).Value));
- var invalidArgs = new object?[] { "plain_label", null! };
+ var invalidArgs = new object?[] { "plain_label", NoLocalLabels, null! };
Assert.False(InvokePrivate("TryBuildSyntheticBlock", invalidArgs));
- Assert.Null(invalidArgs[1]);
+ Assert.Null(invalidArgs[2]);
}
[Fact]
diff --git a/translator/tests/Translator.Tests/PpcLifterAdditionalCoverageTests.cs b/translator/tests/Translator.Tests/PpcLifterAdditionalCoverageTests.cs
index 32162d6..2c0734e 100644
--- a/translator/tests/Translator.Tests/PpcLifterAdditionalCoverageTests.cs
+++ b/translator/tests/Translator.Tests/PpcLifterAdditionalCoverageTests.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Linq;
using Translator.Core.Disassembly;
using Translator.Core.Ir;
@@ -37,6 +38,85 @@ public class PpcLifterAdditionalCoverageTests
Assert.Equal($"0x{branch.EndAddress:X8}", decision.FalseLabel);
}
+ [Fact]
+ public void ConditionalCallTargetIsNotDecodedAsLocalCode()
+ {
+ const uint entry = 0x80000000;
+ const uint callee = entry + 0x10;
+
+ var image = TranslatorCppTestHarness.CreateImage(
+ (entry + 0x00, 0x41820011), // beql callee
+ (entry + 0x04, 0x38600001), // li r3, 1
+ (entry + 0x08, 0x4E800020), // blr
+ (callee + 0x00, 0x38600002), // li r3, 2
+ (callee + 0x04, 0x4E800020)); // blr
+
+ using var disassembler = new PpcDisassembler();
+ var instructions = disassembler.DisassembleFunction(
+ image, entry, maxInstructions: 32, maxBytes: 0x80, knownFunctionEntryPoints: new HashSet { entry, callee });
+
+ Assert.Equal(new[] { entry, entry + 4, entry + 8 }, instructions.Select(ins => ins.Address));
+ }
+
+ [Fact]
+ public void ConditionalCallToLocalTargetKeepsLocalEdge()
+ {
+ const uint entry = 0x80000000;
+
+ var image = TranslatorCppTestHarness.CreateImage(
+ (entry + 0x00, 0x4182000D), // beql local
+ (entry + 0x04, 0x38600001), // li r3, 1
+ (entry + 0x08, 0x4E800020), // blr
+ (entry + 0x0C, 0x38600002), // local: li r3, 2
+ (entry + 0x10, 0x4E800020)); // blr
+
+ using var disassembler = new PpcDisassembler();
+ var instructions = disassembler.DisassembleFunction(
+ image, entry, maxInstructions: 32, maxBytes: 0x80, knownFunctionEntryPoints: new HashSet { entry });
+
+ Assert.Contains(instructions, ins => ins.Address == entry + 0x0C);
+ }
+
+ [Theory]
+ [InlineData(0x41820009u, "beql", "0x80001008")]
+ [InlineData(0x41810009u, "bgtl", "0x80001008")]
+ [InlineData(0x40820009u, "bnel", "0x80001008")]
+ [InlineData(0x42000009u, "bdnzl", "0x80001008")]
+ [InlineData(0x42400009u, "bdzl", "0x80001008")]
+ public void LinkedConditionalRelativeBranchFormsSetLrOnBothPaths(uint word, string mnemonic, string target)
+ {
+ var branch = PpcDecoder.Decode(0x80001000, word);
+ var ir = Assert.Single(new PpcLifter().Lift(new[] { branch })).Ir;
+
+ Assert.Equal(mnemonic, branch.Mnemonic);
+
+ var lrAssign = Assert.Single(ir.OfType());
+ Assert.Equal("lr", lrAssign.Destination);
+ Assert.Equal(unchecked((int)0x80001004u), lrAssign.Value.Constant);
+ Assert.IsType(ir[^2]);
+
+ var decision = Assert.IsType(ir[^1]);
+ Assert.Equal("raw", decision.Condition);
+ Assert.Equal($"link_branch_80001004_{target[2..]}", decision.TrueLabel);
+ Assert.Equal("0x80001004", decision.FalseLabel);
+ Assert.Equal(mnemonic.StartsWith("bd", StringComparison.Ordinal), ir.OfType().Any(b => b.Destination == "ctr"));
+ }
+
+ [Theory]
+ [InlineData(0x4D820021u)]
+ [InlineData(0x4D820421u)]
+ [InlineData(0x4D030021u)]
+ public void LinkedConditionalRegisterBranchesRouteNotTakenPathThroughLrUpdate(uint word)
+ {
+ var branch = PpcDecoder.Decode(0x80001000, word);
+ var ir = Assert.Single(new PpcLifter().Lift(new[] { branch })).Ir;
+
+ var decision = Assert.IsType(ir[^1]);
+ Assert.Equal("link_branch_80001004_80001004", decision.FalseLabel);
+ Assert.StartsWith(branch.Mnemonic.Contains("ctr", StringComparison.Ordinal) ? "call_ctr_" : "call_lr_", decision.TrueLabel, StringComparison.Ordinal);
+ Assert.DoesNotContain(ir, ins => ins is IrAssign { Destination: "lr" });
+ }
+
[Fact]
public void LiftsAdditionalNonDotArithmeticAndLogicalForms()
{
@@ -365,6 +445,7 @@ public class PpcLifterAdditionalCoverageTests
var bclrl = Instruction((12u << 21) | (8u << 16), "bclrl", new PpcConditionRegisterOperand("cr2", 8));
var bclrlBranch = Assert.IsType(Assert.Single(Assert.Single(lifter.Lift(new[] { bclrl })).Ir));
Assert.Contains("call_lr_80000000_80000004", bclrlBranch.TrueLabel, StringComparison.Ordinal);
+ Assert.Equal("link_branch_80000004_80000004", bclrlBranch.FalseLabel);
Assert.Equal("raw", bclrlBranch.Condition);
Assert.Contains("GetCRBit(ctx, 2, 0)", bclrlBranch.ConditionRegister, StringComparison.Ordinal);
}
diff --git a/translator/tests/Translator.Tests/PpcLifterCoverageTests.cs b/translator/tests/Translator.Tests/PpcLifterCoverageTests.cs
index c8ee8af..16ad792 100644
--- a/translator/tests/Translator.Tests/PpcLifterCoverageTests.cs
+++ b/translator/tests/Translator.Tests/PpcLifterCoverageTests.cs
@@ -564,6 +564,7 @@ public class PpcLifterCoverageTests
Assert.Equal("beq", beqlrlBranch.Condition);
Assert.Equal("cr3", beqlrlBranch.ConditionRegister);
Assert.Contains("call_lr_80000000_80000004", beqlrlBranch.TrueLabel, StringComparison.Ordinal);
+ Assert.Equal("link_branch_80000004_80000004", beqlrlBranch.FalseLabel);
var bgtlrl = Instruction(0, "bgtlrl");
var bgtlrlBranch = Assert.IsType(Assert.Single(Assert.Single(lifter.Lift(new[] { bgtlrl })).Ir));