Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,36 @@ Retro Rewind was made by ZPL. More information about Retro Rewind can be found o
Huge parts from the mii renderer were inspired and ported from [ariankordi's branch](https://github.com/ariankordi/FFL-Testing) of abood's [FFL-Testing](https://github.com/aboood40091/FFL-Testing) project. You can find their website [here](https://mii-unsecure.ariankordi.net/).
Some of the icons used in Wheel Wizard are from [Game Icons](https://game-icons.net/about.html). Specifically, the [car wheel icon](https://game-icons.net/1x1/delapouite/car-wheel.html) and the [flat tire icon](https://game-icons.net/1x1/delapouite/flat-tire.html), both created by Delapouite.
Also thanks Chadderz' for the specials icons in ["Chadderz' Terrible Mario Kart Font"](https://wiki.tockdom.com/wiki/CTMKF)

### Native WiiCompiled on Apple Silicon

On macOS 14 or later, the Apple Silicon build supports **Settings → Other → WiiCompiled (beta)**.
Choose a clean **PAL RMCP01** Mario Kart Wii disc image in Settings, then use the Home page to install
and play Retro Rewind natively. The installer verifies the disc, translates the game on your Mac,
and builds ARM64 apps using Metal. Dolphin is optional; its existing Retro Rewind files and NAND
settings can still be used. Intel Macs continue to use Dolphin.

The Mac integration supports install/update, product checks and repair before launch, progress and
cancellation, online/offline payload choices, video settings, and portable uninstallation. Game apps,
source/tooling, build cache, and settings live under WheelWizard's `Recomp` directory. Asset-only
Retro Rewind updates are read live; changed `Code.pul` or mod directory topology triggers compilation.
The setup holds an installation lock while a game it launched is running. Directly opening a game
app bypasses that lock, so close standalone game instances before installing or repairing.

To build from adjacent `WheelWizard` and `Wiicompiled` checkouts:

```sh
./build-native-mac.sh
```

This requires .NET 10, Xcode Command Line Tools, and the native redistributable tools from
`WiiCompiled Setup.app` in Applications. Override `WIICOMPILED_REPO` or `WIICOMPILED_TOOLS_ROOT`
if they are elsewhere. The result is `release/WheelWizard.app`, with a game-code-free setup bundled
so first installation works before a fork release is published. For an existing setup asset, use
`RECOMP_SETUP=/absolute/path/WiiCompiled-Setup-macos-arm64.run ./build-mac.sh`.

Mac updates select `WiiCompiled-Setup-macos-arm64.run` from `DarthMDev/Wiicompiled` releases.
Windows retains its existing repository and `.exe` contract. The local bundle is used when it is at
least as new as the available Mac release. A release package contains source and redistributable tools
only; your disc image, translated game, and Retro Rewind data are never included. Local builds are
ad-hoc signed; distributing them publicly still requires the normal signing/notarization process.
116 changes: 116 additions & 0 deletions WheelWizard.Test/Features/Recomp/RecompMacTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using System.IO.Abstractions;
using Microsoft.Extensions.Logging.Abstractions;
using WheelWizard.GitHub.Domain;
using WheelWizard.Recomp;
using Xunit;

namespace WheelWizard.Test.Features.Recomp;

public class RecompMacTests
{
[Fact]
public void MacReleaseSelectionDoesNotOfferWindowsInstaller()
{
var windows = new GithubRelease
{
TagName = "v9.0.0",
Assets = [new GithubAsset { Name = "WiiCompiled-Setup.exe", BrowserDownloadUrl = "https://example.com/windows" }],
};
var mac = new GithubRelease
{
TagName = "v1.0.0",
Assets = [new GithubAsset { Name = "WiiCompiled-Setup-macos-arm64.run", BrowserDownloadUrl = "https://example.com/mac" }],
};
Assert.Equal("v1.0.0", RecompReleaseResolver.FindLatest([windows, mac], "WiiCompiled-Setup-macos-arm64.run")?.TagName);
Assert.Null(RecompReleaseResolver.FindLatest([windows], "WiiCompiled-Setup-macos-arm64.run"));
}

[Fact]
public async Task MacRunnerPassesLiteralPathsToSetupWithoutShellExpansion()
{
if (!OperatingSystem.IsMacOS())
return;
var directory = Path.Combine(Path.GetTempPath(), "wheelwizard argv " + Guid.NewGuid());
Directory.CreateDirectory(directory);
var script = Path.Combine(directory, "a \"quoted\" setup.run");
try
{
await File.WriteAllTextAsync(script, "#!/bin/bash\nprintf '%s\\n' \"$@\"\n");
var game = "/Users/Zoë/a \"quoted\" \\ game $(touch NEVER).rvz";
var install = "/Users/Zoë/Application Support/Install";
var arguments = RecompSetupCommandBuilder.BuildSilentInstallArguments(
new()
{
GameFilePath = game,
InstallFolderPath = install,
Portable = true,
}
);
var output = new List<string>();
var runner = new RecompProcessRunner(NullLogger<RecompProcessRunner>.Instance);
var result = await runner.RunAsync(script, arguments, null, output.Add);
Assert.True(result.IsSuccess);
Assert.Equal(0, result.Value);
Assert.Equal(game, output[2]);
Assert.Equal(install, output[4]);
Assert.Contains("--portable", output);
}
finally
{
Directory.Delete(directory, true);
}
}

[Fact]
public void MacOffersMetal()
{
if (!OperatingSystem.IsMacOS())
return;
Assert.Equal(new[] { "metal" }, RecompVideoConfig.OfferedGraphicsApis);
Assert.Equal("Metal", RecompVideoConfig.DescribeGraphicsApi("metal"));
}

[Fact]
public async Task MacCancellationAllowsTheHelperToCleanUp()
{
if (!OperatingSystem.IsMacOS())
return;
var directory = Path.Combine(Path.GetTempPath(), "wheelwizard-cancel-" + Guid.NewGuid());
Directory.CreateDirectory(directory);
var script = Path.Combine(directory, "setup.run");
string? signal = null;
var output = new List<string>();
using var cancellation = new CancellationTokenSource();
try
{
await File.WriteAllTextAsync(
script,
"#!/bin/bash\nprintf '%s\\n' \"$MKWCOMPILED_CANCEL_FILE\"\nwhile [[ ! -f \"$MKWCOMPILED_CANCEL_FILE\" ]]; do sleep 0.05; done\necho cleaned-up\nexit 1\n"
);
var runner = new RecompProcessRunner(NullLogger<RecompProcessRunner>.Instance);
var result = await runner.RunAsync(
script,
"",
null,
line =>
{
output.Add(line);
if (signal is null)
{
signal = line;
cancellation.Cancel();
}
},
cancellation.Token
);
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Value);
Assert.Contains("cleaned-up", output);
Assert.False(File.Exists(signal));
}
finally
{
Directory.Delete(directory, true);
}
}
}
6 changes: 3 additions & 3 deletions WheelWizard/Features/Recomp/RecompExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ public static class RecompExtensions
{
/// <summary>
/// Registers the Mario Kart Wii recomp frontend.
/// The recomp only ships for Windows, so on every other platform nothing is registered at all;
/// <c>ISettingsManager.IsRecompModeActive()</c> is false there, so nothing ever resolves these.
/// Register only on Windows and supported Apple Silicon Macs.
/// <c>ISettingsManager.IsRecompModeActive()</c> is false elsewhere, so nothing ever resolves these.
/// </summary>
public static IServiceCollection AddRecomp(this IServiceCollection services)
{
if (!OperatingSystem.IsWindows())
if (!RecompPlatform.IsSupported)
return services;

services
Expand Down
50 changes: 44 additions & 6 deletions WheelWizard/Features/Recomp/RecompInstallService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ CancellationToken cancellationToken
)
{
// No platform guard here on purpose: AddRecomp() is the single gate, so this service only ever
// exists on Windows in the first place.
// exists only on supported platforms.
if (!IsGameFileConfigured())
return Fail(t("message_warning.not_find_game.extra"));

Expand Down Expand Up @@ -515,12 +515,22 @@ public async Task<OperationResult> UninstallAsync(CancellationToken cancellation
private OperationResult UninstallCore()
{
_launchReconciled = false;
if (IsInstallationBusy())
return Fail(InstallationBusyMessage);

// Only the recomp's own directories are removed. The shared Retro Rewind installation lives
// outside them and survives on purpose: it belongs to WheelWizard's Dolphin frontend just as much.
return TryCatch(
() =>
{
// Mac publication can leave these owned siblings after a cancelled build.
if (OperatingSystem.IsMacOS())
{
DeleteFolderIfPresent(environment.InstallFolderPath + ".staging");
DeleteFolderIfPresent(environment.InstallFolderPath + ".previous");
if (fileSystem.File.Exists(environment.InstallFolderPath + ".config-backup"))
fileSystem.File.Delete(environment.InstallFolderPath + ".config-backup");
}
DeleteFolderIfPresent(environment.InstallFolderPath);
DeleteFolderIfPresent(environment.UserDataFolderPath);
DeleteFolderIfPresent(environment.CacheFolderPath);
Expand All @@ -545,6 +555,12 @@ private async Task<OperationResult<string>> EnsureSetupDownloadedAsync(
CancellationToken cancellationToken
)
{
if (OperatingSystem.IsMacOS() && release.SetupDownloadUrl == new Uri(RecompPlatform.BundledSetupPath).AbsoluteUri)
{
return await SetupMatchesVersionAsync(RecompPlatform.BundledSetupPath, release.TagName, cancellationToken)
? Ok(RecompPlatform.BundledSetupPath)
: Fail("The bundled Mac setup has an unexpected version.");
}
var cachedSetupPath = fileSystem.Path.Combine(environment.CacheFolderPath, BuildCachedSetupFileName(release.TagName));
if (IsUsableFile(cachedSetupPath) && await SetupMatchesVersionAsync(cachedSetupPath, release.TagName, cancellationToken))
{
Expand Down Expand Up @@ -590,7 +606,12 @@ private void PruneCachedSetupsExcept(string keepFilePath)
{
if (!fileSystem.Directory.Exists(environment.CacheFolderPath))
return;
foreach (var candidate in fileSystem.Directory.EnumerateFiles(environment.CacheFolderPath, "WiiCompiled-Setup-*.exe"))
foreach (
var candidate in fileSystem.Directory.EnumerateFiles(
environment.CacheFolderPath,
OperatingSystem.IsMacOS() ? "WiiCompiled-Setup-*.run" : "WiiCompiled-Setup-*.exe"
)
)
{
if (string.Equals(candidate, keepFilePath, StringComparison.OrdinalIgnoreCase))
continue;
Expand Down Expand Up @@ -879,18 +900,35 @@ private OperationResult FinishSetupRun(
{
cancellationToken.ThrowIfCancellationRequested();

// A locally built WheelWizard may ship a setup before its fork publishes a release.
RecompRelease? bundled = null;
if (OperatingSystem.IsMacOS() && fileSystem.File.Exists(RecompPlatform.BundledSetupPath))
{
await processRunner.RunAsync(
RecompPlatform.BundledSetupPath,
"--version",
null,
line =>
{
if (RecompVersion.TryParse(line, out var version))
bundled = new("v" + version, version, new Uri(RecompPlatform.BundledSetupPath).AbsoluteUri);
},
cancellationToken
);
}
var releasesResult = await gitHubService.GetReleasesAsync(
RecompReleaseResolver.RepositoryOwner,
RecompPlatform.RepositoryOwner,
RecompReleaseResolver.RepositoryName,
count: 100
);
if (releasesResult.IsFailure)
{
logger.LogWarning("Could not retrieve the recomp releases: {Message}", releasesResult.Error.Message);
return null;
return bundled;
}

return RecompReleaseResolver.FindLatest(releasesResult.Value);
var remote = RecompReleaseResolver.FindLatest(releasesResult.Value, RecompPlatform.ReleaseAssetName);
return bundled is not null && (remote is null || bundled.Version.ComparePrecedenceTo(remote.Version) >= 0) ? bundled : remote;
}

private RecompInstallState? ReadInstalledState()
Expand Down Expand Up @@ -968,7 +1006,7 @@ private bool IsUsableFile(string filePath)
private static string BuildCachedSetupFileName(string tagName)
{
var sanitized = new string(tagName.Select(character => char.IsLetterOrDigit(character) ? character : '-').ToArray());
return $"WiiCompiled-Setup-{sanitized}.exe";
return $"WiiCompiled-Setup-{sanitized}{(OperatingSystem.IsMacOS() ? ".run" : ".exe")}";
}

private static void Report(IProgress<RecompInstallProgress>? progress, string message, int percent) =>
Expand Down
5 changes: 5 additions & 0 deletions WheelWizard/Features/Recomp/RecompLauncher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ public async Task<OperationResult> Launch()
if (progressWindow.WasCancellationRequested || cancellationTokenSource.IsCancellationRequested)
return Fail("WiiCompiled launch preparation was cancelled.");

// Apply the current save-data preference even after an external install or a relink.
var nandResult = dolphinData.ApplyNandToRecompConfig();
if (nandResult.IsFailure)
return nandResult;

// Reconciliation is the only cancellable progress phase. The launch call is
// awaited through game exit.
progressWindow.SetCancellationTokenSource(null);
Expand Down
22 changes: 22 additions & 0 deletions WheelWizard/Features/Recomp/RecompPlatform.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Runtime.InteropServices;

namespace WheelWizard.Recomp;

public static class RecompPlatform
{
public static bool IsSupported =>
OperatingSystem.IsWindows()
|| (OperatingSystem.IsMacOSVersionAtLeast(14) && RuntimeInformation.ProcessArchitecture == Architecture.Arm64);
public static string SetupFileName => OperatingSystem.IsMacOS() ? "WiiCompiled-Setup.run" : "WiiCompiled-Setup.exe";
public static string ReleaseAssetName => OperatingSystem.IsMacOS() ? "WiiCompiled-Setup-macos-arm64.run" : "WiiCompiled-Setup.exe";
public static string RepositoryOwner => OperatingSystem.IsMacOS() ? "DarthMDev" : RecompReleaseResolver.RepositoryOwner;
public static string BundledSetupPath =>
Path.GetFullPath(
Path.Combine(
Path.GetDirectoryName(Environment.ProcessPath) ?? AppContext.BaseDirectory,
"..",
"Resources",
"WiiCompiled-Setup-macos-arm64.run"
)
);
}
41 changes: 34 additions & 7 deletions WheelWizard/Features/Recomp/RecompProcessRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ public async Task<OperationResult<int>> RunAsync(
CancellationToken cancellationToken = default
)
{
var cancellationFile =
OperatingSystem.IsMacOS() && cancellationToken.CanBeCanceled
? Path.Combine(Path.GetTempPath(), "mkwc-cancel-" + Guid.NewGuid().ToString("N"))
: null;
try
{
cancellationToken.ThrowIfCancellationRequested();
Expand All @@ -51,6 +55,9 @@ public async Task<OperationResult<int>> RunAsync(
if (cancellationEvent is not null)
startInfo.Environment[CancellationEventEnvironmentVariable] = cancellationEventName;

if (cancellationFile is not null)
startInfo.Environment["MKWCOMPILED_CANCEL_FILE"] = cancellationFile;

using var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };

process.OutputDataReceived += (_, eventArgs) =>
Expand Down Expand Up @@ -79,9 +86,10 @@ public async Task<OperationResult<int>> RunAsync(
}
catch (OperationCanceledException)
{
var exited = cancellationEvent is not null
? await CancelAndWaitForExitAsync(process, cancellationEvent)
: await KillAndWaitForExitAsync(process);
var exited =
cancellationEvent is not null || cancellationFile is not null
? await CancelAndWaitForExitAsync(process, cancellationEvent, cancellationFile)
: await KillAndWaitForExitAsync(process);
if (!exited)
throw;

Expand All @@ -102,13 +110,30 @@ public async Task<OperationResult<int>> RunAsync(
logger.LogError(exception, "Failed to run '{FileName}'", fileName);
return Fail(exception);
}
finally
{
if (cancellationFile is not null)
{
try
{
File.Delete(cancellationFile);
}
catch (IOException exception)
{
logger.LogDebug(exception, "Could not remove cancellation signal");
}
}
}
}

private static ProcessStartInfo CreateStartInfo(string fileName, string arguments, string? workingDirectory) =>
new()
{
FileName = fileName,
Arguments = arguments,
FileName = OperatingSystem.IsMacOS() && fileName.EndsWith(".run", StringComparison.Ordinal) ? "/bin/bash" : fileName,
Arguments =
OperatingSystem.IsMacOS() && fileName.EndsWith(".run", StringComparison.Ordinal)
? RecompSetupCommandBuilder.Quote(fileName) + " " + arguments
: arguments,
WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? string.Empty : workingDirectory,
UseShellExecute = false,
// The recomp setup is CLI-only. Wheel Wizard supplies the UI, including during launch,
Expand All @@ -118,11 +143,13 @@ private static ProcessStartInfo CreateStartInfo(string fileName, string argument
RedirectStandardError = true,
};

private async Task<bool> CancelAndWaitForExitAsync(Process process, EventWaitHandle cancellationEvent)
private async Task<bool> CancelAndWaitForExitAsync(Process process, EventWaitHandle? cancellationEvent, string? cancellationFile)
{
try
{
cancellationEvent.Set();
cancellationEvent?.Set();
if (cancellationFile is not null)
await File.WriteAllTextAsync(cancellationFile, "cancel");
}
catch (Exception exception)
{
Expand Down
Loading