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 f40442d..a20577a 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 650451a..fc57f41 100755 --- a/Launcher/local-build-macos.command +++ b/Launcher/local-build-macos.command @@ -28,12 +28,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 ;; @@ -51,6 +54,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 @@ -61,6 +65,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 @@ -162,7 +170,7 @@ fi 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" mkw_write_identity "$toolchain_provenance" ToolchainFingerprint "$toolchain_fingerprint" 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" diff --git a/Launcher/local-build.sh b/Launcher/local-build.sh index 90fee9d..a4ae6dc 100755 --- a/Launcher/local-build.sh +++ b/Launcher/local-build.sh @@ -68,6 +68,7 @@ translator_bin_override="" fuse_ld_override="" native_prebuilt_dir="" sysroot="" +setup_version="" usage() { cat <<'EOF' @@ -92,6 +93,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 } @@ -116,11 +119,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) ;; @@ -424,7 +433,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 @@ -443,7 +453,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 0a60213..9038b2d 100755 --- a/Launcher/macos/build-setup-pkg.command +++ b/Launcher/macos/build-setup-pkg.command @@ -157,6 +157,7 @@ if [[ "$host_arch" == arm64 ]]; then host_translator=$translator_arm64; else hos --out "$resources/workspace/runtime/native-index.json" || fail 'the runtime native index could not be extracted' [[ -f "$resources/workspace/runtime/native-index.json" ]] || fail 'the runtime native index was not written' 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/build-cache.sh" "$resources/workspace/Launcher/build-cache.sh" copy_clean "$workspace/Launcher/macos/extract-disc.command" "$resources/workspace/Launcher/macos/extract-disc.command" diff --git a/Launcher/macos/extract-disc.command b/Launcher/macos/extract-disc.command index 99fd2de..14ed633 100755 --- a/Launcher/macos/extract-disc.command +++ b/Launcher/macos/extract-disc.command @@ -58,8 +58,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 40d64f6..e9d2b17 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}] @@ -143,7 +149,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" @@ -169,8 +175,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/dolphin/pad/pad.cpp b/aurora-main/lib/dolphin/pad/pad.cpp index 7ae292f..acdbe2a 100644 --- a/aurora-main/lib/dolphin/pad/pad.cpp +++ b/aurora-main/lib/dolphin/pad/pad.cpp @@ -195,7 +195,7 @@ std::array g_defaultButtonsJoyPair{{ }}; // Wii U Pro Controllers through SDL's HIDAPI Wii driver. No SDL_GamepadType -// singles them out, so they are picked by the name the driver gives them (see +// singles them out, so they are picked by name or USB id (see // __PADSetDefaultMapping). Wii Remotes, with or without a Nunchuk or Classic // Controller, are read by the game through KPAD instead and never get a // GameCube mapping (the runtime hides those ports from PADRead). @@ -451,11 +451,14 @@ static void reset_alt_button_mapping(aurora::input::GameController* controller) // SDL's hidapi Wii driver names the pad "Nintendo Wii U Pro Controller"; the // other names it produces are Wii Remotes, which the game reads through KPAD -// and which therefore never take a GameCube mapping. +// and which therefore never take a GameCube mapping. The Linux kernel driver's +// "Nintendo Wii Remote Pro Controller" is only recognised by its USB id. static bool wii_default_mapping(const aurora::input::GameController* controller, std::array& out) { const char* name = SDL_GetGamepadName(controller->m_controller); - if (name == nullptr || SDL_strstr(name, "Wii U Pro Controller") == nullptr) { + const bool wiiUProId = SDL_GetGamepadVendor(controller->m_controller) == 0x057e && + SDL_GetGamepadProduct(controller->m_controller) == 0x0330; + if (!wiiUProId && (name == nullptr || SDL_strstr(name, "Wii U Pro Controller") == nullptr)) { return false; } out = g_defaultButtonsWiiUPro; 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/input.cpp b/aurora-main/lib/input.cpp index 96a9c1d..ccd1e0a 100644 --- a/aurora-main/lib/input.cpp +++ b/aurora-main/lib/input.cpp @@ -14,6 +14,7 @@ #include #include #include +#include using namespace std::string_view_literals; @@ -23,7 +24,7 @@ absl::flat_hash_map g_GameControllers; namespace { constexpr uint32_t kPortPreferencesMagic = SBIG('CPRT'); -constexpr uint32_t kPortPreferencesVersion = 2; +constexpr uint32_t kPortPreferencesVersion = 3; constexpr uint32_t kMaxPersistedStringLength = 256; enum class PortPreferenceState : uint8_t { @@ -35,6 +36,8 @@ enum class PortPreferenceState : uint8_t { struct ControllerIdentity { std::string guid; std::string serial; + std::string path; + int32_t slot = -1; }; struct PortPreference { @@ -44,6 +47,9 @@ struct PortPreference { std::array g_portPreferences; bool g_portPreferencesLoaded = false; +// Startup controllers are added one by one via events; capture their SDL player index at init, +// before earlier port reassignments can move it. +absl::flat_hash_map g_connectSlots; std::string port_preferences_path() { if (g_config.userPath == nullptr) { @@ -83,6 +89,8 @@ ControllerIdentity controller_identity(const GameController& controller) { SDL_GUIDToString(SDL_GetGamepadGUIDForID(SDL_GetGamepadID(controller.m_controller)), guid, sizeof(guid)); identity.guid = guid; identity.serial = normalize_serial(SDL_GetGamepadSerial(controller.m_controller)); + identity.path = controller.m_path; + identity.slot = controller.m_slot; return identity; } @@ -137,12 +145,16 @@ bool write_string(SDL_IOStream* file, const std::string& value) { return write_value(file, size) && (size == 0 || write_exact(file, value.data(), size)); } -bool read_identity(SDL_IOStream* file, ControllerIdentity& identity) { - return read_string(file, identity.guid) && read_string(file, identity.serial); +bool read_identity(SDL_IOStream* file, uint32_t version, ControllerIdentity& identity) { + if (!read_string(file, identity.guid) || !read_string(file, identity.serial)) { + return false; + } + return version < 3 || (read_string(file, identity.path) && read_value(file, identity.slot)); } bool write_identity(SDL_IOStream* file, const ControllerIdentity& identity) { - return write_string(file, identity.guid) && write_string(file, identity.serial); + return write_string(file, identity.guid) && write_string(file, identity.serial) && + write_string(file, identity.path) && write_value(file, identity.slot); } bool read_port_preferences_file(std::array& preferences) { @@ -159,13 +171,13 @@ bool read_port_preferences_file(std::array& uint32_t magic = 0; uint32_t version = 0; bool ok = read_value(file, magic) && read_value(file, version) && magic == kPortPreferencesMagic && - version == kPortPreferencesVersion; + version >= 2 && version <= kPortPreferencesVersion; for (auto& preference : preferences) { uint8_t state = 0; ControllerIdentity identity; ok = ok && read_value(file, state) && state <= static_cast(PortPreferenceState::Controller) && - read_identity(file, identity); + read_identity(file, version, identity); if (ok) { preference.state = static_cast(state); preference.identity = std::move(identity); @@ -225,22 +237,31 @@ void save_port_preferences() { } } -enum class IdentityMatch { - None, - Fallback, - Exact, -}; +// Zero means no match. Higher scores claim ports first: path+slot > GUID > VID/PID. +constexpr int kScoreVidPid = 1; +constexpr int kScoreGuid = 2; +constexpr int kScoreSlot = 1; +constexpr int kScorePath = 2; -IdentityMatch identity_match(const ControllerIdentity& saved, const ControllerIdentity& current) { +int identity_score(const ControllerIdentity& saved, const ControllerIdentity& current) { if (saved.guid.empty()) { - return IdentityMatch::None; + return 0; } if (saved.guid == current.guid) { - return saved.serial.empty() || serial_matches(saved.serial, current.serial) ? IdentityMatch::Exact - : IdentityMatch::None; + if (!saved.serial.empty() && !serial_matches(saved.serial, current.serial)) { + return 0; + } + int score = kScoreGuid; + if (!saved.path.empty() && saved.path == current.path) { + score += kScorePath; + } + if (saved.slot >= 0 && saved.slot == current.slot) { + score += kScoreSlot; + } + return score; } if (!serial_matches(saved.serial, current.serial)) { - return IdentityMatch::None; + return 0; } // Attempt to match against VID/PID as a fallback if the GUID changes @@ -250,9 +271,9 @@ IdentityMatch identity_match(const ControllerIdentity& saved, const ControllerId uint16_t currentProduct = 0; SDL_GetJoystickGUIDInfo(SDL_StringToGUID(saved.guid.c_str()), &savedVendor, &savedProduct, nullptr, nullptr); SDL_GetJoystickGUIDInfo(SDL_StringToGUID(current.guid.c_str()), ¤tVendor, ¤tProduct, nullptr, nullptr); - return savedVendor != 0 && savedVendor == currentVendor && savedProduct != 0 && savedProduct == currentProduct - ? IdentityMatch::Fallback - : IdentityMatch::None; + const bool sameDevice = + savedVendor != 0 && savedVendor == currentVendor && savedProduct != 0 && savedProduct == currentProduct; + return sameDevice ? kScoreVidPid : 0; } void assign_player_index(GameController& controller, int32_t port) { @@ -267,12 +288,6 @@ int32_t effective_player_index(const GameController& controller) { return player >= 0 ? player : controller.m_playerIndex; } -bool is_instance_claimed(const std::array& claimedControllers, size_t claimedCount, - Uint32 instance) { - return std::find(claimedControllers.begin(), claimedControllers.begin() + claimedCount, instance) != - claimedControllers.begin() + claimedCount; -} - void apply_port_preferences() noexcept { ensure_port_preferences_loaded(); if (!std::any_of(g_portPreferences.begin(), g_portPreferences.end(), @@ -288,45 +303,63 @@ void apply_port_preferences() noexcept { } } - std::array claimedControllers{}; + struct Candidate { + int score; + uint32_t port; + Uint32 instance; + }; + std::vector candidates; + for (const auto& [instance, controller] : g_GameControllers) { + const auto identity = controller_identity(controller); + for (uint32_t port = 0; port < g_portPreferences.size(); ++port) { + if (g_portPreferences[port].state != PortPreferenceState::Controller) { + continue; + } + if (const int score = identity_score(g_portPreferences[port].identity, identity); score > 0) { + candidates.push_back({score, port, instance}); + } + } + } + // Best matches claim first so identical controllers each end up on their own port + std::sort(candidates.begin(), candidates.end(), [](const Candidate& a, const Candidate& b) { + if (a.score != b.score) { + return a.score > b.score; + } + return a.port != b.port ? a.port < b.port : a.instance < b.instance; + }); + + std::array portClaimed{}; + std::array claimedInstances{}; size_t claimedCount = 0; - for (uint32_t port = 0; port < g_portPreferences.size(); ++port) { - const auto& preference = g_portPreferences[port]; - if (preference.state != PortPreferenceState::Controller) { + bool upgraded = false; + for (const auto& candidate : candidates) { + if (portClaimed[candidate.port] || + std::find(claimedInstances.begin(), claimedInstances.begin() + claimedCount, candidate.instance) != + claimedInstances.begin() + claimedCount) { continue; } + portClaimed[candidate.port] = true; + claimedInstances[claimedCount++] = candidate.instance; + auto& controller = g_GameControllers[candidate.instance]; + assign_player_index(controller, static_cast(candidate.port)); - Uint32 fallbackInstance = 0; - GameController* fallbackController = nullptr; - for (auto& [instance, controller] : g_GameControllers) { - if (is_instance_claimed(claimedControllers, claimedCount, instance)) { - continue; + // Preferences saved before path and slot existed gain them on their first GUID match + auto& saved = g_portPreferences[candidate.port].identity; + if (candidate.score >= kScoreGuid && (saved.path.empty() || saved.slot < 0)) { + const auto current = controller_identity(controller); + if (saved.path.empty() && !current.path.empty()) { + saved.path = current.path; + upgraded = true; } - - switch (identity_match(preference.identity, controller_identity(controller))) { - case IdentityMatch::Exact: - assign_player_index(controller, static_cast(port)); - claimedControllers[claimedCount++] = instance; - fallbackController = nullptr; - break; - case IdentityMatch::Fallback: - // Prefer any later exact match before claiming a fallback candidate - if (fallbackController == nullptr) { - fallbackInstance = instance; - fallbackController = &controller; - } - continue; - case IdentityMatch::None: - continue; + if (saved.slot < 0 && current.slot >= 0) { + saved.slot = current.slot; + upgraded = true; } - break; - } - - if (fallbackController != nullptr) { - assign_player_index(*fallbackController, static_cast(port)); - claimedControllers[claimedCount++] = fallbackInstance; } } + if (upgraded) { + save_port_preferences(); + } } // SDL only hands out a player index when the device already had a gamepad mapping @@ -401,6 +434,15 @@ SDL_JoystickID add_controller(SDL_JoystickID which) noexcept { return -1; } controller.m_isGameCube = controller.m_vid == 0x057E && controller.m_pid == 0x0337; + if (const char* path = SDL_GetGamepadPath(ctrl); path != nullptr) { + controller.m_path = path; + } + if (const auto slot = g_connectSlots.find(which); slot != g_connectSlots.end()) { + controller.m_slot = slot->second; + g_connectSlots.erase(slot); + } else { + controller.m_slot = SDL_GetGamepadPlayerIndex(ctrl); + } const char* serial = SDL_GetGamepadSerial(ctrl); controller.m_gameCubeUseOrdinaryStop = controller.m_isGameCube && serial && "GCP+"sv == serial; if (controller.m_isGameCube || @@ -420,8 +462,7 @@ SDL_JoystickID add_controller(SDL_JoystickID which) noexcept { bool hasOtherPortPreference = false; for (size_t port = 1; port < g_portPreferences.size(); ++port) { if (g_portPreferences[port].state == PortPreferenceState::Controller && - identity_match(g_portPreferences[port].identity, controller_identity(g_GameControllers[instance])) != - IdentityMatch::None) { + identity_score(g_portPreferences[port].identity, controller_identity(g_GameControllers[instance])) > 0) { hasOtherPortPreference = true; break; } @@ -457,6 +498,7 @@ bool refresh_controller(SDL_JoystickID instance) noexcept { } void remove_controller(Uint32 instance) noexcept { + g_connectSlots.erase(instance); if (auto it = g_GameControllers.find(instance); it != g_GameControllers.end()) { SDL_CloseGamepad(it->second.m_controller); g_GameControllers.erase(it); @@ -540,6 +582,14 @@ void initialize() noexcept { * as expected */ ASSERT(SDL_Init(SDL_INIT_HAPTIC | SDL_INIT_JOYSTICK | SDL_INIT_GAMEPAD), "Failed to initialize SDL subsystems: {}", SDL_GetError()); + + int count = 0; + if (SDL_JoystickID* ids = SDL_GetGamepads(&count)) { + for (int i = 0; i < count; ++i) { + g_connectSlots[ids[i]] = SDL_GetGamepadPlayerIndexForID(ids[i]); + } + SDL_free(ids); + } } struct MouseScrollStatus { diff --git a/aurora-main/lib/input.hpp b/aurora-main/lib/input.hpp index ae083b1..2740ad6 100644 --- a/aurora-main/lib/input.hpp +++ b/aurora-main/lib/input.hpp @@ -20,6 +20,9 @@ struct GameController { bool m_gameCubeUseOrdinaryStop = false; Sint32 m_index = -1; Sint32 m_playerIndex = -1; + // Device path and the SDL player index at connect (the adapter port for GameCube adapters). + std::string m_path; + Sint32 m_slot = -1; bool m_hasRumble = false; PADDeadZones m_deadZones{ .emulateTriggers = true, 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 61147b5..c34b148 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 2992edd..38c5254 100644 --- a/runtime/cmake/PublicProducts.cmake +++ b/runtime/cmake/PublicProducts.cmake @@ -77,6 +77,10 @@ target_compile_features(mkw_game_data PRIVATE cxx_std_20) target_compile_definitions(mkw_game_data PRIVATE SDL_MAIN_HANDLED _DISABLE_STRING_ANNOTATION _DISABLE_VECTOR_ANNOTATION) +if(MKW_SETUP_VERSION) + set_property(SOURCE "${MKW_RUNTIME_SOURCE_DIR}/game/setup_version.cpp" APPEND PROPERTY + COMPILE_DEFINITIONS "MKW_SETUP_VERSION=\"${MKW_SETUP_VERSION}\"") +endif() mkw_runtime_link_dependencies(mkw_game_data PRIVATE) set_target_properties(mkw_game_data PROPERTIES UNITY_BUILD OFF) mkw_apply_common_compile_options(mkw_game_data) @@ -197,7 +201,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) @@ -231,7 +235,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() @@ -240,7 +244,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}") @@ -252,7 +256,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 @@ -264,7 +268,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 @@ -278,7 +282,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/game/setup_version.cpp b/runtime/game/setup_version.cpp new file mode 100644 index 0000000..13a2d47 --- /dev/null +++ b/runtime/game/setup_version.cpp @@ -0,0 +1,7 @@ +// Compiled per user rather than into the shared runtime archive. +extern const char* const kMkwSetupVersion = +#if defined(MKW_SETUP_VERSION) + MKW_SETUP_VERSION; +#else + ""; +#endif 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 9f893d0..5f37a54 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -152,6 +152,8 @@ private: #endif } // namespace +extern const char* const kMkwSetupVersion; + namespace { std::string g_lastEntryLabel; @@ -378,10 +380,13 @@ 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 (const std::string_view builtIn = kMkwSetupVersion; !builtIn.empty()) { + return std::string(builtIn); + } const auto directory = RuntimeConfigFile::ExecutableDirectory(); if (!directory) { return {}; diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 78c32bf..4836c6e 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."); } } @@ -779,8 +780,15 @@ void DrawControllerSettings() { if (ImGui::BeginMenu("Assign connected controller")) { for (uint32_t index = 0; index < controllerCount; ++index) { const char* name = PADGetNameForControllerIndex(index); + std::string label = name != nullptr ? name : "Unknown controller"; + for (int port = 0; port < PAD_MAX_CONTROLLERS; ++port) { + if (PADGetIndexForPort(port) == static_cast(index)) { + label += " (Port " + std::to_string(port + 1) + ")"; + break; + } + } ImGui::PushID(static_cast(index)); - if (ImGui::MenuItem(name != nullptr ? name : "Unknown controller")) { + if (ImGui::MenuItem(label.c_str())) { PADSetPortForIndex(index, selectedGamePort); g_configuredControllerIndices.fill(std::numeric_limits::min()); ApplyConfiguredMappings(); @@ -790,6 +798,12 @@ void DrawControllerSettings() { ImGui::EndMenu(); } + if (WiiRemoteInput::IsRemoteChannel(selectedGamePort)) { + ImGui::TextDisabled("The game reads this controller as a Wii Remote, so the button mapping does not apply."); + DrawRumbleSettings(); + return; + } + uint32_t mappingCount = 0; PADButtonMapping* mappings = PADGetButtonMappings(static_cast(g_controllerPort), &mappingCount); if (mappings == nullptr || mappingCount != PAD_BUTTON_COUNT) { diff --git a/runtime/src/wii_remote_input.cpp b/runtime/src/wii_remote_input.cpp index 8cd9753..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,16 @@ #include #include +#include #include #include #include +#include #include +#include #include +#include +#include #include namespace WiiRemoteInput { @@ -63,6 +69,8 @@ constexpr uint32_t kClUp = 0x0001, kClLeft = 0x0002, kClZR = 0x0004, kClX = 0x00 kClB = 0x0040, kClZL = 0x0080, kClR = 0x0200, kClPlus = 0x0400, kClHome = 0x0800, kClMinus = 0x1000, kClL = 0x2000, kClDown = 0x4000, kClRight = 0x8000; +constexpr Uint16 kNintendoVendor = 0x057e; + // How long a vanished remote keeps its channel alive with neutral input. SDL's // in-place reconnect after an extension change takes well under a second; a // remote that is really gone shows up as disconnected after this. @@ -86,36 +94,46 @@ bool g_scanning = false; uint64_t g_driverOffSinceMs = 0; // When the current scan started (last Wii controller seen). uint64_t g_lostAtMs = 0; +bool g_hidrawWarned = false; // Instance ids whose accelerometers have been switched on. SDL keeps sensors // off until asked and forgets that when the gamepad is closed, so a re-paired // 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. @@ -155,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. @@ -200,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; @@ -212,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; @@ -231,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); @@ -275,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; } @@ -288,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); @@ -308,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; } } @@ -320,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. @@ -346,6 +441,110 @@ bool AnyWiiControllerConnected() { return false; } +// True for an evdev node served by the kernel hid-wiimote driver. +bool IsKernelWiiNode(SDL_JoystickID id) { + const char* path = SDL_GetGamepadPathForID(id); + return SDL_GetGamepadVendorForID(id) == kNintendoVendor && path != nullptr && + std::strncmp(path, "/dev/input/", 11) == 0; +} + +bool IsClassicNodeName(const char* name) { + return NameContains(name, "Classic Controller"); +} + +// HID device behind a kernel evdev node; the remote and its extension nodes share it. +std::string KernelParent(SDL_JoystickID id) { + static std::unordered_map s_parents; + if (const auto it = s_parents.find(id); it != s_parents.end()) { + return it->second; + } + const char* path = SDL_GetGamepadPathForID(id); + if (path == nullptr) { + return {}; + } + std::error_code ec; + const auto parent = std::filesystem::canonical( + std::filesystem::path("/sys/class/input") / std::filesystem::path(path).filename() / "device" / "device", ec); + if (ec) { + return {}; + } + return s_parents.emplace(id, parent.string()).first->second; +} + +// The kernel node on the same remote as `gamepad`: its Classic Controller node +// when `wantClassic`, else its remote node. +SDL_Gamepad* KernelSibling(SDL_Gamepad* gamepad, bool wantClassic) { + const SDL_JoystickID self = SDL_GetGamepadID(gamepad); + const std::string parent = KernelParent(self); + if (parent.empty()) { + return nullptr; + } + int count = 0; + SDL_JoystickID* ids = SDL_GetGamepads(&count); + if (ids == nullptr) { + return nullptr; + } + SDL_Gamepad* sibling = nullptr; + for (int i = 0; i < count && sibling == nullptr; ++i) { + if (ids[i] != self && IsKernelWiiNode(ids[i]) && + IsClassicNodeName(SDL_GetGamepadNameForID(ids[i])) == wantClassic && KernelParent(ids[i]) == parent) { + sibling = SDL_GetGamepadFromID(ids[i]); + } + } + SDL_free(ids); + return sibling; +} + +// The kernel exposes a remote and its Classic Controller as separate gamepads. +// The remote's port reads both, as on a Wii; the Classic node's own port stays empty. +Kind KindForGamepad(SDL_Gamepad* gamepad) { + if (gamepad == nullptr) { + return Kind::NotWii; + } + const char* name = SDL_GetGamepadName(gamepad); + const Kind kind = KindForName(name); + if ((kind != Kind::Remote && kind != Kind::RemoteWithClassic) || !IsKernelWiiNode(SDL_GetGamepadID(gamepad))) { + return kind; + } + if (IsClassicNodeName(name)) { + return KernelSibling(gamepad, false) != nullptr ? Kind::NotWii : kind; + } + return KernelSibling(gamepad, true) != nullptr ? Kind::RemoteWithClassic : kind; +} + +// A kernel Classic Controller node whose remote node carries it. +bool IsCarriedKernelNode(SDL_Gamepad* gamepad) { + return gamepad != nullptr && IsKernelWiiNode(SDL_GetGamepadID(gamepad)) && + IsClassicNodeName(SDL_GetGamepadName(gamepad)) && KernelSibling(gamepad, false) != nullptr; +} + +// Logs once when the kernel driver serves a Wii controller SDL's driver could have had. +void WarnIfKernelDriverServing() { + if (g_hidrawWarned || !g_wiiDriverEnabled) { + return; + } + const uint64_t now = SDL_GetTicks(); + static uint64_t s_nextCheckMs = 0; + if (now < s_nextCheckMs) { + return; + } + s_nextCheckMs = now + 2000; + int count = 0; + SDL_JoystickID* ids = SDL_GetGamepads(&count); + if (ids == nullptr) { + return; + } + for (int i = 0; i < count && !g_hidrawWarned; ++i) { + const Uint16 product = SDL_GetGamepadProductForID(ids[i]); + if ((product == 0x0306 || product == 0x0330) && IsKernelWiiNode(ids[i])) { + g_hidrawWarned = true; + RT_LOG(RT_TAG_CONFIG) << "A Wii controller is served by the kernel hid-wiimote driver. For full Wii " + << "Remote support see the Linux udev rule in the README." << std::endl; + } + } + SDL_free(ids); +} + // Route SDL's input diagnostics (HIDAPI open failures, the Wii driver's // extension/status messages) into console.log, minus the periodic chatter. // A sub-warning message is written once: SDL repeats the same line on every @@ -464,6 +663,7 @@ void Poll() { for (uint32_t port = 0; port < PAD_MAX_CONTROLLERS; ++port) { (void)EffectiveKind(port); } + WarnIfKernelDriverServing(); if (!g_wiiDriverEnabled) { return; } @@ -518,11 +718,13 @@ uint32_t ScanCount() { return g_scanCount; } -// Maps the gamepad name SDL's Wii driver reports to a Kind. +// Maps the gamepad name SDL reports to a Kind: "Nintendo Wii Remote", +// "... with Nunchuk", "... with Classic Controller" and "Nintendo Wii U Pro +// Controller" from SDL's Wii driver; the Linux kernel's "Nintendo Wii Remote +// Classic Controller" is read like the Classic Controller. Kind KindForName(const char* name) { - // Names come from SDL's hidapi Wii driver: "Nintendo Wii Remote", - // "Nintendo Wii Remote with Nunchuk", "Nintendo Wii Remote with Classic - // Controller" and "Nintendo Wii U Pro Controller". + // The kernel's Pro Controller is a plain gamepad with a built-in SDL mapping. + if (NameContains(name, "Wii Remote Pro Controller")) return Kind::NotWii; if (NameContains(name, "Wii U Pro Controller")) return Kind::WiiUPro; if (!NameContains(name, "Wii Remote")) return Kind::NotWii; if (NameContains(name, "Nunchuk")) return Kind::RemoteWithNunchuk; @@ -534,8 +736,7 @@ Kind KindForName(const char* name) { Kind KindForPort(uint32_t port) { if (port >= PAD_MAX_CONTROLLERS) return Kind::NotWii; SDL_Gamepad* gamepad = SDL_GetGamepadFromPlayerIndex(static_cast(port)); - if (gamepad == nullptr) return Kind::NotWii; - return KindForName(SDL_GetGamepadName(gamepad)); + return KindForGamepad(gamepad); } // Human-readable name of a Kind for the settings overlay. @@ -561,7 +762,7 @@ Kind EffectiveKind(uint32_t chan) { if (chan >= PAD_MAX_CONTROLLERS) return Kind::NotWii; PortMemory& memory = g_ports[chan]; SDL_Gamepad* gamepad = SDL_GetGamepadFromPlayerIndex(static_cast(chan)); - const Kind live = gamepad != nullptr ? KindForName(SDL_GetGamepadName(gamepad)) : Kind::NotWii; + const Kind live = KindForGamepad(gamepad); const uint64_t now = SDL_GetTicks(); if (live != Kind::NotWii) { memory.lastKind = live; @@ -587,7 +788,8 @@ bool IsRemoteChannel(uint32_t chan) { // Marks KPAD-served ports as "no controller" in the GameCube pad statuses. void HideRemotesFromPad(PADStatus* statuses, uint32_t count) { for (uint32_t port = 0; port < count && port < PAD_MAX_CONTROLLERS; ++port) { - if (IsRemoteChannel(port)) { + if (IsRemoteChannel(port) || + IsCarriedKernelNode(SDL_GetGamepadFromPlayerIndex(static_cast(port)))) { statuses[port] = {}; statuses[port].err = PAD_ERR_NO_CONTROLLER; } @@ -602,24 +804,47 @@ 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) { return false; } SDL_Gamepad* gamepad = SDL_GetGamepadFromPlayerIndex(static_cast(chan)); - const Kind kind = gamepad != nullptr ? KindForName(SDL_GetGamepadName(gamepad)) : Kind::NotWii; + const Kind kind = KindForGamepad(gamepad); if (!IsKpadKind(kind)) { const Kind remembered = EffectiveKind(chan); if (!IsKpadKind(remembered)) { @@ -635,26 +860,48 @@ bool ReadKpadSample(uint32_t chan, KpadSample& sample) { EnsureSensors(gamepad, chan); sample = {}; - const auto raw = [&](int index, uint32_t bit) { - if (SDL_GetJoystickButton(joystick, index)) sample.hold |= bit; - }; - raw(kRawA, kWpadA); - raw(kRawB, kWpadB); - raw(kRawOne, kWpadOne); - raw(kRawTwo, kWpadTwo); - raw(kRawPlus, kWpadPlus); - raw(kRawMinus, kWpadMinus); - raw(kRawHome, kWpadHome); - raw(kRawDpadUp, kWpadUp); - raw(kRawDpadDown, kWpadDown); - raw(kRawDpadLeft, kWpadLeft); - raw(kRawDpadRight, kWpadRight); + // The kernel's evdev nodes have regular SDL mappings, so they are read as gamepads. + const bool kernel = IsKernelWiiNode(SDL_GetGamepadID(gamepad)); + const bool classicNode = kernel && IsClassicNodeName(SDL_GetGamepadName(gamepad)); + if (kernel) { + const auto mapped = [&](SDL_GamepadButton button, uint32_t bit) { + if (SDL_GetGamepadButton(gamepad, button)) sample.hold |= bit; + }; + if (!classicNode) { + mapped(SDL_GAMEPAD_BUTTON_EAST, kWpadA); + mapped(SDL_GAMEPAD_BUTTON_SOUTH, kWpadB); + mapped(SDL_GAMEPAD_BUTTON_WEST, kWpadOne); + mapped(SDL_GAMEPAD_BUTTON_NORTH, kWpadTwo); + mapped(SDL_GAMEPAD_BUTTON_START, kWpadPlus); + mapped(SDL_GAMEPAD_BUTTON_BACK, kWpadMinus); + mapped(SDL_GAMEPAD_BUTTON_GUIDE, kWpadHome); + mapped(SDL_GAMEPAD_BUTTON_DPAD_UP, kWpadUp); + mapped(SDL_GAMEPAD_BUTTON_DPAD_DOWN, kWpadDown); + mapped(SDL_GAMEPAD_BUTTON_DPAD_LEFT, kWpadLeft); + mapped(SDL_GAMEPAD_BUTTON_DPAD_RIGHT, kWpadRight); + } + } else { + const auto raw = [&](int index, uint32_t bit) { + if (SDL_GetJoystickButton(joystick, index)) sample.hold |= bit; + }; + raw(kRawA, kWpadA); + raw(kRawB, kWpadB); + raw(kRawOne, kWpadOne); + raw(kRawTwo, kWpadTwo); + raw(kRawPlus, kWpadPlus); + raw(kRawMinus, kWpadMinus); + raw(kRawHome, kWpadHome); + raw(kRawDpadUp, kWpadUp); + raw(kRawDpadDown, kWpadDown); + raw(kRawDpadLeft, kWpadLeft); + raw(kRawDpadRight, kWpadRight); + } float rawG[3] = {}; 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]; @@ -674,8 +921,17 @@ bool ReadKpadSample(uint32_t chan, KpadSample& sample) { // Home, the L/R clicks as shoulders, the D-pad as buttons 11-14 (never // through SDL's gamepad mapping, which expects a hat), ZL/ZR as the // trigger axes. - const auto cl = [&](int index, uint32_t bit) { - if (SDL_GetJoystickButton(joystick, index)) sample.clHold |= bit; + SDL_Gamepad* classic = gamepad; + if (kernel && !classicNode) { + classic = KernelSibling(gamepad, true); + if (classic == nullptr) { + classic = gamepad; + } + } + const auto cl = [&](SDL_GamepadButton button, uint32_t bit) { + if (kernel ? SDL_GetGamepadButton(classic, button) : SDL_GetJoystickButton(joystick, button)) { + sample.clHold |= bit; + } }; cl(SDL_GAMEPAD_BUTTON_EAST, kClA); cl(SDL_GAMEPAD_BUTTON_SOUTH, kClB); @@ -690,20 +946,29 @@ bool ReadKpadSample(uint32_t chan, KpadSample& sample) { cl(SDL_GAMEPAD_BUTTON_DPAD_DOWN, kClDown); cl(SDL_GAMEPAD_BUTTON_DPAD_LEFT, kClLeft); cl(SDL_GAMEPAD_BUTTON_DPAD_RIGHT, kClRight); - if (SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER) > 0) sample.clHold |= kClZL; - if (SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) > 0) sample.clHold |= kClZR; - const Sint16 lx = SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTX); - const Sint16 ly = SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTY); - const Sint16 rx = SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHTX); - const Sint16 ry = SDL_GetGamepadAxis(gamepad, 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 (SDL_GetGamepadAxis(classic, SDL_GAMEPAD_AXIS_LEFT_TRIGGER) > 0) sample.clHold |= kClZL; + if (SDL_GetGamepadAxis(classic, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) > 0) sample.clHold |= kClZR; + const Sint16 lx = SDL_GetGamepadAxis(classic, SDL_GAMEPAD_AXIS_LEFTX); + 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); + 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; @@ -718,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 { @@ -738,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); @@ -747,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. @@ -776,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));