diff --git a/.gitignore b/.gitignore index 0409068..7be3ef6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /generated/ +/runtime/native-index.json /Assets/ /assets/ /PulsarPacks/ diff --git a/Launcher/Build-Installer.ps1 b/Launcher/Build-Installer.ps1 index 17f2b17..e66c3ef 100644 --- a/Launcher/Build-Installer.ps1 +++ b/Launcher/Build-Installer.ps1 @@ -112,6 +112,20 @@ Assert-Directory $dependencySources 'Pinned offline dependency sources' # back to compiling aurora from source, which is what this package exists to # avoid - and a mismatched one would be an ABI hazard. $portableClangHash = Get-MkwFileSha256 (Join-Path $portableTools 'llvm-mingw\bin\clang-22.exe') +# The identity the runtime build compares against the package's runtime archive. +function Get-RuntimeSourceFingerprint([string]$RuntimeDirectory, [string]$DependenciesDirectory) { + $arguments = @("-DMKW_RUNTIME_DIR=$(ConvertTo-MkwCMakePath $RuntimeDirectory)") + $cppWinRt = Join-Path $DependenciesDirectory 'cppwinrt' + if (Test-Path -LiteralPath (Join-Path $cppWinRt 'winrt\base.h') -PathType Leaf) { + $arguments += "-DMKW_CPPWINRT_INCLUDE_DIR=$(ConvertTo-MkwCMakePath $cppWinRt)" + } + $arguments += @('-P', (ConvertTo-MkwCMakePath (Join-Path $RuntimeDirectory 'cmake\RuntimeFingerprint.cmake'))) + $output = @(& (Join-Path $portableTools 'CMake\bin\cmake.exe') @arguments) + if ($LASTEXITCODE -ne 0) { throw "Runtime fingerprinting failed with exit code $LASTEXITCODE." } + $line = $output | Where-Object { "$_" -match '^runtime_fingerprint=[0-9a-f]{64}$' } | Select-Object -First 1 + if (-not $line) { throw "RuntimeFingerprint.cmake printed no fingerprint: $($output -join ' ')" } + return "$line".Substring('runtime_fingerprint='.Length) +} function Test-NativePrebuiltCurrent([string]$PackageDirectory) { $provenancePath = Join-Path $PackageDirectory 'provenance.json' if (-not (Test-Path -LiteralPath $provenancePath -PathType Leaf)) { return $false } @@ -129,6 +143,9 @@ function Test-NativePrebuiltCurrent([string]$PackageDirectory) { # release ships runtime/third_party sources that first-party TUs compile # against, so archives harvested before a vendored change are stale. $recordedThirdParty = $provenance.PSObject.Properties['ThirdPartySourceFingerprint'] + # The precompiled runtime archive must be from these runtime sources; a package without one + # would make every installation compile the runtime itself. + $recordedRuntime = $provenance.PSObject.Properties['RuntimeSourceFingerprint'] return ($provenance.CompilerSha256 -eq $portableClangHash) -and ($provenance.FlagFingerprint -eq (Get-MkwNativeFlagFingerprint)) -and ($null -ne $recordedDawn) -and @@ -136,11 +153,13 @@ function Test-NativePrebuiltCurrent([string]$PackageDirectory) { ($null -ne $recordedAurora) -and ($recordedAurora.Value -eq (Get-MkwAuroraSourceFingerprint (Join-Path $repoRoot 'aurora-main'))) -and ($null -ne $recordedThirdParty) -and - ($recordedThirdParty.Value -eq (Get-MkwThirdPartySourceFingerprint (Join-Path $repoRoot 'runtime\third_party'))) + ($recordedThirdParty.Value -eq (Get-MkwThirdPartySourceFingerprint (Join-Path $repoRoot 'runtime\third_party'))) -and + ($null -ne $recordedRuntime) -and + ($recordedRuntime.Value -eq (Get-RuntimeSourceFingerprint (Join-Path $repoRoot 'runtime') $dependencySources)) } $nativePrebuiltPackage = Join-Path $dependencySources 'native_prebuilt' if (-not (Test-NativePrebuiltCurrent $nativePrebuiltPackage)) { - Write-Host '[0/6] Building the precompiled aurora and third-party package...' + Write-Host '[0/6] Building the precompiled aurora, third-party and runtime package...' & (Join-Path $PSScriptRoot 'Prepare-NativePrebuilt.ps1') ` -PortableToolsDirectory $portableTools -DependencySourceDirectory $dependencySources if ($LASTEXITCODE -ne 0) { throw 'Precompiled aurora package preparation failed.' } @@ -222,6 +241,21 @@ Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'NativeBuildFlags.ps1') -Destina [IO.Directory]::CreateDirectory((Join-Path $workspace 'Dependencies')) | Out-Null foreach ($name in $requiredDependencies) { Copy-Directory (Join-Path $dependencySources $name) (Join-Path $workspace "Dependencies\$name") } +# The translation identity covers the runtime's native registrations through this index (see +# ToolkitFingerprint), so it is extracted from exactly the staged runtime sources. +$nativeIndex = Join-Path $workspace 'runtime\native-index.json' +& $translator emit-native-index --project (Join-Path $workspace 'projects\mkwii\recomp.yml') --out $nativeIndex +if ($LASTEXITCODE -ne 0) { throw "Native index extraction failed with exit code $LASTEXITCODE." } +Assert-File $nativeIndex 'Runtime native index' +# A staged runtime that fingerprints differently from the shipped archive would make every +# installation silently compile the runtime from source. +$stagedRuntimeFingerprint = Get-RuntimeSourceFingerprint (Join-Path $workspace 'runtime') (Join-Path $workspace 'Dependencies') +$packagedRuntimeFingerprint = (Get-Content -LiteralPath (Join-Path $workspace 'Dependencies\native_prebuilt\provenance.json') -Raw | + ConvertFrom-Json).RuntimeSourceFingerprint +if ($stagedRuntimeFingerprint -ne $packagedRuntimeFingerprint) { + throw "The staged runtime ($stagedRuntimeFingerprint) does not match the precompiled runtime archive ($packagedRuntimeFingerprint)." +} + [IO.Directory]::CreateDirectory((Join-Path $payloadRoot 'host')) | Out-Null Copy-Item $setupHost (Join-Path $payloadRoot 'host\WiiCompiled-Setup.exe') diff --git a/Launcher/Prepare-NativePrebuilt.ps1 b/Launcher/Prepare-NativePrebuilt.ps1 index 2757565..4f2616f 100644 --- a/Launcher/Prepare-NativePrebuilt.ps1 +++ b/Launcher/Prepare-NativePrebuilt.ps1 @@ -1,7 +1,8 @@ # Builds the redistributable precompiled aurora + third-party package: aurora (~43% of local build # CPU time) and vendored Crypto++ are identical for every user, so this configures runtime/ like -# LocalBuild.ps1 (both dot-source NativeBuildFlags.ps1), builds just that closure, and harvests the -# archives plus a generated CMake description into launcher/artifacts/dependencies/native_prebuilt. +# LocalBuild.ps1 (both dot-source NativeBuildFlags.ps1), builds just that closure plus the +# game-independent runtime archive, and harvests the archives and a generated CMake description +# into launcher/artifacts/dependencies/native_prebuilt. [CmdletBinding(PositionalBinding = $false)] param( [string]$PortableToolsDirectory = 'launcher/artifacts/portable-tools', @@ -87,24 +88,29 @@ try { -AdditionalArguments @("-DMKW_NATIVE_PREBUILT_EXPORT_DIR=$(ConvertTo-MkwCMakePath $exportDirectory)") Invoke-Checked $cmake $configure 'Configuring the aurora/third-party staging build' -LogPrefix 'MKWNP' + $metaPath = Join-Path $exportDirectory 'meta.txt' + Assert-File $metaPath 'Native prebuilt export metadata' + $meta = @{} + foreach ($line in Get-Content -LiteralPath $metaPath) { + if ($line -match '^([a-z0-9_]+)=(.*)$') { $meta[$Matches[1]] = $Matches[2] } + } + # The game-independent runtime ships precompiled too; it is outside the probe closure. + if (-not $meta.ContainsKey('runtime_fingerprint')) { + throw 'The staging configure exported no runtime_fingerprint; the x86_64 package must carry the runtime archive.' + } + # mkw_np_probe links exactly what mkw_runtime_common and the public products # link, so building it compiles the whole redistributable closure and nothing # else - and a successful link proves the harvested archives are complete. - Invoke-Checked $cmake @('--build', $stage, '--target', 'mkw_np_probe', '--parallel', "$Parallel") ` - 'Building the aurora/third-party closure' -LogPrefix 'MKWNP' + Invoke-Checked $cmake @('--build', $stage, '--target', 'mkw_np_probe', '--target', 'mkw_runtime', + '--parallel', "$Parallel") 'Building the aurora/third-party closure and the runtime' -LogPrefix 'MKWNP' } finally { $env:PATH = $oldPath } # --------------------------------------------------------------------------- -# Read the description CMake wrote, plus the resolved command lines Ninja holds. +# Read the resolved command lines Ninja holds. # --------------------------------------------------------------------------- -$metaPath = Join-Path $exportDirectory 'meta.txt' -Assert-File $metaPath 'Native prebuilt export metadata' -$meta = @{} -foreach ($line in Get-Content -LiteralPath $metaPath) { - if ($line -match '^([a-z0-9_]+)=(.*)$') { $meta[$Matches[1]] = $Matches[2] } -} $buildNinja = Join-Path $stage 'build.ninja' Assert-File $buildNinja 'Generated build.ninja' @@ -234,6 +240,12 @@ foreach ($name in ($targets.Keys | Sort-Object)) { } if ($sharedImports.Count -eq 0) { throw 'No shared libraries were harvested; the SDL/zlib/libpng DLLs would be missing.' } if ($dawnLinkerFile) { $linkerFileToReference[$dawnLinkerFile] = 'dawn::webgpu_dawn' } +$runtimeTarget = $targets['mkw_runtime'] +if ($null -eq $runtimeTarget -or $runtimeTarget.Type -ne 'STATIC_LIBRARY' -or + -not $linkerFileToReference.ContainsKey($runtimeTarget.LinkerFile)) { + throw 'The runtime archive (mkw_runtime) was not built and harvested.' +} +$runtimeArchiveReference = $linkerFileToReference[$runtimeTarget.LinkerFile] # --------------------------------------------------------------------------- # Rewrite the compile/link interface into workspace-relative tokens. @@ -297,6 +309,9 @@ $packageLinkItems = @($linkItems | ForEach-Object { if (StartsWithPath $absolute $dependencyRoot) { return "`@DEPS`@/$(RelativeTo $absolute $dependencyRoot)" } throw "Link item is neither a system library nor a harvested archive: $item" }) +if ($packageLinkItems -contains $runtimeArchiveReference) { + throw 'The runtime archive leaked into the aurora link interface.' +} # fmt's `-include cstdlib` (and anything like it) is recorded from a C++ compile # line, so re-scope it to C++. The runtime also assembles generated .S blobs @@ -333,6 +348,11 @@ $generated.Add('') $generated.Add("set(MKW_NP_DAWN_CONFIG_DIR `"$(ConvertTo-Token $meta['dawn_config_dir'] 'dawn')`")") $generated.Add("set(MKW_NP_DAWN_VERSION `"$($meta['aurora_dawn_version'])`")") $generated.Add('') +$generated.Add('# The precompiled game-independent runtime; RuntimeLibrary.cmake decides whether it applies.') +$generated.Add("set(MKW_NP_RUNTIME_ARCHIVE `"$runtimeArchiveReference`")") +$generated.Add("set(MKW_NP_RUNTIME_FINGERPRINT `"$($meta['runtime_fingerprint'])`")") +$generated.Add("set(MKW_NP_RUNTIME_CPU_PROFILE `"$($meta['runtime_cpu_profile'])`")") +$generated.Add('') $generated.Add('# Shared third-party libraries keep imported SHARED targets so that') $generated.Add('# $ still copies them next to the game executable.') foreach ($import in $sharedImports) { @@ -393,6 +413,9 @@ $provenance = [ordered]@{ # release build and the local build can refuse a package aurora has outgrown. AuroraSourceFingerprint = $auroraSourceFingerprint ThirdPartySourceFingerprint = $thirdPartySourceFingerprint + # runtime/cmake/RuntimeFingerprint.cmake over the runtime sources the archive was built from. + RuntimeSourceFingerprint = $meta['runtime_fingerprint'] + RuntimeCpuProfile = $meta['runtime_cpu_profile'] Sdl3Target = $meta['aurora_sdl3_target'] HarvestedLibraryCount = @($linkerFileToReference.Keys).Count Contents = $contents diff --git a/Launcher/Prepare-NativePrebuilt.sh b/Launcher/Prepare-NativePrebuilt.sh index 7f31799..ebe7050 100755 --- a/Launcher/Prepare-NativePrebuilt.sh +++ b/Launcher/Prepare-NativePrebuilt.sh @@ -2,8 +2,9 @@ # Builds the redistributable precompiled aurora + third-party package for native Linux: aurora # (~43% of local build CPU time per Prepare-NativePrebuilt.ps1), Crypto++ and mbed TLS are identical # for every user under the pinned toolchain prepare-portable-tools.sh bundles, so this configures -# runtime/ against that toolchain, builds just that closure, and harvests the archives plus a -# generated CMake description into an output package - the Linux counterpart to +# runtime/ against that toolchain, builds just that closure (plus, on x86_64, the game-independent +# runtime archive), and harvests the archives plus a generated CMake description into an output +# package - the Linux counterpart to # Launcher/Prepare-NativePrebuilt.ps1, consumed by the same platform-agnostic # runtime/cmake/NativePrebuilt.cmake either script's package works with unmodified. # @@ -191,12 +192,23 @@ if [[ "${cxx_sha256}" != "${compiler_sha256}" ]]; then compiler_sha256="${compiler_sha256}:${cxx_sha256}" fi +# The game-independent runtime ships precompiled on x86_64 only; aarch64 compiles it locally. +runtime_fingerprint="" +if [[ "$arch" == x86_64 ]]; then + runtime_fingerprint=$("$cmake_bin" -DMKW_RUNTIME_DIR="$runtime_source" \ + -P "$runtime_source/cmake/RuntimeFingerprint.cmake" | sed -n 's/^runtime_fingerprint=\([0-9a-f]\{64\}\)$/\1/p') + [[ -n "$runtime_fingerprint" ]] || fail "RuntimeFingerprint.cmake printed no runtime fingerprint" +fi + if [[ "$print_fingerprint_only" -eq 1 ]]; then printf 'compiler_sha256=%s\n' "$compiler_sha256" printf 'flag_fingerprint=%s\n' "$flag_fingerprint" printf 'aurora_fingerprint=%s\n' "$aurora_fingerprint" printf 'third_party_fingerprint=%s\n' "$third_party_fingerprint" printf 'mbedtls_fingerprint=%s\n' "$mbedtls_fingerprint" + if [[ -n "$runtime_fingerprint" ]]; then + printf 'runtime_fingerprint=%s\n' "$runtime_fingerprint" + fi exit 0 fi @@ -220,20 +232,30 @@ echo "Prepare-NativePrebuilt.sh: configuring the aurora/third-party staging buil -DCMAKE_EXE_LINKER_FLAGS="-fuse-ld=lld" \ -DMKW_NATIVE_PREBUILT_EXPORT_DIR="$export_dir" -# mkw_np_probe links exactly what mkw_runtime_common and the public products link, so building it -# compiles the whole redistributable closure and nothing else - and a successful link proves the -# harvested archives are complete. -echo "Prepare-NativePrebuilt.sh: building the aurora/third-party closure..." -"$cmake_bin" --build "$stage_dir" --target mkw_np_probe --parallel "$parallel" - -# --------------------------------------------------------------------------- -# Read the description CMake wrote, plus the resolved command lines Ninja holds. -# --------------------------------------------------------------------------- - meta_path="$export_dir/meta.txt" assert_file "$meta_path" "Native prebuilt export metadata" get_meta() { awk -F= -v k="$1" '$0 ~ "^" k "=" { sub(/^[^=]*=/, ""); print; exit }' "$meta_path"; } +# mkw_np_probe links exactly what mkw_runtime_common and the public products link, so building it +# compiles the whole redistributable closure and nothing else - and a successful link proves the +# harvested archives are complete. The runtime archive is outside that closure and built alongside. +build_targets=(--target mkw_np_probe) +meta_runtime_fingerprint=$(get_meta runtime_fingerprint) +if [[ -n "$meta_runtime_fingerprint" ]]; then + if [[ -n "$runtime_fingerprint" && "$meta_runtime_fingerprint" != "$runtime_fingerprint" ]]; then + fail "The configure's runtime fingerprint ($meta_runtime_fingerprint) differs from RuntimeFingerprint.cmake's ($runtime_fingerprint)" + fi + build_targets+=(--target mkw_runtime) +elif [[ -n "$runtime_fingerprint" ]]; then + fail "The staging configure exported no runtime_fingerprint; the $arch package must carry the runtime archive" +fi +echo "Prepare-NativePrebuilt.sh: building the aurora/third-party closure..." +"$cmake_bin" --build "$stage_dir" "${build_targets[@]}" --parallel "$parallel" + +# --------------------------------------------------------------------------- +# Read the resolved command lines Ninja holds. +# --------------------------------------------------------------------------- + build_ninja="$stage_dir/build.ninja" assert_file "$build_ninja" "Generated build.ninja" @@ -397,6 +419,13 @@ while IFS='|' read -r name type file linkerfile; do linker_file_to_reference["$linkerfile"]="@PKG@/$relative_linker" fi done < "$targets_txt" +runtime_archive_ref="" +if [[ -n "$meta_runtime_fingerprint" ]]; then + runtime_linker=$(awk -F'|' '$1 == "mkw_runtime" && $2 == "STATIC_LIBRARY" { print $4; exit }' "$targets_txt") + [[ -n "$runtime_linker" && -f "$runtime_linker" ]] || fail "The runtime archive (mkw_runtime) was not built" + runtime_archive_ref=${linker_file_to_reference["$(normalize "$runtime_linker")"]:-} + [[ "$runtime_archive_ref" == "@PKG@/lib/"* ]] || fail "The runtime archive (mkw_runtime) was not harvested" +fi mbedtls_refs=() mbedtls_txt="$export_dir/mbedtls.txt" assert_file "$mbedtls_txt" "Mbed TLS export targets list" @@ -498,6 +527,11 @@ for item in "${link_items[@]}"; do esac fail "Link item is neither a system library nor a harvested archive: $item" done +if [[ -n "$runtime_archive_ref" ]]; then + for item in "${package_link_items[@]}"; do + [[ "$item" != "$runtime_archive_ref" ]] || fail "The runtime archive leaked into the aurora link interface" + done +fi # --------------------------------------------------------------------------- # Emit the consumer-facing CMake description. @@ -562,6 +596,12 @@ generated_cmake="$output_dir/native_prebuilt.cmake" format_cmake_block MKW_NP_MBEDTLS_LIBRARIES "${mbedtls_refs[@]}" echo 'set(MKW_NP_MBEDTLS_INCLUDE_DIR "@PKG@/include/mbedtls")' printf 'set(MKW_NP_MBEDTLS_FINGERPRINT "%s")\n' "$mbedtls_fingerprint" + if [[ -n "$runtime_archive_ref" ]]; then + # The precompiled game-independent runtime; RuntimeLibrary.cmake decides whether it applies. + printf 'set(MKW_NP_RUNTIME_ARCHIVE "%s")\n' "$runtime_archive_ref" + printf 'set(MKW_NP_RUNTIME_FINGERPRINT "%s")\n' "$meta_runtime_fingerprint" + printf 'set(MKW_NP_RUNTIME_CPU_PROFILE "%s")\n' "$(get_meta runtime_cpu_profile)" + fi format_cmake_block MKW_NP_AURORA_TARGETS "aurora::gx" "aurora::pad" "aurora::si" "aurora::vi" "aurora::mtx" echo "" printf 'set(MKW_NP_DAWN_CONFIG_DIR "%s")\n' "$dawn_config_token" @@ -597,12 +637,13 @@ harvested_count=${#linker_file_to_reference[@]} python3 - "$output_dir" "$compiler_sha256" "$compiler_version" "$flag_fingerprint" \ "$dawn_version" "$dawn_runtime_sha256" "$aurora_fingerprint" "$third_party_fingerprint" \ - "$sdl3_target" "$harvested_count" "$mbedtls_fingerprint" <<'PY' + "$sdl3_target" "$harvested_count" "$mbedtls_fingerprint" \ + "${runtime_archive_ref:+$meta_runtime_fingerprint}" "${runtime_archive_ref:+$(get_meta runtime_cpu_profile)}" <<'PY' import hashlib, json, os, sys, datetime (output_dir, compiler_sha256, compiler_version, flag_fingerprint, dawn_version, dawn_runtime_sha256, aurora_fingerprint, third_party_fingerprint, sdl3_target, - harvested_count, mbedtls_fingerprint) = sys.argv[1:] + harvested_count, mbedtls_fingerprint, runtime_fingerprint, runtime_cpu_profile) = sys.argv[1:] contents = [] for root, dirs, files in os.walk(output_dir): @@ -644,6 +685,9 @@ provenance = { "HarvestedLibraryCount": int(harvested_count), "Contents": contents, } +if runtime_fingerprint: + provenance["RuntimeSourceFingerprint"] = runtime_fingerprint + provenance["RuntimeCpuProfile"] = runtime_cpu_profile with open(os.path.join(output_dir, "provenance.json"), "w", encoding="utf-8") as fh: json.dump(provenance, fh, indent=2) fh.write("\n") diff --git a/Launcher/Test-PinnedFacts.ps1 b/Launcher/Test-PinnedFacts.ps1 index d7b14bd..5280993 100644 --- a/Launcher/Test-PinnedFacts.ps1 +++ b/Launcher/Test-PinnedFacts.ps1 @@ -127,7 +127,7 @@ foreach ($mandatory in ([regex]::Matches($parameterBlock, } # --- The build steps the installer's progress bar acts on. -$installProgress = Read-SourceFile (Join-Path $setup 'InstallProgress.cs') 'InstallProgress.cs' +$installProgress = Read-SourceFile (Join-Path $common 'InstallProgress.cs') 'InstallProgress.cs' $knownSteps = Get-QuotedList $installProgress '(?s)class BuildStepIds\s*\{(.*?)\n\}' ` 'const string \w+ = "([^"]+)";' 'BuildStepIds' $emittedSteps = @([regex]::Matches($localBuild, "(?:-StepId|Write-MkwBuildStep)\s+'([^']+)'") | diff --git a/Launcher/WiiCompiled.Setup.Windows/InstallProgress.cs b/Launcher/WiiCompiled.Setup.Common/InstallProgress.cs similarity index 75% rename from Launcher/WiiCompiled.Setup.Windows/InstallProgress.cs rename to Launcher/WiiCompiled.Setup.Common/InstallProgress.cs index 2bacbce..1650d98 100644 --- a/Launcher/WiiCompiled.Setup.Windows/InstallProgress.cs +++ b/Launcher/WiiCompiled.Setup.Common/InstallProgress.cs @@ -1,35 +1,19 @@ using System.Text.Json; -namespace WiiCompiled.Setup.Windows; - -/// -/// Stable stage identifiers reported by --progress-json. These are part of the public -/// WheelWizard contract (docs/WHEELWIZARD_CONTRACT.md): the strings may gain new members, but an -/// existing identifier never changes meaning. -/// -internal static class InstallStages -{ - public const string Validate = "validate"; - public const string ExtractToolkit = "extract-toolkit"; - public const string ExtractDisc = "extract-disc"; - public const string PrepareRetro = "prepare-retro"; - public const string BuildBase = "build-base"; - public const string BuildRetro = "build-retro"; - public const string Publish = "publish"; -} +namespace WiiCompiled.Setup.Common; /// /// Where an installation reports what it is doing. Progress is coarse and monotonic; raw translator /// and compiler output is a diagnostic, never progress, because it is unbounded and machine-hostile. /// -internal interface IInstallReporter +public interface IInstallReporter { void Progress(string stage, string message, int percent); void Diagnostic(string line); } /// Reports to a caller-supplied sink; used by console and maintenance commands. -internal sealed class DelegatingInstallReporter : IInstallReporter +public sealed class DelegatingInstallReporter : IInstallReporter { private readonly Action _sink; @@ -43,15 +27,20 @@ internal sealed class DelegatingInstallReporter : IInstallReporter /// The --progress-json protocol: one JSON object per line on stdout, nothing else on stdout, /// diagnostics on stderr. The terminal result line is written exactly once. /// -internal sealed class NdjsonInstallReporter : IInstallReporter +public sealed class NdjsonInstallReporter : IInstallReporter { private static readonly JsonSerializerOptions Options = new() { WriteIndented = false }; + private readonly string _version; private readonly Action? _log; private readonly object _gate = new(); private int _lastPercent; private bool _finished; - public NdjsonInstallReporter(Action? log = null) => _log = log; + public NdjsonInstallReporter(string version, Action? log = null) + { + _version = version; + _log = log; + } public void Progress(string stage, string message, int percent) { @@ -78,7 +67,7 @@ internal sealed class NdjsonInstallReporter : IInstallReporter { if (_finished) return; _finished = true; - WriteLine(new { type = "result", success = true, version = ProductInfo.Version, installDir = installDirectory }); + WriteLine(new { type = "result", success = true, version = _version, installDir = installDirectory }); } } @@ -106,8 +95,8 @@ internal sealed class NdjsonInstallReporter : IInstallReporter } } -/// Plain-text console reporting for a silent install without --progress-json. -internal sealed class ConsoleInstallReporter : IInstallReporter +/// Plain-text console reporting for a run without --progress-json. +public sealed class ConsoleInstallReporter : IInstallReporter { private readonly Action? _log; @@ -126,10 +115,10 @@ internal sealed class ConsoleInstallReporter : IInstallReporter } } -/// Build step identifiers from the bundled script's MKWCBUILD:STEP:<id> lines. The +/// Build step identifiers from the build scripts' MKWCBUILD:STEP:<id> lines. The /// id is the contract; LocalBuild.ps1 emits these exactly and Test-PinnedFacts.ps1 fails the release if /// the two sets disagree, so progress never depends on matching reworded English prose. -internal static class BuildStepIds +public static class BuildStepIds { public const string ReuseBaseTranslation = "reuse-base-translation"; public const string RetranslateBase = "retranslate-base"; @@ -143,16 +132,16 @@ internal static class BuildStepIds } /// -/// Maps one local translate-and-compile run onto a slice of the overall percentage. The bundled -/// build script announces every step it starts with an MKWCBUILD: prefix, so the slice can -/// advance on real events instead of on a timer. +/// Maps one local translate-and-compile run onto a slice of the overall percentage. The build +/// scripts announce every step they start with an MKWCBUILD: prefix, so the slice can advance +/// on real events instead of on a timer. /// -internal sealed class BuildProgressWindow +public sealed class BuildProgressWindow { private const string Marker = "MKWCBUILD:"; private const string StepMarker = "STEP:"; - /// The fraction the compile step reaches; beyond it, compiler output is a heartbeat. + /// The fraction the compile step reaches; beyond it, Ninja's counter drives progress. private const double CompileFraction = 0.58; private static readonly (string Id, double Fraction, string Message)[] Steps = @@ -164,7 +153,7 @@ internal sealed class BuildProgressWindow (BuildStepIds.TranslateMod, 0.38, "Translating the Retro Rewind Code.pul"), (BuildStepIds.GenerateDataInit, 0.44, "Generating game data initialization"), (BuildStepIds.EmitBuildShards, 0.48, "Preparing the native build"), - (BuildStepIds.ConfigureNative, 0.52, "Configuring the bundled compiler"), + (BuildStepIds.ConfigureNative, 0.52, "Configuring the compiler"), (BuildStepIds.Compile, CompileFraction, "Compiling the game. This is the longest step"), ]; @@ -209,19 +198,30 @@ internal sealed class BuildProgressWindow } } - // Anything else - an unknown step identifier from a newer script, a plain MKWCBUILD note, or - // raw tool output - stays a diagnostic and only feeds the heartbeat below. + // Anything else - an unknown step identifier, a plain MKWCBUILD note, or raw tool output - + // stays a diagnostic. _reporter.Diagnostic(line); - // Compilation announces itself once and then emits thousands of compiler lines. Treat that - // output as a heartbeat so the slice keeps creeping forward, but only publish a progress - // line when the rounded percentage actually changes. - if (_fraction >= CompileFraction) + // Ninja prefixes every edge with [done/total]; how many edges there are depends on how much + // of the build is already up to date, so follow the counter rather than counting lines. + if (_fraction >= CompileFraction && TryParseNinjaProgress(line, out var done, out var total)) { - _fraction = Math.Min(0.97, _fraction + 0.0015); + _fraction = Math.Max(_fraction, CompileFraction + (0.97 - CompileFraction) * done / total); Emit(); } } + private static bool TryParseNinjaProgress(string line, out int done, out int total) + { + done = total = 0; + if (!line.StartsWith('[')) return false; + var close = line.IndexOf(']'); + var slash = line.IndexOf('/'); + return close > 0 && slash > 0 && slash < close + && int.TryParse(line.AsSpan(1, slash - 1), out done) + && int.TryParse(line.AsSpan(slash + 1, close - slash - 1), out total) + && total > 0 && done <= total; + } + private void Emit() { var percent = Interpolate(_fraction); diff --git a/Launcher/WiiCompiled.Setup.Linux/DiscTool.cs b/Launcher/WiiCompiled.Setup.Linux/DiscTool.cs index fad7213..fe74bdd 100644 --- a/Launcher/WiiCompiled.Setup.Linux/DiscTool.cs +++ b/Launcher/WiiCompiled.Setup.Linux/DiscTool.cs @@ -15,6 +15,12 @@ internal static class DiscTool string isoPath, ProjectManifest manifest, string assetsDirectory, string workspace, string? nodToolBin, IInstallReporter reporter, CancellationToken cancellationToken) { + if (IsAlreadyExtracted(manifest, assetsDirectory)) + { + reporter.Progress(InstallStages.ExtractDisc, "Reusing the verified extracted disc", 6); + return; + } + var nodTool = nodToolBin ?? await NodToolProvider.ResolveAsync(workspace, cancellationToken); // `nodtool info` only decodes the disc/partition headers (milliseconds); `nodtool extract` @@ -35,6 +41,7 @@ internal static class DiscTool // time, so it has to survive past this install (see Program.cs, which points dvd_root here). reporter.Progress(InstallStages.ExtractDisc, "Extracting the disc image", 4); var dataDir = Path.Combine(assetsDirectory, "DATA"); + File.Delete(Path.Combine(assetsDirectory, ExtractionMarker)); if (Directory.Exists(dataDir)) Directory.Delete(dataDir, recursive: true); await RunExtractAsync(nodTool, isoPath, dataDir, cancellationToken); @@ -61,6 +68,7 @@ internal static class DiscTool Directory.CreateDirectory(assetsDirectory); File.Copy(dolPath, Path.Combine(assetsDirectory, "main.dol"), overwrite: true); File.Copy(relPath, Path.Combine(assetsDirectory, "StaticR.rel"), overwrite: true); + File.WriteAllText(Path.Combine(assetsDirectory, ExtractionMarker), dolSha); reporter.Progress(InstallStages.ExtractDisc, "Disc validated and extracted", 6); } @@ -86,6 +94,22 @@ internal static class DiscTool return stdout; } + private const string ExtractionMarker = ".disc-extracted"; + + // Only one disc revision is accepted, so a completed, verified extraction is identical to a fresh one. + private static bool IsAlreadyExtracted(ProjectManifest manifest, string assetsDirectory) + { + var marker = Path.Combine(assetsDirectory, ExtractionMarker); + var dol = Path.Combine(assetsDirectory, "main.dol"); + var rel = Path.Combine(assetsDirectory, "StaticR.rel"); + if (!File.Exists(marker) || !File.Exists(dol) || !File.Exists(rel) || + !Directory.Exists(Path.Combine(assetsDirectory, "DATA", "files"))) + return false; + return string.Equals(File.ReadAllText(marker).Trim(), manifest.DolSha256, StringComparison.Ordinal) && + string.Equals(Sha256Of(dol), manifest.DolSha256, StringComparison.Ordinal) && + string.Equals(Sha256Of(rel), manifest.RelSha256, StringComparison.Ordinal); + } + private static string Sha256Of(string path) { using var stream = File.OpenRead(path); diff --git a/Launcher/WiiCompiled.Setup.Linux/InstallProgress.cs b/Launcher/WiiCompiled.Setup.Linux/InstallProgress.cs deleted file mode 100644 index 0517dc5..0000000 --- a/Launcher/WiiCompiled.Setup.Linux/InstallProgress.cs +++ /dev/null @@ -1,222 +0,0 @@ -using System.Text.Json; - -namespace WiiCompiled.Setup.Linux; - -// Ported near-verbatim from Launcher/WiiCompiled.Setup/InstallProgress.cs: this whole file is -// platform-neutral (System.Text.Json + Console only), so the NDJSON --progress-json wire protocol -// stays byte-for-byte the same shape a future GUI already speaks on Windows. - -/// -/// Stable stage identifiers reported by --progress-json. Kept intentionally small for this -/// lean Linux installer (no toolkit-extraction/publish-transaction stages, since there is no -/// bundled toolkit or staged workspace copy here - see the plan's "operate on a git checkout" -/// scoping decision). -/// -internal static class InstallStages -{ - public const string Validate = "validate"; - public const string ExtractDisc = "extract-disc"; - public const string Build = "build"; - public const string Shortcuts = "shortcuts"; -} - -/// -/// Where an installation reports what it is doing. Progress is coarse and monotonic; raw translator -/// and compiler output is a diagnostic, never progress, because it is unbounded and machine-hostile. -/// -internal interface IInstallReporter -{ - void Progress(string stage, string message, int percent); - void Diagnostic(string line); -} - -/// -/// The --progress-json protocol: one JSON object per line on stdout, nothing else on stdout, -/// diagnostics on stderr. The terminal result line is written exactly once. -/// -internal sealed class NdjsonInstallReporter : IInstallReporter -{ - private static readonly JsonSerializerOptions Options = new() { WriteIndented = false }; - private readonly object _gate = new(); - private int _lastPercent; - private bool _finished; - - public void Progress(string stage, string message, int percent) - { - lock (_gate) - { - if (_finished) return; - // Percentages are clamped monotonic: a caller's progress bar must never walk backwards - // because a later stage happened to estimate a lower number. - _lastPercent = Math.Clamp(Math.Max(percent, _lastPercent), 0, 99); - WriteLine(new { type = "progress", stage, message, percent = _lastPercent }); - } - } - - public void Diagnostic(string line) => Console.Error.WriteLine(line); - - public void Success(string installDirectory) - { - lock (_gate) - { - if (_finished) return; - _finished = true; - WriteLine(new { type = "result", success = true, version = ProductInfo.Version, installDir = installDirectory }); - } - } - - public void Failure(string error) - { - lock (_gate) - { - if (_finished) return; - _finished = true; - WriteLine(new { type = "result", success = false, error }); - } - } - - /// - /// The terminal result line is the caller's only completion signal, so no exit path may skip it. - /// Callers invoke this from a finally block; it is a no-op once a result was already written. - /// - public void EnsureFinished(string errorIfUnfinished) => Failure(errorIfUnfinished); - - private static void WriteLine(object value) - { - Console.Out.WriteLine(JsonSerializer.Serialize(value, Options)); - Console.Out.Flush(); - } -} - -/// Plain-text console reporting for a run without --progress-json. -internal sealed class ConsoleInstallReporter : IInstallReporter -{ - public void Progress(string stage, string message, int percent) => - Console.Out.WriteLine($"[{percent,3}%] {message}"); - - public void Diagnostic(string line) => Console.Out.WriteLine(line); -} - -/// -/// Build step identifiers from local-build.sh's MKWCBUILD:STEP:<id> lines - the id is -/// the contract, matched against Launcher/local-build.sh's log_step() call sites. -/// -internal static class BuildStepIds -{ - public const string BuildTranslator = "build-translator"; - public const string ReuseBaseTranslation = "reuse-base-translation"; - public const string RetranslateBase = "retranslate-base"; - public const string TranslateBase = "translate-base"; - public const string EmitBaseManifest = "emit-base-manifest"; - public const string TranslateMod = "translate-mod"; - public const string GenerateDataInit = "generate-data-init"; - public const string EmitBuildShards = "emit-build-shards"; - public const string ConfigureNative = "configure-native"; - public const string Compile = "compile"; -} - -/// -/// Maps one local-build.sh run onto a slice of the overall percentage. local-build.sh announces -/// every step it starts with an MKWCBUILD: prefix, so the slice can advance on real events -/// instead of on a timer. -/// -internal sealed class BuildProgressWindow -{ - private const string Marker = "MKWCBUILD:"; - private const string StepMarker = "STEP:"; - - /// The fraction the compile step reaches; beyond it, compiler output is a heartbeat. - private const double CompileFraction = 0.58; - - private static readonly (string Id, double Fraction, string Message)[] Steps = - [ - (BuildStepIds.BuildTranslator, 0.04, "Building the translator"), - (BuildStepIds.ReuseBaseTranslation, 0.30, "Reusing the completed base translation"), - (BuildStepIds.RetranslateBase, 0.08, "The base translation is stale; retranslating it"), - (BuildStepIds.TranslateBase, 0.10, "Translating Mario Kart Wii"), - (BuildStepIds.EmitBaseManifest, 0.34, "Creating the translation manifest"), - (BuildStepIds.TranslateMod, 0.38, "Translating the Retro Rewind Code.pul"), - (BuildStepIds.GenerateDataInit, 0.44, "Generating game data initialization"), - (BuildStepIds.EmitBuildShards, 0.48, "Preparing the native build"), - (BuildStepIds.ConfigureNative, 0.52, "Configuring the compiler"), - (BuildStepIds.Compile, CompileFraction, "Compiling the game. This is the longest step"), - ]; - - private readonly IInstallReporter _reporter; - private readonly string _stage; - private readonly int _start; - private readonly int _end; - private double _fraction; - private string _message = "Preparing the local build"; - private int _reportedPercent = -1; - - public BuildProgressWindow(IInstallReporter reporter, string stage, int start, int end) - { - _reporter = reporter; - _stage = stage; - _start = start; - _end = end; - } - - public void Observe(string line) - { - var index = line.IndexOf(Marker, StringComparison.Ordinal); - if (index >= 0) - { - var text = line[(index + Marker.Length)..].Trim(); - if (text.StartsWith(StepMarker, StringComparison.Ordinal)) - { - var identifier = text[StepMarker.Length..]; - var end = identifier.IndexOf(' '); - if (end >= 0) identifier = identifier[..end]; - foreach (var (id, fraction, message) in Steps) - { - if (!id.Equals(identifier, StringComparison.Ordinal)) continue; - if (fraction > _fraction) - { - _fraction = fraction; - _message = message; - Emit(); - } - return; - } - } - } - - // Anything else - a plain MKWCBUILD note, or raw tool output - stays a diagnostic and only - // feeds the compile progress below. - _reporter.Diagnostic(line); - // Compilation announces itself once and then ninja prefixes every edge with [done/total]. - // Follow that counter rather than counting lines: how many lines a build prints depends on - // whether aurora and its dependencies are prebuilt (AppImage) or compiled here (macOS), so a - // fixed per-line step reached the top long before the game itself compiled. - if (_fraction >= CompileFraction && TryParseNinjaProgress(line, out var done, out var total)) - { - _fraction = Math.Max(_fraction, CompileFraction + (0.97 - CompileFraction) * done / total); - Emit(); - } - } - - private static bool TryParseNinjaProgress(string line, out int done, out int total) - { - done = total = 0; - if (!line.StartsWith('[')) return false; - var close = line.IndexOf(']'); - var slash = line.IndexOf('/'); - return close > 0 && slash > 0 && slash < close - && int.TryParse(line.AsSpan(1, slash - 1), out done) - && int.TryParse(line.AsSpan(slash + 1, close - slash - 1), out total) - && total > 0 && done <= total; - } - - private void Emit() - { - var percent = Interpolate(_fraction); - if (percent == _reportedPercent) return; - _reportedPercent = percent; - _reporter.Progress(_stage, _message, percent); - } - - private int Interpolate(double fraction) => - (int)Math.Round(_start + (_end - _start) * Math.Clamp(fraction, 0, 1)); -} diff --git a/Launcher/WiiCompiled.Setup.Linux/InstallStages.cs b/Launcher/WiiCompiled.Setup.Linux/InstallStages.cs new file mode 100644 index 0000000..a9b1d6c --- /dev/null +++ b/Launcher/WiiCompiled.Setup.Linux/InstallStages.cs @@ -0,0 +1,10 @@ +namespace WiiCompiled.Setup.Linux; + +/// Stable stage identifiers reported by --progress-json. +internal static class InstallStages +{ + public const string Validate = "validate"; + public const string ExtractDisc = "extract-disc"; + public const string Build = "build"; + public const string Shortcuts = "shortcuts"; +} diff --git a/Launcher/WiiCompiled.Setup.Linux/Program.cs b/Launcher/WiiCompiled.Setup.Linux/Program.cs index f3af186..2ce7b3a 100644 --- a/Launcher/WiiCompiled.Setup.Linux/Program.cs +++ b/Launcher/WiiCompiled.Setup.Linux/Program.cs @@ -31,7 +31,7 @@ internal static class Program var (command, flags) = ParseArgs(args); if (command is null) { PrintUsage(); return 1; } var progressJson = flags.ContainsKey("progress-json"); - IInstallReporter reporter = progressJson ? new NdjsonInstallReporter() : new ConsoleInstallReporter(); + IInstallReporter reporter = progressJson ? new NdjsonInstallReporter(ProductInfo.Version) : new ConsoleInstallReporter(); try { diff --git a/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj b/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj index 2737c4c..bb01a11 100644 --- a/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj +++ b/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj @@ -15,5 +15,6 @@ + diff --git a/Launcher/WiiCompiled.Setup.Windows/ConsoleCommands.cs b/Launcher/WiiCompiled.Setup.Windows/ConsoleCommands.cs index fbe9a16..acabbef 100644 --- a/Launcher/WiiCompiled.Setup.Windows/ConsoleCommands.cs +++ b/Launcher/WiiCompiled.Setup.Windows/ConsoleCommands.cs @@ -57,7 +57,7 @@ internal static class ConsoleCommands public static int VerifyInputs(CommandLine command) { - var reporter = command.ProgressJson ? new NdjsonInstallReporter() : null; + var reporter = command.ProgressJson ? new NdjsonInstallReporter(ProductInfo.Version) : null; var temp = Path.Combine(Path.GetTempPath(), "mkwc-verify-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(temp); try @@ -107,7 +107,7 @@ internal static class ConsoleCommands } File.WriteAllText(logPath, $"{ProductInfo.Name} setup {ProductInfo.Version}{Environment.NewLine}"); - var ndjson = command.ProgressJson ? new NdjsonInstallReporter(Log) : null; + var ndjson = command.ProgressJson ? new NdjsonInstallReporter(ProductInfo.Version, Log) : null; IInstallReporter reporter = ndjson ?? (IInstallReporter)new ConsoleInstallReporter(Log); var installDirectory = Path.GetFullPath(command.InstallDirectory!); try @@ -145,7 +145,7 @@ internal static class ConsoleCommands public static int CheckProducts(CommandLine command, CancellationToken cancellationToken = default) { - var reporter = command.ProgressJson ? new NdjsonInstallReporter() : null; + var reporter = command.ProgressJson ? new NdjsonInstallReporter(ProductInfo.Version) : null; var installation = new Installation(string.IsNullOrWhiteSpace(command.InstallDirectory) ? AppContext.BaseDirectory : Path.GetFullPath(command.InstallDirectory)); @@ -198,7 +198,7 @@ internal static class ConsoleCommands } } - var ndjson = command.ProgressJson ? new NdjsonInstallReporter(Log) : null; + var ndjson = command.ProgressJson ? new NdjsonInstallReporter(ProductInfo.Version, Log) : null; IInstallReporter reporter = ndjson is null ? new ConsoleInstallReporter(Log) : ndjson; var installDirectory = Path.GetFullPath(command.InstallDirectory!); var installation = new Installation(installDirectory); diff --git a/Launcher/WiiCompiled.Setup.Windows/InstallStages.cs b/Launcher/WiiCompiled.Setup.Windows/InstallStages.cs new file mode 100644 index 0000000..862295a --- /dev/null +++ b/Launcher/WiiCompiled.Setup.Windows/InstallStages.cs @@ -0,0 +1,17 @@ +namespace WiiCompiled.Setup.Windows; + +/// +/// Stable stage identifiers reported by --progress-json. These are part of the public +/// WheelWizard contract (docs/WHEELWIZARD_CONTRACT.md): the strings may gain new members, but an +/// existing identifier never changes meaning. +/// +internal static class InstallStages +{ + public const string Validate = "validate"; + public const string ExtractToolkit = "extract-toolkit"; + public const string ExtractDisc = "extract-disc"; + public const string PrepareRetro = "prepare-retro"; + public const string BuildBase = "build-base"; + public const string BuildRetro = "build-retro"; + public const string Publish = "publish"; +} diff --git a/Launcher/WiiCompiled.Setup.Windows/Program.cs b/Launcher/WiiCompiled.Setup.Windows/Program.cs index 2f14065..97e5125 100644 --- a/Launcher/WiiCompiled.Setup.Windows/Program.cs +++ b/Launcher/WiiCompiled.Setup.Windows/Program.cs @@ -31,7 +31,7 @@ internal static class Program { // A command line was supplied, so a caller is driving this process. Rejecting the // command with a modal dialog would hang that caller forever. - if (progressJson) new NdjsonInstallReporter().Failure(ex.Message); + if (progressJson) new NdjsonInstallReporter(ProductInfo.Version).Failure(ex.Message); else Console.Error.WriteLine(ex.Message); return 1; } @@ -91,7 +91,7 @@ internal static class Program // errors raised outside it. if (progressJson) { - new NdjsonInstallReporter().Failure(ex.Message); + new NdjsonInstallReporter(ProductInfo.Version).Failure(ex.Message); Console.Error.WriteLine(ex); } else diff --git a/Launcher/WiiCompiled.Setup.Windows/SelfTests.cs b/Launcher/WiiCompiled.Setup.Windows/SelfTests.cs index 21956a7..f40442d 100644 --- a/Launcher/WiiCompiled.Setup.Windows/SelfTests.cs +++ b/Launcher/WiiCompiled.Setup.Windows/SelfTests.cs @@ -1073,10 +1073,10 @@ internal static class SelfTests if (topologyChangedRuntimeAssets == changedRuntimeAssets) throw new Exception("An empty copied runtime-asset directory was omitted from its identity."); - // Cache-reuse subsets: translator or runtime/src changes (the translator regex-scans it - // for native overrides and effect contracts) invalidate translation but not toolchain, - // since CMake/Ninja rebuild exactly the affected objects; a compiler change invalidates - // only toolchain; a native_prebuilt provenance rewrite invalidates neither. + // Cache-reuse subsets: translator or native registration changes invalidate translation + // but not toolchain, since CMake/Ninja rebuild exactly the affected objects; a compiler + // change invalidates only toolchain; a native_prebuilt provenance rewrite invalidates + // neither. Without a current native index, runtime/src itself decides translation. var components = ToolkitFingerprint.ComputeComponents(root); if (components.Compile != afterPatch) throw new Exception("ComputeComponents disagrees with the compile identity."); @@ -1087,15 +1087,36 @@ internal static class SelfTests throw new Exception("A new translator did not change the translation identity."); if (afterNewTranslator.NativeToolchain != components.NativeToolchain) throw new Exception("A new translator needlessly changed the native toolchain identity."); - File.WriteAllText(Path.Combine(root, "BuildWorkspace", "runtime", "src", "main.cpp"), - "int main3();"); + var runtimeMain = Path.Combine(root, "BuildWorkspace", "runtime", "src", "main.cpp"); + File.WriteAllText(runtimeMain, "int main3();"); + var afterUnindexedRuntime = ToolkitFingerprint.ComputeComponents(root); + if (afterUnindexedRuntime.Translation == afterNewTranslator.Translation) + throw new Exception("A runtime source change without a native index did not change the translation identity."); + + File.WriteAllText(runtimeMain, "int main();"); + if (WriteFakeNativeIndex(root, "\"a\"") != + "50b9779591f4b205751361c8899accd362f96ea5dfd32febed1bedca534c523a") + throw new Exception("The native index source digest no longer matches the translator's."); + var indexed = ToolkitFingerprint.ComputeComponents(root); + File.WriteAllText(runtimeMain, "int main3();"); + WriteFakeNativeIndex(root, "\"a\""); var afterNewRuntime = ToolkitFingerprint.ComputeComponents(root); - if (afterNewRuntime.Translation == afterNewTranslator.Translation) - throw new Exception("A runtime source change did not change the translation identity."); - if (afterNewRuntime.NativeToolchain != afterNewTranslator.NativeToolchain) + if (afterNewRuntime.Translation != indexed.Translation) + throw new Exception("A runtime source change with unchanged registrations changed the translation identity."); + if (afterNewRuntime.NativeToolchain != indexed.NativeToolchain) throw new Exception("A runtime source change needlessly changed the native toolchain identity."); - if (afterNewRuntime.Compile == afterNewTranslator.Compile) + if (afterNewRuntime.Compile == indexed.Compile) throw new Exception("A runtime source change did not change the compile identity."); + File.WriteAllText(runtimeMain, "int main4();"); + if (ToolkitFingerprint.ComputeComponents(root).Translation == afterNewRuntime.Translation) + throw new Exception("A stale native index was trusted for the translation identity."); + File.WriteAllText(runtimeMain, "int main3();"); + WriteFakeNativeIndex(root, "\"a\", \"b\""); + afterNewRuntime = ToolkitFingerprint.ComputeComponents(root); + if (afterNewRuntime.Translation == indexed.Translation) + throw new Exception("A native registration change did not change the translation identity."); + if (afterNewRuntime.NativeToolchain != indexed.NativeToolchain) + throw new Exception("A native registration change needlessly changed the native toolchain identity."); File.WriteAllText(Path.Combine(root, "Toolkit", "llvm-mingw", "bin", "clang-22.exe"), "clang-v2"); var afterNewCompiler = ToolkitFingerprint.ComputeComponents(root); @@ -1148,6 +1169,26 @@ internal static class SelfTests } } + /// Writes a native index current for the fake runtime/src; returns its source digest. + private static string WriteFakeNativeIndex(string root, string registrations) + { + var source = Path.Combine(root, "BuildWorkspace", "runtime", "src"); + var manifest = new System.Text.StringBuilder(); + foreach (var (relative, path) in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories) + .Where(path => path.EndsWith(".cpp", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".h", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".hpp", StringComparison.OrdinalIgnoreCase)) + .Select(path => (Path.GetRelativePath(source, path).Replace('\\', '/'), path)) + .OrderBy(item => item.Item1, StringComparer.Ordinal)) + manifest.Append(InputValidation.Sha256File(path)).Append(" ").Append(relative).Append('\n'); + var digest = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(manifest.ToString()))) + .ToLowerInvariant(); + File.WriteAllText(Path.Combine(root, "BuildWorkspace", "runtime", "native-index.json"), + "{\n \"format\": \"wiicompiled-runtime-native-index\",\n \"sourceDigest\": \"" + digest + + "\",\n \"registrations\": [" + registrations + "]\n}\n"); + return digest; + } + private static void CreateFakeToolkit(string root) { Directory.CreateDirectory(Path.Combine(root, "Toolkit", "Translator")); diff --git a/Launcher/WiiCompiled.Setup.Windows/ToolkitFingerprint.cs b/Launcher/WiiCompiled.Setup.Windows/ToolkitFingerprint.cs index ed1c952..61b1e6c 100644 --- a/Launcher/WiiCompiled.Setup.Windows/ToolkitFingerprint.cs +++ b/Launcher/WiiCompiled.Setup.Windows/ToolkitFingerprint.cs @@ -1,5 +1,7 @@ using System.Security.Cryptography; using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; using WiiCompiled.Setup.Common; namespace WiiCompiled.Setup.Windows; @@ -12,9 +14,10 @@ namespace WiiCompiled.Setup.Windows; internal static class ToolkitFingerprint { // v6/native-toolchain v2 forces one clean rebuild past a Ninja stale-object bug. - // translation v2 added runtime/src, which the translator regex-scans for native overrides. + // translation v2 added runtime/src, which the translator regex-scans for native overrides; + // v3 replaced it with the native index extracted from it, whenever that index is current. private const string Version = "mkwc-toolkit-compile-v6"; - private const string TranslationVersion = "mkwc-toolkit-translation-v2"; + private const string TranslationVersion = "mkwc-toolkit-translation-v3"; private const string NativeToolchainVersion = "mkwc-toolkit-native-toolchain-v2"; private const string PackageVersion = "mkwc-toolkit-package-v1"; private const string RuntimeAssetsVersion = "mkwc-product-runtime-assets-v3"; @@ -25,20 +28,25 @@ internal static class ToolkitFingerprint ".cmake", ".txt", ".yml", ".yaml", ".toml", ".json", ".ps1", ".in", ".def", ".patch"]; /// - /// Inputs that decide translator output: translator, translation project, runtime sources it - /// scans for native override registrations/effect contracts, and the build script's translator - /// command lines. Unchanged means the completed base translation can be reused instead of re-run. + /// Inputs that decide translator output: translator, translation project, the native override + /// registrations/effect contracts it extracts from runtime/src (see + /// ), and the build script's translator command lines. + /// Unchanged means the completed base translation can be reused instead of re-run. /// private static readonly string[] TranslationPrefixes = [ InstalledLayout.ToolkitEntryPrefix + "Translator/", - InstalledLayout.WorkspaceEntryPrefix + "projects/", - InstalledLayout.WorkspaceEntryPrefix + "runtime/src/" + InstalledLayout.WorkspaceEntryPrefix + "projects/" ]; private static readonly string[] TranslationFiles = [InstalledLayout.WorkspaceEntryPrefix + "LocalBuild.ps1"]; + private const string NativeSourcePrefix = InstalledLayout.WorkspaceEntryPrefix + "runtime/src/"; + private const string NativeIndexEntry = InstalledLayout.WorkspaceEntryPrefix + "runtime/native-index.json"; + // The file set the translator's native index source digest covers. + private static readonly string[] NativeIndexSourceExtensions = [".cpp", ".h", ".hpp"]; + /// /// Inputs that make compiled objects unsafe to reuse: the compiler/CMake/Ninja toolchain, the /// shipped runtime DLLs, and the scripts owning configure flags. Source/dependency changes are @@ -108,14 +116,16 @@ internal static class ToolkitFingerprint if (entries.Count == 0) throw new InvalidDataException($"No toolkit files were found under {root}; the installation is incomplete."); + var translation = SelectSubset(entries, TranslationPrefixes, TranslationFiles); + AddNativeRegistrations(translation, entries, root); return new ToolkitFingerprintComponents( BuildIdentity(Version, entries), - BuildSubsetIdentity(TranslationVersion, entries, TranslationPrefixes, TranslationFiles), - BuildSubsetIdentity(NativeToolchainVersion, entries, NativeToolchainPrefixes, - NativeToolchainFiles)); + BuildIdentity(TranslationVersion, translation), + BuildIdentity(NativeToolchainVersion, + SelectSubset(entries, NativeToolchainPrefixes, NativeToolchainFiles))); } - private static string BuildSubsetIdentity(string version, SortedDictionary entries, + private static SortedDictionary SelectSubset(SortedDictionary entries, string[] prefixes, string[] files) { var subset = new SortedDictionary(StringComparer.Ordinal); @@ -125,9 +135,61 @@ internal static class ToolkitFingerprint files.Any(file => relative.Equals(file, StringComparison.Ordinal))) subset[relative] = hash; } - return BuildIdentity(version, subset); + return subset; } + /// + /// A native-index.json extracted from exactly the shipped runtime/src stands in for those + /// sources, minus its source digest, so a runtime change that leaves the registrations alone + /// keeps the base translation. A missing, unreadable or stale index keeps the sources instead. + /// + private static void AddNativeRegistrations(SortedDictionary subset, + SortedDictionary entries, string root) + { + var sources = entries.Where(entry => entry.Key.StartsWith(NativeSourcePrefix, StringComparison.Ordinal)) + .ToList(); + var registrations = TryHashCurrentNativeIndex( + Path.Combine(InstalledLayout.Workspace(root), "runtime", "native-index.json"), sources); + if (registrations is not null) + { + subset[NativeIndexEntry] = registrations; + return; + } + foreach (var (relative, hash) in sources) subset[relative] = hash; + } + + private static string? TryHashCurrentNativeIndex(string path, List> sources) + { + if (!File.Exists(path)) return null; + JsonObject? document; + try { document = JsonNode.Parse(File.ReadAllText(path)) as JsonObject; } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + return null; + } + if (document?["sourceDigest"] is not JsonValue recorded || !recorded.TryGetValue(out var digest)) + return null; + + // Mirrors the translator's RuntimeNativeIndexFile.ComputeSourceDigest. + var manifest = new StringBuilder(); + foreach (var (relative, hash) in sources) + { + if (!NativeIndexSourceExtensions.Any(extension => + relative.EndsWith(extension, StringComparison.OrdinalIgnoreCase))) + continue; + manifest.Append(hash).Append(" ").Append(relative, NativeSourcePrefix.Length, + relative.Length - NativeSourcePrefix.Length).Append('\n'); + } + if (!digest.Equals(Sha256Hex(manifest.ToString()), StringComparison.OrdinalIgnoreCase)) + return null; + + document.Remove("sourceDigest"); + return Sha256Hex(document.ToJsonString()); + } + + private static string Sha256Hex(string text) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLowerInvariant(); + /// /// Content identity of the source-owned files copied verbatim beside every product. This is /// intentionally independent of : an asset-only release is published @@ -273,8 +335,7 @@ internal static class ToolkitFingerprint var builder = new StringBuilder(version).Append('\n'); foreach (var (relative, hash) in entries) builder.Append(relative).Append('|').Append(hash).Append('\n'); - return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()))) - .ToLowerInvariant(); + return Sha256Hex(builder.ToString()); } } diff --git a/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj b/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj index 6a30c74..7294e41 100644 --- a/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj +++ b/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj @@ -15,5 +15,6 @@ + diff --git a/Launcher/build-appimage.sh b/Launcher/build-appimage.sh index d8c0a76..3cb9b3c 100755 --- a/Launcher/build-appimage.sh +++ b/Launcher/build-appimage.sh @@ -127,10 +127,10 @@ mkdir -p "$appdir/usr/toolchain" cp -a "$workspace/Launcher/artifacts/portable-tools/toolchain-$appimagetool_arch"/. "$appdir/usr/toolchain/" # Precompiled aurora + third-party package (see Prepare-NativePrebuilt.sh) so a user's own -# local-build.sh never has to compile aurora itself (~43% of local build CPU time). Re-harvesting -# recompiles the aurora/Crypto++/mbed TLS closure with the toolchain above, so this is skipped unless -# --print-fingerprint-only (a fast, build-free check) says the existing package no longer matches -# the current compiler, flags, source trees or mbed TLS pin. +# local-build.sh never has to compile aurora itself (~43% of local build CPU time), nor on x86_64 +# the game-independent runtime. Re-harvesting recompiles that closure with the toolchain above, so +# this is skipped unless --print-fingerprint-only (a fast, build-free check) says the existing +# package no longer matches the current compiler, flags, source trees or mbed TLS pin. native_prebuilt_dir="$workspace/Launcher/artifacts/native-prebuilt-$appimagetool_arch" echo "Checking whether the precompiled aurora + third-party package ($appimagetool_arch) is current..." current_fingerprint=$(bash "$script_dir/Prepare-NativePrebuilt.sh" --arch "$appimagetool_arch" --print-fingerprint-only) @@ -149,6 +149,8 @@ fields = { "aurora_fingerprint": "AuroraSourceFingerprint", "third_party_fingerprint": "ThirdPartySourceFingerprint", "mbedtls_fingerprint": "MbedTlsFingerprint", + # Only printed on x86_64, so a package without the runtime archive is stale there. + "runtime_fingerprint": "RuntimeSourceFingerprint", } print(1 if all(provenance.get(v) == current.get(k) for k, v in fields.items()) else 0) PY @@ -174,7 +176,29 @@ done # is a plain developer build directory. find "$appdir/workspace/aurora-main/extern" -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} + rm -rf "$appdir/workspace/runtime/build" -cp "$workspace/Launcher/local-build.sh" "$appdir/workspace/Launcher/local-build.sh" +cp "$workspace/Launcher/local-build.sh" "$workspace/Launcher/build-cache.sh" "$appdir/workspace/Launcher/" + +# The translation identity (build-cache.sh) covers the runtime's native registrations through this +# index, so it is extracted from exactly the bundled runtime sources. +echo "Extracting the runtime native index..." +"$appdir/usr/bin/translator-cli" emit-native-index \ + --project "$appdir/workspace/projects/mkwii/recomp.yml" \ + --out "$appdir/workspace/runtime/native-index.json" +[[ -f "$appdir/workspace/runtime/native-index.json" ]] || { echo "build-appimage.sh: the native index was not written" >&2; exit 1; } + +# A bundled runtime that fingerprints differently from the shipped archive would make every +# installation silently compile the runtime from source. +if [[ "$appimagetool_arch" == x86_64 ]]; then + staged_runtime_fingerprint=$("$workspace/Launcher/artifacts/portable-tools/toolchain-$appimagetool_arch/bin/cmake" \ + -DMKW_RUNTIME_DIR="$appdir/workspace/runtime" -P "$appdir/workspace/runtime/cmake/RuntimeFingerprint.cmake" | + sed -n 's/^runtime_fingerprint=//p') + packaged_runtime_fingerprint=$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1], encoding="utf-8")).get("RuntimeSourceFingerprint", ""))' \ + "$appdir/native-prebuilt/provenance.json") + if [[ -z "$staged_runtime_fingerprint" || "$staged_runtime_fingerprint" != "$packaged_runtime_fingerprint" ]]; then + echo "build-appimage.sh: the bundled runtime ($staged_runtime_fingerprint) does not match the precompiled runtime archive ($packaged_runtime_fingerprint)" >&2 + exit 1 + fi +fi # AppRun re-syncs runtime/aurora-main/projects/local-build.sh into the writable cache only when # this changes, so it must change whenever any of those bundled paths actually did - a bare commit @@ -204,12 +228,9 @@ CACHE="${XDG_DATA_HOME:-$HOME/.local/share}/WiiCompiled/workspace" mkdir -p "$CACHE" if [ ! -f "$CACHE/.bundle-version" ] || \ [ "$(cat "$HERE/workspace/.bundle-version")" != "$(cat "$CACHE/.bundle-version")" ]; then - mkdir -p "$CACHE/Launcher" - for dir in runtime aurora-main projects; do - rm -rf "$CACHE/$dir" - cp -r "$HERE/workspace/$dir" "$CACHE/$dir" - done - cp "$HERE/workspace/Launcher/local-build.sh" "$CACHE/Launcher/local-build.sh" + # Unchanged files keep their timestamps so the incremental build survives the update. + source "$HERE/workspace/Launcher/build-cache.sh" + mkw_sync_tree "$HERE/workspace" "$CACHE" runtime aurora-main projects Launcher >&2 cp "$HERE/workspace/.bundle-version" "$CACHE/.bundle-version" fi # toolchain/ and native-prebuilt/ are NOT copied into the cache (they're large - ~500 MiB / diff --git a/Launcher/build-cache.sh b/Launcher/build-cache.sh new file mode 100644 index 0000000..f88d1b0 --- /dev/null +++ b/Launcher/build-cache.sh @@ -0,0 +1,148 @@ +# Shared incremental-build logic for the Unix setups (Linux AppImage and macOS), sourced by +# local-build.sh, local-build-macos.command, the AppImage's AppRun and macos/setup.command. +# Mirrors the Windows installer's translation/toolchain identities and timestamp preservation. +# Must stay compatible with macOS's bash 3.2. + +if command -v sha256sum >/dev/null 2>&1; then + mkw_sha256_cmd=(sha256sum) +else + mkw_sha256_cmd=(shasum -a 256) +fi + +mkw_sha256() { + "${mkw_sha256_cmd[@]}" "$@" | awk '{print $1}' +} + +# Prints " " for every file under each directory, relative to $1. +mkw_digest_tree() { + local root=$1; shift + ( + cd "$root" || exit 1 + local dir + for dir in "$@"; do + if [[ -d "$dir" ]]; then + printf 'tree %s\n' "$dir" + find "$dir" -type f -print0 | LC_ALL=C sort -z | xargs -0 "${mkw_sha256_cmd[@]}" /dev/null + else + printf 'absent %s\n' "$dir" + fi + done + ) +} + +# Content-only digest of a file or directory, independent of where it lives (the AppImage mount +# path changes every launch). +mkw_digest_path() { + if [[ -d "$1" ]]; then + mkw_digest_tree "$1" . | mkw_sha256 + elif [[ -f "$1" ]]; then + mkw_sha256 < "$1" + else + printf 'absent\n' + fi +} + +# Same digest as the translator's RuntimeNativeIndexFile.ComputeSourceDigest. $1 = runtime/src. +mkw_native_source_digest() { + ( + cd "$1" || exit 1 + find . -type f \( -iname '*.cpp' -o -iname '*.h' -o -iname '*.hpp' \) -print0 | LC_ALL=C sort -z | + xargs -0 "${mkw_sha256_cmd[@]}" /dev/null + ) | grep -v ' /dev/null$' | sed 's| \./| |' | mkw_sha256 +} + +# The native registrations the translator reads from runtime/src. A current native-index.json +# stands in for the sources (minus its source digest), so runtime edits that leave the +# registrations alone keep the translation; a missing or stale index falls back to the sources. +# $1 = workspace. +mkw_native_index_identity() { + local index="$1/runtime/native-index.json" recorded + recorded=$(mkw_read_identity "$index" sourceDigest) + if [[ -n "$recorded" && -d "$1/runtime/src" && "$recorded" == "$(mkw_native_source_digest "$1/runtime/src")" ]]; then + printf 'native-index %s\n' "$(grep -v '^[[:space:]]*"sourceDigest"[[:space:]]*:' "$index" | mkw_sha256)" + else + mkw_digest_tree "$1" runtime/src + fi +} + +# Everything the base translation depends on: game inputs, project, native registrations, the +# translator itself and the build script that drives it. +# $1 = workspace, $2 = translator file or directory, $3 = build script. +mkw_translation_identity() { + local workspace=$1 translator=$2 script=$3 + { + printf 'mkw-translation-v2\n' + printf 'dol %s\n' "$(mkw_digest_path "$workspace/Assets/main.dol")" + printf 'rel %s\n' "$(mkw_digest_path "$workspace/Assets/StaticR.rel")" + printf 'translator %s\n' "$(mkw_digest_path "$translator")" + printf 'script %s\n' "$(mkw_digest_path "$script")" + mkw_digest_tree "$workspace" projects + mkw_native_index_identity "$workspace" + } | mkw_sha256 +} + +# Inputs that make compiled objects unsafe to reuse: compiler/CMake/Ninja versions and the +# from-source dependency pins. Source changes are excluded; Ninja rebuilds exactly what they touch. +# $1 = workspace, remaining = tools (C compiler, C++ compiler, cmake, ninja). +mkw_toolchain_identity() { + local workspace=$1; shift + local tool + { + printf 'mkw-toolchain-v2\n' + for tool in "$@"; do + # First line only: clang appends its InstalledDir, which moves with the AppImage mount. + printf '%s\n' "$("$tool" --version 2>/dev/null | head -n 1)" + done + mkw_digest_tree "$workspace" aurora-main/extern aurora-main/cmake + printf 'aurora %s\n' "$(mkw_digest_path "$workspace/aurora-main/CMakeLists.txt")" + printf 'mbedtls %s\n' "$(mkw_digest_path "$workspace/runtime/cmake/MbedTLSPin.cmake")" + } | mkw_sha256 +} + +# $1 = JSON file, $2 = key. Prints the recorded string value, or nothing. +mkw_read_identity() { + [[ -f "$1" ]] || return 0 + grep -o "\"$2\" *: *\"[^\"]*\"" "$1" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/' || true +} + +mkw_write_identity() { + printf '{"SchemaVersion":1,"%s":"%s"}\n' "$2" "$3" > "$1" +} + +# Mirrors each named directory from $1 into $2. Unchanged files are left untouched so their +# timestamps (which clang's PCH validation and Ninja compare) survive; changed and new files are +# copied fresh and files no longer shipped are removed. +mkw_sync_tree() { + local source=$1 destination=$2; shift 2 + local dir relative changed=0 unchanged=0 removed=0 + for dir in "$@"; do + if [[ ! -d "$source/$dir" ]]; then + rm -rf "$destination/$dir" + continue + fi + if [[ -e "$destination/$dir" && ! -d "$destination/$dir" ]]; then + rm -f "$destination/$dir" + fi + mkdir -p "$destination/$dir" + while IFS= read -r -d '' relative; do + relative=${relative#./} + if [[ -d "$source/$dir/$relative" ]]; then + [[ -d "$destination/$dir/$relative" ]] || { rm -f "$destination/$dir/$relative"; mkdir -p "$destination/$dir/$relative"; } + elif [[ -f "$destination/$dir/$relative" ]] && cmp -s "$source/$dir/$relative" "$destination/$dir/$relative"; then + unchanged=$((unchanged + 1)) + else + rm -rf "$destination/$dir/$relative" + cp "$source/$dir/$relative" "$destination/$dir/$relative" + changed=$((changed + 1)) + fi + done < <(cd "$source/$dir" && find . -mindepth 1 -print0) + while IFS= read -r -d '' relative; do + relative=${relative#./} + if [[ ! -e "$source/$dir/$relative" && ( -e "$destination/$dir/$relative" || -L "$destination/$dir/$relative" ) ]]; then + [[ -d "$destination/$dir/$relative" ]] || removed=$((removed + 1)) + rm -rf "$destination/$dir/$relative" + fi + done < <(cd "$destination/$dir" && find . -mindepth 1 -depth -print0) + done + printf 'Workspace refreshed: %d changed, %d unchanged, %d removed file(s).\n' "$changed" "$unchanged" "$removed" +} diff --git a/Launcher/local-build-macos.command b/Launcher/local-build-macos.command index 4c238fd..650451a 100755 --- a/Launcher/local-build-macos.command +++ b/Launcher/local-build-macos.command @@ -4,11 +4,12 @@ set -euo pipefail script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) default_workspace=$(cd "$script_dir/.." && pwd) +# shellcheck source=build-cache.sh +source "$script_dir/build-cache.sh" fail() { printf 'local-build-macos.command: error: %s\n' "$*" >&2; exit 1; } step() { printf 'MKWCBUILD:STEP:%s %s\n' "$1" "$2"; } assert_file() { [[ -f "$1" ]] || fail "$2 is missing: $1"; } -sha256() { shasum -a 256 "$1" | awk '{ print $1 }'; } usage() { cat <<'EOF' @@ -118,11 +119,30 @@ if (( builds_retro )); then fi fi -step translate-base 'Translating the user-owned base game' -rm -rf "$functions" "$metadata" "$manifest_dir"; mkdir -p "$functions" "$manifest_dir" -translator translate-recursive "$entry_point" --project "$project" --outdir "$functions" --output-metadata "$metadata" --production-source-bundle "$generated/base_translation_sources.bin" --no-function-files --prune-stale --threads "$translator_threads" -step emit-base-manifest 'Creating the local base translation manifest' -translator emit-base-manifest --project "$project" --out "$manifest_dir" --functions-dir "$functions" --translation-output-metadata "$metadata" --region P +translation_provenance="$generated/translation-provenance.json" +translation_fingerprint=$(mkw_translation_identity "$workspace" "${translator_bin:-$(dirname "${translator_dll:-}")}" "$script_dir/local-build-macos.command") +reuse_base=0 +if [[ "$(mkw_read_identity "$translation_provenance" TranslationFingerprint)" == "$translation_fingerprint" && -f "$metadata" && -f "$manifest" ]]; then + reuse_base=1 +fi +if (( reuse_base && builds_retro )); then + # A base tree that never saw this Code.pul would bake vanilla code into the modded product. + pul_sha=$(mkw_sha256 "$retro_root/Binaries/Code.pul") + if ! grep -q "\"codePulSha256\":\"$pul_sha\"" "$metadata" && \ + ! translator check-base-mod-awareness --project "$project" --profile retro-rewind --translation-output-metadata "$metadata" --code-pul "$retro_root/Binaries/Code.pul"; then + reuse_base=0 + fi +fi +if (( reuse_base )); then + step reuse-base-translation 'Reusing the completed base translation' +else + rm -f "$translation_provenance"; mkdir -p "$functions" "$manifest_dir" + step translate-base 'Translating the user-owned base game' + translator translate-recursive "$entry_point" --project "$project" --outdir "$functions" --output-metadata "$metadata" --production-source-bundle "$generated/base_translation_sources.bin" --no-function-files --prune-stale --threads "$translator_threads" + step emit-base-manifest 'Creating the local base translation manifest' + translator emit-base-manifest --project "$project" --out "$manifest_dir" --functions-dir "$functions" --translation-output-metadata "$metadata" --region P + mkw_write_identity "$translation_provenance" TranslationFingerprint "$translation_fingerprint" +fi if (( builds_retro )); then mod_out="$workspace/build/mods/retro_rewind_full_cpp"; args=(translate-mod --project "$project" --profile retro-rewind --base-manifest "$manifest" --base-translation-output-metadata "$metadata" --code-pul "$retro_root/Binaries/Code.pul" --mod-root "$retro_root" --mod-name 'Retro Rewind' --region P --out "$mod_out" --prefer-cached-inputs --emit-cpp --threads "$translator_threads") if (( skip_retro_wfc )); then args+=(--skip-retro-wfc); else offline_payload="$retro_wfc/binary/payload.RMCPD00.bin"; assert_file "$offline_payload" 'Offline Retro-WFC payload'; args+=(--retro-wfc-payload "$offline_payload"); fi @@ -133,10 +153,17 @@ args=(emit-build-shards --project "$project" --base-metadata "$metadata" --base- if (( builds_retro )); then args+=(--resolved-profile "$mod_out/resolved_dispatch_profile.json" --retro-cpp-dir "$mod_out/cpp"); fi step emit-build-shards 'Preparing native build shards'; translator "${args[@]}" +toolchain_provenance="$native_build/toolchain-provenance.json" +toolchain_fingerprint=$(mkw_toolchain_identity "$workspace" clang clang++ "$cmake_bin" "$ninja_bin") +if [[ -d "$native_build" && "$(mkw_read_identity "$toolchain_provenance" ToolchainFingerprint)" != "$toolchain_fingerprint" ]]; then + printf 'MKWCBUILD: The toolchain or a pinned dependency changed; rebuilding the native build from scratch\n' + rm -rf "$native_build" +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 +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" if [[ "$profile" != retro-rewind ]]; then "$script_dir/macos/publish-app.command" --build-dir "$native_build" --product WiiCompiled --output-dir "${base_output_dir:-$output_dir}" --architecture "$macos_arch" --minimum-system-version "$macos_deployment_target"; fi diff --git a/Launcher/local-build.sh b/Launcher/local-build.sh index f48ae9b..90fee9d 100755 --- a/Launcher/local-build.sh +++ b/Launcher/local-build.sh @@ -39,7 +39,7 @@ require_command() { } sha256_of() { - sha256sum "$1" | awk '{print $1}' + mkw_sha256 "$1" } # --------------------------------------------------------------------------- @@ -47,6 +47,8 @@ sha256_of() { # --------------------------------------------------------------------------- script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=build-cache.sh +source "$script_dir/build-cache.sh" workspace=$(cd "$script_dir/.." && pwd) profile=base output_dir="" @@ -266,10 +268,9 @@ else fi # --------------------------------------------------------------------------- -# Translation cache: this script is the only owner of the reuse decision (unlike LocalBuild.ps1, -# which is handed caller-computed fingerprints by the Windows installer - there is no Linux -# installer yet to supply anything). Hash the game inputs the translation actually depends on; -# a match plus every expected output file present means the previous translation is still good. +# Translation cache: this script is the only owner of the reuse decision. The identity (see +# build-cache.sh) covers the game inputs, project, native registrations, translator and this script, +# matching the Windows installer's translation identity. # --------------------------------------------------------------------------- if (( force_clean_build )); then @@ -277,13 +278,12 @@ if (( force_clean_build )); then rm -rf "$generated" "$base_manifest_dir" "$build" fi -translation_fingerprint=$(cat "$assets/main.dol" "$assets/StaticR.rel" "$project" | sha256sum | awk '{print $1}') +translator_identity_input=${translator_bin:-$(dirname "$translator_dll")} +translation_fingerprint=$(mkw_translation_identity "$workspace" "$translator_identity_input" "$script_dir/local-build.sh") reuse_base=0 -if [[ -f "$translation_provenance" ]]; then - recorded=$(grep -o '"TranslationFingerprint" *: *"[^"]*"' "$translation_provenance" 2>/dev/null | sed 's/.*"\([0-9a-f]*\)"$/\1/' || true) - if [[ "$recorded" == "$translation_fingerprint" && -f "$base_metadata" && -f "$base_manifest" ]]; then - reuse_base=1 - fi +if [[ "$(mkw_read_identity "$translation_provenance" TranslationFingerprint)" == "$translation_fingerprint" \ + && -f "$base_metadata" && -f "$base_manifest" ]]; then + reuse_base=1 fi if (( builds_retro )); then @@ -331,8 +331,7 @@ else translator emit-base-manifest --project "$project" --out "$base_manifest_dir" \ --functions-dir "$functions" --translation-output-metadata "$base_metadata" --region P - printf '{"SchemaVersion":1,"TranslationFingerprint":"%s"}' "$translation_fingerprint" \ - > "$translation_provenance" + mkw_write_identity "$translation_provenance" TranslationFingerprint "$translation_fingerprint" fi if (( builds_retro )); then @@ -407,6 +406,13 @@ if [[ -f "$build/CMakeCache.txt" ]]; then done < <(grep -o ':FILEPATH=.*' "$build/CMakeCache.txt" | sed 's/^:FILEPATH=//') fi fi +# The AppImage keeps tool paths stable across updates, so a changed compiler is only visible here. +toolchain_fingerprint=$(mkw_toolchain_identity "$workspace" "$cc_bin" "$cxx_bin" "$cmake_bin" "$ninja_bin") +if [[ "$keep_native_build" -eq 1 && \ + "$(mkw_read_identity "$toolchain_provenance" ToolchainFingerprint)" != "$toolchain_fingerprint" ]]; then + keep_native_build=0 + stale_reason=" (the toolchain or a pinned dependency changed)" +fi if [[ -d "$build" && "$keep_native_build" -eq 0 ]]; then echo "MKWCBUILD: The native build cache does not belong to this workspace path or toolchain; rebuilding from scratch$stale_reason" rm -rf "$build" @@ -453,6 +459,7 @@ fi log_step configure-native "Configuring the native toolchain" "$cmake_bin" "${configure_args[@]}" +mkw_write_identity "$toolchain_provenance" ToolchainFingerprint "$toolchain_fingerprint" case "$profile" in base) targets=(WiiCompiled) ;; diff --git a/Launcher/macos/build-setup-pkg.command b/Launcher/macos/build-setup-pkg.command index f2ae572..0a60213 100755 --- a/Launcher/macos/build-setup-pkg.command +++ b/Launcher/macos/build-setup-pkg.command @@ -150,8 +150,15 @@ for source in aurora-main projects runtime translator; do [[ -d "$workspace/$source" ]] || fail "required workspace directory is missing: $source" copy_clean "$workspace/$source" "$resources/workspace/$source" done +# The translation identity (build-cache.sh) covers the runtime's native registrations through this +# index, so it is extracted from exactly the packaged runtime sources. +if [[ "$host_arch" == arm64 ]]; then host_translator=$translator_arm64; else host_translator=$translator_x86_64; fi +"$host_translator" emit-native-index --project "$resources/workspace/projects/mkwii/recomp.yml" \ + --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/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" copy_clean "$workspace/Launcher/macos/publish-app.command" "$resources/workspace/Launcher/macos/publish-app.command" chmod +x "$resources/workspace/Launcher/local-build-macos.command" "$resources/workspace/Launcher/macos/"*.command diff --git a/Launcher/macos/extract-disc.command b/Launcher/macos/extract-disc.command index 663b542..99fd2de 100755 --- a/Launcher/macos/extract-disc.command +++ b/Launcher/macos/extract-disc.command @@ -38,6 +38,13 @@ done [[ -x "$nodtool" ]] || fail "nodtool is not executable: $nodtool" assets_dir=$(mkdir -p "$assets_dir" && cd "$assets_dir" && pwd) + +# DATA is published before main.dol and StaticR.rel, so verified files imply a complete extraction. +if [[ -d "$assets_dir/DATA/files" && -f "$assets_dir/main.dol" && -f "$assets_dir/StaticR.rel" ]] && \ + [[ $(sha256 "$assets_dir/main.dol") == "$EXPECTED_DOL_SHA256" && $(sha256 "$assets_dir/StaticR.rel") == "$EXPECTED_REL_SHA256" ]]; then + printf 'MKWCBUILD:STEP:disc-ready Reusing the verified extracted game assets\n' + exit 0 +fi scratch=$(mktemp -d "${TMPDIR:-/tmp}/wiicompiled-disc.XXXXXX") cleanup() { rm -rf "$scratch"; } trap cleanup EXIT diff --git a/Launcher/macos/setup.command b/Launcher/macos/setup.command index b3a31d0..40d64f6 100755 --- a/Launcher/macos/setup.command +++ b/Launcher/macos/setup.command @@ -85,15 +85,12 @@ if (( needs_workspace_refresh )); then /usr/bin/ditto "$workspace_source" "$workspace" else # Refresh only packaged source inputs. Assets and the staged Retro - # Rewind package belong to the user and stay in place. - for source in aurora-main projects runtime translator Launcher; do - rm -rf "$workspace/$source" - /usr/bin/ditto "$workspace_source/$source" "$workspace/$source" - done + # Rewind package belong to the user and stay in place; unchanged files + # keep their timestamps so the incremental native build survives. + # shellcheck source=../build-cache.sh + source "$workspace_source/Launcher/build-cache.sh" + mkw_sync_tree "$workspace_source" "$workspace" aurora-main projects runtime translator Launcher /usr/bin/ditto "$source_bundle_version" "$workspace_bundle_version" - # A dependency provider can be cached in this directory, so make the - # refreshed sources configure from a clean native build tree. - rm -rf "$workspace/native-build-macos-arm64" "$workspace/native-build-macos-x86_64" fi fi diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 6410b0b..61147b5 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -86,6 +86,8 @@ set(MKW_NATIVE_PREBUILT_DIR "" CACHE PATH # package can be harvested from it. set(MKW_NATIVE_PREBUILT_EXPORT_DIR "" CACHE PATH "Maintainer-only: write a description of the configured aurora build here") +option(MKW_USE_PREBUILT_RUNTIME + "Link the native prebuilt package's runtime archive when it matches this workspace" ON) # The two Crypto++ configuration macros are load-bearing for ABI: they shape the # structs and inline code the headers generate, so a first-party TU including @@ -297,20 +299,16 @@ else() TARGET_DIRECTORY aurora_gx PROPERTIES COMPILE_OPTIONS "-fno-finite-math-only") endif() - - if(MKW_NATIVE_PREBUILT_EXPORT_DIR) - include("${CMAKE_CURRENT_LIST_DIR}/cmake/NativePrebuiltExport.cmake") - endif() endif() # Everything from here down is this project's own code, so this is where the # project definitions enter the directory scope. A directory-scoped definition # seeds the COMPILE_DEFINITIONS of targets as they are created, which means: # * aurora-main and its third-party tree were added above -> unaffected; -# * mkw_runtime_common, the translated shard libraries and the products in -# cmake/PublicProducts.cmake are all created below (that file is include()d, -# not add_subdirectory()d, so it shares this directory scope) -> they pick -# the definitions up exactly as before. +# * the runtime library, the translated shard libraries and the products in +# cmake/RuntimeLibrary.cmake and cmake/PublicProducts.cmake are all created +# below (those files are include()d, not add_subdirectory()d, so they share +# this directory scope) -> they pick the definitions up exactly as before. if(MKW_PROJECT_COMPILE_DEFINITIONS) add_compile_definitions(${MKW_PROJECT_COMPILE_DEFINITIONS}) endif() @@ -339,13 +337,27 @@ set(MKW_BASE_PRODUCT_SOURCE "${CMAKE_CURRENT_LIST_DIR}/src/product/base_product. set(MKW_RETRO_REWIND_PRODUCT_SOURCE "${CMAKE_CURRENT_LIST_DIR}/src/product/retro_rewind_product.cpp") # The host ISA guard is the one translation unit that must not receive the # x86-64-v3 flags, so it gets its own object library instead of riding along in -# mkw_runtime_common. See cmake/PublicProducts.cmake and the file's own header. +# the runtime library. See cmake/PublicProducts.cmake and the file's own header. set(MKW_CPU_BASELINE_SOURCE "${CMAKE_CURRENT_LIST_DIR}/src/host_cpu_baseline.cpp") list(REMOVE_ITEM SOURCES ${MKW_BASE_PRODUCT_SOURCE} ${MKW_RETRO_REWIND_PRODUCT_SOURCE} ${MKW_CPU_BASELINE_SOURCE} ${MKW_PLATFORM_SOURCE}) include("${CMAKE_CURRENT_LIST_DIR}/cmake/PlatformTests.cmake") +set(MKW_RUNTIME_SOURCE_DIR "${CMAKE_CURRENT_LIST_DIR}") +include("${CMAKE_CURRENT_LIST_DIR}/cmake/RuntimeLibrary.cmake") + +# The prebuilt export also carries the game-independent runtime as a static +# archive (x86_64 only; AArch64 compiles it locally with -mcpu=native). It is +# defined here, after the project definitions, so its flags match the products'. +if(MKW_NATIVE_PREBUILT_EXPORT_DIR AND NOT MKW_NATIVE_PREBUILT_DIR) + if(MKW_HOST_IS_X86_64) + mkw_add_runtime_library(mkw_runtime STATIC ${SOURCES}) + set_target_properties(mkw_runtime PROPERTIES EXCLUDE_FROM_ALL ON) + endif() + include("${CMAKE_CURRENT_LIST_DIR}/cmake/NativePrebuiltExport.cmake") +endif() + # The translator emits the complete, content-addressed source graph. Consuming # this one manifest keeps configure independent of the 28k generated function # files and of optional Retro Rewind artifacts such as code.map. @@ -353,8 +365,8 @@ if(MKW_BUILD_PRODUCTS) set(MKW_TRANSLATED_SHARD_MANIFEST "${CMAKE_CURRENT_LIST_DIR}/../generated/build_shards/shards.cmake" CACHE FILEPATH "Translator-owned aggregate shard manifest") - # The prebuilt export only needs the aurora/third-party closure configured above, so a - # packaging machine without a translation stops here instead of failing. + # The prebuilt export only needs the aurora/third-party closure and the runtime + # configured above, so a packaging machine without a translation stops here. if(MKW_NATIVE_PREBUILT_EXPORT_DIR AND NOT EXISTS "${MKW_TRANSLATED_SHARD_MANIFEST}") message(STATUS "No translator shard manifest; configuring the native prebuilt export only") return() @@ -371,20 +383,13 @@ if(MKW_BUILD_PRODUCTS) "${MKW_PROFILE_SENSITIVE_CALLER_COUNT} profile-sensitive callers; " "${MKW_RETRO_REWIND_FUNCTION_COUNT} Retro Rewind functions") - set(MKW_RUNTIME_SOURCE_DIR "${CMAKE_CURRENT_LIST_DIR}") include("${CMAKE_CURRENT_LIST_DIR}/cmake/PublicProducts.cmake") else() if(MKW_PLATFORM_MACOS) # Compile-only audit of native runtime sources. It deliberately avoids # translated products until their host dependencies are portable. - # These sources depend on generated/RuntimeConfig.h, which is emitted for - # a particular game by the translator and is intentionally unavailable - # in this platform-only configuration. - set(MKW_MACOS_NATIVE_AUDIT_SOURCES ${SOURCES}) - list(REMOVE_ITEM MKW_MACOS_NATIVE_AUDIT_SOURCES - "${CMAKE_CURRENT_LIST_DIR}/src/abi_bridge.cpp" - "${CMAKE_CURRENT_LIST_DIR}/src/hle/os/os_alarm.cpp") - add_library(mkw_macos_native_compile OBJECT ${MKW_MACOS_NATIVE_AUDIT_SOURCES}) + # runtime/src is game-independent; per-game glue lives in runtime/game. + add_library(mkw_macos_native_compile OBJECT ${SOURCES}) target_include_directories(mkw_macos_native_compile PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include" "${CMAKE_CURRENT_LIST_DIR}/src" "${CMAKE_CURRENT_LIST_DIR}/.." "${CMAKE_CURRENT_LIST_DIR}/../aurora-main/include") diff --git a/runtime/cmake/NativePrebuilt.cmake b/runtime/cmake/NativePrebuilt.cmake index 9cdce89..2fab420 100644 --- a/runtime/cmake/NativePrebuilt.cmake +++ b/runtime/cmake/NativePrebuilt.cmake @@ -3,10 +3,10 @@ # # Produced by launcher/Prepare-NativePrebuilt.ps1 with the same toolchain and the # same canonical flag set the consuming build uses, which is what makes a single -# package valid for every user. Nothing about this project's own code changes: -# the runtime, the translated shards and the products still compile locally and -# still link targets named aurora::gx / aurora::pad / aurora::si / aurora::vi / -# aurora::mtx, so cmake/PublicProducts.cmake needs no prebuilt-specific branch. +# package valid for every user. The translated shards and the products still +# compile locally and still link targets named aurora::gx / aurora::pad / +# aurora::si / aurora::vi / aurora::mtx. The package may also carry the native +# runtime archive; cmake/RuntimeLibrary.cmake decides whether it can be used. # # The aurora and dependency source trees are still present in the workspace - the # runtime includes aurora headers directly - so the package records include @@ -42,6 +42,10 @@ endfunction() mkw_np_resolve(_mkw_np_includes ${MKW_NP_INCLUDE_DIRECTORIES}) mkw_np_resolve(_mkw_np_links ${MKW_NP_LINK_LIBRARIES}) mkw_np_resolve(_mkw_np_dawn_config ${MKW_NP_DAWN_CONFIG_DIR}) +set(MKW_NP_RUNTIME_ARCHIVE_PATH "") +if(MKW_NP_RUNTIME_ARCHIVE) + mkw_np_resolve(MKW_NP_RUNTIME_ARCHIVE_PATH "${MKW_NP_RUNTIME_ARCHIVE}") +endif() if(NOT MKW_PLATFORM_WINDOWS) if(NOT MKW_NP_MBEDTLS_LIBRARIES OR NOT MKW_NP_MBEDTLS_INCLUDE_DIR) message(FATAL_ERROR diff --git a/runtime/cmake/NativePrebuiltExport.cmake b/runtime/cmake/NativePrebuiltExport.cmake index e81b3c4..a5959fe 100644 --- a/runtime/cmake/NativePrebuiltExport.cmake +++ b/runtime/cmake/NativePrebuiltExport.cmake @@ -87,6 +87,11 @@ mkw_collect_buildsystem_targets("${_mkw_np_aurora_dir}" _mkw_np_all_targets) # the scan above cannot see it; it is appended explicitly. The type and closure # checks below still apply to it. list(APPEND _mkw_np_all_targets "mkw_cryptopp") +# The game-independent runtime archive (RuntimeLibrary.cmake). It is outside the +# probe closure, so it ships only when the harvester builds it explicitly. +if(TARGET mkw_runtime) + list(APPEND _mkw_np_all_targets "mkw_runtime") +endif() if(NOT MKW_PLATFORM_WINDOWS) set(_mkw_np_mbedtls_targets mbedtls mbedx509 mbedcrypto) # mbedcrypto links these third-party archives when upstream defines them. @@ -168,5 +173,12 @@ file(WRITE "${MKW_NATIVE_PREBUILT_EXPORT_DIR}/meta.txt" "cxx_compiler=${CMAKE_CXX_COMPILER}\n" "cxx_compiler_version=${CMAKE_CXX_COMPILER_VERSION}\n" "library_targets=${_mkw_np_lib_targets_csv}\n") +if(TARGET mkw_runtime) + mkw_runtime_fingerprint(_mkw_np_runtime_fingerprint "${CMAKE_CURRENT_SOURCE_DIR}" + "${MKW_CPPWINRT_INCLUDE_DIR}" "${MKW_X86_CPU_PROFILE}") + file(APPEND "${MKW_NATIVE_PREBUILT_EXPORT_DIR}/meta.txt" + "runtime_fingerprint=${_mkw_np_runtime_fingerprint}\n" + "runtime_cpu_profile=${MKW_X86_CPU_PROFILE}\n") +endif() message(STATUS "Native prebuilt export: ${MKW_NATIVE_PREBUILT_EXPORT_DIR}") diff --git a/runtime/cmake/PublicProducts.cmake b/runtime/cmake/PublicProducts.cmake index 14f2500..2992edd 100644 --- a/runtime/cmake/PublicProducts.cmake +++ b/runtime/cmake/PublicProducts.cmake @@ -4,55 +4,42 @@ # functions are compiled once into mkw_base_shared; only callers whose direct # ABI differs between profiles receive small base/RR variants. +# Per-game data the runtime reads but does not own. Kept out of the runtime +# library so that library stays game-independent (and can be precompiled). +set(MKW_GAME_DATA_SOURCES "") set(DATA_INIT_FILE "${MKW_RUNTIME_SOURCE_DIR}/../generated/data_sections_init.cpp") set(DATA_INIT_BLOB_ASM "${MKW_RUNTIME_SOURCE_DIR}/../generated/data_sections_init_blobs.S") if(EXISTS "${DATA_INIT_FILE}") - list(APPEND SOURCES "${DATA_INIT_FILE}") + list(APPEND MKW_GAME_DATA_SOURCES "${DATA_INIT_FILE}") endif() # Crash-report symbolization table emitted by generate-data-init. The stub # (deliberately outside the globbed src/ tree so it is never picked up twice) # keeps link succeeding when the generated table has not been produced yet. set(GUEST_SYMBOL_TABLE_FILE "${MKW_RUNTIME_SOURCE_DIR}/../generated/guest_symbol_table.cpp") if(EXISTS "${GUEST_SYMBOL_TABLE_FILE}") - list(APPEND SOURCES "${GUEST_SYMBOL_TABLE_FILE}") + list(APPEND MKW_GAME_DATA_SOURCES "${GUEST_SYMBOL_TABLE_FILE}") else() - list(APPEND SOURCES "${MKW_RUNTIME_SOURCE_DIR}/cmake/guest_symbol_table_stub.cpp") + list(APPEND MKW_GAME_DATA_SOURCES "${MKW_RUNTIME_SOURCE_DIR}/cmake/guest_symbol_table_stub.cpp") endif() if(EXISTS "${DATA_INIT_BLOB_ASM}") enable_language(ASM) set_source_files_properties("${DATA_INIT_BLOB_ASM}" PROPERTIES LANGUAGE ASM SKIP_UNITY_BUILD_INCLUSION ON) - list(APPEND SOURCES "${DATA_INIT_BLOB_ASM}") + list(APPEND MKW_GAME_DATA_SOURCES "${DATA_INIT_BLOB_ASM}") endif() -list(REMOVE_DUPLICATES SOURCES) +# Runtime glue that depends on generated/ headers (e.g. RuntimeConfig.h). +file(GLOB MKW_GAME_GLUE_SOURCES CONFIGURE_DEPENDS "${MKW_RUNTIME_SOURCE_DIR}/game/*.cpp") +list(APPEND MKW_GAME_DATA_SOURCES ${MKW_GAME_GLUE_SOURCES}) if(MKW_PLATFORM_MACOS) find_library(MKW_IOKIT_FRAMEWORK IOKit REQUIRED) find_library(MKW_COREFOUNDATION_FRAMEWORK CoreFoundation REQUIRED) - find_library(MKW_COREAUDIO_FRAMEWORK CoreAudio REQUIRED) endif() -function(mkw_apply_common_compile_options target) - target_compile_options(${target} PRIVATE -O3 -ffast-math -w -pipe) -endfunction() - function(mkw_apply_translated_compile_options target) target_compile_options(${target} PRIVATE -O2 ${MKW_TRANSLATED_PPC_FP_OPTIONS} -fno-slp-vectorize -w -pipe) endfunction() -function(mkw_configure_object_target target) - target_include_directories(${target} PRIVATE - "${MKW_RUNTIME_SOURCE_DIR}/include" - "${MKW_RUNTIME_SOURCE_DIR}/src" - # Workspace root, so translator output is spelled "generated/.h" - # instead of a ../ chain whose depth depends on the includer. - "${MKW_RUNTIME_SOURCE_DIR}/.." - "${MKW_RUNTIME_SOURCE_DIR}/../aurora-main/include") - target_compile_definitions(${target} PRIVATE - TARGET_PC) - set_target_properties(${target} PROPERTIES CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) -endfunction() - # Translated shard TUs are the memory-hungry compiles; everything else in the build is # comparatively small. A dedicated Ninja job pool caps how many of them run at once so the # global parallelism can use every core for the cheap TUs without the memory ceiling being a @@ -74,72 +61,31 @@ function(mkw_configure_translated_target target) mkw_bound_translated_compiles(${target}) endfunction() -add_library(mkw_runtime_common OBJECT ${SOURCES}) -mkw_configure_object_target(mkw_runtime_common) -target_compile_features(mkw_runtime_common PRIVATE cxx_std_20) -target_compile_definitions(mkw_runtime_common PRIVATE +mkw_select_runtime_provider() +if(MKW_RUNTIME_USE_PREBUILT) + mkw_import_prebuilt_runtime(mkw_runtime_prebuilt "${MKW_NP_RUNTIME_ARCHIVE_PATH}") + # A replacement archive can carry an older mtime than the last link, so relink on fingerprint change. + set(MKW_RUNTIME_PREBUILT_STAMP "${CMAKE_BINARY_DIR}/mkw_runtime_prebuilt.stamp") + file(CONFIGURE OUTPUT "${MKW_RUNTIME_PREBUILT_STAMP}" CONTENT "${MKW_NP_RUNTIME_FINGERPRINT}\n") +else() + mkw_add_runtime_library(mkw_runtime_common OBJECT ${SOURCES}) +endif() + +add_library(mkw_game_data OBJECT ${MKW_GAME_DATA_SOURCES}) +mkw_configure_object_target(mkw_game_data) +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) -target_link_libraries(mkw_runtime_common PRIVATE - aurora::gx aurora::pad aurora::si aurora::vi aurora::mtx) -target_link_libraries(mkw_runtime_common PRIVATE mkw_platform mkw::pugixml mkw::toml11 mkw::cryptopp mkw::mbedtls) -if(MKW_PLATFORM_WINDOWS) - target_link_libraries(mkw_runtime_common PRIVATE shell32 windowsapp) -elseif(MKW_PLATFORM_LINUX OR MKW_PLATFORM_MACOS_X86_64) - # ${CMAKE_DL_LIBS} for music_attenuation.cpp's dlopen of libdbus-1 (MPRIS - # media monitoring). Empty on platforms where dl* is already in libc/libSystem. - target_link_libraries(mkw_runtime_common PRIVATE mkw::libco ${CMAKE_DL_LIBS}) -endif() - -if(MKW_PLATFORM_MACOS) - # CoreAudio framework is required for automatic music muting on macOS. - target_link_libraries(mkw_runtime_common PRIVATE "${MKW_COREAUDIO_FRAMEWORK}") - if(MKW_PLATFORM_MACOS_X86_64) - # libco is used by Intel macOS. Apple Silicon uses the local - # x18-safe assembly backend and therefore does not define mkw::libco. - target_link_libraries(mkw_runtime_common PRIVATE mkw::libco) - endif() -endif() - -if(MKW_CPPWINRT_INCLUDE_DIR) - if(NOT EXISTS "${MKW_CPPWINRT_INCLUDE_DIR}/winrt/base.h") - message(FATAL_ERROR - "MKW_CPPWINRT_INCLUDE_DIR does not contain winrt/base.h: ${MKW_CPPWINRT_INCLUDE_DIR}") - endif() - target_include_directories(mkw_runtime_common PRIVATE "${MKW_CPPWINRT_INCLUDE_DIR}") -endif() - -# Keep runtime unity units small and semantically related. The old generated-TU -# batch size put all 57 native runtime sources into one memory-heavy compiler job. -foreach(source IN LISTS SOURCES) - get_filename_component(source_name "${source}" NAME_WE) - string(REPLACE "\\" "/" source_normalized "${source}") - if(source_normalized MATCHES "/hle/gx/") - set(runtime_group "gx_bridge") - elseif(source_name MATCHES "network|socket|dns|dwc|ios") - set(runtime_group "network_ios") - elseif(source_name MATCHES "os_|system|memory|fiber|scheduler") - set(runtime_group "guest_system") - elseif(source_name MATCHES "debug|trace|prof") - set(runtime_group "diagnostics") - else() - string(SHA256 source_hash "${source_name}") - string(SUBSTRING "${source_hash}" 0 4 source_hash_prefix) - math(EXPR runtime_bucket "0x${source_hash_prefix} % 8") - set(runtime_group "runtime_${runtime_bucket}") - endif() - set_source_files_properties("${source}" PROPERTIES UNITY_GROUP "${runtime_group}") -endforeach() -# PPC semantic sources are excluded from unity/PCH and configured in CMakeLists.txt. -set_target_properties(mkw_runtime_common PROPERTIES UNITY_BUILD ON UNITY_BUILD_MODE GROUP) -target_precompile_headers(mkw_runtime_common PRIVATE "${MKW_RUNTIME_SOURCE_DIR}/include/mkw_pch.h") -mkw_apply_common_compile_options(mkw_runtime_common) +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) # Host ISA guard deliberately keeps the plain baseline ISA and checks the # selected x86 profile before optional instructions can execute. AArch64 has # no equivalent optional ISA floor to probe: NEON/FMA are architectural # requirements. -if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64)$") +if(MKW_HOST_IS_X86_64) add_library(mkw_cpu_baseline OBJECT "${MKW_CPU_BASELINE_SOURCE}") target_compile_features(mkw_cpu_baseline PRIVATE cxx_std_17) set_target_properties(mkw_cpu_baseline PROPERTIES UNITY_BUILD OFF) @@ -153,9 +99,22 @@ if(NOT MKW_BASE_COMMON_SHARDS) message(FATAL_ERROR "Translator build graph contains no shared base shards") endif() +# Translated code compiles against the guest ABI surface only, so runtime +# implementation edits do not invalidate its PCH. +set(MKW_TRANSLATED_PCH "${MKW_RUNTIME_SOURCE_DIR}/include/guest_abi/guest_abi_pch.h") +if(NOT EXISTS "${MKW_TRANSLATED_PCH}") + set(MKW_TRANSLATED_PCH "${MKW_RUNTIME_SOURCE_DIR}/include/mkw_pch.h") +endif() + add_library(mkw_base_shared STATIC ${MKW_BASE_COMMON_SHARDS}) mkw_configure_translated_target(mkw_base_shared) -target_precompile_headers(mkw_base_shared PRIVATE "${MKW_RUNTIME_SOURCE_DIR}/include/mkw_pch.h") +target_precompile_headers(mkw_base_shared PRIVATE "${MKW_TRANSLATED_PCH}") + +# Registration and indirect-dispatch tables are translator output too; they +# compile with the translated configuration rather than inside the products. +add_library(mkw_base_registration OBJECT ${MKW_BASE_REGISTRATION_SOURCES}) +mkw_configure_translated_target(mkw_base_registration) +target_precompile_headers(mkw_base_registration REUSE_FROM mkw_base_shared) if(MKW_BASE_PORTABLE_SENSITIVE_SHARDS) add_library(mkw_base_sensitive OBJECT ${MKW_BASE_PORTABLE_SENSITIVE_SHARDS}) @@ -178,16 +137,31 @@ if(MKW_HAVE_RETRO_REWIND) set_source_files_properties("${source}" PROPERTIES LANGUAGE ASM SKIP_PRECOMPILE_HEADERS ON) endif() endforeach() + # mod_data_patches (MKW_RETRO_EXTRA_SOURCES) calls runtime APIs directly; it + # keeps the full runtime include path this target already has. add_library(mkw_retro_rewind_functions OBJECT ${MKW_RETRO_TRANSLATED_SOURCES}) mkw_configure_translated_target(mkw_retro_rewind_functions) target_precompile_headers(mkw_retro_rewind_functions REUSE_FROM mkw_base_shared) + + add_library(mkw_retro_registration OBJECT ${MKW_RETRO_REGISTRATION_SOURCES}) + mkw_configure_translated_target(mkw_retro_registration) + target_precompile_headers(mkw_retro_registration REUSE_FROM mkw_base_shared) endif() function(mkw_configure_product target) - target_sources(${target} PRIVATE $) + if(TARGET mkw_runtime_common) + target_sources(${target} PRIVATE $) + else() + # Natives self-register from static constructors, so every archive + # member must be kept. Listed before mkw_base_shared so single-pass + # linkers resolve the runtime's references to translated functions. + target_link_libraries(${target} PRIVATE "$") + set_property(TARGET ${target} APPEND PROPERTY LINK_DEPENDS "${MKW_RUNTIME_PREBUILT_STAMP}") + endif() + target_sources(${target} PRIVATE $) # Startup CPU check. Must stay a separate object library so it keeps the # plain baseline ISA while everything around it is built for x86-64-v3. - if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64)$") + if(MKW_HOST_IS_X86_64) target_sources(${target} PRIVATE $) endif() target_include_directories(${target} PRIVATE @@ -201,9 +175,6 @@ function(mkw_configure_product target) SDL_MAIN_HANDLED _DISABLE_STRING_ANNOTATION _DISABLE_VECTOR_ANNOTATION TARGET_PC) target_compile_features(${target} PRIVATE cxx_std_20) mkw_apply_common_compile_options(${target}) - # The dispatch-table and registration shards compile inside the product target itself and - # include the same fat translated headers; bound them by the same pool. - mkw_bound_translated_compiles(${target}) target_link_libraries(${target} PRIVATE mkw_platform mkw_base_shared mkw::pugixml mkw::toml11 mkw::cryptopp mkw::mbedtls) @@ -311,8 +282,9 @@ function(mkw_configure_product target) endif() endfunction() -add_executable(WiiCompiled "${MKW_BASE_PRODUCT_SOURCE}" ${MKW_BASE_REGISTRATION_SOURCES}) +add_executable(WiiCompiled "${MKW_BASE_PRODUCT_SOURCE}") mkw_configure_product(WiiCompiled) +target_sources(WiiCompiled PRIVATE $) target_precompile_headers(WiiCompiled PRIVATE "${MKW_RUNTIME_SOURCE_DIR}/include/mkw_pch.h") if(TARGET mkw_base_sensitive) @@ -320,9 +292,10 @@ if(TARGET mkw_base_sensitive) endif() if(MKW_HAVE_RETRO_REWIND) - add_executable(RetroRewind "${MKW_RETRO_REWIND_PRODUCT_SOURCE}" ${MKW_RETRO_REGISTRATION_SOURCES}) + add_executable(RetroRewind "${MKW_RETRO_REWIND_PRODUCT_SOURCE}") mkw_configure_product(RetroRewind) target_precompile_headers(RetroRewind REUSE_FROM WiiCompiled) + target_sources(RetroRewind PRIVATE $) if(TARGET mkw_retro_sensitive) target_sources(RetroRewind PRIVATE $) endif() @@ -336,26 +309,12 @@ else() message(STATUS "RetroRewind target disabled (run translate-mod and emit-build-shards)") endif() -# Windows, Linux and Intel macOS x86_64 use the selected profile. AArch64 -# builds are compiled locally for the host that will run them, so both Linux -# and Apple Silicon use the compiler's native CPU tuning rather than leaving -# target-specific performance on the table. -if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64)$") - set(MKW_BASELINE_ARCH_FLAG -march=x86-64-${MKW_X86_CPU_PROFILE}) -elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64|ARM64)$") - set(MKW_BASELINE_ARCH_FLAG -mcpu=native) -else() - set(MKW_BASELINE_ARCH_FLAG "") -endif() - +# mkw_runtime_common gets the same flags from mkw_add_runtime_library. set(MKW_ALL_BUILD_TARGETS - mkw_runtime_common mkw_base_shared mkw_base_sensitive mkw_retro_sensitive - mkw_retro_rewind_functions WiiCompiled RetroRewind) + mkw_game_data mkw_base_shared mkw_base_registration mkw_base_sensitive mkw_retro_sensitive + mkw_retro_rewind_functions mkw_retro_registration WiiCompiled RetroRewind) foreach(target IN LISTS MKW_ALL_BUILD_TARGETS) - if(TARGET ${target} AND MKW_BASELINE_ARCH_FLAG) - target_compile_options(${target} PRIVATE ${MKW_BASELINE_ARCH_FLAG}) - if(MKW_X86_CPU_PROFILE STREQUAL "v2") - target_compile_definitions(${target} PRIVATE MKW_X86_CPU_PROFILE_V2=1) - endif() + if(TARGET ${target}) + mkw_apply_baseline_arch(${target}) endif() endforeach() diff --git a/runtime/cmake/RuntimeFingerprint.cmake b/runtime/cmake/RuntimeFingerprint.cmake new file mode 100644 index 0000000..40eb50f --- /dev/null +++ b/runtime/cmake/RuntimeFingerprint.cmake @@ -0,0 +1,91 @@ +# Content identity of the game-independent runtime and the rule deciding whether +# a precompiled runtime archive may replace compiling it. Script mode prints +# exactly one stdout line, runtime_fingerprint=: +# cmake -DMKW_RUNTIME_DIR= [-DMKW_CPPWINRT_INCLUDE_DIR=] +# [-DMKW_X86_CPU_PROFILE=v2|v3] -P runtime/cmake/RuntimeFingerprint.cmake +# +# Inputs: src/, include/, cmake/ and CMakeLists.txt (sorted relative paths and +# content hashes), winrt/base.h and the CPU profile. third_party/ has its own +# package fingerprint; game/ is per-user. Dot-files differ between machines. + +function(mkw_runtime_fingerprint_inputs out_var runtime_dir) + get_filename_component(_root "${runtime_dir}" ABSOLUTE) + file(GLOB_RECURSE _files LIST_DIRECTORIES false RELATIVE "${_root}" + "${_root}/src/*" "${_root}/include/*" "${_root}/cmake/*") + list(FILTER _files EXCLUDE REGEX "(^|/)\\.") + list(APPEND _files "CMakeLists.txt") + list(SORT _files COMPARE STRING CASE SENSITIVE) + set(${out_var} "${_files}" PARENT_SCOPE) +endfunction() + +function(mkw_runtime_fingerprint out_var runtime_dir cppwinrt_dir cpu_profile) + get_filename_component(_root "${runtime_dir}" ABSOLUTE) + mkw_runtime_fingerprint_inputs(_files "${_root}") + set(_manifest "") + foreach(_file IN LISTS _files) + if(NOT EXISTS "${_root}/${_file}") + message(FATAL_ERROR "Runtime fingerprint input is missing: ${_root}/${_file}") + endif() + file(SHA256 "${_root}/${_file}" _hash) + string(APPEND _manifest "${_file} ${_hash}\n") + endforeach() + if(cppwinrt_dir) + if(NOT EXISTS "${cppwinrt_dir}/winrt/base.h") + message(FATAL_ERROR "MKW_CPPWINRT_INCLUDE_DIR does not contain winrt/base.h: ${cppwinrt_dir}") + endif() + file(SHA256 "${cppwinrt_dir}/winrt/base.h" _hash) + string(APPEND _manifest "cppwinrt:winrt/base.h ${_hash}\n") + endif() + string(APPEND _manifest "x86_cpu_profile ${cpu_profile}\n") + string(SHA256 _fingerprint "${_manifest}") + set(${out_var} "${_fingerprint}" PARENT_SCOPE) +endfunction() + +# Sets out_use to TRUE/FALSE and out_reason to a one-line explanation. Pure, so +# it can be exercised in script mode without a configured project. +cmake_policy(PUSH) +if(POLICY CMP0174) + cmake_policy(SET CMP0174 NEW) +endif() +function(mkw_runtime_prebuilt_decision out_use out_reason) + cmake_parse_arguments(PARSE_ARGV 2 _d "" + "ARCHIVE;ENABLED;X86_64;ARCHIVE_EXISTS;PACKAGE_PROFILE;PROFILE;PACKAGE_FINGERPRINT;FINGERPRINT" "") + set(_use FALSE) + if(NOT _d_ARCHIVE) + set(_reason "the native prebuilt package has no runtime archive") + elseif(NOT _d_ENABLED) + set(_reason "MKW_USE_PREBUILT_RUNTIME is OFF") + elseif(NOT _d_X86_64) + set(_reason "the precompiled runtime is x86_64-only") + elseif(NOT _d_ARCHIVE_EXISTS) + set(_reason "the runtime archive is missing: ${_d_ARCHIVE}") + elseif(NOT _d_PACKAGE_PROFILE STREQUAL _d_PROFILE) + set(_reason "the package was built for x86-64-${_d_PACKAGE_PROFILE}, this build targets x86-64-${_d_PROFILE}") + elseif(NOT _d_PACKAGE_FINGERPRINT) + set(_reason "the package records no runtime fingerprint") + elseif(NOT _d_FINGERPRINT OR NOT _d_PACKAGE_FINGERPRINT STREQUAL _d_FINGERPRINT) + set(_reason "runtime sources differ from the package (package ${_d_PACKAGE_FINGERPRINT}, workspace ${_d_FINGERPRINT})") + else() + set(_use TRUE) + set(_reason "fingerprint ${_d_FINGERPRINT} matches") + endif() + set(${out_use} ${_use} PARENT_SCOPE) + set(${out_reason} "${_reason}" PARENT_SCOPE) +endfunction() +cmake_policy(POP) + +if(CMAKE_SCRIPT_MODE_FILE) + get_filename_component(_mkw_script "${CMAKE_SCRIPT_MODE_FILE}" REALPATH) + get_filename_component(_mkw_self "${CMAKE_CURRENT_LIST_FILE}" REALPATH) +endif() +if(CMAKE_SCRIPT_MODE_FILE AND _mkw_script STREQUAL _mkw_self) + if(NOT MKW_RUNTIME_DIR OR NOT IS_DIRECTORY "${MKW_RUNTIME_DIR}") + message(FATAL_ERROR "Pass -DMKW_RUNTIME_DIR=") + endif() + if(NOT MKW_X86_CPU_PROFILE) + set(MKW_X86_CPU_PROFILE "v3") + endif() + mkw_runtime_fingerprint(_mkw_fingerprint "${MKW_RUNTIME_DIR}" "${MKW_CPPWINRT_INCLUDE_DIR}" "${MKW_X86_CPU_PROFILE}") + # message() writes to stderr; the echo child inherits stdout. + execute_process(COMMAND "${CMAKE_COMMAND}" -E echo "runtime_fingerprint=${_mkw_fingerprint}") +endif() diff --git a/runtime/cmake/RuntimeLibrary.cmake b/runtime/cmake/RuntimeLibrary.cmake new file mode 100644 index 0000000..75fc9b6 --- /dev/null +++ b/runtime/cmake/RuntimeLibrary.cmake @@ -0,0 +1,180 @@ +# The game-independent native runtime (runtime/src). One definition serves +# three uses: the OBJECT library the products link from source, the STATIC +# archive the native prebuilt export ships (mkw_runtime), and that archive +# imported in place of compiling it (mkw_runtime_prebuilt). + +include("${CMAKE_CURRENT_LIST_DIR}/RuntimeFingerprint.cmake") + +if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64)$") + set(MKW_HOST_IS_X86_64 TRUE) +else() + set(MKW_HOST_IS_X86_64 FALSE) +endif() + +# Windows, Linux and Intel macOS x86_64 use the selected profile. AArch64 +# builds are compiled locally for the host that will run them, so both Linux +# and Apple Silicon use the compiler's native CPU tuning rather than leaving +# target-specific performance on the table. +if(MKW_HOST_IS_X86_64) + set(MKW_BASELINE_ARCH_FLAG -march=x86-64-${MKW_X86_CPU_PROFILE}) +elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64|ARM64)$") + set(MKW_BASELINE_ARCH_FLAG -mcpu=native) +else() + set(MKW_BASELINE_ARCH_FLAG "") +endif() + +if(MKW_PLATFORM_MACOS) + find_library(MKW_COREAUDIO_FRAMEWORK CoreAudio REQUIRED) +endif() + +# CMake decides WHOLE_ARCHIVE's form by probing ld.lld in ELF mode, which +# accepts --push-state; the MinGW lld driver rejects it at link time. +if(MKW_PLATFORM_WINDOWS) + set(CMAKE_CXX_LINK_LIBRARY_USING_WHOLE_ARCHIVE + "LINKER:--whole-archive" "" "LINKER:--no-whole-archive") +endif() + +function(mkw_apply_common_compile_options target) + target_compile_options(${target} PRIVATE -O3 -ffast-math -w -pipe) +endfunction() + +function(mkw_apply_baseline_arch target) + if(MKW_BASELINE_ARCH_FLAG) + target_compile_options(${target} PRIVATE ${MKW_BASELINE_ARCH_FLAG}) + if(MKW_X86_CPU_PROFILE STREQUAL "v2") + target_compile_definitions(${target} PRIVATE MKW_X86_CPU_PROFILE_V2=1) + endif() + endif() +endfunction() + +function(mkw_configure_object_target target) + target_include_directories(${target} PRIVATE + "${MKW_RUNTIME_SOURCE_DIR}/include" + "${MKW_RUNTIME_SOURCE_DIR}/src" + # Workspace root, so translator output is spelled "generated/.h" + # instead of a ../ chain whose depth depends on the includer. + "${MKW_RUNTIME_SOURCE_DIR}/.." + "${MKW_RUNTIME_SOURCE_DIR}/../aurora-main/include") + target_compile_definitions(${target} PRIVATE + TARGET_PC) + set_target_properties(${target} PROPERTIES CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) +endfunction() + +# scope is PRIVATE for a compiled target and INTERFACE for the imported archive. +function(mkw_runtime_link_dependencies target scope) + target_link_libraries(${target} ${scope} + aurora::gx aurora::pad aurora::si aurora::vi aurora::mtx) + target_link_libraries(${target} ${scope} mkw_platform mkw::pugixml mkw::toml11 mkw::cryptopp mkw::mbedtls) + if(MKW_PLATFORM_WINDOWS) + target_link_libraries(${target} ${scope} shell32 windowsapp) + elseif(MKW_PLATFORM_LINUX OR MKW_PLATFORM_MACOS_X86_64) + # ${CMAKE_DL_LIBS} for music_attenuation.cpp's dlopen of libdbus-1 (MPRIS + # media monitoring). Empty on platforms where dl* is already in libc/libSystem. + target_link_libraries(${target} ${scope} mkw::libco ${CMAKE_DL_LIBS}) + endif() + if(MKW_PLATFORM_MACOS) + # CoreAudio framework is required for automatic music muting on macOS. + target_link_libraries(${target} ${scope} "${MKW_COREAUDIO_FRAMEWORK}") + if(MKW_PLATFORM_MACOS_X86_64) + # libco is used by Intel macOS. Apple Silicon uses the local + # x18-safe assembly backend and therefore does not define mkw::libco. + target_link_libraries(${target} ${scope} mkw::libco) + endif() + endif() +endfunction() + +# Compiles the runtime sources as an OBJECT or STATIC library. +function(mkw_add_runtime_library target type) + set(_sources ${ARGN}) + add_library(${target} ${type} ${_sources}) + mkw_configure_object_target(${target}) + target_compile_features(${target} PRIVATE cxx_std_20) + target_compile_definitions(${target} PRIVATE + SDL_MAIN_HANDLED + _DISABLE_STRING_ANNOTATION _DISABLE_VECTOR_ANNOTATION) + mkw_runtime_link_dependencies(${target} PRIVATE) + + if(MKW_CPPWINRT_INCLUDE_DIR) + if(NOT EXISTS "${MKW_CPPWINRT_INCLUDE_DIR}/winrt/base.h") + message(FATAL_ERROR + "MKW_CPPWINRT_INCLUDE_DIR does not contain winrt/base.h: ${MKW_CPPWINRT_INCLUDE_DIR}") + endif() + target_include_directories(${target} PRIVATE "${MKW_CPPWINRT_INCLUDE_DIR}") + endif() + + # Keep runtime unity units small and semantically related. The old generated-TU + # batch size put all 57 native runtime sources into one memory-heavy compiler job. + foreach(source IN LISTS _sources) + get_filename_component(source_name "${source}" NAME_WE) + string(REPLACE "\\" "/" source_normalized "${source}") + if(source_normalized MATCHES "/hle/gx/") + set(runtime_group "gx_bridge") + elseif(source_name MATCHES "network|socket|dns|dwc|ios") + set(runtime_group "network_ios") + elseif(source_name MATCHES "os_|system|memory|fiber|scheduler") + set(runtime_group "guest_system") + elseif(source_name MATCHES "debug|trace|prof") + set(runtime_group "diagnostics") + else() + string(SHA256 source_hash "${source_name}") + string(SUBSTRING "${source_hash}" 0 4 source_hash_prefix) + math(EXPR runtime_bucket "0x${source_hash_prefix} % 8") + set(runtime_group "runtime_${runtime_bucket}") + endif() + set_source_files_properties("${source}" PROPERTIES UNITY_GROUP "${runtime_group}") + endforeach() + # PPC semantic sources are excluded from unity/PCH and configured in CMakeLists.txt. + set_target_properties(${target} PROPERTIES UNITY_BUILD ON UNITY_BUILD_MODE GROUP) + target_precompile_headers(${target} PRIVATE "${MKW_RUNTIME_SOURCE_DIR}/include/mkw_pch.h") + mkw_apply_common_compile_options(${target}) + mkw_apply_baseline_arch(${target}) +endfunction() + +function(mkw_import_prebuilt_runtime target archive) + add_library(${target} STATIC IMPORTED GLOBAL) + set_target_properties(${target} PROPERTIES IMPORTED_LOCATION "${archive}") + mkw_runtime_link_dependencies(${target} INTERFACE) +endfunction() + +# Sets MKW_RUNTIME_USE_PREBUILT in the caller. The archive replaces the +# runtime's objects only when it was built from exactly this workspace's +# runtime inputs for the same x86-64 profile. +function(mkw_select_runtime_provider) + set(_archive "${MKW_NP_RUNTIME_ARCHIVE_PATH}") + set(_exists FALSE) + if(_archive AND EXISTS "${_archive}") + set(_exists TRUE) + endif() + set(_fingerprint "") + if(_exists AND MKW_USE_PREBUILT_RUNTIME AND MKW_HOST_IS_X86_64 AND + MKW_NP_RUNTIME_CPU_PROFILE STREQUAL MKW_X86_CPU_PROFILE) + mkw_runtime_fingerprint(_fingerprint "${MKW_RUNTIME_SOURCE_DIR}" + "${MKW_CPPWINRT_INCLUDE_DIR}" "${MKW_X86_CPU_PROFILE}") + endif() + mkw_runtime_prebuilt_decision(_use _reason + ARCHIVE "${_archive}" + ENABLED "${MKW_USE_PREBUILT_RUNTIME}" + X86_64 "${MKW_HOST_IS_X86_64}" + ARCHIVE_EXISTS "${_exists}" + PACKAGE_PROFILE "${MKW_NP_RUNTIME_CPU_PROFILE}" + PROFILE "${MKW_X86_CPU_PROFILE}" + PACKAGE_FINGERPRINT "${MKW_NP_RUNTIME_FINGERPRINT}" + FINGERPRINT "${_fingerprint}") + if(NOT MKW_NATIVE_PREBUILT_DIR) + set(_reason "no native prebuilt package is configured") + endif() + + if(_use) + message(STATUS "Native runtime: precompiled archive ${_archive} (${_reason})") + # Any runtime edit must re-run configure so the mismatch is noticed. + mkw_runtime_fingerprint_inputs(_inputs "${MKW_RUNTIME_SOURCE_DIR}") + list(TRANSFORM _inputs PREPEND "${MKW_RUNTIME_SOURCE_DIR}/") + if(MKW_CPPWINRT_INCLUDE_DIR) + list(APPEND _inputs "${MKW_CPPWINRT_INCLUDE_DIR}/winrt/base.h") + endif() + set_property(DIRECTORY "${CMAKE_SOURCE_DIR}" APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${_inputs}) + else() + message(STATUS "Native runtime: compiled from source (${_reason})") + endif() + set(MKW_RUNTIME_USE_PREBUILT ${_use} PARENT_SCOPE) +endfunction() diff --git a/runtime/game/runtime_config_values.cpp b/runtime/game/runtime_config_values.cpp new file mode 100644 index 0000000..e7677ff --- /dev/null +++ b/runtime/game/runtime_config_values.cpp @@ -0,0 +1,8 @@ +#include "guest_small_data.h" + +#include "generated/RuntimeConfig.h" + +namespace GuestSmallData { +extern const uint32_t kSda1Base = RuntimeConfig::SDA1_BASE; +extern const uint32_t kSda2Base = RuntimeConfig::SDA2_BASE; +} // namespace GuestSmallData diff --git a/runtime/include/abi_bridge.h b/runtime/include/abi_bridge.h index e4b87f9..f7474e1 100644 --- a/runtime/include/abi_bridge.h +++ b/runtime/include/abi_bridge.h @@ -18,638 +18,14 @@ #include #include -inline void InvokeIndirectCpu(uint32_t target, CpuContext* ctx); +#include "guest_abi/dispatch.h" -// Some game-facing runtime options alter arguments at well-defined ABI -// boundaries. Keep this independent of the dispatch mechanism: generated -// static calls deliberately bypass InvokeDirectCpu for performance. -// (used for the path mask filtering in ScnRenderer::createPath: depth of -// field is always removed, bloom when the user disabled it) -inline void ApplyRuntimeCallOptions(uint32_t target, CpuContext* ctx) { - if (target == 0x8023BD38u) { - // ScnRenderer::createPath receives the post-processing path mask in r4. - ctx->gpr[4] = RuntimeGameGraphicsOptions::FilterScnRendererPathMask(ctx->gpr[4]); - } -} - -// Persistent per-thread CPU context used across translated function calls. -CpuContext& GetPersistentCpuContext(); void InitializePersistentCpuContext(); -enum class FunctionKind : uint8_t { - BaseTranslated = 0, - ModTranslated = 1, - Native = 2, -}; - -inline constexpr uint32_t kPpcAllNonvolatileFprMask = 0xffffc000u; -inline constexpr uint32_t kBaseTranslatedFunctionPriority = 0; -inline constexpr uint32_t kModTranslatedFunctionPriorityBase = 100; -inline constexpr uint32_t kNativeFunctionPriority = 10000; - -#include "isa/ppc_isa_cr.h" - -struct TranslatedFunctionInfo { - uint32_t address = 0; - const char* name = ""; - uint64_t moduleId = 0; - uint32_t priority = 0; - uint32_t nonvolatileFprWriteMask = kPpcAllNonvolatileFprMask; - void* entryPoint = nullptr; // Raw typed function pointer for indirect-call resolution - void (*rawCpuInvoker)(CpuContext*) = nullptr; - bool mustRemainDynamicallyDispatchable = true; - FunctionKind kind = FunctionKind::BaseTranslated; -}; - -struct RawDispatchRecord { - uint32_t address = 0; - void (*entry)(CpuContext*) = nullptr; - uint32_t nonvolatileFprWriteMask = kPpcAllNonvolatileFprMask; - bool preserveNonvolatileGprs = false; -}; - -// Translator-owned indirect dispatch is split by the high address byte and -// then by 4 KiB guest pages. Each populated segment stores only the contiguous -// page range that contains verified function entry points. The final search is -// bounded to at most the 1024 aligned instruction addresses in one guest page. -struct StaticIndirectDispatchPage { - uint32_t firstEntry = 0; - uint16_t entryCount = 0; - // Explicit tail padding to an 8-byte stride. The emitter writes it as a - // literal third initializer, so it is part of the generated-data contract. - uint16_t reserved = 0; -}; - -struct StaticIndirectDispatchSegment { - const StaticIndirectDispatchPage* pages = nullptr; - uint16_t firstPage = 0; - uint16_t pageCount = 0; -}; - -struct StaticIndirectDispatchTable { - const char* profileName = nullptr; - const StaticIndirectDispatchSegment* segments = nullptr; // Exactly 256 high-byte segments. - const RawDispatchRecord* entries = nullptr; - size_t entryCount = 0; -}; - -inline const RawDispatchRecord* FindStaticIndirectDispatchEntry( - const StaticIndirectDispatchTable* table, - uint32_t address) noexcept { - if (!table || !table->segments || !table->entries || table->entryCount == 0) { - return nullptr; - } - - const auto& segment = table->segments[address >> 24]; - const uint32_t pageNumber = (address >> 12) & 0x0FFFu; - if (!segment.pages || pageNumber < segment.firstPage) { - return nullptr; - } - const uint32_t relativePage = pageNumber - segment.firstPage; - if (relativePage >= segment.pageCount) { - return nullptr; - } - - const auto& page = segment.pages[relativePage]; - const size_t first = page.firstEntry; - const size_t count = page.entryCount; - if (first > table->entryCount || count > table->entryCount - first) { - return nullptr; - } - - size_t lower = first; - size_t upper = first + count; - while (lower < upper) { - const size_t middle = lower + (upper - lower) / 2; - const uint32_t candidate = table->entries[middle].address; - if (candidate < address) { - lower = middle + 1; - } else { - upper = middle; - } - } - return lower < first + count && table->entries[lower].address == address - ? &table->entries[lower] - : nullptr; -} - -void RegisterStaticIndirectDispatchTable(const StaticIndirectDispatchTable* table); -inline std::atomic g_publishedStaticIndirectDispatchTable{nullptr}; - -class StaticIndirectDispatchTableRegistrar { -public: - explicit StaticIndirectDispatchTableRegistrar(const StaticIndirectDispatchTable* table) { - RegisterStaticIndirectDispatchTable(table); - } -}; - -inline unsigned PpcLowestSetBitIndex(uint32_t value) noexcept { - return static_cast(__builtin_ctz(value)); -} - -class PpcNonvolatileFprGuard { -public: - explicit PpcNonvolatileFprGuard(CpuContext* cpu, uint32_t mask = kPpcAllNonvolatileFprMask) noexcept - : cpu_(cpu), mask_(mask & kPpcAllNonvolatileFprMask) { - if (!cpu_ || mask_ == 0) { - return; - } - // Real masks are a single contiguous register run - f14..f31 for the - // conservative default and f23..f31 or similar for a measured writer - - // so resolve the run once and copy it as one straight-line block instead - // of testing 18 mask bits. Any other mask keeps the per-bit loop, which - // saves and restores exactly the same registers. - const uint32_t lowest = mask_ & (0u - mask_); - const uint32_t aboveRun = mask_ + lowest; - if ((aboveRun & (aboveRun - 1u)) == 0u) { - const unsigned first = PpcLowestSetBitIndex(mask_); - const unsigned end = aboveRun == 0u ? 32u : PpcLowestSetBitIndex(aboveRun); - firstSlot_ = static_cast(first - 14u); - slotCount_ = static_cast(end - first); - const size_t count = slotCount_; - for (size_t i = 0; i < count; ++i) { - saved_[firstSlot_ + i] = cpu_->fpr[first + i]; - } - return; - } - for (size_t i = 0; i < saved_.size(); ++i) { - if ((mask_ & (1u << (14 + i))) == 0) { - continue; - } - saved_[i] = cpu_->fpr[14 + i]; - } - } - - ~PpcNonvolatileFprGuard() noexcept { - if (!cpu_ || mask_ == 0) { - return; - } - if (slotCount_ != 0) { - const unsigned first = 14u + firstSlot_; - const size_t count = slotCount_; - for (size_t i = 0; i < count; ++i) { - cpu_->fpr[first + i] = saved_[firstSlot_ + i]; - } - return; - } - for (size_t i = 0; i < saved_.size(); ++i) { - if ((mask_ & (1u << (14 + i))) == 0) { - continue; - } - cpu_->fpr[14 + i] = saved_[i]; - } - } - - PpcNonvolatileFprGuard(const PpcNonvolatileFprGuard&) = delete; - PpcNonvolatileFprGuard& operator=(const PpcNonvolatileFprGuard&) = delete; - -private: - CpuContext* cpu_ = nullptr; - uint32_t mask_ = 0; - // Contiguous-run description of mask_. slotCount_ == 0 means the mask is not - // a single run, so the destructor mirrors the constructor's per-bit loop. - uint8_t firstSlot_ = 0; - uint8_t slotCount_ = 0; - // Every element selected by mask_ is written before it is read. Leaving the - // remaining slots uninitialized avoids zero-filling all 18 values around a - // call that may preserve only one or two architectural FPRs. - std::array saved_; -}; - -class PpcNonvolatileGprGuard { -public: - explicit PpcNonvolatileGprGuard(CpuContext* cpu, bool enabled = true) noexcept - : cpu_(enabled ? cpu : nullptr) { - if (!cpu_) { - return; - } - for (size_t i = 0; i < saved_.size(); ++i) { - saved_[i] = cpu_->gpr[14 + i]; - } - } - - ~PpcNonvolatileGprGuard() noexcept { - if (!cpu_) { - return; - } - for (size_t i = 0; i < saved_.size(); ++i) { - cpu_->gpr[14 + i] = saved_[i]; - } - } - - PpcNonvolatileGprGuard(const PpcNonvolatileGprGuard&) = delete; - PpcNonvolatileGprGuard& operator=(const PpcNonvolatileGprGuard&) = delete; - -private: - CpuContext* cpu_ = nullptr; - // Left uninitialized: enabled writes all 18 slots before reading them, disabled reads none, - // so skipping the zero-fill saves 72 bytes of stack work on the common disabled hot path. - std::array saved_; -}; - -// Guest address of OSLoadContext, which models rfi-style context restoration -// and intentionally replaces the full guest register file instead of returning -// like a normal ABI call. It can never be guarded. -inline constexpr uint32_t kOSLoadContextAddress = 0x801A1F58u; - -inline bool ShouldPreserveNonvolatileGprsForRawCpuCall(const TranslatedFunctionInfo* info) noexcept { - return info->kind == FunctionKind::Native && info->address != kOSLoadContextAddress; -} - -inline uint32_t NonvolatileFprGuardMaskFor(const TranslatedFunctionInfo* info) noexcept { - if (!info || info->nonvolatileFprWriteMask == 0) { - return 0; - } - return info->nonvolatileFprWriteMask & kPpcAllNonvolatileFprMask; -} - -template -struct KnownTranslatedCpuCall { - static constexpr bool kAvailable = false; - static constexpr uint32_t kNonvolatileFprWriteMask = kPpcAllNonvolatileFprMask; - static constexpr bool kMustRemainDynamicallyDispatchable = true; - static constexpr void (*Entry)(CpuContext*) = nullptr; -}; - -// Translator-emitted trait specializations, one macro shared by every generated shard. -// `addr` is the 8-digit hex entry point, `winner` the resolved symbol (mods/Retro Rewind can -// publish their own name). A specialization only exists for a single statically bound winner, -// so overridable/dynamically-dispatchable are hardcoded here rather than passed in. -#define MKW_TRANSLATED_TRAIT(addr, winner, nonvolatile_fpr_write_mask) \ - extern "C" void winner(CpuContext* ctx); \ - template <> \ - struct KnownTranslatedCpuCall<0x##addr##u> { \ - static constexpr bool kAvailable = true; \ - static constexpr uint32_t kNonvolatileFprWriteMask = nonvolatile_fpr_write_mask; \ - static constexpr bool kMustRemainDynamicallyDispatchable = false; \ - static constexpr void (*Entry)(CpuContext*) = &winner; \ - } - -template -struct KnownNativeCpuCall { - static constexpr bool kAvailable = false; - static constexpr uint32_t kNonvolatileFprWriteMask = kPpcAllNonvolatileFprMask; - static constexpr void (*Entry)(CpuContext*) = nullptr; -}; - -template -struct KnownTypedNativeCpuCall { - static constexpr bool kAvailable = false; -}; - -#include "native_cpu_calls.inc" - -// Direct-mapped thread-local memo in front of the sorted registry lookup every bctrl performs; -// a miss just re-runs the sorted lookup. 512 entries (12 KiB) covers the per-frame indirect -// working set while staying L1/L2 resident, unlike 4096 which would thrash L2. Must stay a -// power of two, the index masks with (size - 1). -inline constexpr size_t kIndirectDispatchCacheEntries = 512; - -// Namespace-scope `inline thread_local`, not function-local `static thread_local`, to avoid a -// thread-static init epoch check on every bctrl (same as g_currentCpuContext in ppc_runtime.h). -struct IndirectResolvedDispatchMemoEntry { - bool valid; - uint32_t address; - const TranslatedFunctionInfo* info; -}; -inline thread_local IndirectResolvedDispatchMemoEntry - g_indirectResolvedDispatchMemo[kIndirectDispatchCacheEntries]{}; - -struct IndirectRawDispatchMemoEntry { - bool valid; - uint32_t address; - const RawDispatchRecord* record; -}; -inline thread_local IndirectRawDispatchMemoEntry - g_indirectRawDispatchMemo[kIndirectDispatchCacheEntries]{}; - -class TranslatedFunctionRegistry { -public: - static void Register(TranslatedFunctionInfo info); - static void Finalize(); - static inline bool IsLookupPublished() noexcept { - return lookupPublished_.load(std::memory_order_acquire); - } - static std::optional FindNearestByAddress(uint32_t address); - static inline const TranslatedFunctionInfo* FindByAddressPtr(uint32_t address) { - if (!lookupPublished_.load(std::memory_order_acquire)) { - return FindByAddressPtrSlow(address); - } - - auto& cached = g_indirectResolvedDispatchMemo[ - ((address >> 2) * 2654435761u) & (kIndirectDispatchCacheEntries - 1)]; - if (cached.valid && cached.address == address) { - return cached.info; - } - - const auto* info = FindByAddressPtrSlow(address); - cached = {true, address, info}; - return info; - } - - // Hot wrapper: only the direct-mapped memo probe stays inline. Inlining the - // generated-table binary search alongside it pushed the whole function past - // the inliner's budget and out-of-lined the probe itself, which is the part - // every bctrl executes. - static MKW_PPC_FORCE_INLINE const RawDispatchRecord* FindRawByAddressPtr(uint32_t address) { - if (!lookupPublished_.load(std::memory_order_acquire)) { - return nullptr; - } - - auto& cached = g_indirectRawDispatchMemo[ - ((address >> 2) * 2654435761u) & (kIndirectDispatchCacheEntries - 1)]; - if (cached.valid && cached.address == address) { - return cached.record; - } - - return FindRawByAddressPtrMiss(address, cached); - } - // Look up a registered function by host (native) address. - // This enables stack trace symbolication for translated functions. - static std::optional FindByHostAddress(uintptr_t hostAddr); - -private: - // Memo miss: resolve against the header-inline generated table first; only dynamically - // registered records or a not-yet-published table fall through to the slow path. - static MKW_PPC_NO_INLINE MKW_PPC_COLD const RawDispatchRecord* FindRawByAddressPtrMiss( - uint32_t address, IndirectRawDispatchMemoEntry& cached) { - const RawDispatchRecord* record = nullptr; - if (const auto* table = - g_publishedStaticIndirectDispatchTable.load(std::memory_order_acquire)) { - record = FindStaticIndirectDispatchEntry(table, address); - } - if (record == nullptr) { - record = FindRawByAddressPtrSlow(address); - } - cached = {true, address, record}; - return record; - } - - static const TranslatedFunctionInfo* FindByAddressPtrSlow(uint32_t address); - static const RawDispatchRecord* FindRawByAddressPtrSlow(uint32_t address); - - // Finalize publishes immutable registry, generated-table, and dynamic raw - // dispatch storage. Header-inlined cache hits are enabled only after that - // release publication, so a pre-finalization miss can never poison them. - inline static std::atomic_bool lookupPublished_{false}; -}; - -// Cold, profile-owned registration data is emitted in compact table shards. -// Keeping it out of generated function bodies avoids one static constructor -// per translated function. -struct BulkTranslatedFunctionRecord { - uint32_t address; - const char* name; - void (*entry)(CpuContext*); - FunctionKind kind; - bool preservesNonvolatileFprs; - uint32_t nonvolatileFprWriteMask; - uint32_t priority; - uint64_t moduleId; - bool mustRemainDynamicallyDispatchable; -}; - -void RegisterBulkTranslatedFunctions(const BulkTranslatedFunctionRecord* records, size_t count); - -class BulkTranslatedFunctionRegistrar { -public: - BulkTranslatedFunctionRegistrar(const BulkTranslatedFunctionRecord* records, size_t count) { - RegisterBulkTranslatedFunctions(records, count); - } -}; - -MKW_PPC_FORCE_INLINE bool TryDispatchResolvedCpuTarget(const TranslatedFunctionInfo* info, CpuContext* cpu) { - if (!info || !info->rawCpuInvoker) { - return false; - } - - RecompMod::ScopedTranslatedExecutionAddress translatedExecution(info->address); - PpcNonvolatileFprGuard fprGuard(cpu, NonvolatileFprGuardMaskFor(info)); - PpcNonvolatileGprGuard gprGuard(cpu, ShouldPreserveNonvolatileGprsForRawCpuCall(info)); - if (TryGetCpuContext() != cpu) { - CpuContextScope scope(cpu); - info->rawCpuInvoker(cpu); - return true; - } - info->rawCpuInvoker(cpu); - return true; -} - -inline bool TryDispatchRawCpuTarget(const RawDispatchRecord* record, CpuContext* cpu) { - if (!record || !record->entry) { - return false; - } - RecompMod::ScopedTranslatedExecutionAddress translatedExecution(record->address); - PpcNonvolatileGprGuard gprGuard(cpu, record->preserveNonvolatileGprs); - PpcNonvolatileFprGuard fprGuard(cpu, record->nonvolatileFprWriteMask); - if (TryGetCpuContext() != cpu) { - CpuContextScope scope(cpu); - record->entry(cpu); - } else { - record->entry(cpu); - } - return true; -} - -template -inline void DispatchKnownTranslatedCpuTargetStatic(CpuContext* cpu) { - const auto invokeKnownTranslated = [&]() { - KnownTranslatedCpuCall::Entry(cpu); - }; - - // A statically resolved, non-overridable translated call already running - // in this CpuContext needs no registry, context, or diagnostic target - // transition. This is the normal generated-to-generated hot path. - if (TryGetCpuContext() == cpu) { - if constexpr (KnownTranslatedCpuCall::kNonvolatileFprWriteMask == 0) { - invokeKnownTranslated(); - return; - } else { - PpcNonvolatileFprGuard fprGuard( - cpu, KnownTranslatedCpuCall::kNonvolatileFprWriteMask); - invokeKnownTranslated(); - return; - } - } - - CpuContextScope contextScope(cpu); - RecompMod::ScopedTranslatedExecutionAddress translatedExecution(Target); - if constexpr (KnownTranslatedCpuCall::kNonvolatileFprWriteMask == 0) { - invokeKnownTranslated(); - return; - } else { - PpcNonvolatileFprGuard fprGuard(cpu, KnownTranslatedCpuCall::kNonvolatileFprWriteMask); - invokeKnownTranslated(); - } -} - -[[noreturn]] inline void ReportMissingCpuTarget(uint32_t target, CpuContext* cpu) { - // Centralized crash path: stderr diagnostics, heuristics, crash artifacts - // in the run's log folder, popup, exit. - RuntimeCrash::FatalMissingGuestTarget(target, cpu); -} - -template -inline const TranslatedFunctionInfo* ResolveDirectCpuTargetInfo() { - return TranslatedFunctionRegistry::FindByAddressPtr(Target); -} - -template -inline const TranslatedFunctionInfo* ResolveDirectCpuTargetInfoCached() { - // Only latch a result once the registry is published (immutable for the process lifetime); - // a pre-finalization miss or provisional winner must never be cached. 0 = not yet resolved, - // 1 = negative-cache sentinel, avoiding a second ready flag on the hot path. - static std::atomic cachedValue{0}; - constexpr uintptr_t kNegativeCache = 1; - const uintptr_t cached = cachedValue.load(std::memory_order_acquire); - if (cached != 0) { - return cached == kNegativeCache - ? nullptr - : reinterpret_cast(cached); - } - if (!TranslatedFunctionRegistry::IsLookupPublished()) { - return ResolveDirectCpuTargetInfo(); - } - const auto* info = ResolveDirectCpuTargetInfo(); - cachedValue.store(info != nullptr - ? reinterpret_cast(info) - : kNegativeCache, - std::memory_order_release); - return info; -} - -// Caches the "is the published winner still base translation?" verdict for state-free fast -// paths guarding tens of thousands of call sites. Same latching rule as above: 0 = unresolved, -// 1 = negative, 2 = positive, and a verdict computed pre-publication is never stored. -template -inline bool IsBaseTranslatedCpuTargetActive() { - static std::atomic cachedVerdict{0}; - constexpr uint8_t kNegativeVerdict = 1; - constexpr uint8_t kPositiveVerdict = 2; - const uint8_t cached = cachedVerdict.load(std::memory_order_acquire); - if (cached != 0) { - return cached == kPositiveVerdict; - } - const bool publishable = TranslatedFunctionRegistry::IsLookupPublished(); - const auto* info = ResolveDirectCpuTargetInfo(); - const bool active = info != nullptr && info->kind == FunctionKind::BaseTranslated; - if (publishable) { - cachedVerdict.store(active ? kPositiveVerdict : kNegativeVerdict, - std::memory_order_release); - } - return active; -} - -template -inline void InvokeDirectCpu(CpuContext* ctx) { - static_assert(Target != 0, "InvokeDirectCpu cannot target address 0"); - CpuContext* cpu = ctx ? ctx : &GetPersistentCpuContext(); - ApplyRuntimeCallOptions(Target, cpu); - if constexpr (KnownNativeCpuCall::kAvailable) { - const auto invokeKnownNative = [&]() { - PpcNonvolatileGprGuard gprGuard(cpu); - if (TryGetCpuContext() != cpu) { - CpuContextScope scope(cpu); - KnownNativeCpuCall::Entry(cpu); - return; - } - KnownNativeCpuCall::Entry(cpu); - }; - if constexpr (KnownNativeCpuCall::kNonvolatileFprWriteMask == 0) { - invokeKnownNative(); - } else { - PpcNonvolatileFprGuard fprGuard(cpu, KnownNativeCpuCall::kNonvolatileFprWriteMask); - invokeKnownNative(); - } - return; - } - - if constexpr (KnownTypedNativeCpuCall::kAvailable) { - PpcNonvolatileGprGuard gprGuard(cpu); - if (TryGetCpuContext() != cpu) { - CpuContextScope scope(cpu); - KnownTypedNativeCpuCall::Invoke(cpu); - } else { - KnownTypedNativeCpuCall::Invoke(cpu); - } - return; - } - - if constexpr (KnownTranslatedCpuCall::kAvailable) { - DispatchKnownTranslatedCpuTargetStatic(cpu); - return; - } - - const auto* registeredInfo = ResolveDirectCpuTargetInfoCached(); - if (TryDispatchResolvedCpuTarget(registeredInfo, cpu)) { - return; - } - - ReportMissingCpuTarget(Target, cpu); -} - -// Indirect jump (bctr without link) - used for switch tables. -inline void InvokeIndirectJump(uint32_t target, CpuContext* ctx) { - // Use the caller's context, not the persistent global context! - // This is essential for tail calls via bctr where the 'this' pointer (r3) - // must be passed correctly to the target function. - CpuContext* cpu = ctx ? ctx : &GetPersistentCpuContext(); - if (TryDispatchRawCpuTarget(TranslatedFunctionRegistry::FindRawByAddressPtr(target), cpu)) { - return; - } - - // Cold fallback for dynamically registered or signature-only targets. - const auto* info = TranslatedFunctionRegistry::FindByAddressPtr(target); - if (info) { - if (TryDispatchResolvedCpuTarget(info, cpu)) { - return; - } - } - - // If not registered, this is likely a switch table jump to an intra-function label. - // The translator should have recognized this pattern and generated proper switch code. - RT_LOG(RT_TAG_RUNTIME) << "InvokeIndirectJump: target 0x" - << std::hex << target << std::dec - << " is not a registered function.\n" - << "This is likely a switch statement jump table that the translator\n" - << "failed to recognize. The function containing this bctr needs\n" - << "proper switch pattern recognition." << std::endl; - RT_LOG(RT_TAG_RUNTIME) << "Indirect jump context: pc=0x" << std::hex << cpu->pc - << " lr=0x" << cpu->lr << " ctr=0x" << cpu->ctr - << " r1=0x" << cpu->gpr[1] << " r3=0x" << cpu->gpr[3] - << " r29=0x" << cpu->gpr[29] << " r30=0x" << cpu->gpr[30] - << " r31=0x" << cpu->gpr[31] << std::dec << std::endl; - DumpHostStackTraceForRuntimeHelper(); - std::fflush(stderr); - std::ostringstream message; - message << "The game stopped because an indirect jump targeted guest address 0x" - << std::hex << target - << ", but that address is not a registered translated function.\n\n" - << "The translator may have missed a switch/jump-table target."; - // Same artifact set as RuntimeCrash::FatalMissingGuestTarget, which is this - // path's sibling for indirect *calls*: MarkFatalErrorReported below stops the - // atexit reporter, so the crash log has to be written here. - RuntimeCrash::WriteCrashArtifacts("missing_jump_target", message.str(), &target); - SetRuntimeExitCode(EXIT_FAILURE); - ShowRuntimeFatalPopup("Missing indirect jump target", message.str()); - MarkFatalErrorReported(); - std::exit(EXIT_FAILURE); -} - -inline void InvokeIndirectCpu(uint32_t target, CpuContext* ctx) { - CpuContext* cpu = ctx ? ctx : &GetPersistentCpuContext(); - if (target == 0) { - ReportMissingCpuTarget(target, cpu); - } - ApplyRuntimeCallOptions(target, cpu); - if (TryDispatchRawCpuTarget(TranslatedFunctionRegistry::FindRawByAddressPtr(target), cpu)) { - return; - } - if (TryDispatchResolvedCpuTarget(TranslatedFunctionRegistry::FindByAddressPtr(target), cpu)) { - return; - } - ReportMissingCpuTarget(target, cpu); -} +std::optional FindNearestTranslatedFunction(uint32_t address); +// Look up a registered function by host (native) address. +// This enables stack trace symbolication for translated functions. +std::optional FindTranslatedFunctionByHostAddress(uintptr_t hostAddr); template class AbiTrampoline; diff --git a/runtime/include/guest_abi/abi_version.h b/runtime/include/guest_abi/abi_version.h new file mode 100644 index 0000000..ce7f320 --- /dev/null +++ b/runtime/include/guest_abi/abi_version.h @@ -0,0 +1,4 @@ +#pragma once + +// Bump whenever guest_abi/, isa/ or ppc_isa_memory.h changes what translated code compiles against. +#define MKW_GUEST_ABI_VERSION 1 diff --git a/runtime/include/guest_abi/cpu_context.h b/runtime/include/guest_abi/cpu_context.h new file mode 100644 index 0000000..70e2f2b --- /dev/null +++ b/runtime/include/guest_abi/cpu_context.h @@ -0,0 +1,84 @@ +#pragma once + +#include "isa/ppc_isa_fpenv.h" + +#include + +union PPC_FPR { + uint64_t raw; + double d; + struct { +#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ + float ps1; // Low word (Least Significant) + float ps0; // High word (Most Significant) +#else + float ps0; // High word + float ps1; // Low word +#endif + } paired; +}; + +// PowerPC CPU Context +struct CpuContext { + // Standard GPRs + uint32_t gpr[32]; + + // Special Purpose Registers defined by standard PPC + uint32_t cr; // Condition Register + uint32_t lr; // Link Register + uint32_t ctr; // Count Register + uint32_t xer; // Integer Exception Register + uint32_t fpscr; // Floating-Point Status and Control Register + + // Program State + uint32_t pc; // Program Counter (not used alot tho) + + // Floating Point Registers (Modified for Paired Single support) + PPC_FPR fpr[32]; + + // Broadway Specific Extensions + uint32_t gqr[8]; // Graphics Quantization Registers + uint32_t hid0; // HID0 + uint32_t hid1; // HID1 + uint32_t hid2; // HID2 + + uint32_t srr0; // Save/Restore Register 0 + uint32_t srr1; // Save/Restore Register 1 + uint32_t msr; // Machine State Register +}; + +inline thread_local CpuContext* g_currentCpuContext = nullptr; + +class CpuContextScope { +public: + explicit CpuContextScope(CpuContext* ctx) + : previous_(g_currentCpuContext) + { + g_currentCpuContext = ctx; + + savedMxcsr_ = MkwGetHostFpControl(); + if (ctx != nullptr) + MkwApplyHostNiMode(ctx->fpscr); + } + + ~CpuContextScope() + { + g_currentCpuContext = previous_; + if (previous_ != nullptr) + MkwApplyHostNiMode(previous_->fpscr); + else + MkwRestoreHostMxcsr(savedMxcsr_); + } + + CpuContextScope(const CpuContextScope&) = delete; + CpuContextScope& operator=(const CpuContextScope&) = delete; + +private: + CpuContext* previous_ = nullptr; + uint32_t savedMxcsr_ = 0; +}; + +inline CpuContext* TryGetCpuContext() noexcept +{ + return g_currentCpuContext; +} diff --git a/runtime/include/guest_abi/dispatch.h b/runtime/include/guest_abi/dispatch.h new file mode 100644 index 0000000..9752c04 --- /dev/null +++ b/runtime/include/guest_abi/dispatch.h @@ -0,0 +1,618 @@ +#pragma once + +#include "guest_abi/cpu_context.h" +#include "guest_abi/exec_guard.h" +#include "isa/ppc_isa_config.h" +#include "isa/ppc_isa_cr.h" + +#include +#include +#include +#include + +namespace RuntimeCrash { +[[noreturn]] void FatalMissingGuestTarget(uint32_t target, struct CpuContext* cpu) noexcept; +} // namespace RuntimeCrash + +inline void InvokeIndirectCpu(uint32_t target, CpuContext* ctx); + +uint32_t FilterScnRendererPathMaskOption(uint32_t pathMask) noexcept; + +// Some game-facing runtime options alter arguments at well-defined ABI +// boundaries. Keep this independent of the dispatch mechanism: generated +// static calls deliberately bypass InvokeDirectCpu for performance. +// (used for the path mask filtering in ScnRenderer::createPath: depth of +// field is always removed, bloom when the user disabled it) +inline void ApplyRuntimeCallOptions(uint32_t target, CpuContext* ctx) { + if (target == 0x8023BD38u) { + // ScnRenderer::createPath receives the post-processing path mask in r4. + ctx->gpr[4] = FilterScnRendererPathMaskOption(ctx->gpr[4]); + } +} + +// Persistent per-thread CPU context used across translated function calls. +CpuContext& GetPersistentCpuContext(); + +enum class FunctionKind : uint8_t { + BaseTranslated = 0, + ModTranslated = 1, + Native = 2, +}; + +inline constexpr uint32_t kPpcAllNonvolatileFprMask = 0xffffc000u; +inline constexpr uint32_t kBaseTranslatedFunctionPriority = 0; +inline constexpr uint32_t kModTranslatedFunctionPriorityBase = 100; +inline constexpr uint32_t kNativeFunctionPriority = 10000; + +struct TranslatedFunctionInfo { + uint32_t address = 0; + const char* name = ""; + uint64_t moduleId = 0; + uint32_t priority = 0; + uint32_t nonvolatileFprWriteMask = kPpcAllNonvolatileFprMask; + void* entryPoint = nullptr; // Raw typed function pointer for indirect-call resolution + void (*rawCpuInvoker)(CpuContext*) = nullptr; + bool mustRemainDynamicallyDispatchable = true; + FunctionKind kind = FunctionKind::BaseTranslated; +}; + +struct RawDispatchRecord { + uint32_t address = 0; + void (*entry)(CpuContext*) = nullptr; + uint32_t nonvolatileFprWriteMask = kPpcAllNonvolatileFprMask; + bool preserveNonvolatileGprs = false; +}; + +// Translator-owned indirect dispatch is split by the high address byte and +// then by 4 KiB guest pages. Each populated segment stores only the contiguous +// page range that contains verified function entry points. The final search is +// bounded to at most the 1024 aligned instruction addresses in one guest page. +struct StaticIndirectDispatchPage { + uint32_t firstEntry = 0; + uint16_t entryCount = 0; + // Explicit tail padding to an 8-byte stride. The emitter writes it as a + // literal third initializer, so it is part of the generated-data contract. + uint16_t reserved = 0; +}; + +struct StaticIndirectDispatchSegment { + const StaticIndirectDispatchPage* pages = nullptr; + uint16_t firstPage = 0; + uint16_t pageCount = 0; +}; + +struct StaticIndirectDispatchTable { + const char* profileName = nullptr; + const StaticIndirectDispatchSegment* segments = nullptr; // Exactly 256 high-byte segments. + const RawDispatchRecord* entries = nullptr; + size_t entryCount = 0; +}; + +inline const RawDispatchRecord* FindStaticIndirectDispatchEntry( + const StaticIndirectDispatchTable* table, + uint32_t address) noexcept { + if (!table || !table->segments || !table->entries || table->entryCount == 0) { + return nullptr; + } + + const auto& segment = table->segments[address >> 24]; + const uint32_t pageNumber = (address >> 12) & 0x0FFFu; + if (!segment.pages || pageNumber < segment.firstPage) { + return nullptr; + } + const uint32_t relativePage = pageNumber - segment.firstPage; + if (relativePage >= segment.pageCount) { + return nullptr; + } + + const auto& page = segment.pages[relativePage]; + const size_t first = page.firstEntry; + const size_t count = page.entryCount; + if (first > table->entryCount || count > table->entryCount - first) { + return nullptr; + } + + size_t lower = first; + size_t upper = first + count; + while (lower < upper) { + const size_t middle = lower + (upper - lower) / 2; + const uint32_t candidate = table->entries[middle].address; + if (candidate < address) { + lower = middle + 1; + } else { + upper = middle; + } + } + return lower < first + count && table->entries[lower].address == address + ? &table->entries[lower] + : nullptr; +} + +void RegisterStaticIndirectDispatchTable(const StaticIndirectDispatchTable* table); +inline std::atomic g_publishedStaticIndirectDispatchTable{nullptr}; + +class StaticIndirectDispatchTableRegistrar { +public: + explicit StaticIndirectDispatchTableRegistrar(const StaticIndirectDispatchTable* table) { + RegisterStaticIndirectDispatchTable(table); + } +}; + +inline unsigned PpcLowestSetBitIndex(uint32_t value) noexcept { + return static_cast(__builtin_ctz(value)); +} + +class PpcNonvolatileFprGuard { +public: + explicit PpcNonvolatileFprGuard(CpuContext* cpu, uint32_t mask = kPpcAllNonvolatileFprMask) noexcept + : cpu_(cpu), mask_(mask & kPpcAllNonvolatileFprMask) { + if (!cpu_ || mask_ == 0) { + return; + } + // Real masks are a single contiguous register run - f14..f31 for the + // conservative default and f23..f31 or similar for a measured writer - + // so resolve the run once and copy it as one straight-line block instead + // of testing 18 mask bits. Any other mask keeps the per-bit loop, which + // saves and restores exactly the same registers. + const uint32_t lowest = mask_ & (0u - mask_); + const uint32_t aboveRun = mask_ + lowest; + if ((aboveRun & (aboveRun - 1u)) == 0u) { + const unsigned first = PpcLowestSetBitIndex(mask_); + const unsigned end = aboveRun == 0u ? 32u : PpcLowestSetBitIndex(aboveRun); + firstSlot_ = static_cast(first - 14u); + slotCount_ = static_cast(end - first); + const size_t count = slotCount_; + for (size_t i = 0; i < count; ++i) { + saved_[firstSlot_ + i] = cpu_->fpr[first + i]; + } + return; + } + for (size_t i = 0; i < saved_.size(); ++i) { + if ((mask_ & (1u << (14 + i))) == 0) { + continue; + } + saved_[i] = cpu_->fpr[14 + i]; + } + } + + ~PpcNonvolatileFprGuard() noexcept { + if (!cpu_ || mask_ == 0) { + return; + } + if (slotCount_ != 0) { + const unsigned first = 14u + firstSlot_; + const size_t count = slotCount_; + for (size_t i = 0; i < count; ++i) { + cpu_->fpr[first + i] = saved_[firstSlot_ + i]; + } + return; + } + for (size_t i = 0; i < saved_.size(); ++i) { + if ((mask_ & (1u << (14 + i))) == 0) { + continue; + } + cpu_->fpr[14 + i] = saved_[i]; + } + } + + PpcNonvolatileFprGuard(const PpcNonvolatileFprGuard&) = delete; + PpcNonvolatileFprGuard& operator=(const PpcNonvolatileFprGuard&) = delete; + +private: + CpuContext* cpu_ = nullptr; + uint32_t mask_ = 0; + // Contiguous-run description of mask_. slotCount_ == 0 means the mask is not + // a single run, so the destructor mirrors the constructor's per-bit loop. + uint8_t firstSlot_ = 0; + uint8_t slotCount_ = 0; + // Every element selected by mask_ is written before it is read. Leaving the + // remaining slots uninitialized avoids zero-filling all 18 values around a + // call that may preserve only one or two architectural FPRs. + std::array saved_; +}; + +class PpcNonvolatileGprGuard { +public: + explicit PpcNonvolatileGprGuard(CpuContext* cpu, bool enabled = true) noexcept + : cpu_(enabled ? cpu : nullptr) { + if (!cpu_) { + return; + } + for (size_t i = 0; i < saved_.size(); ++i) { + saved_[i] = cpu_->gpr[14 + i]; + } + } + + ~PpcNonvolatileGprGuard() noexcept { + if (!cpu_) { + return; + } + for (size_t i = 0; i < saved_.size(); ++i) { + cpu_->gpr[14 + i] = saved_[i]; + } + } + + PpcNonvolatileGprGuard(const PpcNonvolatileGprGuard&) = delete; + PpcNonvolatileGprGuard& operator=(const PpcNonvolatileGprGuard&) = delete; + +private: + CpuContext* cpu_ = nullptr; + // Left uninitialized: enabled writes all 18 slots before reading them, disabled reads none, + // so skipping the zero-fill saves 72 bytes of stack work on the common disabled hot path. + std::array saved_; +}; + +// Guest address of OSLoadContext, which models rfi-style context restoration +// and intentionally replaces the full guest register file instead of returning +// like a normal ABI call. It can never be guarded. +inline constexpr uint32_t kOSLoadContextAddress = 0x801A1F58u; + +inline bool ShouldPreserveNonvolatileGprsForRawCpuCall(const TranslatedFunctionInfo* info) noexcept { + return info->kind == FunctionKind::Native && info->address != kOSLoadContextAddress; +} + +inline uint32_t NonvolatileFprGuardMaskFor(const TranslatedFunctionInfo* info) noexcept { + if (!info || info->nonvolatileFprWriteMask == 0) { + return 0; + } + return info->nonvolatileFprWriteMask & kPpcAllNonvolatileFprMask; +} + +template +struct KnownTranslatedCpuCall { + static constexpr bool kAvailable = false; + static constexpr uint32_t kNonvolatileFprWriteMask = kPpcAllNonvolatileFprMask; + static constexpr bool kMustRemainDynamicallyDispatchable = true; + static constexpr void (*Entry)(CpuContext*) = nullptr; +}; + +// Translator-emitted trait specializations, one macro shared by every generated shard. +// `addr` is the 8-digit hex entry point, `winner` the resolved symbol (mods/Retro Rewind can +// publish their own name). A specialization only exists for a single statically bound winner, +// so overridable/dynamically-dispatchable are hardcoded here rather than passed in. +#define MKW_TRANSLATED_TRAIT(addr, winner, nonvolatile_fpr_write_mask) \ + extern "C" void winner(CpuContext* ctx); \ + template <> \ + struct KnownTranslatedCpuCall<0x##addr##u> { \ + static constexpr bool kAvailable = true; \ + static constexpr uint32_t kNonvolatileFprWriteMask = nonvolatile_fpr_write_mask; \ + static constexpr bool kMustRemainDynamicallyDispatchable = false; \ + static constexpr void (*Entry)(CpuContext*) = &winner; \ + } + +template +struct KnownNativeCpuCall { + static constexpr bool kAvailable = false; + static constexpr uint32_t kNonvolatileFprWriteMask = kPpcAllNonvolatileFprMask; + static constexpr void (*Entry)(CpuContext*) = nullptr; +}; + +template +struct KnownTypedNativeCpuCall { + static constexpr bool kAvailable = false; +}; + +#include "guest_abi/native_cpu_calls.inc" + +// Direct-mapped thread-local memo in front of the sorted registry lookup every bctrl performs; +// a miss just re-runs the sorted lookup. 512 entries (12 KiB) covers the per-frame indirect +// working set while staying L1/L2 resident, unlike 4096 which would thrash L2. Must stay a +// power of two, the index masks with (size - 1). +inline constexpr size_t kIndirectDispatchCacheEntries = 512; + +// Namespace-scope `inline thread_local`, not function-local `static thread_local`, to avoid a +// thread-static init epoch check on every bctrl (same as g_currentCpuContext in guest_abi/cpu_context.h). +struct IndirectResolvedDispatchMemoEntry { + bool valid; + uint32_t address; + const TranslatedFunctionInfo* info; +}; +inline thread_local IndirectResolvedDispatchMemoEntry + g_indirectResolvedDispatchMemo[kIndirectDispatchCacheEntries]{}; + +struct IndirectRawDispatchMemoEntry { + bool valid; + uint32_t address; + const RawDispatchRecord* record; +}; +inline thread_local IndirectRawDispatchMemoEntry + g_indirectRawDispatchMemo[kIndirectDispatchCacheEntries]{}; + +class TranslatedFunctionRegistry { +public: + static void Register(TranslatedFunctionInfo info); + static void Finalize(); + static inline bool IsLookupPublished() noexcept { + return lookupPublished_.load(std::memory_order_acquire); + } + static inline const TranslatedFunctionInfo* FindByAddressPtr(uint32_t address) { + if (!lookupPublished_.load(std::memory_order_acquire)) { + return FindByAddressPtrSlow(address); + } + + auto& cached = g_indirectResolvedDispatchMemo[ + ((address >> 2) * 2654435761u) & (kIndirectDispatchCacheEntries - 1)]; + if (cached.valid && cached.address == address) { + return cached.info; + } + + const auto* info = FindByAddressPtrSlow(address); + cached = {true, address, info}; + return info; + } + + // Hot wrapper: only the direct-mapped memo probe stays inline. Inlining the + // generated-table binary search alongside it pushed the whole function past + // the inliner's budget and out-of-lined the probe itself, which is the part + // every bctrl executes. + static MKW_PPC_FORCE_INLINE const RawDispatchRecord* FindRawByAddressPtr(uint32_t address) { + if (!lookupPublished_.load(std::memory_order_acquire)) { + return nullptr; + } + + auto& cached = g_indirectRawDispatchMemo[ + ((address >> 2) * 2654435761u) & (kIndirectDispatchCacheEntries - 1)]; + if (cached.valid && cached.address == address) { + return cached.record; + } + + return FindRawByAddressPtrMiss(address, cached); + } + +private: + // Memo miss: resolve against the header-inline generated table first; only dynamically + // registered records or a not-yet-published table fall through to the slow path. + static MKW_PPC_NO_INLINE MKW_PPC_COLD const RawDispatchRecord* FindRawByAddressPtrMiss( + uint32_t address, IndirectRawDispatchMemoEntry& cached) { + const RawDispatchRecord* record = nullptr; + if (const auto* table = + g_publishedStaticIndirectDispatchTable.load(std::memory_order_acquire)) { + record = FindStaticIndirectDispatchEntry(table, address); + } + if (record == nullptr) { + record = FindRawByAddressPtrSlow(address); + } + cached = {true, address, record}; + return record; + } + + static const TranslatedFunctionInfo* FindByAddressPtrSlow(uint32_t address); + static const RawDispatchRecord* FindRawByAddressPtrSlow(uint32_t address); + + // Finalize publishes immutable registry, generated-table, and dynamic raw + // dispatch storage. Header-inlined cache hits are enabled only after that + // release publication, so a pre-finalization miss can never poison them. + inline static std::atomic_bool lookupPublished_{false}; +}; + +// Cold, profile-owned registration data is emitted in compact table shards. +// Keeping it out of generated function bodies avoids one static constructor +// per translated function. +struct BulkTranslatedFunctionRecord { + uint32_t address; + const char* name; + void (*entry)(CpuContext*); + FunctionKind kind; + bool preservesNonvolatileFprs; + uint32_t nonvolatileFprWriteMask; + uint32_t priority; + uint64_t moduleId; + bool mustRemainDynamicallyDispatchable; +}; + +void RegisterBulkTranslatedFunctions(const BulkTranslatedFunctionRecord* records, size_t count); + +class BulkTranslatedFunctionRegistrar { +public: + BulkTranslatedFunctionRegistrar(const BulkTranslatedFunctionRecord* records, size_t count) { + RegisterBulkTranslatedFunctions(records, count); + } +}; + +MKW_PPC_FORCE_INLINE bool TryDispatchResolvedCpuTarget(const TranslatedFunctionInfo* info, CpuContext* cpu) { + if (!info || !info->rawCpuInvoker) { + return false; + } + + RecompMod::ScopedTranslatedExecutionAddress translatedExecution(info->address); + PpcNonvolatileFprGuard fprGuard(cpu, NonvolatileFprGuardMaskFor(info)); + PpcNonvolatileGprGuard gprGuard(cpu, ShouldPreserveNonvolatileGprsForRawCpuCall(info)); + if (TryGetCpuContext() != cpu) { + CpuContextScope scope(cpu); + info->rawCpuInvoker(cpu); + return true; + } + info->rawCpuInvoker(cpu); + return true; +} + +inline bool TryDispatchRawCpuTarget(const RawDispatchRecord* record, CpuContext* cpu) { + if (!record || !record->entry) { + return false; + } + RecompMod::ScopedTranslatedExecutionAddress translatedExecution(record->address); + PpcNonvolatileGprGuard gprGuard(cpu, record->preserveNonvolatileGprs); + PpcNonvolatileFprGuard fprGuard(cpu, record->nonvolatileFprWriteMask); + if (TryGetCpuContext() != cpu) { + CpuContextScope scope(cpu); + record->entry(cpu); + } else { + record->entry(cpu); + } + return true; +} + +template +inline void DispatchKnownTranslatedCpuTargetStatic(CpuContext* cpu) { + const auto invokeKnownTranslated = [&]() { + KnownTranslatedCpuCall::Entry(cpu); + }; + + // A statically resolved, non-overridable translated call already running + // in this CpuContext needs no registry, context, or diagnostic target + // transition. This is the normal generated-to-generated hot path. + if (TryGetCpuContext() == cpu) { + if constexpr (KnownTranslatedCpuCall::kNonvolatileFprWriteMask == 0) { + invokeKnownTranslated(); + return; + } else { + PpcNonvolatileFprGuard fprGuard( + cpu, KnownTranslatedCpuCall::kNonvolatileFprWriteMask); + invokeKnownTranslated(); + return; + } + } + + CpuContextScope contextScope(cpu); + RecompMod::ScopedTranslatedExecutionAddress translatedExecution(Target); + if constexpr (KnownTranslatedCpuCall::kNonvolatileFprWriteMask == 0) { + invokeKnownTranslated(); + return; + } else { + PpcNonvolatileFprGuard fprGuard(cpu, KnownTranslatedCpuCall::kNonvolatileFprWriteMask); + invokeKnownTranslated(); + } +} + +[[noreturn]] inline void ReportMissingCpuTarget(uint32_t target, CpuContext* cpu) { + // Centralized crash path: stderr diagnostics, heuristics, crash artifacts + // in the run's log folder, popup, exit. + RuntimeCrash::FatalMissingGuestTarget(target, cpu); +} + +template +inline const TranslatedFunctionInfo* ResolveDirectCpuTargetInfo() { + return TranslatedFunctionRegistry::FindByAddressPtr(Target); +} + +template +inline const TranslatedFunctionInfo* ResolveDirectCpuTargetInfoCached() { + // Only latch a result once the registry is published (immutable for the process lifetime); + // a pre-finalization miss or provisional winner must never be cached. 0 = not yet resolved, + // 1 = negative-cache sentinel, avoiding a second ready flag on the hot path. + static std::atomic cachedValue{0}; + constexpr uintptr_t kNegativeCache = 1; + const uintptr_t cached = cachedValue.load(std::memory_order_acquire); + if (cached != 0) { + return cached == kNegativeCache + ? nullptr + : reinterpret_cast(cached); + } + if (!TranslatedFunctionRegistry::IsLookupPublished()) { + return ResolveDirectCpuTargetInfo(); + } + const auto* info = ResolveDirectCpuTargetInfo(); + cachedValue.store(info != nullptr + ? reinterpret_cast(info) + : kNegativeCache, + std::memory_order_release); + return info; +} + +// Caches the "is the published winner still base translation?" verdict for state-free fast +// paths guarding tens of thousands of call sites. Same latching rule as above: 0 = unresolved, +// 1 = negative, 2 = positive, and a verdict computed pre-publication is never stored. +template +inline bool IsBaseTranslatedCpuTargetActive() { + static std::atomic cachedVerdict{0}; + constexpr uint8_t kNegativeVerdict = 1; + constexpr uint8_t kPositiveVerdict = 2; + const uint8_t cached = cachedVerdict.load(std::memory_order_acquire); + if (cached != 0) { + return cached == kPositiveVerdict; + } + const bool publishable = TranslatedFunctionRegistry::IsLookupPublished(); + const auto* info = ResolveDirectCpuTargetInfo(); + const bool active = info != nullptr && info->kind == FunctionKind::BaseTranslated; + if (publishable) { + cachedVerdict.store(active ? kPositiveVerdict : kNegativeVerdict, + std::memory_order_release); + } + return active; +} + +template +inline void InvokeDirectCpu(CpuContext* ctx) { + static_assert(Target != 0, "InvokeDirectCpu cannot target address 0"); + CpuContext* cpu = ctx ? ctx : &GetPersistentCpuContext(); + ApplyRuntimeCallOptions(Target, cpu); + if constexpr (KnownNativeCpuCall::kAvailable) { + const auto invokeKnownNative = [&]() { + PpcNonvolatileGprGuard gprGuard(cpu); + if (TryGetCpuContext() != cpu) { + CpuContextScope scope(cpu); + KnownNativeCpuCall::Entry(cpu); + return; + } + KnownNativeCpuCall::Entry(cpu); + }; + if constexpr (KnownNativeCpuCall::kNonvolatileFprWriteMask == 0) { + invokeKnownNative(); + } else { + PpcNonvolatileFprGuard fprGuard(cpu, KnownNativeCpuCall::kNonvolatileFprWriteMask); + invokeKnownNative(); + } + return; + } + + if constexpr (KnownTypedNativeCpuCall::kAvailable) { + PpcNonvolatileGprGuard gprGuard(cpu); + if (TryGetCpuContext() != cpu) { + CpuContextScope scope(cpu); + KnownTypedNativeCpuCall::Invoke(cpu); + } else { + KnownTypedNativeCpuCall::Invoke(cpu); + } + return; + } + + if constexpr (KnownTranslatedCpuCall::kAvailable) { + DispatchKnownTranslatedCpuTargetStatic(cpu); + return; + } + + const auto* registeredInfo = ResolveDirectCpuTargetInfoCached(); + if (TryDispatchResolvedCpuTarget(registeredInfo, cpu)) { + return; + } + + ReportMissingCpuTarget(Target, cpu); +} + +// Cold tail of InvokeIndirectJump: a bctr target that is not a registered function. +[[noreturn]] MKW_PPC_NO_INLINE MKW_PPC_COLD void ReportMissingIndirectJumpTarget(uint32_t target, CpuContext* cpu); + +// Indirect jump (bctr without link) - used for switch tables. +inline void InvokeIndirectJump(uint32_t target, CpuContext* ctx) { + // Use the caller's context, not the persistent global context! + // This is essential for tail calls via bctr where the 'this' pointer (r3) + // must be passed correctly to the target function. + CpuContext* cpu = ctx ? ctx : &GetPersistentCpuContext(); + if (TryDispatchRawCpuTarget(TranslatedFunctionRegistry::FindRawByAddressPtr(target), cpu)) { + return; + } + + // Cold fallback for dynamically registered or signature-only targets. + const auto* info = TranslatedFunctionRegistry::FindByAddressPtr(target); + if (info) { + if (TryDispatchResolvedCpuTarget(info, cpu)) { + return; + } + } + ReportMissingIndirectJumpTarget(target, cpu); +} + +inline void InvokeIndirectCpu(uint32_t target, CpuContext* ctx) { + CpuContext* cpu = ctx ? ctx : &GetPersistentCpuContext(); + if (target == 0) { + ReportMissingCpuTarget(target, cpu); + } + ApplyRuntimeCallOptions(target, cpu); + if (TryDispatchRawCpuTarget(TranslatedFunctionRegistry::FindRawByAddressPtr(target), cpu)) { + return; + } + if (TryDispatchResolvedCpuTarget(TranslatedFunctionRegistry::FindByAddressPtr(target), cpu)) { + return; + } + ReportMissingCpuTarget(target, cpu); +} diff --git a/runtime/include/guest_abi/exec_guard.h b/runtime/include/guest_abi/exec_guard.h new file mode 100644 index 0000000..2338370 --- /dev/null +++ b/runtime/include/guest_abi/exec_guard.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include + +namespace RecompMod { + +// Diagnostic-only, read by fatal reporters (unmapped access, executable-write, CPU dumps). +// Defined here as `inline thread_local` with a constant initializer, not `extern thread_local` +// in the .cpp: every indirect dispatch scopes this, so an out-of-line ctor/dtor would cost two +// un-inlinable calls plus a register spill each for three instructions of work. +inline thread_local uint32_t g_currentTranslatedExecutionAddress = 0; + +class ScopedTranslatedExecutionAddress { +public: + explicit ScopedTranslatedExecutionAddress(uint32_t address) noexcept + : previous_(g_currentTranslatedExecutionAddress) { + // Address 0 means "nothing new to report" - the enclosing scope's value + // stays visible, and the unconditional restore below keeps nesting exact. + if (address != 0) { + g_currentTranslatedExecutionAddress = address; + } + } + + ~ScopedTranslatedExecutionAddress() noexcept { + g_currentTranslatedExecutionAddress = previous_; + } + + ScopedTranslatedExecutionAddress(const ScopedTranslatedExecutionAddress&) = delete; + ScopedTranslatedExecutionAddress& operator=(const ScopedTranslatedExecutionAddress&) = delete; + +private: + uint32_t previous_ = 0; +}; + +inline constexpr uint32_t kExecutableWriteGuardPageShift = 12; +inline constexpr uint32_t kExecutableWriteGuardPageCount = 1u << (32 - kExecutableWriteGuardPageShift); +inline constexpr uint32_t kExecutableWriteGuardCoarsePageShift = 20; +inline constexpr uint32_t kExecutableWriteGuardCoarsePageCount = 1u << (32 - kExecutableWriteGuardCoarsePageShift); +inline constexpr uint32_t kExecutableWriteGuardMidPageShift = 16; +inline constexpr uint32_t kExecutableWriteGuardMidPageCount = 1u << (32 - kExecutableWriteGuardMidPageShift); + +extern std::atomic g_executableWriteGuardEnabled; +extern std::atomic g_executableWriteGuardPages[kExecutableWriteGuardPageCount]; +extern std::atomic g_executableWriteGuardCoarsePages[kExecutableWriteGuardCoarsePageCount]; +extern std::atomic g_executableWriteGuardMidPages[kExecutableWriteGuardMidPageCount]; + +} // namespace RecompMod diff --git a/runtime/include/guest_abi/flat_memory.h b/runtime/include/guest_abi/flat_memory.h new file mode 100644 index 0000000..a2c6130 --- /dev/null +++ b/runtime/include/guest_abi/flat_memory.h @@ -0,0 +1,59 @@ +#pragma once + +// Flat 4 GiB guest address space constants used by translated accesses; see guest_flat_memory.h. +#include +#include + +namespace GuestFlat { + +// Fixed base so the emitted access is `[reg + imm64-in-register]` with no load +// of a global. +inline constexpr uint64_t kGuestSpaceSize = 0x1'0000'0000ull; +inline constexpr size_t kGuestPageSize = 0x1000; +#if defined(__x86_64__) +// 16 TiB: clear of the Windows ASan shadow (32 TiB) and of the usual image/heap +// placement. +inline constexpr uintptr_t kFixedFlatGuestBase = 0x0000'1000'0000'0000ull; +#elif defined(__aarch64__) && defined(__APPLE__) +// Keep this well above the low address ranges that Darwin's ASLR may use for +// a PIE executable and its shared cache. Apple Silicon's user VA is wider +// than Linux's 39-bit minimum, so this 512 GiB region is available while the +// Linux AArch64 target retains its 64 GiB placement below. +inline constexpr uintptr_t kFixedFlatGuestBase = 0x0000'0080'0000'0000ull; +#elif defined(__aarch64__) +// 16 TiB (this arch's x86_64 sibling value) is unreachable on any AArch64 +// kernel configured for 39-bit virtual addresses (512 GiB ceiling) - common on +// older/embedded targets (e.g. this project's own tested Jetson/L4T board, kernel +// 4.9). There mmap() silently ignores a hint above the ceiling and hands back an +// address near the top of the real range instead, which this module's caller +// then rejects as "already occupied". 64 GiB is reachable on every AArch64 VA +// width in real use (39-bit minimum and up) and was confirmed via direct mmap +// probing to sit far below where the PIE image, heap, shared libraries and +// stack actually land (all clustered above ~340 GiB on a 39-bit/512 GiB system). +inline constexpr uintptr_t kFixedFlatGuestBase = 0x0000'0010'0000'0000ull; +#else +#error "guest_abi/flat_memory.h has no fixed flat guest base chosen for this architecture" +#endif + +#define MKW_FLAT_GUEST_BASE (reinterpret_cast(GuestFlat::kFixedFlatGuestBase)) + +// True when a host VM page covers more than one 4 KiB Wii page. In that +// configuration, guest-view page protection cannot safely represent per-Wii- +// page MMIO, deferred-read, or executable-write state, so general translated +// accesses must use the checked Memory::* path. +// Windows user mode and x86-64 always use a 4 KiB base page, so those builds +// fold this to a compile-time false: it appears in every flat access and must +// not become a hot-path load. Only AArch64, where the page size is a kernel +// configuration (4/16/64 KiB), has to probe it at runtime. +#if defined(_WIN32) || defined(__x86_64__) +#define MKW_GUEST_FLAT_FIXED_PAGE_SIZE 1 +#endif + +#if defined(MKW_GUEST_FLAT_FIXED_PAGE_SIZE) +inline constexpr bool RequiresCheckedAccess() noexcept { return false; } +#else +extern bool g_requiresCheckedAccess; +inline bool RequiresCheckedAccess() noexcept { return g_requiresCheckedAccess; } +#endif + +} // namespace GuestFlat diff --git a/runtime/include/guest_abi/guest_abi.h b/runtime/include/guest_abi/guest_abi.h new file mode 100644 index 0000000..3fd4a5e --- /dev/null +++ b/runtime/include/guest_abi/guest_abi.h @@ -0,0 +1,19 @@ +#pragma once + +// The only header translated code includes. Everything reachable from here is the +// guest ABI; runtime-only headers must never be pulled in. +#include "guest_abi/abi_version.h" + +#include "isa/ppc_isa_config.h" +#include "isa/big_endian.h" +#include "isa/ppc_isa_fpenv.h" +#include "guest_abi/cpu_context.h" +#include "isa/ppc_isa_int.h" +#include "isa/ppc_isa_float.h" +#include "guest_abi/flat_memory.h" +#include "guest_abi/exec_guard.h" +#include "guest_abi/memory.h" +#include "isa/ppc_isa_quantized.h" +#include "isa/ppc_isa_cr.h" +#include "guest_abi/guest_cr.h" +#include "guest_abi/dispatch.h" diff --git a/runtime/include/guest_abi/guest_abi_pch.h b/runtime/include/guest_abi/guest_abi_pch.h new file mode 100644 index 0000000..2d8be20 --- /dev/null +++ b/runtime/include/guest_abi/guest_abi_pch.h @@ -0,0 +1,7 @@ +#pragma once + +#include +#include +#include + +#include "guest_abi/guest_abi.h" diff --git a/runtime/include/guest_abi/guest_cr.h b/runtime/include/guest_abi/guest_cr.h new file mode 100644 index 0000000..46c58a4 --- /dev/null +++ b/runtime/include/guest_abi/guest_cr.h @@ -0,0 +1,66 @@ +#pragma once +#include "guest_abi/cpu_context.h" + +#include +#include + +// Condition Register Fields +#define CR_LT 0 +#define CR_GT 1 +#define CR_EQ 2 +#define CR_SO 3 + +[[noreturn]] void PPC_Undefined(uint32_t pc, uint32_t rawInstruction, const char* details); + +// Used by generated code when the translator encounters privileged/unmodeled PPC instructions +// (e.g. rfi). This is intentionally loud so missing HLE hooks are easy to find. +#define UNDEFINED(pc, rawInstruction, details) PPC_Undefined((pc), (rawInstruction), (details)) + +// Helper to set CR bits (Signed) +inline void SetCR(CpuContext* cpu, int field, int32_t a, int32_t b) { + uint32_t crField = 0; + if (a < b) crField |= 0x8; // LT + if (a > b) crField |= 0x4; // GT + if (a == b) crField |= 0x2; // EQ + crField |= (cpu->xer >> 31) & 1u; // SO + + int shift = (7 - field) * 4; + uint32_t mask = 0xF << shift; + cpu->cr = (cpu->cr & ~mask) | (crField << shift); +} + +// Helper to set CR bits (Unsigned) +inline void SetCR(CpuContext* cpu, int field, uint32_t a, uint32_t b) { + uint32_t crField = 0; + if (a < b) crField |= 0x8; // LT + if (a > b) crField |= 0x4; // GT + if (a == b) crField |= 0x2; // EQ + crField |= (cpu->xer >> 31) & 1u; // SO + + int shift = (7 - field) * 4; + uint32_t mask = 0xF << shift; + cpu->cr = (cpu->cr & ~mask) | (crField << shift); +} + +// Helper to set CR bits (Floating-point) +inline void SetCRFloat(CpuContext* cpu, int field, double a, double b) { + uint32_t crField = 0; + if (std::isnan(a) || std::isnan(b)) { + // Unordered: LT/GT/EQ clear, SO set. + crField = 0x1; + } else { + if (a < b) crField |= 0x8; // LT + if (a > b) crField |= 0x4; // GT + if (a == b) crField |= 0x2; // EQ + } + + int shift = (7 - field) * 4; + uint32_t mask = 0xF << shift; + cpu->cr = (cpu->cr & ~mask) | (crField << shift); +} + +// Helper to get CR bit +inline bool GetCRBit(CpuContext* cpu, int field, int bit) { + int shift = (7 - field) * 4 + (3 - bit); + return (cpu->cr >> shift) & 1; +} diff --git a/runtime/include/guest_abi/memory.h b/runtime/include/guest_abi/memory.h new file mode 100644 index 0000000..7a5f9ab --- /dev/null +++ b/runtime/include/guest_abi/memory.h @@ -0,0 +1,114 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +class Memory { +public: + using DeferredReadCallback = bool (*)(void* user); + + static constexpr size_t kMem1Size = 24u * 1024u * 1024u; + static constexpr size_t kMem2Size = 128u * 1024u * 1024u; + static constexpr uint32_t kMem1PhysicalBase = 0x00000000u; + static constexpr uint32_t kMem1CachedBase = 0x80000000u; + static constexpr uint32_t kMem1UncachedBase = 0xC0000000u; + static constexpr uint32_t kMem2PhysicalBase = 0x10000000u; + static constexpr uint32_t kMem2CachedBase = 0x90000000u; + static constexpr uint32_t kMem2UncachedBase = 0xD0000000u; + static constexpr uint32_t kMem2PhysicalEnd = + kMem2PhysicalBase + static_cast(kMem2Size); + static constexpr uint32_t kMem2CachedEnd = + kMem2CachedBase + static_cast(kMem2Size); + static constexpr uint32_t kMem2UncachedEnd = + kMem2UncachedBase + static_cast(kMem2Size); + + struct RegionConfig { + std::string name; + uint32_t baseAddress = 0; + size_t sizeBytes = 0; + }; + + struct Config { + std::vector regions; + static Config WiiDefaults(); + }; + + class AccessViolation : public std::runtime_error { + public: + AccessViolation(uint32_t address, size_t length, std::string_view reason); + + uint32_t address() const noexcept { return address_; } + size_t length() const noexcept { return length_; } + std::string_view reason() const noexcept { return reason_; } + + private: + uint32_t address_ = 0; + size_t length_ = 0; + std::string reason_; + }; + + static void Init(const Config& config); + // Single flat MEM1 region. Not used by the shipped runtime, but the + // translator integration-test harnesses emit calls to it. + static void Init(size_t mem1Size); + static void Reset(); + // Executable ranges are normally registered during startup. Rebuild the + // writable fast-path classification after each registration so a page + // previously classified as ordinary data cannot retain a stale direct + // write bias. + static void RefreshWritableFastPathsForExecutableRanges(); + + static uint8_t Read8(uint32_t addr); + static uint16_t Read16(uint32_t addr); + static uint32_t Read32(uint32_t addr); + static uint64_t Read64(uint32_t addr); + static float ReadFloat32(uint32_t addr); + static double ReadFloat64(uint32_t addr); + static void Write8(uint32_t addr, uint8_t val); + static void Write16(uint32_t addr, uint16_t val); + static void Write32(uint32_t addr, uint32_t val); + static void Write64(uint32_t addr, uint64_t val); + static void WriteFloat32(uint32_t addr, double val); + static void WriteFloat64(uint32_t addr, double val); + + // Exception-safe scalar access for HLE code. These keep Read32/Write32's + // full mapping behavior and only convert an unmapped address into a failure + // result; they are deliberately not MemoryInline::Try*GuestScalar, which is + // the translated-code fast path over the page table. + static bool TryRead32(uint32_t addr, uint32_t& value) noexcept { + try { + value = Read32(addr); + return true; + } catch (const AccessViolation&) { + return false; + } + } + + static bool TryWrite32(uint32_t addr, uint32_t value) noexcept { + try { + Write32(addr, value); + return true; + } catch (const AccessViolation&) { + return false; + } + } + + static uint8_t* GetPointer(uint32_t addr); + static uint8_t* GetPointer(uint32_t addr, size_t length); + static bool Contains(uint32_t addr, size_t length = 1); + + static uint64_t RegisterDeferredRead(uint32_t addr, size_t length, + DeferredReadCallback callback, void* user); + static void ClearDeferredReads(); + + // sizeBytes reports the storage actually allocated, which is not always the + // configured size (aliased MEM1/MEM2 windows are clamped to what is behind + // them). Used by the crash dump. + static std::vector DescribeRegions(); +}; + +#include "guest_abi/memory_access.h" diff --git a/runtime/include/guest_abi/memory_access.h b/runtime/include/guest_abi/memory_access.h new file mode 100644 index 0000000..289f928 --- /dev/null +++ b/runtime/include/guest_abi/memory_access.h @@ -0,0 +1,732 @@ +#pragma once + +#include "guest_abi/exec_guard.h" +#include "guest_abi/flat_memory.h" +#include "guest_abi/memory.h" + +#include +#include +#include + +// Hooks for GX HLE FIFO handling +extern "C" { + void GX_HLE_FIFO_WriteFloat(float val); + void GX_HLE_FIFO_Write32(uint32_t val); + void GX_HLE_FIFO_Write16(uint16_t val); + void GX_HLE_FIFO_Write8(uint8_t val); + void GX_HLE_FIFO_WriteBurst(const uint8_t* data, uint32_t sizeBytes); +} + +namespace MemoryInline { +#if defined(_WIN32) +#define MKW_MEMORY_FORCE_INLINE __forceinline +#define MKW_MEMORY_NO_INLINE __declspec(noinline) +#else +// See runtime/include/isa/ppc_isa_config.h for why non-Windows Clang needs the GNU-attribute +// spellings instead of the MS-extension keywords. +#define MKW_MEMORY_FORCE_INLINE __attribute__((always_inline)) inline +#define MKW_MEMORY_NO_INLINE __attribute__((noinline)) +#endif +#define MKW_MEMORY_COLD __attribute__((cold)) +inline constexpr uint32_t kPageShift = 20; +inline constexpr uint32_t kPageSize = 1u << kPageShift; +inline constexpr uint32_t kPageMask = kPageSize - 1u; +inline constexpr uint32_t kPageCount = 1u << (32 - kPageShift); +inline constexpr uint32_t kMaxFastScalarSize = 8; +inline constexpr uint32_t kWritableSubPageShift = RecompMod::kExecutableWriteGuardPageShift; +inline constexpr uint32_t kWritableSubPageSize = 1u << kWritableSubPageShift; +inline constexpr uint32_t kWritableSubPagesPerPage = kPageSize / kWritableSubPageSize; + +struct PageEntry { + uint8_t* base = nullptr; + uint32_t limit = 0; +}; + +extern PageEntry g_pageTable[kPageCount]; +// Encoded (host page base - guest page base) + 1 for full pages whose next +// page is contiguous. This permits any native access up to 8 bytes without a +// per-access mask/limit check. Zero retains the general PageEntry fallback. +extern uintptr_t g_fullPageBias[kPageCount]; +// Runtime-active readable biases. Deferred-read pages clear these entries once +// instead of paying a mode branch on every translated read. +extern uintptr_t g_fullReadablePageBias[kPageCount]; +// Same encoding, but only for pages proven not to contain executable bytes. +// Executable-range registration invalidates entries before guest execution. +extern uintptr_t g_fullWritablePageBias[kPageCount]; + +// Allocated only for mapped 1 MiB pages that contain both executable and data +// 4 KiB pages. Entries use the same encoded host bias as the coarse table. +// Exact executable bits remain authoritative and are checked at lookup time, +// including both sides of a cross-4-KiB access. +struct SparseWritablePageTable { + uintptr_t encodedBias[kWritableSubPagesPerPage]{}; +}; +extern const SparseWritablePageTable* g_sparseWritablePageTables[kPageCount]; + +// Nonzero while any registered deferred read overlaps the page. Small +// mappings (e.g. the 16 KiB locked cache) have no coarse bias entry, so a +// zero readable bias alone cannot distinguish "deferred content pending" +// from "small but plain memory"; range resolution needs the explicit flag. +extern uint8_t g_deferredReadCoveredPages[kPageCount]; + +MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD uint8_t Read8Slow(uint32_t addr); +MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD uint16_t Read16Slow(uint32_t addr); +MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD uint32_t Read32Slow(uint32_t addr); +MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD uint64_t Read64Slow(uint32_t addr); +MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD float ReadFloat32Slow(uint32_t addr); +MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD double ReadFloat64Slow(uint32_t addr); +MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD void Write8Slow(uint32_t addr, uint8_t val); +MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD void Write16Slow(uint32_t addr, uint16_t val); +MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD void Write32Slow(uint32_t addr, uint32_t val); +MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD void Write64Slow(uint32_t addr, uint64_t val); +MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD void WriteFloat32Slow(uint32_t addr, double val); +MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD void WriteFloat64Slow(uint32_t addr, double val); +template +MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD T ReadResolvedFallback(uint32_t addr); +MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD float ReadResolvedFallbackFloat32(uint32_t addr); +MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD double ReadResolvedFallbackFloat64(uint32_t addr); +template +MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD void WriteResolvedFallback(uint32_t addr, T value); +MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD void WriteResolvedFallbackFloat32(uint32_t addr, double val); +MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD void WriteResolvedFallbackFloat64(uint32_t addr, double val); +bool ResolveDeferredReads(uint32_t addr, size_t length); + +constexpr bool IsMmioAddress(uint32_t addr) { + return addr >= 0xCC000000u && addr < 0xCE000000u; +} + +constexpr bool IsGpuFifoAddress(uint32_t addr) { + return addr >= 0xCC008000u && addr < 0xCC008100u; +} + +// Page protections can't cover this: an MMIO write must reach GX HLE with its value or be +// reported, and a fault record can't carry the value, so this mask/compare sits in front of +// every flat store instead. +MKW_MEMORY_FORCE_INLINE bool FlatWriteNeedsPolicy(uint32_t address) { + return (address & 0xFE000000u) == 0xCC000000u; // 0xCC000000..0xCDFFFFFF +} + +// Gekko stfs conversion is a bit-level narrowing operation. In particular it +// does not behave like a host double-to-float cast for values which were left +// in double precision, and it has hardware-tested handling for tiny values. +MKW_MEMORY_FORCE_INLINE uint32_t ConvertPpcDoubleToSingleBits(double value) { + uint64_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + const uint32_t exponent = static_cast((bits >> 52) & 0x7FFu); + // The subnormal-single arm applies to exactly the exponents 874..896; every + // other exponent takes the plain sign/exponent/fraction narrowing below. + // Inside that window the exponent field is nonzero, so the magnitude cannot + // be zero and needs no separate test - the zero case (exponent 0) reaches + // the narrowing exactly as it did when the two arms shared that test. + if (exponent - 874u <= 22u) [[unlikely]] + { + uint32_t narrowed = static_cast( + 0x80000000ULL | ((bits & 0x000FFFFFFFFFFFFFULL) >> 21)); + narrowed >>= (905u - exponent); + narrowed |= static_cast((bits >> 32) & 0x80000000ULL); + return narrowed; + } + + // Results below the documented conversion range are architecturally + // undefined; this is the behavior measured on Gekko/Broadway hardware. + return static_cast( + ((bits >> 32) & 0xC0000000ULL) | ((bits >> 29) & 0x3FFFFFFFULL)); +} + +MKW_MEMORY_FORCE_INLINE float PpcSingleBitsToFloat(uint32_t bits) { + float value = 0.0f; + std::memcpy(&value, &bits, sizeof(value)); + return value; +} + +MKW_MEMORY_FORCE_INLINE bool TryGetPointerFast(uint32_t address, size_t length, uint8_t*& pointer) { + const uint32_t page = address >> kPageShift; + if (length <= 8) { + const uintptr_t encodedBias = g_fullPageBias[page]; + if (encodedBias != 0) { + pointer = reinterpret_cast((encodedBias - 1u) + address); + return true; + } + } + const uint32_t offset = address & kPageMask; + const auto& entry = g_pageTable[page]; + if (!entry.base || offset + length > entry.limit) { + pointer = nullptr; + return false; + } + pointer = entry.base + offset; + return true; +} + +inline uint8_t* GetPointerFast(uint32_t address, size_t length) { + uint8_t* pointer = nullptr; + return TryGetPointerFast(address, length, pointer) ? pointer : nullptr; +} + +MKW_MEMORY_FORCE_INLINE bool TryGetWritablePointerFast( + uint32_t address, size_t length, uint8_t*& pointer) { + // The sparse-table hit below distinguishes itself from the small-mapping + // hit through `pointer`, so the out-parameter must start null regardless + // of what the caller passed in. (A caller handing in an uninitialized + // pointer used to turn every sparse hit into a write through stack + // garbage - random host memory corruption.) + pointer = nullptr; + if (length == 0 || length > 8 || address > UINT32_MAX - (length - 1)) + return false; + const uint32_t coarsePage = address >> kPageShift; + uintptr_t encodedBias = g_fullWritablePageBias[coarsePage]; + const uint32_t endAddress = address + static_cast(length - 1); + const uint32_t firstExactPage = address >> kWritableSubPageShift; + const uint32_t lastExactPage = endAddress >> kWritableSubPageShift; + if (encodedBias != 0 && (endAddress >> kPageShift) != coarsePage && + RecompMod::g_executableWriteGuardPages[lastExactPage].load( + std::memory_order_relaxed) != 0) { + return false; + } + if (encodedBias == 0) { + const auto* subTable = g_sparseWritablePageTables[coarsePage]; + if (subTable != nullptr) { + encodedBias = subTable->encodedBias[ + (address & kPageMask) >> kWritableSubPageShift]; + if (encodedBias == 0) + return false; + } else { + // Small mappings such as Broadway's 16 KiB locked cache cannot + // populate the full-1-MiB bias table. Keep them native by proving + // the exact access against the ordinary page entry, then applying + // the same executable-write policy as a sparse-table hit. + const uint32_t offset = address & kPageMask; + const auto& entry = g_pageTable[coarsePage]; + if (!entry.base || offset + length > entry.limit) + return false; + pointer = entry.base + offset; + } + + // The exact 4 KiB guard is the final authority. Checking it on every + // checked/sparse hit also makes later executable-range registration + // safe when a prebuilt table still contains the old mapped bias. + if (RecompMod::g_executableWriteGuardPages[firstExactPage].load( + std::memory_order_relaxed) != 0 || + (lastExactPage != firstExactPage && + RecompMod::g_executableWriteGuardPages[lastExactPage].load( + std::memory_order_relaxed) != 0)) { + return false; + } + + if (pointer != nullptr) + return true; + } + if (encodedBias == 0) + return false; + pointer = reinterpret_cast((encodedBias - 1u) + address); + return true; +} + +// Flat form: guest_flat_memory.h's page protections already answer mapped/non-deferred/non-executable, so resolving is pure address arithmetic. +// Two checks stay inline: a wrapped guest address can't survive 64-bit `host + rangeOffset`, and an MMIO write's value isn't recoverable from a +// fault record, so a write touching that window must resolve null and fall back to Memory::Write*. Checking both range endpoints is a complete +// proof since length <= kPageSize (1 MiB) can't straddle the 32 MiB MMIO window. +MKW_MEMORY_FORCE_INLINE uint8_t* ResolveRangeHost(uint32_t base, int32_t minOffset, uint32_t length, + bool needsRead, bool needsWrite) { + (void)needsRead; + const uint32_t guestStart = base + static_cast(minOffset); + if (length == 0 || length > kPageSize || guestStart > UINT32_MAX - (length - 1)) return nullptr; + if (GuestFlat::RequiresCheckedAccess()) { + // A host page can cover multiple independently-special Wii pages. + // Returning null keeps resolved accesses on the checked Memory::* + // path, which materializes deferred reads and applies write policy. + (void)needsWrite; + return nullptr; + } + if (needsWrite && + (FlatWriteNeedsPolicy(guestStart) || FlatWriteNeedsPolicy(guestStart + (length - 1)))) + [[unlikely]] return nullptr; + return MKW_FLAT_GUEST_BASE + guestStart; +} + +// Guest-address byte order. Distinct from isa/big_endian.h, which is the +// host-pointer codec; do not "unify" them. +inline uint16_t ByteSwap16(uint16_t value) { + return __builtin_bswap16(value); +} + +inline uint32_t ByteSwap32(uint32_t value) { + return __builtin_bswap32(value); +} + +inline uint64_t ByteSwap64(uint64_t value) { + return __builtin_bswap64(value); +} + +template +inline T MaybeByteSwap(T value) { + if constexpr (sizeof(T) == 1) { + return value; + } else if constexpr (sizeof(T) == 2) { + return static_cast(ByteSwap16(static_cast(value))); + } else if constexpr (sizeof(T) == 4) { + return static_cast(ByteSwap32(static_cast(value))); + } else if constexpr (sizeof(T) == 8) { + return static_cast(ByteSwap64(static_cast(value))); + } else { + return value; + } +} + +template +MKW_MEMORY_FORCE_INLINE bool ReadResolvedScalar(uint8_t* host, uint32_t rangeOffset, T& outValue) { + if (!host) return false; + if constexpr (sizeof(T) == 1) { + outValue = host[rangeOffset]; + } else { + T value = 0; + std::memcpy(&value, host + rangeOffset, sizeof(T)); + outValue = MaybeByteSwap(value); + } + return true; +} + +struct ResolvedLoadPair { + uint32_t first = 0; + uint32_t second = 0; + bool valid = false; +}; + +MKW_MEMORY_FORCE_INLINE ResolvedLoadPair ReadResolvedPair16( + uint8_t* host, uint32_t rangeOffset) { + uint32_t packed = 0; + if (!ReadResolvedScalar(host, rangeOffset, packed)) return {}; + return {packed >> 16, packed & 0xFFFFu, true}; +} + +MKW_MEMORY_FORCE_INLINE ResolvedLoadPair ReadResolvedPair32(uint8_t* host, uint32_t rangeOffset) { + uint64_t packed = 0; + if (!ReadResolvedScalar(host, rangeOffset, packed)) return {}; + return {static_cast(packed >> 32), static_cast(packed), true}; +} + +template +MKW_MEMORY_FORCE_INLINE bool WriteResolvedPairFast( + uint8_t* host, uint32_t rangeOffset, Packed packed) { + if (!host) return false; + const Packed swapped = MaybeByteSwap(packed); + std::memcpy(host + rangeOffset, &swapped, sizeof(swapped)); + return true; +} + +MKW_MEMORY_FORCE_INLINE bool WriteResolvedPair16( + uint8_t* host, uint32_t rangeOffset, uint32_t packed) { + return WriteResolvedPairFast(host, rangeOffset, packed); +} + +MKW_MEMORY_FORCE_INLINE bool WriteResolvedPair32( + uint8_t* host, uint32_t rangeOffset, uint64_t packed) { + return WriteResolvedPairFast(host, rangeOffset, packed); +} + +template +MKW_MEMORY_FORCE_INLINE bool WriteResolvedScalar(uint8_t* host, uint32_t rangeOffset, T value) { + if (!host) return false; + if constexpr (sizeof(T) == 1) { + host[rangeOffset] = static_cast(value); + } else { + const T swapped = MaybeByteSwap(value); + std::memcpy(host + rangeOffset, &swapped, sizeof(T)); + } + return true; +} + +template +inline bool TryReadMappedScalar(uint32_t address, T& outValue) { + uint8_t* ptr = nullptr; + if (TryGetPointerFast(address, sizeof(T), ptr)) { + if constexpr (sizeof(T) == 1) { + outValue = *ptr; + } else { + T value = 0; + std::memcpy(&value, ptr, sizeof(T)); + outValue = MaybeByteSwap(value); + } + return true; + } + return false; +} + +template +MKW_MEMORY_FORCE_INLINE bool TryReadGuestScalar(uint32_t address, T& outValue) { + const uintptr_t encodedBias = g_fullReadablePageBias[address >> kPageShift]; + if (encodedBias == 0) [[unlikely]] + return false; + auto* ptr = reinterpret_cast((encodedBias - 1u) + address); + if constexpr (sizeof(T) == 1) { + outValue = *ptr; + } else { + T value = 0; + std::memcpy(&value, ptr, sizeof(T)); + outValue = MaybeByteSwap(value); + } + return true; +} + +template +MKW_MEMORY_FORCE_INLINE bool TryWriteGuestScalar(uint32_t address, T value) { + static_assert(sizeof(T) >= 1 && sizeof(T) <= kMaxFastScalarSize); + // A mixed executable/data 1 MiB page zeroes the coarse writable bias even though most of + // its 4 KiB sub-pages are plain data; MKW's THP buffers share such a page with .text, which + // used to force ~15% of total CPU through the cold path. The sparse sub-page tier below + // keeps those stores native while the exact 4 KiB executable guards stay authoritative. + if (address > UINT32_MAX - static_cast(sizeof(T) - 1u)) [[unlikely]] + return false; + const uint32_t coarsePage = address >> kPageShift; + const uint32_t endAddress = address + static_cast(sizeof(T) - 1u); + if ((endAddress >> kPageShift) != coarsePage) [[unlikely]] + return false; + const uintptr_t encodedBias = g_fullWritablePageBias[coarsePage]; + uint8_t* ptr = nullptr; + if (encodedBias != 0) { + ptr = reinterpret_cast((encodedBias - 1u) + address); + } else if (!TryGetWritablePointerFast(address, sizeof(T), ptr)) [[unlikely]] { + return false; + } + if constexpr (sizeof(T) == 1) { + *ptr = static_cast(value); + } else { + const T swapped = MaybeByteSwap(value); + std::memcpy(ptr, &swapped, sizeof(T)); + } + return true; +} + +template +inline bool WriteStackScalarFast(uint32_t address, T value) { + uint8_t* ptr = nullptr; + if (TryGetPointerFast(address, sizeof(T), ptr)) { + if constexpr (sizeof(T) == 1) { + *ptr = static_cast(value); + } else { + const T swapped = MaybeByteSwap(value); + std::memcpy(ptr, &swapped, sizeof(T)); + } + return true; + } + return false; +} + +inline uint8_t ReadStack8(uint32_t address) { + uint8_t value = 0; + return TryReadMappedScalar(address, value) ? value : Memory::Read8(address); +} + +inline uint16_t ReadStack16(uint32_t address) { + uint16_t value = 0; + return TryReadMappedScalar(address, value) ? value : Memory::Read16(address); +} + +inline uint32_t ReadStack32(uint32_t address) { + uint32_t value = 0; + return TryReadMappedScalar(address, value) ? value : Memory::Read32(address); +} + +inline uint64_t ReadStack64(uint32_t address) { + uint64_t value = 0; + return TryReadMappedScalar(address, value) ? value : Memory::Read64(address); +} + + + +inline void WriteStack8(uint32_t address, uint8_t value) { + if (!WriteStackScalarFast(address, value)) { + Memory::Write8(address, value); + } +} + +inline void WriteStack16(uint32_t address, uint16_t value) { + if (!WriteStackScalarFast(address, value)) { + Memory::Write16(address, value); + } +} + +inline void WriteStack32(uint32_t address, uint32_t value) { + if (!WriteStackScalarFast(address, value)) { + Memory::Write32(address, value); + } +} + +inline void WriteStack64(uint32_t address, uint64_t value) { + if (!WriteStackScalarFast(address, value)) { + Memory::Write64(address, value); + } +} + +inline void WriteStackFloat32(uint32_t address, double value) { + const uint32_t bits = ConvertPpcDoubleToSingleBits(value); + if (!WriteStackScalarFast(address, bits)) { + Memory::WriteFloat32(address, value); + } +} + +inline void WriteStackFloat64(uint32_t address, double value) { + uint64_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + if (!WriteStackScalarFast(address, bits)) { + Memory::WriteFloat64(address, value); + } +} + +template +MKW_MEMORY_FORCE_INLINE T ReadResolved(uint8_t* host, uint32_t rangeOffset, uint32_t address, + SlowRead slow) { + T value = 0; + if (ReadResolvedScalar(host, rangeOffset, value)) { + return value; + } + [[unlikely]] return slow(address); +} + +MKW_MEMORY_FORCE_INLINE uint8_t ReadResolved8(uint8_t* r, uint32_t o, uint32_t a) { return ReadResolved(r, o, a, ReadResolvedFallback); } +MKW_MEMORY_FORCE_INLINE uint16_t ReadResolved16(uint8_t* r, uint32_t o, uint32_t a) { return ReadResolved(r, o, a, ReadResolvedFallback); } +MKW_MEMORY_FORCE_INLINE uint32_t ReadResolved32(uint8_t* r, uint32_t o, uint32_t a) { return ReadResolved(r, o, a, ReadResolvedFallback); } +// Live via isa/ppc_isa_quantized.h (the psq resolved tier packs two lanes into +// one 64-bit access); generated code never names it directly. +MKW_MEMORY_FORCE_INLINE uint64_t ReadResolved64(uint8_t* r, uint32_t o, uint32_t a) { return ReadResolved(r, o, a, ReadResolvedFallback); } +MKW_MEMORY_FORCE_INLINE float ReadResolvedFloat32(uint8_t* r, uint32_t o, uint32_t a) { + uint32_t bits = 0; + if (!ReadResolvedScalar(r, o, bits)) [[unlikely]] return ReadResolvedFallbackFloat32(a); + float value; std::memcpy(&value, &bits, sizeof(value)); return value; +} +MKW_MEMORY_FORCE_INLINE double ReadResolvedFloat64(uint8_t* r, uint32_t o, uint32_t a) { + uint64_t bits = 0; + if (!ReadResolvedScalar(r, o, bits)) [[unlikely]] return ReadResolvedFallbackFloat64(a); + double value; std::memcpy(&value, &bits, sizeof(value)); return value; +} + +template +MKW_MEMORY_FORCE_INLINE void WriteResolved(uint8_t* host, uint32_t rangeOffset, uint32_t address, T value, + SlowWrite slow) { + if (WriteResolvedScalar(host, rangeOffset, value)) { + return; + } + [[unlikely]] slow(address, value); +} + +MKW_MEMORY_FORCE_INLINE void WriteResolved8(uint8_t* r, uint32_t o, uint32_t a, uint8_t v) { WriteResolved(r, o, a, v, WriteResolvedFallback); } +MKW_MEMORY_FORCE_INLINE void WriteResolved16(uint8_t* r, uint32_t o, uint32_t a, uint16_t v) { WriteResolved(r, o, a, v, WriteResolvedFallback); } +MKW_MEMORY_FORCE_INLINE void WriteResolved32(uint8_t* r, uint32_t o, uint32_t a, uint32_t v) { WriteResolved(r, o, a, v, WriteResolvedFallback); } +// Live via isa/ppc_isa_quantized.h, as ReadResolved64 above. +MKW_MEMORY_FORCE_INLINE void WriteResolved64(uint8_t* r, uint32_t o, uint32_t a, uint64_t v) { WriteResolved(r, o, a, v, WriteResolvedFallback); } +MKW_MEMORY_FORCE_INLINE void WriteResolvedFloat32(uint8_t* r, uint32_t o, uint32_t a, double v) { + const uint32_t bits = ConvertPpcDoubleToSingleBits(v); + if (WriteResolvedScalar(r, o, bits)) return; + [[unlikely]] WriteResolvedFallbackFloat32(a, v); +} +MKW_MEMORY_FORCE_INLINE void WriteResolvedFloat64(uint8_t* r, uint32_t o, uint32_t a, double v) { + uint64_t bits; std::memcpy(&bits, &v, sizeof(bits)); + if (WriteResolvedScalar(r, o, bits)) return; + [[unlikely]] WriteResolvedFallbackFloat64(a, v); +} + +// Flat guest memory (audit item T-MEM): the 4 GiB reservation makes a guest access a byte swap +// around `*(T*)(base + addr)`, no page-table load or limit check (interception model documented +// in guest_flat_memory.h). The one exception kept inline is the MMIO write policy, since the +// written value can't be recovered from a fault record. +// +// When a host VM page is larger than a 4 KiB Wii page, guest-view protections +// cannot distinguish adjacent special Wii pages. The general FlatRead*/ +// FlatWrite* helpers then use the checked page-table path, which materializes +// deferred reads and applies executable-write/MMIO policy before touching RAM. +// FlatWriteRam* remains direct because the translator emits it only for +// addresses it has proven are ordinary RAM. + +template +MKW_MEMORY_FORCE_INLINE T FlatLoad(uint32_t address) { + T value{}; + std::memcpy(&value, MKW_FLAT_GUEST_BASE + address, sizeof(T)); + return MaybeByteSwap(value); +} + +template +MKW_MEMORY_FORCE_INLINE void FlatStore(uint32_t address, T value) { + const T swapped = MaybeByteSwap(value); + std::memcpy(MKW_FLAT_GUEST_BASE + address, &swapped, sizeof(T)); +} + +MKW_MEMORY_FORCE_INLINE uint8_t FlatRead8(uint32_t address) { + if (GuestFlat::RequiresCheckedAccess()) return Memory::Read8(address); + return FlatLoad(address); +} +MKW_MEMORY_FORCE_INLINE uint16_t FlatRead16(uint32_t address) { + if (GuestFlat::RequiresCheckedAccess()) return Memory::Read16(address); + return FlatLoad(address); +} +MKW_MEMORY_FORCE_INLINE uint32_t FlatRead32(uint32_t address) { + if (GuestFlat::RequiresCheckedAccess()) return Memory::Read32(address); + return FlatLoad(address); +} + +MKW_MEMORY_FORCE_INLINE float FlatReadFloat32(uint32_t address) { + const uint32_t bits = FlatRead32(address); + float value = 0.0f; + std::memcpy(&value, &bits, sizeof(value)); + return value; +} + +MKW_MEMORY_FORCE_INLINE double FlatReadFloat64(uint32_t address) { + const uint64_t bits = GuestFlat::RequiresCheckedAccess() + ? Memory::Read64(address) : FlatLoad(address); + double value = 0.0; + std::memcpy(&value, &bits, sizeof(value)); + return value; +} + +MKW_MEMORY_FORCE_INLINE void FlatWrite8(uint32_t address, uint8_t value) { + if (GuestFlat::RequiresCheckedAccess()) { Memory::Write8(address, value); return; } + if (FlatWriteNeedsPolicy(address)) [[unlikely]] { Write8Slow(address, value); return; } + FlatStore(address, value); +} + +MKW_MEMORY_FORCE_INLINE void FlatWrite16(uint32_t address, uint16_t value) { + if (GuestFlat::RequiresCheckedAccess()) { Memory::Write16(address, value); return; } + if (FlatWriteNeedsPolicy(address)) [[unlikely]] { Write16Slow(address, value); return; } + FlatStore(address, value); +} + +MKW_MEMORY_FORCE_INLINE void FlatWrite32(uint32_t address, uint32_t value) { + if (GuestFlat::RequiresCheckedAccess()) { Memory::Write32(address, value); return; } + if (FlatWriteNeedsPolicy(address)) [[unlikely]] { Write32Slow(address, value); return; } + FlatStore(address, value); +} + + +MKW_MEMORY_FORCE_INLINE void FlatWriteFloat32(uint32_t address, double value) { + if (GuestFlat::RequiresCheckedAccess()) { Memory::WriteFloat32(address, value); return; } + const uint32_t bits = ConvertPpcDoubleToSingleBits(value); + if (FlatWriteNeedsPolicy(address)) [[unlikely]] { WriteFloat32Slow(address, value); return; } + FlatStore(address, bits); +} + +MKW_MEMORY_FORCE_INLINE void FlatWriteFloat64(uint32_t address, double value) { + if (GuestFlat::RequiresCheckedAccess()) { Memory::WriteFloat64(address, value); return; } + uint64_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + if (FlatWriteNeedsPolicy(address)) [[unlikely]] { WriteFloat64Slow(address, value); return; } + FlatStore(address, bits); +} + +// Check-free stores: emitted ONLY for addresses the translator proved at translate time are ordinary guest RAM (r1-relative stack slots, ~45% +// of flat stores), skipping the MMIO mask/compare that's pure overhead there. Still safe if that proof were ever wrong: the flat view maps +// 0xCC000000..0xCDFFFFFF PAGE_NOACCESS, so a stray MMIO store faults into the same handler and diagnostic as the checked path, just reported +// instead of dispatched inline. Never use these for an address the translator hasn't proven. + +MKW_MEMORY_FORCE_INLINE void FlatWriteRam8(uint32_t address, uint8_t value) { + FlatStore(address, value); +} + +MKW_MEMORY_FORCE_INLINE void FlatWriteRam16(uint32_t address, uint16_t value) { + FlatStore(address, value); +} + +MKW_MEMORY_FORCE_INLINE void FlatWriteRam32(uint32_t address, uint32_t value) { + FlatStore(address, value); +} + + +MKW_MEMORY_FORCE_INLINE void FlatWriteRamFloat32(uint32_t address, double value) { + FlatStore(address, ConvertPpcDoubleToSingleBits(value)); +} + +MKW_MEMORY_FORCE_INLINE void FlatWriteRamFloat64(uint32_t address, double value) { + uint64_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + FlatStore(address, bits); +} + +} // namespace MemoryInline + +#undef MKW_MEMORY_FORCE_INLINE +#undef MKW_MEMORY_NO_INLINE +#undef MKW_MEMORY_COLD + +inline uint8_t Memory::Read8(uint32_t addr) { + uint8_t value = 0; + if (!MemoryInline::TryReadGuestScalar(addr, value)) [[unlikely]] + return MemoryInline::Read8Slow(addr); + return value; +} + +inline uint16_t Memory::Read16(uint32_t addr) { + uint16_t value = 0; + if (!MemoryInline::TryReadGuestScalar(addr, value)) [[unlikely]] + return MemoryInline::Read16Slow(addr); + return value; +} + +inline uint32_t Memory::Read32(uint32_t addr) { + uint32_t value = 0; + if (!MemoryInline::TryReadGuestScalar(addr, value)) [[unlikely]] + return MemoryInline::Read32Slow(addr); + return value; +} + +inline uint64_t Memory::Read64(uint32_t addr) { + uint64_t value = 0; + if (!MemoryInline::TryReadGuestScalar(addr, value)) [[unlikely]] + return MemoryInline::Read64Slow(addr); + return value; +} + +inline float Memory::ReadFloat32(uint32_t addr) { + uint32_t bits = 0; + if (!MemoryInline::TryReadGuestScalar(addr, bits)) [[unlikely]] + return MemoryInline::ReadFloat32Slow(addr); + float value = 0.0f; + std::memcpy(&value, &bits, sizeof(value)); + return value; +} + +inline double Memory::ReadFloat64(uint32_t addr) { + uint64_t bits = 0; + if (!MemoryInline::TryReadGuestScalar(addr, bits)) [[unlikely]] + return MemoryInline::ReadFloat64Slow(addr); + double value = 0.0; + std::memcpy(&value, &bits, sizeof(value)); + return value; +} + +inline void Memory::Write8(uint32_t addr, uint8_t val) { + if (!MemoryInline::TryWriteGuestScalar(addr, val)) [[unlikely]] + MemoryInline::Write8Slow(addr, val); +} + +inline void Memory::Write16(uint32_t addr, uint16_t val) { + if (!MemoryInline::TryWriteGuestScalar(addr, val)) [[unlikely]] + MemoryInline::Write16Slow(addr, val); +} + +inline void Memory::Write32(uint32_t addr, uint32_t val) { + if (!MemoryInline::TryWriteGuestScalar(addr, val)) [[unlikely]] + MemoryInline::Write32Slow(addr, val); +} + +inline void Memory::Write64(uint32_t addr, uint64_t val) { + if (!MemoryInline::TryWriteGuestScalar(addr, val)) [[unlikely]] + MemoryInline::Write64Slow(addr, val); +} + +inline void Memory::WriteFloat32(uint32_t addr, double val) { + { + const uint32_t bits = MemoryInline::ConvertPpcDoubleToSingleBits(val); + if (MemoryInline::TryWriteGuestScalar(addr, bits)) + return; + } + [[unlikely]] MemoryInline::WriteFloat32Slow(addr, val); +} + +inline void Memory::WriteFloat64(uint32_t addr, double val) { + { + uint64_t bits = 0; + std::memcpy(&bits, &val, sizeof(bits)); + if (MemoryInline::TryWriteGuestScalar(addr, bits)) + return; + } + [[unlikely]] MemoryInline::WriteFloat64Slow(addr, val); +} diff --git a/runtime/include/native_cpu_calls.inc b/runtime/include/guest_abi/native_cpu_calls.inc similarity index 97% rename from runtime/include/native_cpu_calls.inc rename to runtime/include/guest_abi/native_cpu_calls.inc index e8bc77d..83fa99a 100644 --- a/runtime/include/native_cpu_calls.inc +++ b/runtime/include/guest_abi/native_cpu_calls.inc @@ -1,9 +1,10 @@ // Native call catalog used by InvokeDirectCpu. Keep project-specific HLE -// address mappings here; abi_bridge.h contains only the generic ABI machinery. +// address mappings here; dispatch.h contains only the generic ABI machinery. +// Part of the guest ABI: translated shards bind these calls at compile time. // // This is a .inc, not a header: it has no includes of its own and references // CpuContext, KnownTypedNativeCpuCall<> and KnownNativeCpuCall<>, all of which -// are declared above its single mid-file include site in abi_bridge.h. It +// are declared above its single mid-file include site in dispatch.h. It // cannot compile standalone and must never be included anywhere else. extern "C" void nw4r__lyt__detail__DrawQuad_800847c0(CpuContext* ctx); diff --git a/runtime/include/guest_flat_memory.h b/runtime/include/guest_flat_memory.h index 6c0bdd3..ca9fc46 100644 --- a/runtime/include/guest_flat_memory.h +++ b/runtime/include/guest_flat_memory.h @@ -9,39 +9,10 @@ #include #include +#include "guest_abi/flat_memory.h" + namespace GuestFlat { -// Fixed base so the emitted access is `[reg + imm64-in-register]` with no load -// of a global. -inline constexpr uint64_t kGuestSpaceSize = 0x1'0000'0000ull; -inline constexpr size_t kGuestPageSize = 0x1000; -#if defined(__x86_64__) -// 16 TiB: clear of the Windows ASan shadow (32 TiB) and of the usual image/heap -// placement. -inline constexpr uintptr_t kFixedFlatGuestBase = 0x0000'1000'0000'0000ull; -#elif defined(__aarch64__) && defined(__APPLE__) -// Keep this well above the low address ranges that Darwin's ASLR may use for -// a PIE executable and its shared cache. Apple Silicon's user VA is wider -// than Linux's 39-bit minimum, so this 512 GiB region is available while the -// Linux AArch64 target retains its 64 GiB placement below. -inline constexpr uintptr_t kFixedFlatGuestBase = 0x0000'0080'0000'0000ull; -#elif defined(__aarch64__) -// 16 TiB (this arch's x86_64 sibling value) is unreachable on any AArch64 -// kernel configured for 39-bit virtual addresses (512 GiB ceiling) - common on -// older/embedded targets (e.g. this project's own tested Jetson/L4T board, kernel -// 4.9). There mmap() silently ignores a hint above the ceiling and hands back an -// address near the top of the real range instead, which this module's caller -// then rejects as "already occupied". 64 GiB is reachable on every AArch64 VA -// width in real use (39-bit minimum and up) and was confirmed via direct mmap -// probing to sit far below where the PIE image, heap, shared libraries and -// stack actually land (all clustered above ~340 GiB on a 39-bit/512 GiB system). -inline constexpr uintptr_t kFixedFlatGuestBase = 0x0000'0010'0000'0000ull; -#else -#error "guest_flat_memory.h has no fixed flat guest base chosen for this architecture" -#endif - -#define MKW_FLAT_GUEST_BASE (reinterpret_cast(GuestFlat::kFixedFlatGuestBase)) - enum class Backing { Owned, Mem1, @@ -65,25 +36,6 @@ struct FaultCounters { // True once the reservation exists and translated code may use the flat path. bool IsActive(); -// True when a host VM page covers more than one 4 KiB Wii page. In that -// configuration, guest-view page protection cannot safely represent per-Wii- -// page MMIO, deferred-read, or executable-write state, so general translated -// accesses must use the checked Memory::* path. -// Windows user mode and x86-64 always use a 4 KiB base page, so those builds -// fold this to a compile-time false: it appears in every flat access and must -// not become a hot-path load. Only AArch64, where the page size is a kernel -// configuration (4/16/64 KiB), has to probe it at runtime. -#if defined(_WIN32) || defined(__x86_64__) -#define MKW_GUEST_FLAT_FIXED_PAGE_SIZE 1 -#endif - -#if defined(MKW_GUEST_FLAT_FIXED_PAGE_SIZE) -inline constexpr bool RequiresCheckedAccess() noexcept { return false; } -#else -extern bool g_requiresCheckedAccess; -inline bool RequiresCheckedAccess() noexcept { return g_requiresCheckedAccess; } -#endif - // Reserves the 4 GiB space (once per process) and maps every requested region // into both views. Throws std::runtime_error with a precise diagnosis when the // reservation, the section objects or a view cannot be created - a silent diff --git a/runtime/include/guest_small_data.h b/runtime/include/guest_small_data.h new file mode 100644 index 0000000..78138a9 --- /dev/null +++ b/runtime/include/guest_small_data.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +// PowerPC EABI small-data base pointers of the translated game. Defined per game +// in runtime/game/runtime_config_values.cpp so the runtime itself stays game-independent. +namespace GuestSmallData { +extern const uint32_t kSda1Base; // _SDA_BASE_, loaded into r13 +extern const uint32_t kSda2Base; // _SDA2_BASE_, loaded into r2 +} // namespace GuestSmallData diff --git a/runtime/include/isa/ppc_isa_context.h b/runtime/include/isa/ppc_isa_context.h index 227987e..c560295 100644 --- a/runtime/include/isa/ppc_isa_context.h +++ b/runtime/include/isa/ppc_isa_context.h @@ -1,5 +1,6 @@ #pragma once #include "ppc_isa_fpenv.h" +#include "guest_abi/cpu_context.h" #include #include @@ -10,85 +11,6 @@ void ShowRuntimeFatalPopup(std::string_view category, std::string_view details) noexcept; -union PPC_FPR { - uint64_t raw; - double d; - struct { -#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ - float ps1; // Low word (Least Significant) - float ps0; // High word (Most Significant) -#else - float ps0; // High word - float ps1; // Low word -#endif - } paired; -}; - -// PowerPC CPU Context -struct CpuContext { - // Standard GPRs - uint32_t gpr[32]; - - // Special Purpose Registers defined by standard PPC - uint32_t cr; // Condition Register - uint32_t lr; // Link Register - uint32_t ctr; // Count Register - uint32_t xer; // Integer Exception Register - uint32_t fpscr; // Floating-Point Status and Control Register - - // Program State - uint32_t pc; // Program Counter (not used alot tho) - - // Floating Point Registers (Modified for Paired Single support) - PPC_FPR fpr[32]; - - // Broadway Specific Extensions - uint32_t gqr[8]; // Graphics Quantization Registers - uint32_t hid0; // HID0 - uint32_t hid1; // HID1 - uint32_t hid2; // HID2 - - uint32_t srr0; // Save/Restore Register 0 - uint32_t srr1; // Save/Restore Register 1 - uint32_t msr; // Machine State Register -}; - -inline thread_local CpuContext* g_currentCpuContext = nullptr; - -class CpuContextScope { -public: - explicit CpuContextScope(CpuContext* ctx) - : previous_(g_currentCpuContext) - { - g_currentCpuContext = ctx; - - savedMxcsr_ = MkwGetHostFpControl(); - if (ctx != nullptr) - MkwApplyHostNiMode(ctx->fpscr); - } - - ~CpuContextScope() - { - g_currentCpuContext = previous_; - if (previous_ != nullptr) - MkwApplyHostNiMode(previous_->fpscr); - else - MkwRestoreHostMxcsr(savedMxcsr_); - } - - CpuContextScope(const CpuContextScope&) = delete; - CpuContextScope& operator=(const CpuContextScope&) = delete; - -private: - CpuContext* previous_ = nullptr; - uint32_t savedMxcsr_ = 0; -}; - -inline CpuContext* TryGetCpuContext() noexcept -{ - return g_currentCpuContext; -} - inline CpuContext* CurrentCpuContext() { CpuContext* cpu = TryGetCpuContext(); @@ -110,79 +32,7 @@ inline CpuContext* CurrentCpuContext() return cpu; } -// Condition Register Fields -#define CR_LT 0 -#define CR_GT 1 -#define CR_EQ 2 -#define CR_SO 3 +#include "guest_abi/guest_cr.h" extern "C" void DumpHostStackTraceForRuntimeHelper(); void MarkFatalErrorReported(); - -[[noreturn]] inline void PPC_Undefined(uint32_t pc, uint32_t rawInstruction, const char* details) -{ - std::fprintf(stderr, - "[runtime] UNDEFINED guest instruction: pc=0x%08X raw=0x%08X %s\n", - pc, - rawInstruction, - details ? details : ""); - char message[256]{}; - std::snprintf(message, sizeof(message), - "The game stopped because it reached an unsupported guest instruction at PC 0x%08X (instruction 0x%08X).\n\n%s", - pc, rawInstruction, details ? details : "No additional details were provided."); - ShowRuntimeFatalPopup("Unsupported guest instruction", message); - std::abort(); -} - -// Used by generated code when the translator encounters privileged/unmodeled PPC instructions -// (e.g. rfi). This is intentionally loud so missing HLE hooks are easy to find. -#define UNDEFINED(pc, rawInstruction, details) PPC_Undefined((pc), (rawInstruction), (details)) - -// Helper to set CR bits (Signed) -inline void SetCR(CpuContext* cpu, int field, int32_t a, int32_t b) { - uint32_t crField = 0; - if (a < b) crField |= 0x8; // LT - if (a > b) crField |= 0x4; // GT - if (a == b) crField |= 0x2; // EQ - crField |= (cpu->xer >> 31) & 1u; // SO - - int shift = (7 - field) * 4; - uint32_t mask = 0xF << shift; - cpu->cr = (cpu->cr & ~mask) | (crField << shift); -} - -// Helper to set CR bits (Unsigned) -inline void SetCR(CpuContext* cpu, int field, uint32_t a, uint32_t b) { - uint32_t crField = 0; - if (a < b) crField |= 0x8; // LT - if (a > b) crField |= 0x4; // GT - if (a == b) crField |= 0x2; // EQ - crField |= (cpu->xer >> 31) & 1u; // SO - - int shift = (7 - field) * 4; - uint32_t mask = 0xF << shift; - cpu->cr = (cpu->cr & ~mask) | (crField << shift); -} - -// Helper to set CR bits (Floating-point) -inline void SetCRFloat(CpuContext* cpu, int field, double a, double b) { - uint32_t crField = 0; - if (std::isnan(a) || std::isnan(b)) { - // Unordered: LT/GT/EQ clear, SO set. - crField = 0x1; - } else { - if (a < b) crField |= 0x8; // LT - if (a > b) crField |= 0x4; // GT - if (a == b) crField |= 0x2; // EQ - } - - int shift = (7 - field) * 4; - uint32_t mask = 0xF << shift; - cpu->cr = (cpu->cr & ~mask) | (crField << shift); -} - -// Helper to get CR bit -inline bool GetCRBit(CpuContext* cpu, int field, int bit) { - int shift = (7 - field) * 4 + (3 - bit); - return (cpu->cr >> shift) & 1; -} diff --git a/runtime/include/isa/ppc_isa_float.h b/runtime/include/isa/ppc_isa_float.h index e5f6c62..4aa6f4e 100644 --- a/runtime/include/isa/ppc_isa_float.h +++ b/runtime/include/isa/ppc_isa_float.h @@ -22,7 +22,7 @@ // the psq_l/psq_st tier that does lives in ppc_isa_quantized.h. #include "ppc_isa_config.h" -#include "ppc_isa_context.h" +#include "guest_abi/cpu_context.h" #include #include diff --git a/runtime/include/isa/ppc_isa_fpenv.h b/runtime/include/isa/ppc_isa_fpenv.h index f3bc7b9..46726b7 100644 --- a/runtime/include/isa/ppc_isa_fpenv.h +++ b/runtime/include/isa/ppc_isa_fpenv.h @@ -34,7 +34,7 @@ inline constexpr double kMkwNiFlushThreshold = 0x1p-126; // 0x3810000000000000 inline thread_local double g_mkwNiFlushThreshold = 0.0; // Host FP control register access (MXCSR on x86_64, FPCR on AArch64), abstracted so -// MkwApplyHostNiMode/MkwRestoreHostMxcsr and CpuContextScope (ppc_isa_context.h) don't each need +// MkwApplyHostNiMode/MkwRestoreHostMxcsr and CpuContextScope (guest_abi/cpu_context.h) don't each need // their own per-arch branch. inline uint32_t MkwGetHostFpControl() noexcept { diff --git a/runtime/include/isa/ppc_isa_quantized.h b/runtime/include/isa/ppc_isa_quantized.h index a5066f8..27a7fb7 100644 --- a/runtime/include/isa/ppc_isa_quantized.h +++ b/runtime/include/isa/ppc_isa_quantized.h @@ -2,7 +2,7 @@ #include "ppc_isa_config.h" -#include "ppc_isa_context.h" +#include "guest_abi/cpu_context.h" #include "ppc_isa_float.h" #include "big_endian.h" diff --git a/runtime/include/memory.h b/runtime/include/memory.h index 14fe9ca..a54e770 100644 --- a/runtime/include/memory.h +++ b/runtime/include/memory.h @@ -8,109 +8,7 @@ #include #include -class Memory { -public: - using DeferredReadCallback = bool (*)(void* user); - - static constexpr size_t kMem1Size = 24u * 1024u * 1024u; - static constexpr size_t kMem2Size = 128u * 1024u * 1024u; - static constexpr uint32_t kMem1PhysicalBase = 0x00000000u; - static constexpr uint32_t kMem1CachedBase = 0x80000000u; - static constexpr uint32_t kMem1UncachedBase = 0xC0000000u; - static constexpr uint32_t kMem2PhysicalBase = 0x10000000u; - static constexpr uint32_t kMem2CachedBase = 0x90000000u; - static constexpr uint32_t kMem2UncachedBase = 0xD0000000u; - static constexpr uint32_t kMem2PhysicalEnd = - kMem2PhysicalBase + static_cast(kMem2Size); - static constexpr uint32_t kMem2CachedEnd = - kMem2CachedBase + static_cast(kMem2Size); - static constexpr uint32_t kMem2UncachedEnd = - kMem2UncachedBase + static_cast(kMem2Size); - - struct RegionConfig { - std::string name; - uint32_t baseAddress = 0; - size_t sizeBytes = 0; - }; - - struct Config { - std::vector regions; - static Config WiiDefaults(); - }; - - class AccessViolation : public std::runtime_error { - public: - AccessViolation(uint32_t address, size_t length, std::string_view reason); - - uint32_t address() const noexcept { return address_; } - size_t length() const noexcept { return length_; } - std::string_view reason() const noexcept { return reason_; } - - private: - uint32_t address_ = 0; - size_t length_ = 0; - std::string reason_; - }; - - static void Init(const Config& config); - // Single flat MEM1 region. Not used by the shipped runtime, but the - // translator integration-test harnesses emit calls to it. - static void Init(size_t mem1Size); - static void Reset(); - // Executable ranges are normally registered during startup. Rebuild the - // writable fast-path classification after each registration so a page - // previously classified as ordinary data cannot retain a stale direct - // write bias. - static void RefreshWritableFastPathsForExecutableRanges(); - - static uint8_t Read8(uint32_t addr); - static uint16_t Read16(uint32_t addr); - static uint32_t Read32(uint32_t addr); - static uint64_t Read64(uint32_t addr); - static float ReadFloat32(uint32_t addr); - static double ReadFloat64(uint32_t addr); - static void Write8(uint32_t addr, uint8_t val); - static void Write16(uint32_t addr, uint16_t val); - static void Write32(uint32_t addr, uint32_t val); - static void Write64(uint32_t addr, uint64_t val); - static void WriteFloat32(uint32_t addr, double val); - static void WriteFloat64(uint32_t addr, double val); - - // Exception-safe scalar access for HLE code. These keep Read32/Write32's - // full mapping behavior and only convert an unmapped address into a failure - // result; they are deliberately not MemoryInline::Try*GuestScalar, which is - // the translated-code fast path over the page table. - static bool TryRead32(uint32_t addr, uint32_t& value) noexcept { - try { - value = Read32(addr); - return true; - } catch (const AccessViolation&) { - return false; - } - } - - static bool TryWrite32(uint32_t addr, uint32_t value) noexcept { - try { - Write32(addr, value); - return true; - } catch (const AccessViolation&) { - return false; - } - } - - static uint8_t* GetPointer(uint32_t addr); - static uint8_t* GetPointer(uint32_t addr, size_t length); - static bool Contains(uint32_t addr, size_t length = 1); - - static uint64_t RegisterDeferredRead(uint32_t addr, size_t length, - DeferredReadCallback callback, void* user); - static void ClearDeferredReads(); - - // sizeBytes reports the storage actually allocated, which is not always the - // configured size (aliased MEM1/MEM2 windows are clamped to what is behind - // them). Used by the crash dump. - static std::vector DescribeRegions(); -}; +#include "guest_abi/memory.h" // The translated-code access layer is kept separately from the public memory API. #include "memory_access.h" diff --git a/runtime/include/memory_access.h b/runtime/include/memory_access.h index 7ac535d..e5addd9 100644 --- a/runtime/include/memory_access.h +++ b/runtime/include/memory_access.h @@ -4,729 +4,4 @@ #include "memory.h" #include "recomp_mod_loader.h" -#include -#include -#include - -// Hooks for GX HLE FIFO handling -extern "C" { - void GX_HLE_FIFO_WriteFloat(float val); - void GX_HLE_FIFO_Write32(uint32_t val); - void GX_HLE_FIFO_Write16(uint16_t val); - void GX_HLE_FIFO_Write8(uint8_t val); - void GX_HLE_FIFO_WriteBurst(const uint8_t* data, uint32_t sizeBytes); -} - -namespace MemoryInline { -#if defined(_WIN32) -#define MKW_MEMORY_FORCE_INLINE __forceinline -#define MKW_MEMORY_NO_INLINE __declspec(noinline) -#else -// See runtime/include/isa/ppc_isa_config.h for why non-Windows Clang needs the GNU-attribute -// spellings instead of the MS-extension keywords. -#define MKW_MEMORY_FORCE_INLINE __attribute__((always_inline)) inline -#define MKW_MEMORY_NO_INLINE __attribute__((noinline)) -#endif -#define MKW_MEMORY_COLD __attribute__((cold)) -inline constexpr uint32_t kPageShift = 20; -inline constexpr uint32_t kPageSize = 1u << kPageShift; -inline constexpr uint32_t kPageMask = kPageSize - 1u; -inline constexpr uint32_t kPageCount = 1u << (32 - kPageShift); -inline constexpr uint32_t kMaxFastScalarSize = 8; -inline constexpr uint32_t kWritableSubPageShift = RecompMod::kExecutableWriteGuardPageShift; -inline constexpr uint32_t kWritableSubPageSize = 1u << kWritableSubPageShift; -inline constexpr uint32_t kWritableSubPagesPerPage = kPageSize / kWritableSubPageSize; - -struct PageEntry { - uint8_t* base = nullptr; - uint32_t limit = 0; -}; - -extern PageEntry g_pageTable[kPageCount]; -// Encoded (host page base - guest page base) + 1 for full pages whose next -// page is contiguous. This permits any native access up to 8 bytes without a -// per-access mask/limit check. Zero retains the general PageEntry fallback. -extern uintptr_t g_fullPageBias[kPageCount]; -// Runtime-active readable biases. Deferred-read pages clear these entries once -// instead of paying a mode branch on every translated read. -extern uintptr_t g_fullReadablePageBias[kPageCount]; -// Same encoding, but only for pages proven not to contain executable bytes. -// Executable-range registration invalidates entries before guest execution. -extern uintptr_t g_fullWritablePageBias[kPageCount]; - -// Allocated only for mapped 1 MiB pages that contain both executable and data -// 4 KiB pages. Entries use the same encoded host bias as the coarse table. -// Exact executable bits remain authoritative and are checked at lookup time, -// including both sides of a cross-4-KiB access. -struct SparseWritablePageTable { - uintptr_t encodedBias[kWritableSubPagesPerPage]{}; -}; -extern const SparseWritablePageTable* g_sparseWritablePageTables[kPageCount]; - -// Nonzero while any registered deferred read overlaps the page. Small -// mappings (e.g. the 16 KiB locked cache) have no coarse bias entry, so a -// zero readable bias alone cannot distinguish "deferred content pending" -// from "small but plain memory"; range resolution needs the explicit flag. -extern uint8_t g_deferredReadCoveredPages[kPageCount]; - -MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD uint8_t Read8Slow(uint32_t addr); -MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD uint16_t Read16Slow(uint32_t addr); -MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD uint32_t Read32Slow(uint32_t addr); -MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD uint64_t Read64Slow(uint32_t addr); -MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD float ReadFloat32Slow(uint32_t addr); -MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD double ReadFloat64Slow(uint32_t addr); -MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD void Write8Slow(uint32_t addr, uint8_t val); -MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD void Write16Slow(uint32_t addr, uint16_t val); -MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD void Write32Slow(uint32_t addr, uint32_t val); -MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD void Write64Slow(uint32_t addr, uint64_t val); -MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD void WriteFloat32Slow(uint32_t addr, double val); -MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD void WriteFloat64Slow(uint32_t addr, double val); -template -MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD T ReadResolvedFallback(uint32_t addr); -MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD float ReadResolvedFallbackFloat32(uint32_t addr); -MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD double ReadResolvedFallbackFloat64(uint32_t addr); -template -MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD void WriteResolvedFallback(uint32_t addr, T value); -MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD void WriteResolvedFallbackFloat32(uint32_t addr, double val); -MKW_MEMORY_NO_INLINE MKW_MEMORY_COLD void WriteResolvedFallbackFloat64(uint32_t addr, double val); -bool ResolveDeferredReads(uint32_t addr, size_t length); - -constexpr bool IsMmioAddress(uint32_t addr) { - return addr >= 0xCC000000u && addr < 0xCE000000u; -} - -constexpr bool IsGpuFifoAddress(uint32_t addr) { - return addr >= 0xCC008000u && addr < 0xCC008100u; -} - -// Page protections can't cover this: an MMIO write must reach GX HLE with its value or be -// reported, and a fault record can't carry the value, so this mask/compare sits in front of -// every flat store instead. -MKW_MEMORY_FORCE_INLINE bool FlatWriteNeedsPolicy(uint32_t address) { - return (address & 0xFE000000u) == 0xCC000000u; // 0xCC000000..0xCDFFFFFF -} - -// Gekko stfs conversion is a bit-level narrowing operation. In particular it -// does not behave like a host double-to-float cast for values which were left -// in double precision, and it has hardware-tested handling for tiny values. -MKW_MEMORY_FORCE_INLINE uint32_t ConvertPpcDoubleToSingleBits(double value) { - uint64_t bits = 0; - std::memcpy(&bits, &value, sizeof(bits)); - const uint32_t exponent = static_cast((bits >> 52) & 0x7FFu); - // The subnormal-single arm applies to exactly the exponents 874..896; every - // other exponent takes the plain sign/exponent/fraction narrowing below. - // Inside that window the exponent field is nonzero, so the magnitude cannot - // be zero and needs no separate test - the zero case (exponent 0) reaches - // the narrowing exactly as it did when the two arms shared that test. - if (exponent - 874u <= 22u) [[unlikely]] - { - uint32_t narrowed = static_cast( - 0x80000000ULL | ((bits & 0x000FFFFFFFFFFFFFULL) >> 21)); - narrowed >>= (905u - exponent); - narrowed |= static_cast((bits >> 32) & 0x80000000ULL); - return narrowed; - } - - // Results below the documented conversion range are architecturally - // undefined; this is the behavior measured on Gekko/Broadway hardware. - return static_cast( - ((bits >> 32) & 0xC0000000ULL) | ((bits >> 29) & 0x3FFFFFFFULL)); -} - -MKW_MEMORY_FORCE_INLINE float PpcSingleBitsToFloat(uint32_t bits) { - float value = 0.0f; - std::memcpy(&value, &bits, sizeof(value)); - return value; -} - -MKW_MEMORY_FORCE_INLINE bool TryGetPointerFast(uint32_t address, size_t length, uint8_t*& pointer) { - const uint32_t page = address >> kPageShift; - if (length <= 8) { - const uintptr_t encodedBias = g_fullPageBias[page]; - if (encodedBias != 0) { - pointer = reinterpret_cast((encodedBias - 1u) + address); - return true; - } - } - const uint32_t offset = address & kPageMask; - const auto& entry = g_pageTable[page]; - if (!entry.base || offset + length > entry.limit) { - pointer = nullptr; - return false; - } - pointer = entry.base + offset; - return true; -} - -inline uint8_t* GetPointerFast(uint32_t address, size_t length) { - uint8_t* pointer = nullptr; - return TryGetPointerFast(address, length, pointer) ? pointer : nullptr; -} - -MKW_MEMORY_FORCE_INLINE bool TryGetWritablePointerFast( - uint32_t address, size_t length, uint8_t*& pointer) { - // The sparse-table hit below distinguishes itself from the small-mapping - // hit through `pointer`, so the out-parameter must start null regardless - // of what the caller passed in. (A caller handing in an uninitialized - // pointer used to turn every sparse hit into a write through stack - // garbage - random host memory corruption.) - pointer = nullptr; - if (length == 0 || length > 8 || address > UINT32_MAX - (length - 1)) - return false; - const uint32_t coarsePage = address >> kPageShift; - uintptr_t encodedBias = g_fullWritablePageBias[coarsePage]; - const uint32_t endAddress = address + static_cast(length - 1); - const uint32_t firstExactPage = address >> kWritableSubPageShift; - const uint32_t lastExactPage = endAddress >> kWritableSubPageShift; - if (encodedBias != 0 && (endAddress >> kPageShift) != coarsePage && - RecompMod::g_executableWriteGuardPages[lastExactPage].load( - std::memory_order_relaxed) != 0) { - return false; - } - if (encodedBias == 0) { - const auto* subTable = g_sparseWritablePageTables[coarsePage]; - if (subTable != nullptr) { - encodedBias = subTable->encodedBias[ - (address & kPageMask) >> kWritableSubPageShift]; - if (encodedBias == 0) - return false; - } else { - // Small mappings such as Broadway's 16 KiB locked cache cannot - // populate the full-1-MiB bias table. Keep them native by proving - // the exact access against the ordinary page entry, then applying - // the same executable-write policy as a sparse-table hit. - const uint32_t offset = address & kPageMask; - const auto& entry = g_pageTable[coarsePage]; - if (!entry.base || offset + length > entry.limit) - return false; - pointer = entry.base + offset; - } - - // The exact 4 KiB guard is the final authority. Checking it on every - // checked/sparse hit also makes later executable-range registration - // safe when a prebuilt table still contains the old mapped bias. - if (RecompMod::g_executableWriteGuardPages[firstExactPage].load( - std::memory_order_relaxed) != 0 || - (lastExactPage != firstExactPage && - RecompMod::g_executableWriteGuardPages[lastExactPage].load( - std::memory_order_relaxed) != 0)) { - return false; - } - - if (pointer != nullptr) - return true; - } - if (encodedBias == 0) - return false; - pointer = reinterpret_cast((encodedBias - 1u) + address); - return true; -} - -// Flat form: guest_flat_memory.h's page protections already answer mapped/non-deferred/non-executable, so resolving is pure address arithmetic. -// Two checks stay inline: a wrapped guest address can't survive 64-bit `host + rangeOffset`, and an MMIO write's value isn't recoverable from a -// fault record, so a write touching that window must resolve null and fall back to Memory::Write*. Checking both range endpoints is a complete -// proof since length <= kPageSize (1 MiB) can't straddle the 32 MiB MMIO window. -MKW_MEMORY_FORCE_INLINE uint8_t* ResolveRangeHost(uint32_t base, int32_t minOffset, uint32_t length, - bool needsRead, bool needsWrite) { - (void)needsRead; - const uint32_t guestStart = base + static_cast(minOffset); - if (length == 0 || length > kPageSize || guestStart > UINT32_MAX - (length - 1)) return nullptr; - if (GuestFlat::RequiresCheckedAccess()) { - // A host page can cover multiple independently-special Wii pages. - // Returning null keeps resolved accesses on the checked Memory::* - // path, which materializes deferred reads and applies write policy. - (void)needsWrite; - return nullptr; - } - if (needsWrite && - (FlatWriteNeedsPolicy(guestStart) || FlatWriteNeedsPolicy(guestStart + (length - 1)))) - [[unlikely]] return nullptr; - return MKW_FLAT_GUEST_BASE + guestStart; -} - -// Guest-address byte order. Distinct from isa/big_endian.h, which is the -// host-pointer codec; do not "unify" them. -inline uint16_t ByteSwap16(uint16_t value) { - return __builtin_bswap16(value); -} - -inline uint32_t ByteSwap32(uint32_t value) { - return __builtin_bswap32(value); -} - -inline uint64_t ByteSwap64(uint64_t value) { - return __builtin_bswap64(value); -} - -template -inline T MaybeByteSwap(T value) { - if constexpr (sizeof(T) == 1) { - return value; - } else if constexpr (sizeof(T) == 2) { - return static_cast(ByteSwap16(static_cast(value))); - } else if constexpr (sizeof(T) == 4) { - return static_cast(ByteSwap32(static_cast(value))); - } else if constexpr (sizeof(T) == 8) { - return static_cast(ByteSwap64(static_cast(value))); - } else { - return value; - } -} - -template -MKW_MEMORY_FORCE_INLINE bool ReadResolvedScalar(uint8_t* host, uint32_t rangeOffset, T& outValue) { - if (!host) return false; - if constexpr (sizeof(T) == 1) { - outValue = host[rangeOffset]; - } else { - T value = 0; - std::memcpy(&value, host + rangeOffset, sizeof(T)); - outValue = MaybeByteSwap(value); - } - return true; -} - -struct ResolvedLoadPair { - uint32_t first = 0; - uint32_t second = 0; - bool valid = false; -}; - -MKW_MEMORY_FORCE_INLINE ResolvedLoadPair ReadResolvedPair16( - uint8_t* host, uint32_t rangeOffset) { - uint32_t packed = 0; - if (!ReadResolvedScalar(host, rangeOffset, packed)) return {}; - return {packed >> 16, packed & 0xFFFFu, true}; -} - -MKW_MEMORY_FORCE_INLINE ResolvedLoadPair ReadResolvedPair32(uint8_t* host, uint32_t rangeOffset) { - uint64_t packed = 0; - if (!ReadResolvedScalar(host, rangeOffset, packed)) return {}; - return {static_cast(packed >> 32), static_cast(packed), true}; -} - -template -MKW_MEMORY_FORCE_INLINE bool WriteResolvedPairFast( - uint8_t* host, uint32_t rangeOffset, Packed packed) { - if (!host) return false; - const Packed swapped = MaybeByteSwap(packed); - std::memcpy(host + rangeOffset, &swapped, sizeof(swapped)); - return true; -} - -MKW_MEMORY_FORCE_INLINE bool WriteResolvedPair16( - uint8_t* host, uint32_t rangeOffset, uint32_t packed) { - return WriteResolvedPairFast(host, rangeOffset, packed); -} - -MKW_MEMORY_FORCE_INLINE bool WriteResolvedPair32( - uint8_t* host, uint32_t rangeOffset, uint64_t packed) { - return WriteResolvedPairFast(host, rangeOffset, packed); -} - -template -MKW_MEMORY_FORCE_INLINE bool WriteResolvedScalar(uint8_t* host, uint32_t rangeOffset, T value) { - if (!host) return false; - if constexpr (sizeof(T) == 1) { - host[rangeOffset] = static_cast(value); - } else { - const T swapped = MaybeByteSwap(value); - std::memcpy(host + rangeOffset, &swapped, sizeof(T)); - } - return true; -} - -template -inline bool TryReadMappedScalar(uint32_t address, T& outValue) { - uint8_t* ptr = nullptr; - if (TryGetPointerFast(address, sizeof(T), ptr)) { - if constexpr (sizeof(T) == 1) { - outValue = *ptr; - } else { - T value = 0; - std::memcpy(&value, ptr, sizeof(T)); - outValue = MaybeByteSwap(value); - } - return true; - } - return false; -} - -template -MKW_MEMORY_FORCE_INLINE bool TryReadGuestScalar(uint32_t address, T& outValue) { - const uintptr_t encodedBias = g_fullReadablePageBias[address >> kPageShift]; - if (encodedBias == 0) [[unlikely]] - return false; - auto* ptr = reinterpret_cast((encodedBias - 1u) + address); - if constexpr (sizeof(T) == 1) { - outValue = *ptr; - } else { - T value = 0; - std::memcpy(&value, ptr, sizeof(T)); - outValue = MaybeByteSwap(value); - } - return true; -} - -template -MKW_MEMORY_FORCE_INLINE bool TryWriteGuestScalar(uint32_t address, T value) { - static_assert(sizeof(T) >= 1 && sizeof(T) <= kMaxFastScalarSize); - // A mixed executable/data 1 MiB page zeroes the coarse writable bias even though most of - // its 4 KiB sub-pages are plain data; MKW's THP buffers share such a page with .text, which - // used to force ~15% of total CPU through the cold path. The sparse sub-page tier below - // keeps those stores native while the exact 4 KiB executable guards stay authoritative. - if (address > UINT32_MAX - static_cast(sizeof(T) - 1u)) [[unlikely]] - return false; - const uint32_t coarsePage = address >> kPageShift; - const uint32_t endAddress = address + static_cast(sizeof(T) - 1u); - if ((endAddress >> kPageShift) != coarsePage) [[unlikely]] - return false; - const uintptr_t encodedBias = g_fullWritablePageBias[coarsePage]; - uint8_t* ptr = nullptr; - if (encodedBias != 0) { - ptr = reinterpret_cast((encodedBias - 1u) + address); - } else if (!TryGetWritablePointerFast(address, sizeof(T), ptr)) [[unlikely]] { - return false; - } - if constexpr (sizeof(T) == 1) { - *ptr = static_cast(value); - } else { - const T swapped = MaybeByteSwap(value); - std::memcpy(ptr, &swapped, sizeof(T)); - } - return true; -} - -template -inline bool WriteStackScalarFast(uint32_t address, T value) { - uint8_t* ptr = nullptr; - if (TryGetPointerFast(address, sizeof(T), ptr)) { - if constexpr (sizeof(T) == 1) { - *ptr = static_cast(value); - } else { - const T swapped = MaybeByteSwap(value); - std::memcpy(ptr, &swapped, sizeof(T)); - } - return true; - } - return false; -} - -inline uint8_t ReadStack8(uint32_t address) { - uint8_t value = 0; - return TryReadMappedScalar(address, value) ? value : Memory::Read8(address); -} - -inline uint16_t ReadStack16(uint32_t address) { - uint16_t value = 0; - return TryReadMappedScalar(address, value) ? value : Memory::Read16(address); -} - -inline uint32_t ReadStack32(uint32_t address) { - uint32_t value = 0; - return TryReadMappedScalar(address, value) ? value : Memory::Read32(address); -} - -inline uint64_t ReadStack64(uint32_t address) { - uint64_t value = 0; - return TryReadMappedScalar(address, value) ? value : Memory::Read64(address); -} - - - -inline void WriteStack8(uint32_t address, uint8_t value) { - if (!WriteStackScalarFast(address, value)) { - Memory::Write8(address, value); - } -} - -inline void WriteStack16(uint32_t address, uint16_t value) { - if (!WriteStackScalarFast(address, value)) { - Memory::Write16(address, value); - } -} - -inline void WriteStack32(uint32_t address, uint32_t value) { - if (!WriteStackScalarFast(address, value)) { - Memory::Write32(address, value); - } -} - -inline void WriteStack64(uint32_t address, uint64_t value) { - if (!WriteStackScalarFast(address, value)) { - Memory::Write64(address, value); - } -} - -inline void WriteStackFloat32(uint32_t address, double value) { - const uint32_t bits = ConvertPpcDoubleToSingleBits(value); - if (!WriteStackScalarFast(address, bits)) { - Memory::WriteFloat32(address, value); - } -} - -inline void WriteStackFloat64(uint32_t address, double value) { - uint64_t bits = 0; - std::memcpy(&bits, &value, sizeof(bits)); - if (!WriteStackScalarFast(address, bits)) { - Memory::WriteFloat64(address, value); - } -} - -template -MKW_MEMORY_FORCE_INLINE T ReadResolved(uint8_t* host, uint32_t rangeOffset, uint32_t address, - SlowRead slow) { - T value = 0; - if (ReadResolvedScalar(host, rangeOffset, value)) { - return value; - } - [[unlikely]] return slow(address); -} - -MKW_MEMORY_FORCE_INLINE uint8_t ReadResolved8(uint8_t* r, uint32_t o, uint32_t a) { return ReadResolved(r, o, a, ReadResolvedFallback); } -MKW_MEMORY_FORCE_INLINE uint16_t ReadResolved16(uint8_t* r, uint32_t o, uint32_t a) { return ReadResolved(r, o, a, ReadResolvedFallback); } -MKW_MEMORY_FORCE_INLINE uint32_t ReadResolved32(uint8_t* r, uint32_t o, uint32_t a) { return ReadResolved(r, o, a, ReadResolvedFallback); } -// Live via isa/ppc_isa_quantized.h (the psq resolved tier packs two lanes into -// one 64-bit access); generated code never names it directly. -MKW_MEMORY_FORCE_INLINE uint64_t ReadResolved64(uint8_t* r, uint32_t o, uint32_t a) { return ReadResolved(r, o, a, ReadResolvedFallback); } -MKW_MEMORY_FORCE_INLINE float ReadResolvedFloat32(uint8_t* r, uint32_t o, uint32_t a) { - uint32_t bits = 0; - if (!ReadResolvedScalar(r, o, bits)) [[unlikely]] return ReadResolvedFallbackFloat32(a); - float value; std::memcpy(&value, &bits, sizeof(value)); return value; -} -MKW_MEMORY_FORCE_INLINE double ReadResolvedFloat64(uint8_t* r, uint32_t o, uint32_t a) { - uint64_t bits = 0; - if (!ReadResolvedScalar(r, o, bits)) [[unlikely]] return ReadResolvedFallbackFloat64(a); - double value; std::memcpy(&value, &bits, sizeof(value)); return value; -} - -template -MKW_MEMORY_FORCE_INLINE void WriteResolved(uint8_t* host, uint32_t rangeOffset, uint32_t address, T value, - SlowWrite slow) { - if (WriteResolvedScalar(host, rangeOffset, value)) { - return; - } - [[unlikely]] slow(address, value); -} - -MKW_MEMORY_FORCE_INLINE void WriteResolved8(uint8_t* r, uint32_t o, uint32_t a, uint8_t v) { WriteResolved(r, o, a, v, WriteResolvedFallback); } -MKW_MEMORY_FORCE_INLINE void WriteResolved16(uint8_t* r, uint32_t o, uint32_t a, uint16_t v) { WriteResolved(r, o, a, v, WriteResolvedFallback); } -MKW_MEMORY_FORCE_INLINE void WriteResolved32(uint8_t* r, uint32_t o, uint32_t a, uint32_t v) { WriteResolved(r, o, a, v, WriteResolvedFallback); } -// Live via isa/ppc_isa_quantized.h, as ReadResolved64 above. -MKW_MEMORY_FORCE_INLINE void WriteResolved64(uint8_t* r, uint32_t o, uint32_t a, uint64_t v) { WriteResolved(r, o, a, v, WriteResolvedFallback); } -MKW_MEMORY_FORCE_INLINE void WriteResolvedFloat32(uint8_t* r, uint32_t o, uint32_t a, double v) { - const uint32_t bits = ConvertPpcDoubleToSingleBits(v); - if (WriteResolvedScalar(r, o, bits)) return; - [[unlikely]] WriteResolvedFallbackFloat32(a, v); -} -MKW_MEMORY_FORCE_INLINE void WriteResolvedFloat64(uint8_t* r, uint32_t o, uint32_t a, double v) { - uint64_t bits; std::memcpy(&bits, &v, sizeof(bits)); - if (WriteResolvedScalar(r, o, bits)) return; - [[unlikely]] WriteResolvedFallbackFloat64(a, v); -} - -// Flat guest memory (audit item T-MEM): the 4 GiB reservation makes a guest access a byte swap -// around `*(T*)(base + addr)`, no page-table load or limit check (interception model documented -// in guest_flat_memory.h). The one exception kept inline is the MMIO write policy, since the -// written value can't be recovered from a fault record. -// -// When a host VM page is larger than a 4 KiB Wii page, guest-view protections -// cannot distinguish adjacent special Wii pages. The general FlatRead*/ -// FlatWrite* helpers then use the checked page-table path, which materializes -// deferred reads and applies executable-write/MMIO policy before touching RAM. -// FlatWriteRam* remains direct because the translator emits it only for -// addresses it has proven are ordinary RAM. - -template -MKW_MEMORY_FORCE_INLINE T FlatLoad(uint32_t address) { - T value{}; - std::memcpy(&value, MKW_FLAT_GUEST_BASE + address, sizeof(T)); - return MaybeByteSwap(value); -} - -template -MKW_MEMORY_FORCE_INLINE void FlatStore(uint32_t address, T value) { - const T swapped = MaybeByteSwap(value); - std::memcpy(MKW_FLAT_GUEST_BASE + address, &swapped, sizeof(T)); -} - -MKW_MEMORY_FORCE_INLINE uint8_t FlatRead8(uint32_t address) { - if (GuestFlat::RequiresCheckedAccess()) return Memory::Read8(address); - return FlatLoad(address); -} -MKW_MEMORY_FORCE_INLINE uint16_t FlatRead16(uint32_t address) { - if (GuestFlat::RequiresCheckedAccess()) return Memory::Read16(address); - return FlatLoad(address); -} -MKW_MEMORY_FORCE_INLINE uint32_t FlatRead32(uint32_t address) { - if (GuestFlat::RequiresCheckedAccess()) return Memory::Read32(address); - return FlatLoad(address); -} - -MKW_MEMORY_FORCE_INLINE float FlatReadFloat32(uint32_t address) { - const uint32_t bits = FlatRead32(address); - float value = 0.0f; - std::memcpy(&value, &bits, sizeof(value)); - return value; -} - -MKW_MEMORY_FORCE_INLINE double FlatReadFloat64(uint32_t address) { - const uint64_t bits = GuestFlat::RequiresCheckedAccess() - ? Memory::Read64(address) : FlatLoad(address); - double value = 0.0; - std::memcpy(&value, &bits, sizeof(value)); - return value; -} - -MKW_MEMORY_FORCE_INLINE void FlatWrite8(uint32_t address, uint8_t value) { - if (GuestFlat::RequiresCheckedAccess()) { Memory::Write8(address, value); return; } - if (FlatWriteNeedsPolicy(address)) [[unlikely]] { Write8Slow(address, value); return; } - FlatStore(address, value); -} - -MKW_MEMORY_FORCE_INLINE void FlatWrite16(uint32_t address, uint16_t value) { - if (GuestFlat::RequiresCheckedAccess()) { Memory::Write16(address, value); return; } - if (FlatWriteNeedsPolicy(address)) [[unlikely]] { Write16Slow(address, value); return; } - FlatStore(address, value); -} - -MKW_MEMORY_FORCE_INLINE void FlatWrite32(uint32_t address, uint32_t value) { - if (GuestFlat::RequiresCheckedAccess()) { Memory::Write32(address, value); return; } - if (FlatWriteNeedsPolicy(address)) [[unlikely]] { Write32Slow(address, value); return; } - FlatStore(address, value); -} - - -MKW_MEMORY_FORCE_INLINE void FlatWriteFloat32(uint32_t address, double value) { - if (GuestFlat::RequiresCheckedAccess()) { Memory::WriteFloat32(address, value); return; } - const uint32_t bits = ConvertPpcDoubleToSingleBits(value); - if (FlatWriteNeedsPolicy(address)) [[unlikely]] { WriteFloat32Slow(address, value); return; } - FlatStore(address, bits); -} - -MKW_MEMORY_FORCE_INLINE void FlatWriteFloat64(uint32_t address, double value) { - if (GuestFlat::RequiresCheckedAccess()) { Memory::WriteFloat64(address, value); return; } - uint64_t bits = 0; - std::memcpy(&bits, &value, sizeof(bits)); - if (FlatWriteNeedsPolicy(address)) [[unlikely]] { WriteFloat64Slow(address, value); return; } - FlatStore(address, bits); -} - -// Check-free stores: emitted ONLY for addresses the translator proved at translate time are ordinary guest RAM (r1-relative stack slots, ~45% -// of flat stores), skipping the MMIO mask/compare that's pure overhead there. Still safe if that proof were ever wrong: the flat view maps -// 0xCC000000..0xCDFFFFFF PAGE_NOACCESS, so a stray MMIO store faults into the same handler and diagnostic as the checked path, just reported -// instead of dispatched inline. Never use these for an address the translator hasn't proven. - -MKW_MEMORY_FORCE_INLINE void FlatWriteRam8(uint32_t address, uint8_t value) { - FlatStore(address, value); -} - -MKW_MEMORY_FORCE_INLINE void FlatWriteRam16(uint32_t address, uint16_t value) { - FlatStore(address, value); -} - -MKW_MEMORY_FORCE_INLINE void FlatWriteRam32(uint32_t address, uint32_t value) { - FlatStore(address, value); -} - - -MKW_MEMORY_FORCE_INLINE void FlatWriteRamFloat32(uint32_t address, double value) { - FlatStore(address, ConvertPpcDoubleToSingleBits(value)); -} - -MKW_MEMORY_FORCE_INLINE void FlatWriteRamFloat64(uint32_t address, double value) { - uint64_t bits = 0; - std::memcpy(&bits, &value, sizeof(bits)); - FlatStore(address, bits); -} - -} // namespace MemoryInline - -#undef MKW_MEMORY_FORCE_INLINE -#undef MKW_MEMORY_NO_INLINE -#undef MKW_MEMORY_COLD - -inline uint8_t Memory::Read8(uint32_t addr) { - uint8_t value = 0; - if (!MemoryInline::TryReadGuestScalar(addr, value)) [[unlikely]] - return MemoryInline::Read8Slow(addr); - return value; -} - -inline uint16_t Memory::Read16(uint32_t addr) { - uint16_t value = 0; - if (!MemoryInline::TryReadGuestScalar(addr, value)) [[unlikely]] - return MemoryInline::Read16Slow(addr); - return value; -} - -inline uint32_t Memory::Read32(uint32_t addr) { - uint32_t value = 0; - if (!MemoryInline::TryReadGuestScalar(addr, value)) [[unlikely]] - return MemoryInline::Read32Slow(addr); - return value; -} - -inline uint64_t Memory::Read64(uint32_t addr) { - uint64_t value = 0; - if (!MemoryInline::TryReadGuestScalar(addr, value)) [[unlikely]] - return MemoryInline::Read64Slow(addr); - return value; -} - -inline float Memory::ReadFloat32(uint32_t addr) { - uint32_t bits = 0; - if (!MemoryInline::TryReadGuestScalar(addr, bits)) [[unlikely]] - return MemoryInline::ReadFloat32Slow(addr); - float value = 0.0f; - std::memcpy(&value, &bits, sizeof(value)); - return value; -} - -inline double Memory::ReadFloat64(uint32_t addr) { - uint64_t bits = 0; - if (!MemoryInline::TryReadGuestScalar(addr, bits)) [[unlikely]] - return MemoryInline::ReadFloat64Slow(addr); - double value = 0.0; - std::memcpy(&value, &bits, sizeof(value)); - return value; -} - -inline void Memory::Write8(uint32_t addr, uint8_t val) { - if (!MemoryInline::TryWriteGuestScalar(addr, val)) [[unlikely]] - MemoryInline::Write8Slow(addr, val); -} - -inline void Memory::Write16(uint32_t addr, uint16_t val) { - if (!MemoryInline::TryWriteGuestScalar(addr, val)) [[unlikely]] - MemoryInline::Write16Slow(addr, val); -} - -inline void Memory::Write32(uint32_t addr, uint32_t val) { - if (!MemoryInline::TryWriteGuestScalar(addr, val)) [[unlikely]] - MemoryInline::Write32Slow(addr, val); -} - -inline void Memory::Write64(uint32_t addr, uint64_t val) { - if (!MemoryInline::TryWriteGuestScalar(addr, val)) [[unlikely]] - MemoryInline::Write64Slow(addr, val); -} - -inline void Memory::WriteFloat32(uint32_t addr, double val) { - { - const uint32_t bits = MemoryInline::ConvertPpcDoubleToSingleBits(val); - if (MemoryInline::TryWriteGuestScalar(addr, bits)) - return; - } - [[unlikely]] MemoryInline::WriteFloat32Slow(addr, val); -} - -inline void Memory::WriteFloat64(uint32_t addr, double val) { - { - uint64_t bits = 0; - std::memcpy(&bits, &val, sizeof(bits)); - if (MemoryInline::TryWriteGuestScalar(addr, bits)) - return; - } - [[unlikely]] MemoryInline::WriteFloat64Slow(addr, val); -} +#include "guest_abi/memory_access.h" diff --git a/runtime/include/ppc_isa_memory.h b/runtime/include/ppc_isa_memory.h index aed39dd..62c31dd 100644 --- a/runtime/include/ppc_isa_memory.h +++ b/runtime/include/ppc_isa_memory.h @@ -3,8 +3,6 @@ // Host memory seam for isa/ppc_isa_quantized.h. That header includes // "ppc_isa_memory.h" by plain quoted name and expects whatever it resolves to // to supply the Memory / MemoryInline APIs and the flat-guest-base macros its -// psq fast paths use. This file is this project's implementation of that -// contract; memory.h chains memory_access.h, which defines MemoryInline. +// psq fast paths use. Part of the guest ABI, so it resolves to guest_abi/ only. -#include "memory.h" // chains memory_access.h, which supplies MemoryInline and - // includes guest_flat_memory.h for the flat-base macros +#include "guest_abi/memory.h" diff --git a/runtime/include/ppc_runtime.h b/runtime/include/ppc_runtime.h index 4816c8c..8a8e35f 100644 --- a/runtime/include/ppc_runtime.h +++ b/runtime/include/ppc_runtime.h @@ -12,3 +12,4 @@ #include "isa/ppc_isa_int.h" #include "isa/ppc_isa_float.h" #include "isa/ppc_isa_quantized.h" +#include "memory.h" diff --git a/runtime/include/recomp_mod_loader.h b/runtime/include/recomp_mod_loader.h index f527424..30f000d 100644 --- a/runtime/include/recomp_mod_loader.h +++ b/runtime/include/recomp_mod_loader.h @@ -8,6 +8,8 @@ #include #include +#include "guest_abi/exec_guard.h" + struct CpuContext; namespace RecompMod { @@ -20,46 +22,6 @@ struct MemoryReservation { std::string name; }; -// Diagnostic-only, read by fatal reporters (unmapped access, executable-write, CPU dumps). -// Defined here as `inline thread_local` with a constant initializer, not `extern thread_local` -// in the .cpp: every indirect dispatch scopes this, so an out-of-line ctor/dtor would cost two -// un-inlinable calls plus a register spill each for three instructions of work. -inline thread_local uint32_t g_currentTranslatedExecutionAddress = 0; - -class ScopedTranslatedExecutionAddress { -public: - explicit ScopedTranslatedExecutionAddress(uint32_t address) noexcept - : previous_(g_currentTranslatedExecutionAddress) { - // Address 0 means "nothing new to report" - the enclosing scope's value - // stays visible, and the unconditional restore below keeps nesting exact. - if (address != 0) { - g_currentTranslatedExecutionAddress = address; - } - } - - ~ScopedTranslatedExecutionAddress() noexcept { - g_currentTranslatedExecutionAddress = previous_; - } - - ScopedTranslatedExecutionAddress(const ScopedTranslatedExecutionAddress&) = delete; - ScopedTranslatedExecutionAddress& operator=(const ScopedTranslatedExecutionAddress&) = delete; - -private: - uint32_t previous_ = 0; -}; - -inline constexpr uint32_t kExecutableWriteGuardPageShift = 12; -inline constexpr uint32_t kExecutableWriteGuardPageCount = 1u << (32 - kExecutableWriteGuardPageShift); -inline constexpr uint32_t kExecutableWriteGuardCoarsePageShift = 20; -inline constexpr uint32_t kExecutableWriteGuardCoarsePageCount = 1u << (32 - kExecutableWriteGuardCoarsePageShift); -inline constexpr uint32_t kExecutableWriteGuardMidPageShift = 16; -inline constexpr uint32_t kExecutableWriteGuardMidPageCount = 1u << (32 - kExecutableWriteGuardMidPageShift); - -extern std::atomic g_executableWriteGuardEnabled; -extern std::atomic g_executableWriteGuardPages[kExecutableWriteGuardPageCount]; -extern std::atomic g_executableWriteGuardCoarsePages[kExecutableWriteGuardCoarsePageCount]; -extern std::atomic g_executableWriteGuardMidPages[kExecutableWriteGuardMidPageCount]; - // Two initializer phases, both emitted by the translator's mod data-patch // writer. (A third, plain RegisterInitializer/RunInitializers pair existed with // no registrant on either side and was removed.) diff --git a/runtime/src/abi_bridge.cpp b/runtime/src/abi_bridge.cpp index 4ef3b2b..fd1a70d 100644 --- a/runtime/src/abi_bridge.cpp +++ b/runtime/src/abi_bridge.cpp @@ -4,13 +4,15 @@ #include #include #include +#include +#include #include #include #include #include #include #include -#include "generated/RuntimeConfig.h" +#include "guest_small_data.h" #include "runtime_log.h" // Nothing reads CpuContext by offset; these asserts just canary accidental field reordering @@ -253,6 +255,41 @@ std::string ValidateGeneratedIndirectDispatchLocked() { } } +uint32_t FilterScnRendererPathMaskOption(uint32_t pathMask) noexcept { + return RuntimeGameGraphicsOptions::FilterScnRendererPathMask(pathMask); +} + +void ReportMissingIndirectJumpTarget(uint32_t target, CpuContext* cpu) { + // If not registered, this is likely a switch table jump to an intra-function label. + // The translator should have recognized this pattern and generated proper switch code. + RT_LOG(RT_TAG_RUNTIME) << "InvokeIndirectJump: target 0x" + << std::hex << target << std::dec + << " is not a registered function.\n" + << "This is likely a switch statement jump table that the translator\n" + << "failed to recognize. The function containing this bctr needs\n" + << "proper switch pattern recognition." << std::endl; + RT_LOG(RT_TAG_RUNTIME) << "Indirect jump context: pc=0x" << std::hex << cpu->pc + << " lr=0x" << cpu->lr << " ctr=0x" << cpu->ctr + << " r1=0x" << cpu->gpr[1] << " r3=0x" << cpu->gpr[3] + << " r29=0x" << cpu->gpr[29] << " r30=0x" << cpu->gpr[30] + << " r31=0x" << cpu->gpr[31] << std::dec << std::endl; + DumpHostStackTraceForRuntimeHelper(); + std::fflush(stderr); + std::ostringstream message; + message << "The game stopped because an indirect jump targeted guest address 0x" + << std::hex << target + << ", but that address is not a registered translated function.\n\n" + << "The translator may have missed a switch/jump-table target."; + // Same artifact set as RuntimeCrash::FatalMissingGuestTarget, which is this + // path's sibling for indirect *calls*: MarkFatalErrorReported below stops the + // atexit reporter, so the crash log has to be written here. + RuntimeCrash::WriteCrashArtifacts("missing_jump_target", message.str(), &target); + SetRuntimeExitCode(EXIT_FAILURE); + ShowRuntimeFatalPopup("Missing indirect jump target", message.str()); + MarkFatalErrorReported(); + std::exit(EXIT_FAILURE); +} + void RegisterStaticIndirectDispatchTable(const StaticIndirectDispatchTable* table) { std::lock_guard lock(RegistryMutex()); if (RegistryFrozen().load(std::memory_order_acquire)) { @@ -403,7 +440,7 @@ const RawDispatchRecord* TranslatedFunctionRegistry::FindRawByAddressPtrSlow(uin return it != entries.end() && it->address == address ? &*it : nullptr; } -std::optional TranslatedFunctionRegistry::FindByHostAddress(uintptr_t hostAddr) { +std::optional FindTranslatedFunctionByHostAddress(uintptr_t hostAddr) { const bool frozen = RegistryFrozen().load(std::memory_order_acquire); if (!frozen) { std::lock_guard lock(RegistryMutex()); @@ -439,7 +476,7 @@ std::optional TranslatedFunctionRegistry::FindByHostAddr return std::nullopt; } -std::optional TranslatedFunctionRegistry::FindNearestByAddress(uint32_t address) { +std::optional FindNearestTranslatedFunction(uint32_t address) { std::optional best; uint32_t bestDelta = UINT32_MAX; const auto scan = [&] { @@ -478,9 +515,24 @@ void InitializePersistentCpuContext() { // seed the PowerPC ABI environmental registers if no one has yet: // r2 = _SDA2_BASE_ (read-only small data), r13 = _SDA_BASE_. if (g_persistentCpu.gpr[2] == 0) { - g_persistentCpu.gpr[2] = RuntimeConfig::SDA2_BASE; + g_persistentCpu.gpr[2] = GuestSmallData::kSda2Base; } if (g_persistentCpu.gpr[13] == 0) { - g_persistentCpu.gpr[13] = RuntimeConfig::SDA1_BASE; + g_persistentCpu.gpr[13] = GuestSmallData::kSda1Base; } } + +[[noreturn]] void PPC_Undefined(uint32_t pc, uint32_t rawInstruction, const char* details) +{ + std::fprintf(stderr, + "[runtime] UNDEFINED guest instruction: pc=0x%08X raw=0x%08X %s\n", + pc, + rawInstruction, + details ? details : ""); + char message[256]{}; + std::snprintf(message, sizeof(message), + "The game stopped because it reached an unsupported guest instruction at PC 0x%08X (instruction 0x%08X).\n\n%s", + pc, rawInstruction, details ? details : "No additional details were provided."); + ShowRuntimeFatalPopup("Unsupported guest instruction", message); + std::abort(); +} diff --git a/runtime/src/hle/os/os_alarm.cpp b/runtime/src/hle/os/os_alarm.cpp index 49abda7..a0bf0c0 100644 --- a/runtime/src/hle/os/os_alarm.cpp +++ b/runtime/src/hle/os/os_alarm.cpp @@ -11,7 +11,7 @@ #include "ppc_runtime.h" #include "runtime_log.h" #include "hle/net/network.h" -#include "generated/RuntimeConfig.h" +#include "guest_small_data.h" #include "os_internal.h" extern "C" void func_801AADE0(CpuContext* ctx); @@ -41,7 +41,7 @@ struct AlarmProcessScope { // the other call sites have never logged. void EnsureSda1Base(CpuContext* cpu, bool logOnce = false) { - if (cpu->gpr[13] == RuntimeConfig::SDA1_BASE) { + if (cpu->gpr[13] == GuestSmallData::kSda1Base) { return; } @@ -51,7 +51,7 @@ void EnsureSda1Base(CpuContext* cpu, bool logOnce = false) RT_LOG(RT_TAG_OS) << "OS_HLE_ProcessAlarms: fixing r13 to SDA1 base" << std::endl; }); } - cpu->gpr[13] = RuntimeConfig::SDA1_BASE; + cpu->gpr[13] = GuestSmallData::kSda1Base; } void IncrementSchedulerDisableCount() diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 1597d20..9f893d0 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -625,7 +625,7 @@ std::string FormatHostStackTrace(unsigned framesToSkip) { symbol->MaxNameLen = MAX_SYM_NAME; if (symbolsReady && SymFromAddr(process, addr, &symbolDisp, symbol)) { symbolName = symbol->Name; - } else if (auto info = TranslatedFunctionRegistry::FindByHostAddress(static_cast(addr))) { + } else if (auto info = FindTranslatedFunctionByHostAddress(static_cast(addr))) { // DbgHelp could not name it; the translated-function registry can. translatedFuncName = info->name; ppcAddress = info->address; diff --git a/runtime/src/system_bridge.cpp b/runtime/src/system_bridge.cpp index 8b07d12..c67eb8a 100644 --- a/runtime/src/system_bridge.cpp +++ b/runtime/src/system_bridge.cpp @@ -57,8 +57,8 @@ std::optional PrintableU32(uint32_t value) { return out; } -std::optional FindNearestTranslatedFunction(uint32_t address) { - auto best = TranslatedFunctionRegistry::FindNearestByAddress(address); +std::optional FindNearbyTranslatedFunction(uint32_t address) { + auto best = FindNearestTranslatedFunction(address); // Crash-only heuristic: large gaps are usually data/FourCC values, not code offsets. if (best && address - best->address < 0x10000u) { @@ -161,7 +161,7 @@ void PrintGuestValue(std::ostream& os, std::string_view label, uint32_t value) { } else { os << "<" << exact->name << ">"; } - } else if (auto nearest = FindNearestTranslatedFunction(value)) { + } else if (auto nearest = FindNearbyTranslatedFunction(value)) { if (mapName && mapStart >= nearest->address) { os << "<" << mapName; if (value != mapStart) { @@ -255,7 +255,7 @@ void DumpGuestStackHints(std::ostream& os, const CpuContext* cpu) { if (!value) { continue; } - if (!TranslatedFunctionRegistry::FindByAddressPtr(*value) && !FindNearestTranslatedFunction(*value)) { + if (!TranslatedFunctionRegistry::FindByAddressPtr(*value) && !FindNearbyTranslatedFunction(*value)) { continue; } if (printed == 0) { diff --git a/translator/README.md b/translator/README.md index 99596a0..81c3c47 100644 --- a/translator/README.md +++ b/translator/README.md @@ -66,6 +66,7 @@ Relative paths resolve from `workspace_root`, which itself resolves from the man - `generate-data-init --project path` - `emit-base-manifest --project path` - `emit-build-shards --project path` +- `emit-native-index --project path --out path [--native-source-dir path]` - writes the serialized runtime native index - `translate-mod --project path [--profile name] ...` - static Kamek/Pulsar module translation Any command prints its own option list with `--help`. diff --git a/translator/src/Translator.Cli/Program.cs b/translator/src/Translator.Cli/Program.cs index 2ffd646..db6ba74 100644 --- a/translator/src/Translator.Cli/Program.cs +++ b/translator/src/Translator.Cli/Program.cs @@ -68,8 +68,8 @@ var canonicalIrStore = new CanonicalIrStore(); // recursive ABI cache in one stable pass, exactly like translate-mod does. var inferredGuestAbi = new Lazy(() => new InferredGuestFunctionAbiProvider(image.Value, canonicalIrStore)); -var runtimeNativeIndex = new Lazy(() => - RuntimeNativeIndexBuilder.Build(RequireProject().Runtime.NativeRegistrationRoot)); +var runtimeNativeIndex = new Lazy(() => RuntimeNativeIndexBuilder.Resolve( + RequireProject().Runtime.NativeRegistrationRoot, RequireProject().Runtime.NativeIndex, Console.Error)); // Void-stub signatures are only trusted as declared guest ABIs when the stub // lives under runtime.native_abi_directories (the vetted set); everywhere else // the C++ signature is documentation and the inferred ABI stays authoritative. @@ -164,6 +164,7 @@ return command switch "translate-recursive" => RunTranslateRecursive(tail), "translate-mod" => RunTranslateMod(tail), "emit-build-shards" => RunEmitBuildShards(tail), + "emit-native-index" => RunEmitNativeIndex(tail), "emit-base-manifest" => RunEmitBaseManifest(tail), "check-base-mod-awareness" => RunCheckBaseModAwareness(tail), "validate-retro-wfc-payload" => RunValidateRetroWfcPayload(tail), @@ -222,13 +223,21 @@ int RunEmitBuildShards(string[] argsTail) var baseFunctions = OptionValue(argsTail, "--base-functions-dir") ?? Path.Combine(root, "generated", "functions"); var output = OptionValue(argsTail, "--out") ?? Path.Combine(root, "generated", "build_shards"); var nativeSources = OptionValue(argsTail, "--native-source-dir") ?? Path.Combine(root, "runtime", "src"); + var nativeIndexOption = OptionValue(argsTail, "--native-index"); var resolvedProfile = OptionValue(argsTail, "--resolved-profile"); var retroCpp = OptionValue(argsTail, "--retro-cpp-dir"); try { var nativeSourcePath = Path.GetFullPath(nativeSources); - var nativeIndex = RuntimeNativeIndexBuilder.Build(nativeSourcePath); + var nativeIndexPath = string.IsNullOrWhiteSpace(nativeIndexOption) + ? Path.Combine( + Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(nativeSourcePath))!, + RuntimeNativeIndexFile.DefaultFileName) + : Path.GetFullPath(nativeIndexOption); + if (!Directory.Exists(nativeSourcePath) && !File.Exists(nativeIndexPath)) + throw new DirectoryNotFoundException(nativeSourcePath); + var nativeIndex = RuntimeNativeIndexBuilder.Resolve(nativeSourcePath, nativeIndexPath, Console.Error); var result = TranslatedBuildShardEmitter.Emit(new TranslatedBuildShardOptions( Path.GetFullPath(baseMetadata), Path.GetFullPath(baseFunctions), @@ -269,6 +278,33 @@ int RunEmitBuildShards(string[] argsTail) } } +int RunEmitNativeIndex(string[] argsTail) +{ + var output = OptionValue(argsTail, "--out"); + if (string.IsNullOrWhiteSpace(output)) + { + Console.Error.WriteLine("[translator] emit-native-index needs --out."); + return 1; + } + var nativeSourcePath = Path.GetFullPath( + OptionValue(argsTail, "--native-source-dir") ?? RequireProject().Runtime.NativeRegistrationRoot); + if (!Directory.Exists(nativeSourcePath)) + { + Console.Error.WriteLine($"[translator] native source directory not found: {nativeSourcePath}"); + return 1; + } + + // Digest first: sources edited mid-scan leave a stale digest, which only forces a rescan. + var digest = RuntimeNativeIndexFile.ComputeSourceDigest(nativeSourcePath); + var index = RuntimeNativeIndexBuilder.Build(nativeSourcePath); + var outputPath = Path.GetFullPath(output); + var changed = RuntimeNativeIndexFile.Write(outputPath, index, digest); + Console.WriteLine($"[translator] native index {(changed ? "written" : "unchanged")}: {outputPath}"); + Console.WriteLine($" registrations/void-stub ABIs/effects: {index.Registrations.Length:N0}/{index.VoidStubAbis.Length:N0}/{index.Effects.Length:N0}"); + Console.WriteLine($" source digest: {digest}"); + return 0; +} + byte[] LoadBinaryInput(string spec, string outDir, string downloadFileName) { if (Uri.TryCreate(spec, UriKind.Absolute, out var uri) && @@ -2940,7 +2976,7 @@ int EmitGuestSymbolTable(FunctionMap? map, string outputPath) sb.Append("\",\n"); } sb.Append("};\n\n} // extern \"C\"\n"); - File.WriteAllText(outputPath, sb.ToString()); + FileOutput.WriteTextIfChanged(outputPath, sb.ToString()); return entries.Count; } @@ -3573,6 +3609,7 @@ static string[] KnownCommands() => new[] "translate-mod", "emit-base-manifest", "emit-build-shards", + "emit-native-index", "check-base-mod-awareness", "validate-retro-wfc-payload" }; @@ -3633,10 +3670,16 @@ static (string? Positional, CommandOption[] Options)? CommandSpec(string command new("--base-metadata", "path"), new("--base-functions-dir", "path"), new("--native-source-dir", "path"), + new("--native-index", "path"), new("--resolved-profile", "path"), new("--retro-cpp-dir", "path"), new("--out", "generated/build_shards") }), + "emit-native-index" => (null, new CommandOption[] + { + new("--native-source-dir", "path"), + new("--out", "path", Required: true) + }), _ => null }; diff --git a/translator/src/Translator.Cli/TranslationProjectConfig.cs b/translator/src/Translator.Cli/TranslationProjectConfig.cs index 2deb7df..bfbd71b 100644 --- a/translator/src/Translator.Cli/TranslationProjectConfig.cs +++ b/translator/src/Translator.Cli/TranslationProjectConfig.cs @@ -104,7 +104,8 @@ internal sealed class TranslationProjectConfig .ToArray(); var runtime = new ProjectRuntime( abiDirectories, - ResolvePath(workspaceRoot, dto.Runtime?.NativeRegistrationRoot ?? "runtime/src")); + ResolvePath(workspaceRoot, dto.Runtime?.NativeRegistrationRoot ?? "runtime/src"), + ResolvePath(workspaceRoot, dto.Runtime?.NativeIndex ?? "runtime/native-index.json")); var outputRoot = ResolvePath(workspaceRoot, dto.Output?.Root ?? "generated"); var output = new ProjectOutput( outputRoot, @@ -416,6 +417,7 @@ internal sealed class TranslationProjectConfig { public List? NativeAbiDirectories { get; init; } public string? NativeRegistrationRoot { get; init; } + public string? NativeIndex { get; init; } } private sealed class OutputDto @@ -475,7 +477,8 @@ internal sealed record ProjectTranslation( bool AllowUnsupportedInstructions); internal sealed record ProjectRuntime( IReadOnlyList NativeAbiDirectories, - string NativeRegistrationRoot); + string NativeRegistrationRoot, + string NativeIndex); internal sealed record ProjectOutput( string Root, string Functions, diff --git a/translator/src/Translator.Core/Build/TranslatedBuildShardEmitter.cs b/translator/src/Translator.Core/Build/TranslatedBuildShardEmitter.cs index c29a0ca..de8c42a 100644 --- a/translator/src/Translator.Core/Build/TranslatedBuildShardEmitter.cs +++ b/translator/src/Translator.Core/Build/TranslatedBuildShardEmitter.cs @@ -288,10 +288,10 @@ public static partial class TranslatedBuildShardEmitter { throw new FileNotFoundException("Required shard input is missing.", options.BaseMetadataPath); } - foreach (var path in new[] { options.BaseFunctionsDirectory, options.NativeSourceDirectory }) - { - if (!Directory.Exists(path)) throw new DirectoryNotFoundException(path); - } + if (!Directory.Exists(options.BaseFunctionsDirectory)) + throw new DirectoryNotFoundException(options.BaseFunctionsDirectory); + if (options.NativeIndex is null && !Directory.Exists(options.NativeSourceDirectory)) + throw new DirectoryNotFoundException(options.NativeSourceDirectory); if (options.BaseShardCount < 1 || options.ModShardCount < 1 || options.RegistrationShardCount < 1) { throw new ArgumentOutOfRangeException(nameof(options), "Shard counts must be positive."); @@ -629,7 +629,7 @@ public static partial class TranslatedBuildShardEmitter var traitsPath = Path.Combine(directory, $"shard_{hash[..24]}_traits.h"); var source = new StringBuilder(); source.AppendLine("// Translator-owned content-addressed aggregate shard; do not edit."); - source.AppendLine("#include \"abi_bridge.h\""); + source.AppendLine("#include \"guest_abi/guest_abi.h\""); // Quoted include resolves the sibling traits header on every toolchain and keeps // the shard text free of the emitting machine's absolute paths. source.AppendLine($"#include \"{Path.GetFileName(traitsPath)}\""); @@ -909,7 +909,7 @@ public static partial class TranslatedBuildShardEmitter var path = Path.Combine(directory, $"registration_{index:D2}_{hash[..16]}.cpp"); var source = new StringBuilder(); source.AppendLine("// Translator-owned bulk registration; do not edit."); - source.AppendLine("#include \"abi_bridge.h\""); + source.AppendLine("#include \"guest_abi/guest_abi.h\""); source.AppendLine("#include "); foreach (var symbol in group.Select(static record => record.Symbol).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)) { @@ -955,7 +955,7 @@ public static partial class TranslatedBuildShardEmitter var path = Path.Combine(directory, $"indirect_dispatch_{hash[..16]}.cpp"); var source = new StringBuilder(); source.AppendLine("// Translator-owned immutable indirect dispatch table; do not edit."); - source.AppendLine("#include \"abi_bridge.h\""); + source.AppendLine("#include \"guest_abi/guest_abi.h\""); source.AppendLine("#include "); foreach (var symbol in entries.Select(static pair => pair.Value.WinnerSymbol!) .Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)) diff --git a/translator/src/Translator.Core/CodeGen/CxxLinearCodeGenerator.cs b/translator/src/Translator.Core/CodeGen/CxxLinearCodeGenerator.cs index e34b2a9..ed6d82c 100644 --- a/translator/src/Translator.Core/CodeGen/CxxLinearCodeGenerator.cs +++ b/translator/src/Translator.Core/CodeGen/CxxLinearCodeGenerator.cs @@ -176,10 +176,7 @@ public sealed partial class CxxLinearCodeGenerator : LeafRegisterCacheInitializers.Empty; var sb = new StringBuilder(); sb.AppendLine("#include "); - sb.AppendLine("#include \"ppc_runtime.h\""); - sb.AppendLine("#include \"abi_bridge.h\""); - sb.AppendLine("#include \"memory.h\""); - sb.AppendLine("#include \"recomp_mod_loader.h\""); + sb.AppendLine("#include \"guest_abi/guest_abi.h\""); sb.AppendLine(); var forwardDecls = CollectCallTargets(func) diff --git a/translator/src/Translator.Core/NativeSourceParsing.cs b/translator/src/Translator.Core/NativeSourceParsing.cs index d65fcb1..7042cc9 100644 --- a/translator/src/Translator.Core/NativeSourceParsing.cs +++ b/translator/src/Translator.Core/NativeSourceParsing.cs @@ -12,21 +12,30 @@ internal static class NativeSourceParsing public static IReadOnlyList ReadDirectory(string directory) { var root = Path.GetFullPath(directory); + return EnumerateSourcePaths(root) + .Order(StringComparer.OrdinalIgnoreCase) + .Select(path => new NativeSourceFile( + path, + RelativePath(root, path), + StripCommentsAndLiterals(File.ReadAllText(path)))) + .ToArray(); + } + + /// Every file the scanner reads; the native index digest covers exactly this set. + public static IEnumerable EnumerateSourcePaths(string root) + { if (!Directory.Exists(root)) return []; return Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories) .Where(static path => path.EndsWith(".cpp", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".h", StringComparison.OrdinalIgnoreCase) || - path.EndsWith(".hpp", StringComparison.OrdinalIgnoreCase)) - .Order(StringComparer.OrdinalIgnoreCase) - .Select(path => new NativeSourceFile( - path, - Path.GetRelativePath(root, path).Replace('\\', '/'), - StripCommentsAndLiterals(File.ReadAllText(path)))) - .ToArray(); + path.EndsWith(".hpp", StringComparison.OrdinalIgnoreCase)); } + public static string RelativePath(string root, string path) => + Path.GetRelativePath(root, path).Replace('\\', '/'); + public static IEnumerable SplitArguments(string arguments) { var start = 0; diff --git a/translator/src/Translator.Core/RuntimeNativeIndex.cs b/translator/src/Translator.Core/RuntimeNativeIndex.cs index 9783c3f..01acd6d 100644 --- a/translator/src/Translator.Core/RuntimeNativeIndex.cs +++ b/translator/src/Translator.Core/RuntimeNativeIndex.cs @@ -26,7 +26,8 @@ public sealed record RuntimeNativeEffectEntry( /// /// One process-local view of the runtime's native registrations and their guest /// ABI contracts. C++ remains the only source of truth: the translator builds -/// this index once, shares it across all consumers, and writes nothing to disk. +/// this index once and shares it across all consumers. Only emit-native-index +/// persists it (). /// public sealed record RuntimeNativeIndex( RuntimeNativeRegistration[] Registrations, @@ -69,6 +70,28 @@ public static class RuntimeNativeIndexBuilder .ToArray()); } + /// + /// The index for : the serialized index at + /// when it matches the sources (or the sources are absent), + /// otherwise a fresh scan. + /// + public static RuntimeNativeIndex Resolve( + string nativeSourceDirectory, string? indexPath, TextWriter? diagnostics = null) + { + var sourceRoot = Path.GetFullPath(nativeSourceDirectory); + if (string.IsNullOrWhiteSpace(indexPath) || !File.Exists(indexPath)) + return Build(sourceRoot); + + var expectedDigest = Directory.Exists(sourceRoot) + ? RuntimeNativeIndexFile.ComputeSourceDigest(sourceRoot) + : null; + if (RuntimeNativeIndexFile.TryLoad(indexPath, expectedDigest, out var index, out var reason)) + return index; + + diagnostics?.WriteLine($"[translator] ignoring native index {indexPath} ({reason}); scanning {sourceRoot}"); + return Build(sourceRoot); + } + private static IEnumerable ScanRegistrations( IReadOnlyList sources) { @@ -95,7 +118,9 @@ public static class RuntimeNativeIndexBuilder .Distinct() .OrderBy(static registration => registration.Address) .ThenBy(static registration => registration.Symbol, StringComparer.Ordinal) - .ThenBy(static registration => registration.SourceFile, StringComparer.Ordinal); + .ThenBy(static registration => registration.SourceFile, StringComparer.Ordinal) + .ThenBy(static registration => registration.IsTranslatedOverride) + .ThenBy(static registration => registration.ExcludesBaseTranslation); } private static uint ParseAddress(string value) => GuestTargetParser.ParseHexAddress(value); diff --git a/translator/src/Translator.Core/RuntimeNativeIndexFile.cs b/translator/src/Translator.Core/RuntimeNativeIndexFile.cs new file mode 100644 index 0000000..0be7120 --- /dev/null +++ b/translator/src/Translator.Core/RuntimeNativeIndexFile.cs @@ -0,0 +1,281 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Translator.Core.Analysis; +using Translator.Core.IO; + +namespace Translator.Core; + +/// +/// On-disk form of . It is only trusted while its +/// source digest matches the native sources it was extracted from. +/// +public static class RuntimeNativeIndexFile +{ + public const string Format = "wiicompiled-runtime-native-index"; + public const int FormatVersion = 1; + // Bump whenever the scanner or the ABI/effect analyzers can produce different output for the same sources. + public const int ExtractorVersion = 1; + public const string DefaultFileName = "native-index.json"; + + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + WriteIndented = true + }; + + /// + /// Lowercase hex SHA-256 over one "<file sha256> <relative path>\n" line per + /// scanned file, ordered by relative path (ordinal, '/' separators). + /// + public static string ComputeSourceDigest(string nativeSourceDirectory) + { + var root = Path.GetFullPath(nativeSourceDirectory); + var manifest = new StringBuilder(); + foreach (var (relative, path) in NativeSourceParsing.EnumerateSourcePaths(root) + .Select(path => (Relative: NativeSourceParsing.RelativePath(root, path), Path: path)) + .OrderBy(static item => item.Relative, StringComparer.Ordinal)) + { + using var stream = File.OpenRead(path); + manifest.Append(Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant()) + .Append(" ").Append(relative).Append('\n'); + } + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(manifest.ToString()))).ToLowerInvariant(); + } + + public static byte[] Serialize(RuntimeNativeIndex index, string sourceDigest) + { + var document = new IndexDocument + { + Format = Format, + FormatVersion = FormatVersion, + ExtractorVersion = ExtractorVersion, + SourceDigest = sourceDigest, + Registrations = index.Registrations + .OrderBy(static registration => registration.Address) + .ThenBy(static registration => registration.Symbol, StringComparer.Ordinal) + .ThenBy(static registration => registration.SourceFile, StringComparer.Ordinal) + .ThenBy(static registration => registration.IsTranslatedOverride) + .ThenBy(static registration => registration.ExcludesBaseTranslation) + .Select(static registration => new RegistrationDto + { + Address = FormatAddress(registration.Address), + Symbol = registration.Symbol, + SourceFile = registration.SourceFile, + IsTranslatedOverride = registration.IsTranslatedOverride, + ExcludesBaseTranslation = registration.ExcludesBaseTranslation + }) + .ToList(), + VoidStubAbis = index.VoidStubAbis + .OrderBy(static entry => entry.Address) + .Select(static entry => new AbiDto + { + Address = FormatAddress(entry.Address), + ArgumentRegisters = entry.ArgumentRegisters.ToList(), + ScalarFloatArgumentRegisters = entry.ScalarFloatArgumentRegisters.ToList() + }) + .ToList(), + Effects = index.Effects + .OrderBy(static entry => entry.Address) + .Select(static entry => new EffectDto + { + Address = FormatAddress(entry.Address), + IsPrecise = entry.IsPrecise, + Contract = ToDto(entry.Address, entry.Contract) + }) + .ToList() + }; + + // Indented output uses the platform newline on net8; pin it so the bytes are identical everywhere. + var json = JsonSerializer.Serialize(document, Options).Replace("\r\n", "\n", StringComparison.Ordinal); + return Encoding.UTF8.GetBytes(json + "\n"); + } + + public static bool Write(string path, RuntimeNativeIndex index, string sourceDigest) => + FileOutput.WriteBytesIfChanged(path, Serialize(index, sourceDigest)); + + /// + /// Loads the index at when it was produced by this extractor and, unless + /// is null, from exactly those sources. + /// + public static bool TryLoad( + string path, + string? expectedSourceDigest, + out RuntimeNativeIndex index, + out string reason) + { + index = new RuntimeNativeIndex([], [], []); + IndexDocument? document; + try + { + using var parsed = JsonDocument.Parse(File.ReadAllBytes(path)); + var root = parsed.RootElement; + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("format", out var format) || format.ValueKind != JsonValueKind.String || + format.GetString() != Format) + { + reason = "not a runtime native index"; + return false; + } + if (!root.TryGetProperty("formatVersion", out var formatVersion) || + formatVersion.ValueKind != JsonValueKind.Number || formatVersion.GetInt32() != FormatVersion) + { + reason = "format version differs"; + return false; + } + if (!root.TryGetProperty("extractorVersion", out var extractorVersion) || + extractorVersion.ValueKind != JsonValueKind.Number || extractorVersion.GetInt32() != ExtractorVersion) + { + reason = "extractor version differs"; + return false; + } + document = root.Deserialize(Options); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or FormatException) + { + reason = $"unreadable: {ex.Message}"; + return false; + } + + if (document is null) + { + reason = "empty"; + return false; + } + if (expectedSourceDigest is not null && + !string.Equals(document.SourceDigest, expectedSourceDigest, StringComparison.OrdinalIgnoreCase)) + { + reason = "native sources changed"; + return false; + } + + try + { + index = new RuntimeNativeIndex( + Present(document.Registrations, "registrations") + .Select(static registration => new RuntimeNativeRegistration( + ParseAddress(registration.Address), + Present(registration.Symbol, "symbol"), + Present(registration.SourceFile, "sourceFile"), + registration.IsTranslatedOverride, + registration.ExcludesBaseTranslation)) + .ToArray(), + Present(document.VoidStubAbis, "voidStubAbis") + .Select(static entry => new RuntimeNativeAbiEntry( + ParseAddress(entry.Address), + Present(entry.ArgumentRegisters, "argumentRegisters").Select(static r => Present(r, "register")).ToArray(), + Present(entry.ScalarFloatArgumentRegisters, "scalarFloatArgumentRegisters").Select(static r => Present(r, "register")).ToArray())) + .ToArray(), + Present(document.Effects, "effects") + .Select(static entry => new RuntimeNativeEffectEntry( + ParseAddress(entry.Address), FromDto(Present(entry.Contract, "contract")), entry.IsPrecise)) + .ToArray()); + } + catch (FormatException ex) + { + reason = $"unreadable: {ex.Message}"; + return false; + } + + reason = string.Empty; + return true; + } + + private static string FormatAddress(uint address) => $"0x{address:X8}"; + + private static uint ParseAddress(string text) => GuestTargetParser.ParseHexAddress(text); + + private static T Present(T? value, string name) where T : class => + value ?? throw new FormatException($"'{name}' is null."); + + private static ContractDto ToDto(uint address, GuestAbiContract contract) + { + var dto = new ContractDto + { + GprReadBeforeWriteMask = contract.GprReadBeforeWriteMask, + GprPossibleWriteMask = contract.GprPossibleWriteMask, + GprReturnMask = contract.GprReturnMask, + FprReadBeforeWriteMask = contract.FprReadBeforeWriteMask, + FprPossibleWriteMask = contract.FprPossibleWriteMask, + FprReturnMask = contract.FprReturnMask, + CrReadBeforeWriteMask = contract.CrReadBeforeWriteMask, + CrPossibleWriteMask = contract.CrPossibleWriteMask, + ReadsXerBeforeWrite = contract.ReadsXerBeforeWrite, + MayWriteXer = contract.MayWriteXer, + ReadsCtrBeforeWrite = contract.ReadsCtrBeforeWrite, + MayWriteCtr = contract.MayWriteCtr, + ReadsLrBeforeWrite = contract.ReadsLrBeforeWrite, + MayWriteLr = contract.MayWriteLr, + BoundaryFlags = (int)contract.BoundaryFlags + }; + // Native contracts only ever set the positional masks; anything else would be lost on load. + if (!FromDto(dto).Equals(contract)) + throw new InvalidOperationException( + $"Native contract for 0x{address:X8} uses fields the native index does not serialize."); + return dto; + } + + private static GuestAbiContract FromDto(ContractDto dto) => + new(dto.GprReadBeforeWriteMask, dto.GprPossibleWriteMask, dto.GprReturnMask, + dto.FprReadBeforeWriteMask, dto.FprPossibleWriteMask, dto.FprReturnMask, + dto.CrReadBeforeWriteMask, dto.CrPossibleWriteMask, + dto.ReadsXerBeforeWrite, dto.MayWriteXer, + dto.ReadsCtrBeforeWrite, dto.MayWriteCtr, + dto.ReadsLrBeforeWrite, dto.MayWriteLr, + (GuestCallBoundaryFlags)dto.BoundaryFlags, Array.Empty()); + + private sealed class IndexDocument + { + public required string Format { get; init; } + public required int FormatVersion { get; init; } + public required int ExtractorVersion { get; init; } + public required string SourceDigest { get; init; } + public required List Registrations { get; init; } + public required List VoidStubAbis { get; init; } + public required List Effects { get; init; } + } + + private sealed class RegistrationDto + { + public required string Address { get; init; } + public required string Symbol { get; init; } + public required string SourceFile { get; init; } + public required bool IsTranslatedOverride { get; init; } + public required bool ExcludesBaseTranslation { get; init; } + } + + private sealed class AbiDto + { + public required string Address { get; init; } + public required List ArgumentRegisters { get; init; } + public required List ScalarFloatArgumentRegisters { get; init; } + } + + private sealed class EffectDto + { + public required string Address { get; init; } + public required bool IsPrecise { get; init; } + public required ContractDto Contract { get; init; } + } + + private sealed class ContractDto + { + public required uint GprReadBeforeWriteMask { get; init; } + public required uint GprPossibleWriteMask { get; init; } + public required uint GprReturnMask { get; init; } + public required uint FprReadBeforeWriteMask { get; init; } + public required uint FprPossibleWriteMask { get; init; } + public required uint FprReturnMask { get; init; } + public required byte CrReadBeforeWriteMask { get; init; } + public required byte CrPossibleWriteMask { get; init; } + public required bool ReadsXerBeforeWrite { get; init; } + public required bool MayWriteXer { get; init; } + public required bool ReadsCtrBeforeWrite { get; init; } + public required bool MayWriteCtr { get; init; } + public required bool ReadsLrBeforeWrite { get; init; } + public required bool MayWriteLr { get; init; } + public required int BoundaryFlags { get; init; } + } +} diff --git a/translator/tests/Translator.Tests/RuntimeNativeIndexTests.cs b/translator/tests/Translator.Tests/RuntimeNativeIndexTests.cs index e630654..595d97f 100644 --- a/translator/tests/Translator.Tests/RuntimeNativeIndexTests.cs +++ b/translator/tests/Translator.Tests/RuntimeNativeIndexTests.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using Translator.Core; using Translator.Core.CodeGen; using Xunit; @@ -6,6 +8,24 @@ namespace Translator.Tests; public sealed class RuntimeNativeIndexTests { + // The Windows setup self-test and Launcher/build-cache.sh pin the same fixture digest. + [Fact] + public void SourceDigestMatchesInstallerImplementations() + { + var directory = Path.Combine(Path.GetTempPath(), $"mkw-native-digest-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + File.WriteAllText(Path.Combine(directory, "main.cpp"), "int main();"); + Assert.Equal("50b9779591f4b205751361c8899accd362f96ea5dfd32febed1bedca534c523a", + RuntimeNativeIndexFile.ComputeSourceDigest(directory)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + [Fact] public void BuildCapturesEveryRuntimeRegistrationKind() { @@ -72,6 +92,7 @@ public sealed class RuntimeNativeIndexTests var outOfScope = RuntimeNativeFunctionAbiProvider.FromIndex(index, new HashSet()); Assert.False(outOfScope.TryGetGuestFunctionAbi("func_80001234", out _)); + RuntimeNativeIndexBuilder.Resolve(directory, Path.Combine(directory, "missing-index.json")); Assert.Equal([sourcePath], Directory.GetFiles(directory)); } finally @@ -80,4 +101,173 @@ public sealed class RuntimeNativeIndexTests Directory.Delete(directory, recursive: true); } } + + [Fact] + public void SerializedRepositoryIndexRoundTripsExactly() + { + var sources = Path.Combine(ProjectPathsForTests.FindRepositoryRoot(), "runtime", "src"); + var built = RuntimeNativeIndexBuilder.Build(sources); + Assert.NotEmpty(built.Registrations); + Assert.NotEmpty(built.VoidStubAbis); + Assert.NotEmpty(built.Effects); + + var path = Path.Combine(Path.GetTempPath(), $"mkw-native-index-{Guid.NewGuid():N}.json"); + try + { + var bytes = RuntimeNativeIndexFile.Serialize(built, "digest"); + File.WriteAllBytes(path, bytes); + Assert.True(RuntimeNativeIndexFile.TryLoad(path, "digest", out var loaded, out var reason), reason); + AssertIndexEqual(built, loaded); + Assert.Equal(bytes, RuntimeNativeIndexFile.Serialize(loaded, "digest")); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ResolveUsesAMatchingIndexAndKeepsWorkingWithoutSources() + { + var root = CreateFixture(); + try + { + var sources = Path.Combine(root, "src"); + var indexPath = Path.Combine(root, RuntimeNativeIndexFile.DefaultFileName); + var built = RuntimeNativeIndexBuilder.Build(sources); + Assert.True(RuntimeNativeIndexFile.Write( + indexPath, built, RuntimeNativeIndexFile.ComputeSourceDigest(sources))); + + var diagnostics = new StringWriter(); + AssertIndexEqual(built, RuntimeNativeIndexBuilder.Resolve(sources, indexPath, diagnostics)); + Assert.Equal("", diagnostics.ToString()); + Assert.Contains(built.Registrations, static entry => entry.SourceFile == "hle/gx/stubs.cpp"); + Assert.Contains(built.Effects, static entry => entry.IsPrecise); + Assert.Contains(built.Effects, static entry => !entry.IsPrecise); + + Directory.Delete(sources, recursive: true); + AssertIndexEqual(built, RuntimeNativeIndexBuilder.Resolve(sources, indexPath, diagnostics)); + Assert.Equal("", diagnostics.ToString()); + Assert.Empty(RuntimeNativeIndexBuilder.Build(sources).Registrations); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void ResolveRescansWhenTheIndexIsStale() + { + var root = CreateFixture(); + try + { + var sources = Path.Combine(root, "src"); + var indexPath = Path.Combine(root, RuntimeNativeIndexFile.DefaultFileName); + RuntimeNativeIndexFile.Write( + indexPath, RuntimeNativeIndexBuilder.Build(sources), RuntimeNativeIndexFile.ComputeSourceDigest(sources)); + + File.AppendAllText(Path.Combine(sources, "natives.cpp"), "\nREGISTER_NATIVE_FUNCTION(0x80009990, Added);\n"); + var diagnostics = new StringWriter(); + var resolved = RuntimeNativeIndexBuilder.Resolve(sources, indexPath, diagnostics); + Assert.Contains(resolved.Registrations, static entry => entry.Address == 0x80009990u); + var current = RuntimeNativeIndexBuilder.Build(sources); + AssertIndexEqual(current, resolved); + Assert.Contains("native sources changed", diagnostics.ToString()); + + var json = Encoding.UTF8.GetString( + RuntimeNativeIndexFile.Serialize(current, RuntimeNativeIndexFile.ComputeSourceDigest(sources))) + .Replace($"\"extractorVersion\": {RuntimeNativeIndexFile.ExtractorVersion}", "\"extractorVersion\": 0"); + File.WriteAllText(indexPath, json); + diagnostics = new StringWriter(); + AssertIndexEqual(current, RuntimeNativeIndexBuilder.Resolve(sources, indexPath, diagnostics)); + Assert.Contains("extractor version differs", diagnostics.ToString()); + + File.WriteAllText(indexPath, "{ not json"); + diagnostics = new StringWriter(); + AssertIndexEqual(current, RuntimeNativeIndexBuilder.Resolve(sources, indexPath, diagnostics)); + Assert.Contains("unreadable", diagnostics.ToString()); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void SerializationAndDigestAreDeterministicAcrossLocations() + { + var first = CreateFixture(); + var second = CreateFixture(); + try + { + var firstSources = Path.Combine(first, "src"); + var secondSources = Path.Combine(second, "src"); + var digest = RuntimeNativeIndexFile.ComputeSourceDigest(firstSources); + Assert.Equal(digest, RuntimeNativeIndexFile.ComputeSourceDigest(secondSources)); + + var bytes = RuntimeNativeIndexFile.Serialize(RuntimeNativeIndexBuilder.Build(firstSources), digest); + Assert.Equal(bytes, RuntimeNativeIndexFile.Serialize(RuntimeNativeIndexBuilder.Build(firstSources), digest)); + Assert.Equal(bytes, RuntimeNativeIndexFile.Serialize(RuntimeNativeIndexBuilder.Build(secondSources), digest)); + Assert.DoesNotContain((byte)'\r', bytes); + Assert.Equal((byte)'\n', bytes[^1]); + + var expected = new StringBuilder(); + foreach (var relative in new[] { "hle/gx/stubs.cpp", "include/decl.h", "natives.cpp" }) + { + var fileHash = SHA256.HashData(File.ReadAllBytes(Path.Combine(firstSources, relative))); + expected.Append(Convert.ToHexString(fileHash).ToLowerInvariant()).Append(" ").Append(relative).Append('\n'); + } + Assert.Equal( + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(expected.ToString()))).ToLowerInvariant(), + digest); + + File.WriteAllText(Path.Combine(firstSources, "notes.txt"), "not scanned"); + Assert.Equal(digest, RuntimeNativeIndexFile.ComputeSourceDigest(firstSources)); + File.AppendAllText(Path.Combine(firstSources, "include", "decl.h"), "\n"); + Assert.NotEqual(digest, RuntimeNativeIndexFile.ComputeSourceDigest(firstSources)); + } + finally + { + Directory.Delete(first, recursive: true); + Directory.Delete(second, recursive: true); + } + } + + internal static string CreateFixture() + { + var root = Path.Combine(Path.GetTempPath(), $"mkw-native-fixture-{Guid.NewGuid():N}"); + var sources = Path.Combine(root, "src"); + Directory.CreateDirectory(Path.Combine(sources, "hle", "gx")); + Directory.CreateDirectory(Path.Combine(sources, "include")); + File.WriteAllText(Path.Combine(sources, "natives.cpp"), """ + extern "C" void Precise(CpuContext* ctx) { ctx->gpr[3] = ctx->gpr[4]; ctx->lr = 0; } + extern "C" void Complete(CpuContext* ctx) { Helper(ctx); } + REGISTER_NATIVE_FUNCTION(0x80000010, Precise); + REGISTER_NATIVE_FUNCTION_AS(0x80000020, Complete, "alias"); + REGISTER_TRANSLATED_FUNCTION(0x80000030, Translated); + GX_FATAL_STUB(80000050, "Fatal") + """); + File.WriteAllText(Path.Combine(sources, "hle", "gx", "stubs.cpp"), """ + extern "C" void Typed(float value, uint32_t count) { (void)value; (void)count; } + PPC_NATIVE_OVERRIDE_VOID(80001234, Typed, (float value, uint32_t count), (value, count)); + """); + File.WriteAllText(Path.Combine(sources, "include", "decl.h"), """ + REGISTER_NATIVE_FUNCTION(0x80000060, Declared); + """); + return root; + } + + internal static void AssertIndexEqual(RuntimeNativeIndex expected, RuntimeNativeIndex actual) + { + Assert.Equal(expected.Registrations, actual.Registrations); + Assert.Equal(expected.Effects, actual.Effects); + Assert.Equal(expected.VoidStubAbis.Length, actual.VoidStubAbis.Length); + foreach (var (left, right) in expected.VoidStubAbis.Zip(actual.VoidStubAbis)) + { + Assert.Equal(left.Address, right.Address); + Assert.Equal(left.ArgumentRegisters, right.ArgumentRegisters); + Assert.Equal(left.ScalarFloatArgumentRegisters, right.ScalarFloatArgumentRegisters); + } + } } diff --git a/translator/tests/Translator.Tests/TranslatedBuildShardEmitterTests.cs b/translator/tests/Translator.Tests/TranslatedBuildShardEmitterTests.cs index f0a0f78..384c742 100644 --- a/translator/tests/Translator.Tests/TranslatedBuildShardEmitterTests.cs +++ b/translator/tests/Translator.Tests/TranslatedBuildShardEmitterTests.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using Translator.Core; using Translator.Core.Build; using Translator.Core.Mods; using Xunit; @@ -359,6 +360,62 @@ public sealed class TranslatedBuildShardEmitterTests } } + [Fact] + public void LoadedNativeIndexEmitsTheSameGraphAsAScan() + { + var root = Path.Combine(Path.GetTempPath(), $"translator-native-index-shards-{Guid.NewGuid():N}"); + var functions = Path.Combine(root, "functions"); + var native = Path.Combine(root, "runtime", "src"); + var output = Path.Combine(root, "out"); + Directory.CreateDirectory(functions); + try + { + var sources = new Dictionary(); + foreach (var address in new[] { 0x80001000u, 0x80003000u, 0x80004000u }) + { + sources[address] = $$""" + #include "abi_bridge.h" + extern "C" void func_{{address:X8}}(CpuContext*) {} + // RECOMP_REGISTRATION base 0x{{address:X8}} func_{{address:X8}} preserves=true fpr_mask=0x00000000 + """; + Write(Path.Combine(functions, $"func_{address:X8}.cpp"), sources[address]); + } + Write(Path.Combine(native, "hle", "override.cpp"), + "REGISTER_TRANSLATED_FUNCTION(0x80003000, RuntimeOverride_80003000);\n" + + "REGISTER_NATIVE_FUNCTION(0x80004000, NativeOverride_80004000);\n"); + var metadataPath = Path.Combine(root, "base_output.json"); + BaseTranslationOutputMetadataFile.WriteIfChangedAtomic(metadataPath, + BaseTranslationOutputMetadata.Create( + sources.Select(item => Metadata($"func_{item.Key:X8}.cpp", item.Key, item.Value)).ToList(), + TranslationQualityMetadata.Clean, "identity")); + + var indexPath = Path.Combine(root, "runtime", RuntimeNativeIndexFile.DefaultFileName); + var scanned = RuntimeNativeIndexBuilder.Resolve(native, indexPath); + TranslatedBuildShardEmitter.Emit(new TranslatedBuildShardOptions( + metadataPath, functions, output, native, BaseShardCount: 2, ModShardCount: 1, + RegistrationShardCount: 1, NativeIndex: scanned)); + var expected = Snapshot(output); + Assert.Contains(expected.Values, static text => text.Contains("RuntimeOverride_80003000")); + + RuntimeNativeIndexFile.Write(indexPath, scanned, RuntimeNativeIndexFile.ComputeSourceDigest(native)); + Directory.Delete(native, recursive: true); + Directory.Delete(output, recursive: true); + var loaded = RuntimeNativeIndexBuilder.Resolve(native, indexPath); + TranslatedBuildShardEmitter.Emit(new TranslatedBuildShardOptions( + metadataPath, functions, output, native, BaseShardCount: 2, ModShardCount: 1, + RegistrationShardCount: 1, NativeIndex: loaded)); + Assert.Equal(expected, Snapshot(output)); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + + static SortedDictionary Snapshot(string directory) => + new(Directory.GetFiles(directory, "*", SearchOption.AllDirectories) + .ToDictionary(path => Path.GetRelativePath(directory, path), File.ReadAllText), StringComparer.Ordinal); + } + private static BaseTranslationFunctionMetadata Metadata(string path, uint address, string content) { var bytes = Encoding.UTF8.GetBytes(content);