diff --git a/Launcher/LocalBuild.ps1 b/Launcher/LocalBuild.ps1 index dbfc8db..2a36909 100644 --- a/Launcher/LocalBuild.ps1 +++ b/Launcher/LocalBuild.ps1 @@ -429,7 +429,8 @@ try { } $redist = Join-Path $Toolkit 'Redist' if (Test-Path -LiteralPath $redist -PathType Container) { - Copy-Item -Path (Join-Path $redist '*.dll') -Destination $Destination -Force + Get-ChildItem -LiteralPath $redist -Filter '*.dll' -File | + Copy-Item -Destination $Destination -Force } foreach ($name in @('config','wii_bootstrap')) { $directory = Join-Path $build $name diff --git a/Launcher/NativeBuildFlags.ps1 b/Launcher/NativeBuildFlags.ps1 index 7d88f4b..00bb12e 100644 --- a/Launcher/NativeBuildFlags.ps1 +++ b/Launcher/NativeBuildFlags.ps1 @@ -145,7 +145,8 @@ function Invoke-Checked([string]$FilePath, [string[]]$Arguments, [string]$Descri else { Write-Host "${LogPrefix}: $Description" } $quotedArguments = @($Arguments | ForEach-Object { if ($_.Contains('"')) { throw "A native build argument contains an unsupported quote: $_" } - '"' + $_ + '"' + # Backslashes directly before the closing quote would otherwise escape it. + '"' + ($_ -replace '(\\+)$', '$1$1') + '"' }) if ($WaitForProcessTree) { $process = Start-Process -FilePath $FilePath -ArgumentList $quotedArguments ` 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/InstallerEngine.cs b/Launcher/WiiCompiled.Setup.Windows/InstallerEngine.cs index fb96d3c..f69e4f9 100644 --- a/Launcher/WiiCompiled.Setup.Windows/InstallerEngine.cs +++ b/Launcher/WiiCompiled.Setup.Windows/InstallerEngine.cs @@ -501,17 +501,31 @@ internal sealed class InstallerEngine var dol = Path.Combine(dataRoot, "sys", "main.dol"); var rel = Path.Combine(dataRoot, "files", "rel", "StaticR.rel"); var fst = Path.Combine(dataRoot, "sys", "fst.bin"); - if (!File.Exists(dol) || !File.Exists(rel) || !File.Exists(fst)) - throw new InvalidDataException("The disc extraction is missing required Mario Kart Wii files."); - var dolHash = InputValidation.Sha256File(dol); - var relHash = InputValidation.Sha256File(rel); - if (!dolHash.Equals(manifest.ExpectedDolSha256, StringComparison.OrdinalIgnoreCase) || - !relHash.Equals(manifest.ExpectedRelSha256, StringComparison.OrdinalIgnoreCase)) - { + var missing = new[] { dol, rel, fst }.Where(path => !File.Exists(path)) + .Select(path => Path.GetRelativePath(dataRoot, path)).ToList(); + if (missing.Count > 0) throw new InvalidDataException( - "The disc is RMCP01 but does not match the supported clean PAL revision. " + - "Patched or otherwise modified game code cannot be installed safely."); + "The disc extraction is missing required Mario Kart Wii files: " + + string.Join(", ", missing) + "."); + + var mismatches = new List(); + foreach (var (name, path, expected) in new[] + { + ("main.dol", dol, manifest.ExpectedDolSha256), + ("StaticR.rel", rel, manifest.ExpectedRelSha256) + }) + { + var actual = InputValidation.Sha256File(path); + if (!actual.Equals(expected, StringComparison.OrdinalIgnoreCase)) + mismatches.Add($"{name} is {new FileInfo(path).Length:N0} bytes with SHA-256 {actual} " + + $"(expected {expected})"); } + + if (mismatches.Count > 0) + throw new InvalidDataException( + "The disc is RMCP01 but its game code does not match the supported clean PAL revision: " + + string.Join("; ", mismatches) + ". " + + "Patched or otherwise modified game code cannot be installed safely."); } private static void ValidateInstallDirectory(string path) => 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..09b9544 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 @@ -437,7 +447,9 @@ if [[ "$cc_bin" == */* ]]; then configure_args+=(-DCMAKE_ASM_COMPILER="$cc_bin") fi if [[ -n "$fuse_ld_override" ]]; then - configure_args+=(-DCMAKE_EXE_LINKER_FLAGS="-fuse-ld=$fuse_ld_override") + # CMAKE_EXE_LINKER_FLAGS is one string that CMake pastes into the shell link command. + printf -v fuse_ld_quoted '%q' "$fuse_ld_override" + configure_args+=(-DCMAKE_EXE_LINKER_FLAGS="-fuse-ld=$fuse_ld_quoted") fi if [[ -n "$native_prebuilt_dir" ]]; then configure_args+=(-DMKW_NATIVE_PREBUILT_DIR="$native_prebuilt_dir") 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/Launcher/macos/extract-disc.command b/Launcher/macos/extract-disc.command index 663b542..baf4b22 100755 --- a/Launcher/macos/extract-disc.command +++ b/Launcher/macos/extract-disc.command @@ -51,8 +51,10 @@ dol=$(find "$scratch/extracted" -type f -path '*/sys/main.dol' -print -quit) rel=$(find "$scratch/extracted" -type f -path '*/files/rel/StaticR.rel' -print -quit) [[ -n "$dol" ]] || fail 'nodtool extraction did not contain sys/main.dol' [[ -n "$rel" ]] || fail 'nodtool extraction did not contain files/rel/StaticR.rel' -[[ $(sha256 "$dol") == "$EXPECTED_DOL_SHA256" ]] || fail 'disc is not the supported clean PAL RMCP01 Mario Kart Wii image' -[[ $(sha256 "$rel") == "$EXPECTED_REL_SHA256" ]] || fail 'disc has an unexpected StaticR.rel; use a clean PAL RMCP01 image' +dol_sha=$(sha256 "$dol") +rel_sha=$(sha256 "$rel") +[[ $dol_sha == "$EXPECTED_DOL_SHA256" ]] || fail "main.dol does not match the clean PAL RMCP01 image (expected $EXPECTED_DOL_SHA256, got $dol_sha)" +[[ $rel_sha == "$EXPECTED_REL_SHA256" ]] || fail "StaticR.rel does not match the clean PAL RMCP01 image (expected $EXPECTED_REL_SHA256, got $rel_sha)" data_root=$(dirname "$(dirname "$dol")") [[ -d "$data_root/files" ]] || fail 'nodtool extraction did not contain the Wii files directory' diff --git a/Launcher/macos/setup.command b/Launcher/macos/setup.command index b3a31d0..53dd4e2 100755 --- a/Launcher/macos/setup.command +++ b/Launcher/macos/setup.command @@ -11,7 +11,13 @@ workspace="$support_root/BuildWorkspace" products="$support_root/Products" fail() { printf 'WiiCompiled Setup: %s\n' "$*" >&2; exit 1; } -notice() { /usr/bin/osascript -e "display dialog \"${1//\"/\\\"}\" buttons {\"OK\"} default button \"OK\" with icon caution" >/dev/null; } +notice() { + /usr/bin/osascript - "$1" >/dev/null <<'APPLESCRIPT' +on run argv + display dialog (item 1 of argv) buttons {"OK"} default button "OK" with icon caution +end run +APPLESCRIPT +} usage() { cat <<'EOF' Usage: setup.command --game IMAGE [--retro-dir DIR] [--install-location {user|applications}] @@ -146,7 +152,7 @@ fi "$workspace/Launcher/local-build-macos.command" "${build_args[@]}" --profile "$profile" --cmake "$cmake_bin" --ninja "$ninja_bin" --translator-bin "$translator" config="$support_root/Config.toml" -toml_string() { printf '%s' "$1" | sed -e 's/\\\\/\\\\\\\\/g' -e 's/"/\\\\"/g'; } +toml_string() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; } set_path() { local key=$1 value=$2 encoded line temporary encoded=$(toml_string "$value"); line="$key = \"$encoded\""; temporary="$config.tmp" @@ -172,8 +178,13 @@ install_app() { local app=$1 [[ -d "$products/$app" ]] || return 0 if [[ "$destination" == /Applications ]]; then - command="mkdir -p /Applications && rm -rf '/Applications/$app' && ditto '$products/$app' '/Applications/$app'" - /usr/bin/osascript -e "do shell script \"$command\" with administrator privileges" + /usr/bin/osascript - "$products/$app" "/Applications/$app" <<'APPLESCRIPT' +on run argv + set sourcePath to quoted form of (item 1 of argv) + set installedPath to quoted form of (item 2 of argv) + do shell script "mkdir -p /Applications && rm -rf " & installedPath & " && /usr/bin/ditto " & sourcePath & " " & installedPath with administrator privileges +end run +APPLESCRIPT else mkdir -p "$destination"; rm -rf "$destination/$app"; /usr/bin/ditto "$products/$app" "$destination/$app" fi diff --git a/aurora-main/cmake/AuroraCopyRuntimeDLLs.cmake b/aurora-main/cmake/AuroraCopyRuntimeDLLs.cmake index b4fa166..66710f0 100644 --- a/aurora-main/cmake/AuroraCopyRuntimeDLLs.cmake +++ b/aurora-main/cmake/AuroraCopyRuntimeDLLs.cmake @@ -15,6 +15,7 @@ function(aurora_copy_runtime_dlls target) add_custom_command(TARGET ${target} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different $ $ COMMAND_EXPAND_LISTS + VERBATIM ) endforeach () @@ -30,6 +31,7 @@ function(aurora_copy_runtime_dlls target) if (EXISTS "${_dawn_bin_dir}/${_dll}") add_custom_command(TARGET ${target} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_dawn_bin_dir}/${_dll}" $ + VERBATIM ) endif () endforeach () diff --git a/aurora-main/cmake/AuroraSDL3Patches.cmake b/aurora-main/cmake/AuroraSDL3Patches.cmake index 7af051e..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. # @@ -118,6 +164,219 @@ static void UpdateDeviceIdentity(SDL_HIDAPI_Device *device) now = SDL_GetTicks(); ]==]) + + # Classic Controller sticks: the stock path stretches each half-range by learned min/max; + # this posts WPAD's linear value (raw - first-report centre) * unit, times 64. + _aurora_sdl3_replace("${_wii}" "Wii Classic Controller linear sticks (helper)" +[==[static void HandleGamepadControllerButtonData(SDL_DriverWii_Context *ctx, SDL_Joystick *joystick, const WiiButtonData *data) +{ +]==] +[==[/* WiiCompiled: axis = (raw - centre) * unit * 64, y inverted; see AuroraSDL3Patches.cmake */ +static void PostClassicStickLinear(Uint64 timestamp, SDL_Joystick *joystick, StickCalibrationData *calibration, Uint8 axis, Uint16 data, int unit) +{ + int value; + + if (!calibration->center) { + // Center on first read + calibration->center = data; + return; + } + value = ((int)data - (int)calibration->center) * unit * 64; + if (axis == SDL_GAMEPAD_AXIS_LEFTY || axis == SDL_GAMEPAD_AXIS_RIGHTY) { + value = -value; + } + SDL_SendJoystickAxis(timestamp, joystick, axis, (Sint16)SDL_clamp(value, SDL_JOYSTICK_AXIS_MIN, SDL_JOYSTICK_AXIS_MAX)); +} + +static void HandleGamepadControllerButtonData(SDL_DriverWii_Context *ctx, SDL_Joystick *joystick, const WiiButtonData *data) +{ +]==]) + _aurora_sdl3_replace("${_wii}" "Wii Classic Controller linear sticks (axes)" +[==[ PostStickCalibrated(ctx->timestamp, joystick, &ctx->m_StickCalibrationData[0], SDL_GAMEPAD_AXIS_LEFTX, lx); + PostStickCalibrated(ctx->timestamp, joystick, &ctx->m_StickCalibrationData[1], SDL_GAMEPAD_AXIS_LEFTY, ly); + PostStickCalibrated(ctx->timestamp, joystick, &ctx->m_StickCalibrationData[2], SDL_GAMEPAD_AXIS_RIGHTX, rx); + PostStickCalibrated(ctx->timestamp, joystick, &ctx->m_StickCalibrationData[3], SDL_GAMEPAD_AXIS_RIGHTY, ry); +]==] +[==[ PostClassicStickLinear(ctx->timestamp, joystick, &ctx->m_StickCalibrationData[0], SDL_GAMEPAD_AXIS_LEFTX, lx, 16); + PostClassicStickLinear(ctx->timestamp, joystick, &ctx->m_StickCalibrationData[1], SDL_GAMEPAD_AXIS_LEFTY, ly, 16); + 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/aurora-main/lib/gx/shader.cpp b/aurora-main/lib/gx/shader.cpp index 5bbdc4d..15025fe 100644 --- a/aurora-main/lib/gx/shader.cpp +++ b/aurora-main/lib/gx/shader.cpp @@ -174,6 +174,12 @@ static std::string color_arg_reg(GXTevColorArg arg, size_t stageIdx, const Shade case GX_CC_KONST: { switch (stage.kcSel) { DEFAULT_FATAL("invalid kcSel {}", underlying(stage.kcSel)); + // Unsourced encodings evaluate to zero in hardware. + case static_cast(0x08): + case static_cast(0x09): + case static_cast(0x0A): + case static_cast(0x0B): + return "vec3f(0.0)"; case GX_TEV_KCSEL_8_8: return "vec3f(1.0)"; case GX_TEV_KCSEL_7_8: @@ -275,6 +281,16 @@ static std::string alpha_arg_reg(GXTevAlphaArg arg, size_t stageIdx, const Shade case GX_CA_KONST: { switch (stage.kaSel) { DEFAULT_FATAL("invalid kaSel {}", underlying(stage.kaSel)); + // Unsourced encodings evaluate to zero in hardware. + case static_cast(0x08): + case static_cast(0x09): + case static_cast(0x0A): + case static_cast(0x0B): + case static_cast(0x0C): + case static_cast(0x0D): + case static_cast(0x0E): + case static_cast(0x0F): + return "0.0"; case GX_TEV_KASEL_8_8: return "1.0"; case GX_TEV_KASEL_7_8: diff --git a/aurora-main/lib/webgpu/gpu.cpp b/aurora-main/lib/webgpu/gpu.cpp index 37515f2..2e01e39 100644 --- a/aurora-main/lib/webgpu/gpu.cpp +++ b/aurora-main/lib/webgpu/gpu.cpp @@ -893,9 +893,14 @@ void release_surface() noexcept { } g_surface = {}; if (hadSurface && g_instance && g_device && g_queue) { + // Retired swapchain is freed after a later submission completes and the device ticks. + submit_staging_commands(g_device.CreateCommandEncoder().Finish()); const auto future = g_queue.OnSubmittedWorkDone( wgpu::CallbackMode::WaitAnyOnly, [](wgpu::QueueWorkDoneStatus, wgpu::StringView) {}); - g_instance.WaitAny(future, 1000000000); + if (const auto status = g_instance.WaitAny(future, 1000000000); status != wgpu::WaitStatus::Success) { + Log.warn("Timed out waiting for queue idle on surface release: {}", magic_enum::enum_name(status)); + } + g_device.Tick(); } } diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 6410b0b..ad031ba 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -42,6 +42,12 @@ else() "WiiCompiled supports 64-bit LLVM-MinGW Clang on Windows, native Linux x86_64/aarch64, or Apple Clang on macOS x86_64/arm64") endif() +if(MKW_PLATFORM_WINDOWS) + # CMake and Ninja write response files with Windows quoting, but clang tokenizes them with + # POSIX rules by default, where a bare apostrophe in a path opens a quoted span. + add_link_options(--rsp-quoting=windows) +endif() + if(NOT CMAKE_BUILD_TYPE STREQUAL "Release") message(FATAL_ERROR "WiiCompiled only supports Release builds") endif() @@ -56,6 +62,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..ea821b7 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) @@ -226,7 +229,7 @@ function(mkw_configure_product target) (MKW_SQLITE_TARGET_TYPE STREQUAL "SHARED_LIBRARY" OR MKW_SQLITE_TARGET_TYPE STREQUAL "MODULE_LIBRARY")) add_custom_command(TARGET ${target} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different - $ $) + $ $ VERBATIM) endif() if(MKW_PLATFORM_WINDOWS) @@ -260,7 +263,7 @@ function(mkw_configure_product target) endif() add_custom_command(TARGET ${target} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${runtime_dll_path}" $) + "${runtime_dll_path}" $ VERBATIM) endforeach() endif() @@ -269,7 +272,7 @@ function(mkw_configure_product target) message(FATAL_ERROR "Missing Wii first-run bootstrap payload: ${MKW_WII_BOOTSTRAP_SOURCE_DIR}") endif() add_custom_command(TARGET ${target} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory - "${MKW_WII_BOOTSTRAP_SOURCE_DIR}" "$/wii_bootstrap") + "${MKW_WII_BOOTSTRAP_SOURCE_DIR}" "$/wii_bootstrap" VERBATIM) set(MKW_DSP_COEFFICIENT_ROM "${MKW_RUNTIME_SOURCE_DIR}/assets/dsp/dsp_coef.bin") if(NOT EXISTS "${MKW_DSP_COEFFICIENT_ROM}") @@ -281,7 +284,7 @@ function(mkw_configure_product target) message(FATAL_ERROR "Wii DSP coefficient ROM hash mismatch: ${MKW_DSP_COEFFICIENT_ROM_SHA256}") endif() add_custom_command(TARGET ${target} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${MKW_DSP_COEFFICIENT_ROM}" "$/dsp_coef.bin") + "${MKW_DSP_COEFFICIENT_ROM}" "$/dsp_coef.bin" VERBATIM) # Aurora imports this portable recipe database into each user's writable # pipeline cache. Keep the upstream filename so its default resourcesPath @@ -293,7 +296,7 @@ function(mkw_configure_product target) endif() add_custom_command(TARGET ${target} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different "${MKW_INITIAL_PIPELINE_CACHE}" - "$/initial_pipeline_cache.db") + "$/initial_pipeline_cache.db" VERBATIM) # Non-Windows TLS (runtime/src/hle/net/network_ssl.cpp's mbed TLS path) needs a trusted root # CA bundle to verify server certificates against - Windows gets this for free from the OS via @@ -307,7 +310,7 @@ function(mkw_configure_product target) message(FATAL_ERROR "Missing TLS root CA bundle: ${MKW_CA_CERTIFICATE_BUNDLE}") endif() add_custom_command(TARGET ${target} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${MKW_CA_CERTIFICATE_BUNDLE}" "$/cacert.pem") + "${MKW_CA_CERTIFICATE_BUNDLE}" "$/cacert.pem" VERBATIM) endif() endfunction() diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index 2204f70..99d6c2b 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -69,10 +69,11 @@ struct RuntimeUserConfig { // SDL's sensor frame (x right, y out of the button face, z towards the user). // SDL's Wii driver falls back to a nominal zero point when its read of the // remote's calibration block times out (common over Bluetooth), so this is - // measured in the overlay with the remote at rest. - std::optional wiiAccelOffsetX; - std::optional wiiAccelOffsetY; - std::optional wiiAccelOffsetZ; + // measured in the overlay with the remote at rest. Stored per remote under + // [controller.wii_accel_offsets]; the old shared + // wii_accel_offset_* keys are only detected, to report that they are ignored. + std::map> wiiAccelOffsets; + bool hasLegacyWiiAccelOffset = false; // Debugging aid: append every KPAD sample of the Bluetooth remote (raw and // corrected accelerometer, buttons) to wii_accel_trace.csv next to Config.toml. std::optional wiiAccelTrace; @@ -138,28 +139,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(); @@ -403,6 +382,28 @@ inline void AppendOverlayRoots(RuntimeUserConfig& config, const std::string& roo } // Reads every supported setting out of a parsed Config.toml document. +// [x, y, z] of finite numbers (integers allowed), else nullopt. +inline std::optional> ParseAccelOffset(const toml::value& value) { + if (!value.is_array() || value.as_array().size() != 3) { + return std::nullopt; + } + std::array offset{}; + for (size_t i = 0; i < 3; ++i) { + const toml::value& element = value.as_array()[i]; + if (element.is_floating()) { + offset[i] = element.as_floating(); + } else if (element.is_integer()) { + offset[i] = static_cast(element.as_integer()); + } else { + return std::nullopt; + } + if (!std::isfinite(offset[i])) { + return std::nullopt; + } + } + return offset; +} + inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { RuntimeUserConfig config; @@ -426,6 +427,13 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { config.controllerExpressions[key] = value.as_string(); } } + if (section->contains("wii_accel_offsets") && section->at("wii_accel_offsets").is_table()) { + for (const auto& [key, value] : section->at("wii_accel_offsets").as_table()) { + if (const auto offset = ParseAccelOffset(value)) { + config.wiiAccelOffsets[key] = *offset; + } + } + } } config.widescreen = FindConfigValue(document, "video", "widescreen"); @@ -487,9 +495,9 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { FindConfigValue(document, "audio", "attenuate_music_when_media_plays"); config.wiiRemotes = FindConfigValue(document, "controller", "wii_remotes"); config.wiiContinuousScan = FindConfigValue(document, "controller", "wii_continuous_scan"); - config.wiiAccelOffsetX = FindConfigValue(document, "controller", "wii_accel_offset_x"); - config.wiiAccelOffsetY = FindConfigValue(document, "controller", "wii_accel_offset_y"); - config.wiiAccelOffsetZ = FindConfigValue(document, "controller", "wii_accel_offset_z"); + for (const char* key : {"wii_accel_offset_x", "wii_accel_offset_y", "wii_accel_offset_z"}) { + config.hasLegacyWiiAccelOffset |= FindConfigValue(document, "controller", key).value_or(0.0) != 0.0; + } config.wiiAccelTrace = FindConfigValue(document, "controller", "wii_accel_trace"); config.networkEnabled = FindConfigValue(document, "network", "enabled"); config.discordPresenceEnabled = FindConfigValue(document, "discord", "enabled"); @@ -546,68 +554,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 +609,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); } @@ -884,12 +872,13 @@ inline bool SetWiiContinuousScanEnabled(bool value) { return WriteSetting("controller", "wii_continuous_scan", value ? "true" : "false"); } -// Wii Remote accelerometer zero-point correction (g, SDL sensor frame); all zero -// when the remote has not been calibrated. -inline std::array WiiAccelOffset() { - const RuntimeUserConfig& config = Get(); - return {config.wiiAccelOffsetX.value_or(0.0), config.wiiAccelOffsetY.value_or(0.0), - config.wiiAccelOffsetZ.value_or(0.0)}; +// Stored correction of the remote identified by `remoteKey`, if it has one. +inline std::optional> WiiAccelOffsetFor(const std::string& remoteKey) { + const auto it = Get().wiiAccelOffsets.find(remoteKey); + if (it == Get().wiiAccelOffsets.end()) { + return std::nullopt; + } + return it->second; } // Whether to write the per-frame accelerometer trace (off unless asked for). @@ -897,26 +886,14 @@ inline bool WiiAccelTraceEnabled(bool fallback = false) { return Get().wiiAccelTrace.value_or(fallback); } -// True while a non-zero correction is stored ("Clear calibration" writes zeros). -inline bool HasWiiAccelOffset() { - const std::array offset = WiiAccelOffset(); - return offset[0] != 0.0 || offset[1] != 0.0 || offset[2] != 0.0; -} - -// Persists the accelerometer correction measured by the overlay's calibration. -inline bool SetWiiAccelOffset(const std::array& offset) { - Mutable().wiiAccelOffsetX = offset[0]; - Mutable().wiiAccelOffsetY = offset[1]; - Mutable().wiiAccelOffsetZ = offset[2]; - bool ok = true; - const char* keys[3] = {"wii_accel_offset_x", "wii_accel_offset_y", "wii_accel_offset_z"}; - for (size_t i = 0; i < 3; ++i) { - // Always a float literal, so a whole-number offset does not come back as a TOML integer. - std::ostringstream formatted; - formatted << std::fixed << std::setprecision(4) << offset[i]; - ok = WriteSetting("controller", keys[i], formatted.str()) && ok; - } - return ok; +// Persists the accelerometer correction of one remote. `remoteKey` must be a bare TOML key. +inline bool SetWiiAccelOffsetFor(const std::string& remoteKey, const std::array& offset) { + Mutable().wiiAccelOffsets[remoteKey] = offset; + // Always float literals, so a whole-number offset does not come back as a TOML integer. + std::ostringstream formatted; + formatted << std::fixed << std::setprecision(4) << '[' << offset[0] << ", " << offset[1] << ", " << offset[2] + << ']'; + return WriteSetting("controller.wii_accel_offsets", remoteKey, formatted.str()); } // Target frame rate for frame interpolation, or 0 to disable it. diff --git a/runtime/include/wii_remote_input.h b/runtime/include/wii_remote_input.h index 986ec64..d6780cd 100644 --- a/runtime/include/wii_remote_input.h +++ b/runtime/include/wii_remote_input.h @@ -1,6 +1,8 @@ #pragma once +#include #include +#include struct PADStatus; @@ -73,23 +75,20 @@ 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, stored per +// physical remote under [controller.wii_accel_offsets]. Expects the remote lying +// still, buttons up; Poll() averages about a second and stores it for that remote. void StartAccelCalibration(uint32_t chan); -// Forgets the stored correction. -void ClearAccelCalibration(); -bool IsAccelCalibrating(); +// Forgets the stored correction of the remote on `chan`. +void ClearAccelCalibration(uint32_t chan); +bool IsAccelCalibrating(uint32_t chan); // 0..1 while a calibration is collecting samples. float AccelCalibrationProgress(); -// Outcome of the last calibration attempt for the overlay, or nullptr. -const char* AccelCalibrationMessage(); +// Correction in effect for the remote on `chan` (zeros when uncalibrated), or +// nullopt when the port has no remote. +std::optional> ReadAccelOffset(uint32_t chan); +// Outcome of the last calibration attempt on `chan` for the overlay, or nullptr. +const char* AccelCalibrationMessage(uint32_t chan); // Reports "no controller" on the GameCube side for every port served through // KPAD, so the game never sees the same remote twice. Runs after aurora's PADRead. diff --git a/runtime/src/hle/input/pad.cpp b/runtime/src/hle/input/pad.cpp index 23ff800..c0bbfdb 100644 --- a/runtime/src/hle/input/pad.cpp +++ b/runtime/src/hle/input/pad.cpp @@ -89,6 +89,11 @@ void WritePadStatus(uint32_t base, const PADStatus& status) { } // namespace +extern "C" bool PAD_HLE_RumbleEnabled() +{ + return g_rumbleEnabled.load(std::memory_order_relaxed); +} + extern "C" void PAD_HLE_SetRumbleEnabled(bool enabled) { g_rumbleEnabled.store(enabled, std::memory_order_relaxed); diff --git a/runtime/src/hle/input/wpad.cpp b/runtime/src/hle/input/wpad.cpp index b3985ff..21c2e46 100644 --- a/runtime/src/hle/input/wpad.cpp +++ b/runtime/src/hle/input/wpad.cpp @@ -5,6 +5,9 @@ #include +#include + +extern "C" bool PAD_HLE_RumbleEnabled(); void NandQueueIosCallback(uint32_t callbackPtr, int32_t result, uint32_t callbackArg); namespace { @@ -143,8 +146,8 @@ PPC_NATIVE_OVERRIDE(801C0990, WPADProbe_HLE, int32_t, (uint32_t chan, uint32_t t extern "C" void WPADControlMotor_HLE(uint32_t chan, uint32_t command) { - (void)chan; - (void)command; + const bool on = command != 0 && PAD_HLE_RumbleEnabled(); + PADControlMotor(chan, on ? PAD_MOTOR_RUMBLE : PAD_MOTOR_STOP_HARD); } PPC_NATIVE_OVERRIDE(801C0EC4, WPADControlMotor_HLE, void, (uint32_t chan, uint32_t command), (chan, command)); 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 0907883..4e16ae3 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -309,10 +309,9 @@ 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. - if (WiiRemoteInput::IsAccelCalibrating()) { + // Residual per-axis bias left after SDL's factory-calibrated decode, measured + // here with the remote at rest. + if (WiiRemoteInput::IsAccelCalibrating(port)) { ImGui::ProgressBar(WiiRemoteInput::AccelCalibrationProgress(), ImVec2(220.0f, 0.0f), "Hold still..."); } else if (ImGui::Button("Calibrate (remote lying flat, buttons up)")) { WiiRemoteInput::StartAccelCalibration(port); @@ -322,18 +321,20 @@ void DrawWiiRemoteAccelerometer(uint32_t port) { "for about two seconds. Corrects the steering offset of a remote held sideways."); } ImGui::SameLine(); - ImGui::BeginDisabled(!RuntimeConfigFile::HasWiiAccelOffset() || WiiRemoteInput::IsAccelCalibrating()); + const auto stored = WiiRemoteInput::ReadAccelOffset(port); + const std::array offset = stored.value_or(std::array{}); + const bool calibrated = offset[0] != 0.0 || offset[1] != 0.0 || offset[2] != 0.0; + ImGui::BeginDisabled(!calibrated || WiiRemoteInput::IsAccelCalibrating(port)); if (ImGui::Button("Clear calibration")) { - WiiRemoteInput::ClearAccelCalibration(); + WiiRemoteInput::ClearAccelCalibration(port); } ImGui::EndDisabled(); - if (const char* message = WiiRemoteInput::AccelCalibrationMessage()) { + if (const char* message = WiiRemoteInput::AccelCalibrationMessage(port)) { ImGui::TextWrapped("%s", message); - } else if (RuntimeConfigFile::HasWiiAccelOffset()) { - const std::array offset = RuntimeConfigFile::WiiAccelOffset(); + } else if (calibrated) { 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 7654c3c..b1df42e 100644 --- a/runtime/src/wii_remote_input.cpp +++ b/runtime/src/wii_remote_input.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -12,11 +13,13 @@ #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -98,30 +101,39 @@ bool g_hidrawWarned = false; // remote gets a fresh id and is enabled again. std::array g_sensorsEnabledFor{}; -// Zero-point correction subtracted from the remote's accelerometer, in g and in -// SDL's sensor frame; loaded from Config.toml on first use, replaced by a -// calibration run. The Nunchuk accelerometer is left uncorrected. -std::array g_accelOffset{}; -bool g_accelOffsetLoaded = false; +// Zero-point correction subtracted from a remote's accelerometer, in g and in +// SDL's sensor frame, with the config key it is stored under. Resolved once per +// gamepad instance (a re-created joystick of the same remote resolves to the same +// key) and replaced by a calibration run. The Nunchuk accelerometer is left +// uncorrected. +struct RemoteCalibration { + SDL_JoystickID instance = 0; + std::string key; + std::array offset{}; +}; +std::array g_remoteCalibration{}; // Frames sampled by a calibration run (about 1.5 s at 60 Hz) and how far a // 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; uint32_t chan = 0; + SDL_JoystickID instance = 0; int count = 0; double sum[3] = {}; float first[3] = {}; }; AccelCalibration g_calibration; char g_calibrationMessage[160] = {}; +uint32_t g_calibrationMessageChan = 0; +SDL_JoystickID g_calibrationMessageRemote = 0; // Last accepted KPAD acc per port, for the remote and for the Nunchuk. Reused on // frames that bring no sample or a glitched one, so the game never sees a jump. @@ -161,14 +173,73 @@ void EnsureSensors(SDL_Gamepad* gamepad, uint32_t port) { } } -// Loads the stored zero-point correction once. -const std::array& AccelOffset() { - if (!g_accelOffsetLoaded) { - const std::array stored = RuntimeConfigFile::WiiAccelOffset(); - for (size_t i = 0; i < 3; ++i) g_accelOffset[i] = static_cast(stored[i]); - g_accelOffsetLoaded = true; +// USB interface number of the HID device at `path`, or -1 when it is not a USB +// interface or cannot be found. A DolphinBar shows each remote slot as one. +int UsbInterfaceForPath(const char* path, Uint16 vendor, Uint16 product) { + if (path == nullptr) { + return -1; } - return g_accelOffset; + int interfaceNumber = -1; + SDL_hid_device_info* list = SDL_hid_enumerate(vendor, product); + for (const SDL_hid_device_info* info = list; info != nullptr; info = info->next) { + if (info->path != nullptr && std::strcmp(info->path, path) == 0) { + interfaceNumber = info->interface_number; + break; + } + } + SDL_hid_free_enumeration(list); + return interfaceNumber; +} + +// Config key identifying the physical remote behind a gamepad: its HID serial +// (the Bluetooth address when paired directly), else its USB interface number +// (a DolphinBar has no serial and one interface per remote slot) and the game +// port, else the game port. Only characters valid in a bare TOML key. +std::string RemoteKey(SDL_Gamepad* gamepad, uint32_t port) { + std::string serial; + if (const char* raw = SDL_GetGamepadSerial(gamepad)) { + for (const char* c = raw; *c != '\0'; ++c) { + if (std::isalnum(static_cast(*c))) { + serial += static_cast(std::tolower(static_cast(*c))); + } + } + } + if (serial.find_first_not_of('0') != std::string::npos) { + return "serial_" + serial; + } + const Uint16 vendor = SDL_GetGamepadVendor(gamepad); + const Uint16 product = SDL_GetGamepadProduct(gamepad); + const int interfaceNumber = UsbInterfaceForPath(SDL_GetGamepadPath(gamepad), vendor, product); + char key[48]; + if (interfaceNumber >= 0) { + std::snprintf(key, sizeof(key), "usb_%04x_%04x_if%d_port%u", vendor, product, interfaceNumber, + port + 1); + } else { + std::snprintf(key, sizeof(key), "port_%u", port + 1); + } + return key; +} + +// Calibration of the remote currently on `port`, resolved on first use per gamepad +// instance. A remote without a stored entry has a zero offset. +RemoteCalibration& CalibrationFor(SDL_Gamepad* gamepad, uint32_t port) { + RemoteCalibration& calibration = g_remoteCalibration[port]; + const SDL_JoystickID id = SDL_GetGamepadID(gamepad); + if (calibration.instance != id) { + static bool s_legacyReported = false; + if (!s_legacyReported && RuntimeConfigFile::Get().hasLegacyWiiAccelOffset) { + s_legacyReported = true; + RT_LOG(RT_TAG_CONFIG) << "Old shared Wii Remote calibration ignored; recalibrate each remote in F10" + << std::endl; + } + calibration.instance = id; + calibration.key = RemoteKey(gamepad, port); + const std::optional> stored = RuntimeConfigFile::WiiAccelOffsetFor(calibration.key); + for (size_t i = 0; i < 3; ++i) calibration.offset[i] = stored ? static_cast((*stored)[i]) : 0.0f; + RT_LOG(RT_TAG_CONFIG) << "Wii Remote on port " << (port + 1) << ": calibration key " << calibration.key + << (stored ? "" : " (no stored offset)") << std::endl; + } + return calibration; } // Raw SDL sample in m/s^2, rejecting anything SDL has not delivered yet. @@ -206,9 +277,10 @@ bool IsGlitchedSample(SDL_SensorType sensor, const float* g) { // +x left, +y towards the user, +z out of the button face (wiibrew, Dolphin); // SDL_hidapi_wii.c posts (-wiiX, wiiZ, wiiY): x right across the face, y out of // the face (+1 at rest, buttons up), z towards the user, i.e. away from the tip. -// The remote's sample gets the zero-point correction; the Nunchuk's does not. -// False when there is no sample yet or the sample is a glitch (see above). -bool ReadAccelG(SDL_Gamepad* gamepad, SDL_SensorType sensor, float* g) { +// The remote's sample gets the zero-point correction of the remote on `port`; the +// Nunchuk's does not. False when there is no sample yet or the sample is a glitch +// (see above). +bool ReadAccelG(SDL_Gamepad* gamepad, uint32_t port, SDL_SensorType sensor, float* g) { float sdl[3] = {}; if (!ReadSdlAccel(gamepad, sensor, sdl)) { return false; @@ -218,7 +290,7 @@ bool ReadAccelG(SDL_Gamepad* gamepad, SDL_SensorType sensor, float* g) { return false; } if (sensor == SDL_SENSOR_ACCEL) { - const std::array& offset = AccelOffset(); + const std::array& offset = CalibrationFor(gamepad, port).offset; for (int i = 0; i < 3; ++i) g[i] -= offset[i]; } return true; @@ -237,9 +309,9 @@ void AccelGToKpad(const float* g, float* kpad) { } // Sensor -> KPAD acc for ReadKpadSample. -bool ReadAccelAsKpad(SDL_Gamepad* gamepad, SDL_SensorType sensor, float* kpad) { +bool ReadAccelAsKpad(SDL_Gamepad* gamepad, uint32_t port, SDL_SensorType sensor, float* kpad) { float g[3] = {}; - if (!ReadAccelG(gamepad, sensor, g)) { + if (!ReadAccelG(gamepad, port, sensor, g)) { return false; } AccelGToKpad(g, kpad); @@ -281,10 +353,25 @@ void TraceSample(uint32_t chan, const KpadSample& sample, const float* rawG, boo trace.flush(); } -// Ends a calibration run with a message for the overlay. -void FinishAccelCalibration(const char* message) { - g_calibration.active = false; +// Gamepad of the Wii Remote on `chan`, or nullptr. +SDL_Gamepad* RemoteGamepad(uint32_t chan) { + if (chan >= PAD_MAX_CONTROLLERS || !IsRemoteChannel(chan)) { + return nullptr; + } + return SDL_GetGamepadFromPlayerIndex(static_cast(chan)); +} + +void SetCalibrationMessage(uint32_t chan, const char* message) { + SDL_Gamepad* gamepad = RemoteGamepad(chan); + g_calibrationMessageChan = chan; + g_calibrationMessageRemote = gamepad != nullptr ? SDL_GetGamepadID(gamepad) : 0; std::snprintf(g_calibrationMessage, sizeof(g_calibrationMessage), "%s", message); +} + +// Ends a calibration run with a message for the overlay. +void FinishAccelCalibration(uint32_t chan, const char* message) { + g_calibration.active = false; + SetCalibrationMessage(chan, message); RT_LOG(RT_TAG_CONFIG) << "Wii Remote accelerometer calibration: " << message << std::endl; } @@ -294,9 +381,10 @@ void StepAccelCalibration() { if (!g_calibration.active) { return; } - SDL_Gamepad* gamepad = SDL_GetGamepadFromPlayerIndex(static_cast(g_calibration.chan)); - if (gamepad == nullptr || !IsRemoteChannel(g_calibration.chan)) { - FinishAccelCalibration("Cancelled: the Wii Remote went away."); + SDL_Gamepad* gamepad = RemoteGamepad(g_calibration.chan); + if (gamepad == nullptr || + SDL_GetGamepadID(gamepad) != g_calibration.instance) { + FinishAccelCalibration(g_calibration.chan, "Cancelled: the Wii Remote went away."); return; } EnsureSensors(gamepad, g_calibration.chan); @@ -314,7 +402,7 @@ void StepAccelCalibration() { } else { for (int i = 0; i < 3; ++i) { if (std::fabs(g[i] - g_calibration.first[i]) > kCalibrationMaxDeviationG) { - FinishAccelCalibration("Failed: the remote moved. Put it down, buttons up, and try again."); + FinishAccelCalibration(g_calibration.chan, "Failed: the remote moved. Put it down, buttons up, and try again."); return; } } @@ -326,19 +414,20 @@ 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) { - FinishAccelCalibration("Failed: the remote was not resting flat with the buttons up."); + if (length < kCalibrationMinGravityG || length > kCalibrationMaxGravityG || + mean[1] / length < kCalibrationMinTiltCosine) { + FinishAccelCalibration(g_calibration.chan, "Failed: the remote was not resting flat with the buttons up."); return; } const std::array offset = {mean[0], mean[1] - 1.0, mean[2]}; - for (int i = 0; i < 3; ++i) g_accelOffset[i] = static_cast(offset[i]); - g_accelOffsetLoaded = true; - const bool saved = RuntimeConfigFile::SetWiiAccelOffset(offset); + RemoteCalibration& calibration = CalibrationFor(gamepad, g_calibration.chan); + for (int i = 0; i < 3; ++i) calibration.offset[i] = static_cast(offset[i]); + const bool saved = RuntimeConfigFile::SetWiiAccelOffsetFor(calibration.key, offset); char message[160]; std::snprintf(message, sizeof(message), "%s offset x %+.3f y %+.3f z %+.3f g", saved ? "Calibrated:" : "Calibrated (could not write Config.toml):", offset[0], offset[1], offset[2]); - FinishAccelCalibration(message); + FinishAccelCalibration(g_calibration.chan, message); } // True when any controller aurora knows about is a Wii device. @@ -715,17 +804,40 @@ void FillGraceSample(uint32_t chan, Kind kind, KpadSample& sample) { sample.hasClassic = kind == Kind::RemoteWithClassic; } -// SDL's stick axis (-32767..32767, y down) as WPADCLStatus carries it: WPAD -// normalises every Classic Controller stick, whatever the report's resolution, -// to a signed 10-bit value, -512..511 with 0 at the centre and +y up (RVL SDK -// WPAD.h; wut's WPADStatusClassic documents the same range). +// The patched SDL Wii driver posts Classic Controller sticks as WPAD's own +// value times 64 (aurora-main/cmake/AuroraSDL3Patches.cmake), so WPADCLStatus +// is the axis divided back: signed, 0 at the centre, -512..511, +y up. int16_t ClassicStickRaw(Sint16 axis, bool invert) { + const long value = invert ? -static_cast(axis) : axis; + return static_cast(std::clamp(std::lround(value / 64.0), -512L, 511L)); +} + +// The kernel driver's evdev axes span SDL's full range; scaled to WPAD's. +int16_t KernelClassicStickRaw(Sint16 axis, bool invert) { float value = static_cast(axis) / 32767.0f; if (invert) value = -value; value = std::clamp(value, -1.0f, 1.0f); return static_cast(std::clamp(std::lround(value * 512.0f), -512L, 511L)); } +// KPAD's default Classic Controller stick limits; the game uses the cross clamp. +constexpr float kKpadStickMin = 60.0f; +constexpr float kKpadStickMax = 308.0f; + +// KPAD cross clamp: per-axis dead zone and scale, then the unit circle. +void ClassicStickFloat(const int16_t raw[2], float out[2]) { + for (int i = 0; i < 2; ++i) { + const float magnitude = std::fabs(static_cast(raw[i])); + const float scaled = std::clamp((magnitude - kKpadStickMin) / (kKpadStickMax - kKpadStickMin), 0.0f, 1.0f); + out[i] = raw[i] < 0 ? -scaled : scaled; + } + const float length = std::sqrt(out[0] * out[0] + out[1] * out[1]); + if (length > 1.0f) { + out[0] /= length; + out[1] /= length; + } +} + // Samples buttons, accelerometers and the extension of the remote on a port. bool ReadKpadSample(uint32_t chan, KpadSample& sample) { if (chan >= PAD_MAX_CONTROLLERS) { @@ -789,7 +901,7 @@ bool ReadKpadSample(uint32_t chan, KpadSample& sample) { const bool haveRaw = ReadSdlAccel(gamepad, SDL_SENSOR_ACCEL, rawG); for (float& v : rawG) v /= kStandardGravity; LastAcc& last = g_lastAcc[chan]; - const bool accepted = ReadAccelAsKpad(gamepad, SDL_SENSOR_ACCEL, sample.acc); + const bool accepted = ReadAccelAsKpad(gamepad, chan, SDL_SENSOR_ACCEL, sample.acc); if (accepted) { last.valid = true; for (int i = 0; i < 3; ++i) last.acc[i] = sample.acc[i]; @@ -840,14 +952,23 @@ bool ReadKpadSample(uint32_t chan, KpadSample& sample) { const Sint16 ly = SDL_GetGamepadAxis(classic, SDL_GAMEPAD_AXIS_LEFTY); const Sint16 rx = SDL_GetGamepadAxis(classic, SDL_GAMEPAD_AXIS_RIGHTX); const Sint16 ry = SDL_GetGamepadAxis(classic, SDL_GAMEPAD_AXIS_RIGHTY); - sample.clLStick[0] = std::clamp(static_cast(lx) / 32767.0f, -1.0f, 1.0f); - sample.clLStick[1] = std::clamp(-static_cast(ly) / 32767.0f, -1.0f, 1.0f); - sample.clRStick[0] = std::clamp(static_cast(rx) / 32767.0f, -1.0f, 1.0f); - sample.clRStick[1] = std::clamp(-static_cast(ry) / 32767.0f, -1.0f, 1.0f); - sample.clLStickRaw[0] = ClassicStickRaw(lx, false); - sample.clLStickRaw[1] = ClassicStickRaw(ly, true); - sample.clRStickRaw[0] = ClassicStickRaw(rx, false); - sample.clRStickRaw[1] = ClassicStickRaw(ry, true); + if (kernel) { + sample.clLStickRaw[0] = KernelClassicStickRaw(lx, false); + sample.clLStickRaw[1] = KernelClassicStickRaw(ly, true); + sample.clRStickRaw[0] = KernelClassicStickRaw(rx, false); + sample.clRStickRaw[1] = KernelClassicStickRaw(ry, true); + sample.clLStick[0] = std::clamp(static_cast(lx) / 32767.0f, -1.0f, 1.0f); + sample.clLStick[1] = std::clamp(-static_cast(ly) / 32767.0f, -1.0f, 1.0f); + sample.clRStick[0] = std::clamp(static_cast(rx) / 32767.0f, -1.0f, 1.0f); + sample.clRStick[1] = std::clamp(-static_cast(ry) / 32767.0f, -1.0f, 1.0f); + } else { + sample.clLStickRaw[0] = ClassicStickRaw(lx, false); + sample.clLStickRaw[1] = ClassicStickRaw(ly, true); + sample.clRStickRaw[0] = ClassicStickRaw(rx, false); + sample.clRStickRaw[1] = ClassicStickRaw(ry, true); + ClassicStickFloat(sample.clLStickRaw, sample.clLStick); + ClassicStickFloat(sample.clRStickRaw, sample.clRStick); + } // Only the full-press click of L/R reaches SDL; report it as a full pull. sample.clTriggerL = (sample.clHold & kClL) ? 255 : 0; sample.clTriggerR = (sample.clHold & kClR) ? 255 : 0; @@ -862,7 +983,7 @@ bool ReadKpadSample(uint32_t chan, KpadSample& sample) { sample.stick[1] = -static_cast(SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTY)) / 32767.0f; for (float& v : sample.stick) v = std::clamp(v, -1.0f, 1.0f); LastAcc& lastNunchuk = g_lastNunchukAcc[chan]; - if (ReadAccelAsKpad(gamepad, SDL_SENSOR_ACCEL_L, sample.nunchukAcc)) { + if (ReadAccelAsKpad(gamepad, chan, SDL_SENSOR_ACCEL_L, sample.nunchukAcc)) { lastNunchuk.valid = true; for (int i = 0; i < 3; ++i) lastNunchuk.acc[i] = sample.nunchukAcc[i]; } else { @@ -882,7 +1003,7 @@ bool ReadAccelDebug(uint32_t chan, float sdlG[3], float kpadAcc[3]) { return false; } EnsureSensors(gamepad, chan); - if (!ReadAccelG(gamepad, SDL_SENSOR_ACCEL, sdlG)) { + if (!ReadAccelG(gamepad, chan, SDL_SENSOR_ACCEL, sdlG)) { return false; } AccelGToKpad(sdlG, kpadAcc); @@ -891,28 +1012,47 @@ bool ReadAccelDebug(uint32_t chan, float sdlG[3], float kpadAcc[3]) { // Begins collecting rest samples from the remote on `chan`. void StartAccelCalibration(uint32_t chan) { - if (!IsRemoteChannel(chan)) { - FinishAccelCalibration("No Wii Remote on this port."); + SDL_Gamepad* gamepad = RemoteGamepad(chan); + if (gamepad == nullptr) { + FinishAccelCalibration(chan, "No Wii Remote on this port."); return; } g_calibration = {}; g_calibration.active = true; g_calibration.chan = chan; + g_calibration.instance = SDL_GetGamepadID(gamepad); g_calibrationMessage[0] = '\0'; } -// Drops the stored correction and goes back to SDL's raw reading. -void ClearAccelCalibration() { - g_calibration.active = false; - g_accelOffset = {}; - g_accelOffsetLoaded = true; - RuntimeConfigFile::SetWiiAccelOffset({0.0, 0.0, 0.0}); - std::snprintf(g_calibrationMessage, sizeof(g_calibrationMessage), "Calibration cleared."); +// Zeroes the stored correction of the remote on `chan`. +void ClearAccelCalibration(uint32_t chan) { + if (g_calibration.chan == chan) { + g_calibration.active = false; + } + SDL_Gamepad* gamepad = RemoteGamepad(chan); + if (gamepad == nullptr) { + SetCalibrationMessage(chan, "No Wii Remote on this port."); + return; + } + RemoteCalibration& calibration = CalibrationFor(gamepad, chan); + calibration.offset = {}; + RuntimeConfigFile::SetWiiAccelOffsetFor(calibration.key, {0.0, 0.0, 0.0}); + SetCalibrationMessage(chan, "Calibration cleared."); } -// True while a calibration run is collecting samples. -bool IsAccelCalibrating() { - return g_calibration.active; +// Correction in effect for the remote on `chan`, for the overlay. +std::optional> ReadAccelOffset(uint32_t chan) { + SDL_Gamepad* gamepad = RemoteGamepad(chan); + if (gamepad == nullptr) { + return std::nullopt; + } + const RemoteCalibration& calibration = CalibrationFor(gamepad, chan); + return std::array{calibration.offset[0], calibration.offset[1], calibration.offset[2]}; +} + +// True while a calibration run is collecting samples from `chan`. +bool IsAccelCalibrating(uint32_t chan) { + return g_calibration.active && g_calibration.chan == chan; } // Share of the calibration samples collected so far, 0..1; 0 when idle. @@ -920,9 +1060,14 @@ float AccelCalibrationProgress() { return g_calibration.active ? static_cast(g_calibration.count) / kCalibrationSamples : 0.0f; } -// Outcome of the last calibration run for the overlay, or nullptr before any. -const char* AccelCalibrationMessage() { - return g_calibrationMessage[0] != '\0' ? g_calibrationMessage : nullptr; +// Outcome of the last calibration run on `chan` for the overlay, or nullptr. +const char* AccelCalibrationMessage(uint32_t chan) { + if (g_calibrationMessage[0] == '\0' || g_calibrationMessageChan != chan) { + return nullptr; + } + SDL_Gamepad* gamepad = RemoteGamepad(chan); + const SDL_JoystickID remote = gamepad != nullptr ? SDL_GetGamepadID(gamepad) : 0; + return remote == g_calibrationMessageRemote ? g_calibrationMessage : nullptr; } } // namespace WiiRemoteInput 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));