commit ec226e83488ffa8c1d2cdbe47e5a7d24c7266fc7
Author: patchzyy <64382339+patchzyy@users.noreply.github.com>
Date: Sun Aug 23 17:10:50 2026 +0200
init
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..903e5cd
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,5 @@
+# Auto detect text files and perform LF normalization
+* text=auto
+
+# Patch files must stay LF: git apply matches context bytes against LF upstream sources
+*.patch -text
diff --git a/.github/ISSUE_TEMPLATE/1-crash-report.yml b/.github/ISSUE_TEMPLATE/1-crash-report.yml
new file mode 100644
index 0000000..4ebf2bd
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/1-crash-report.yml
@@ -0,0 +1,97 @@
+name: Crash / freeze report
+description: The game crashed, froze, or closed on its own.
+title: "[Crash] "
+labels: ["crash"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for the report! Crashes are almost impossible to fix without logs, so please fill this in fully.
+
+ **Where to find your logs** (paste the path into the File Explorer address bar):
+ - Playing through **WheelWizard** (most people): `%APPDATA%\CT-MKWII\Recomp\UserData\Logs`
+ - Installed with the standalone setup exe: `%LOCALAPPDATA%\WiiCompiled\Logs`
+
+ Every run gets its own folder. Open the **newest** one and attach (drag & drop) `console.log` and any `crash_*.txt` file from it.
+ - type: checkboxes
+ id: preflight
+ attributes:
+ label: Before you continue
+ options:
+ - label: I'm on the latest release of WiiCompiled.
+ required: true
+ - label: I searched the existing issues and nobody has reported this crash yet.
+ required: true
+ - label: This is about the game runtime, not the WheelWizard app itself (WheelWizard problems go to their tracker - see the link on the "New issue" page).
+ required: true
+ - type: input
+ id: version
+ attributes:
+ label: WiiCompiled version
+ description: It's the very first line of `console.log` (the log you're attaching below) - it starts with `[runtime] WiiCompiled v…`.
+ placeholder: "e.g. v0.2.12"
+ validations:
+ required: true
+ - type: dropdown
+ id: when
+ attributes:
+ label: When does it crash?
+ options:
+ - During startup / before the menus
+ - In the menus
+ - When loading a race
+ - During a race (offline)
+ - During a race (online)
+ - After finishing a race / results screen
+ - Randomly / no clear pattern
+ validations:
+ required: true
+ - type: dropdown
+ id: repro
+ attributes:
+ label: How often does it happen?
+ options:
+ - Every time
+ - Most of the time
+ - Sometimes
+ - Happened only once so far
+ validations:
+ required: true
+ - type: textarea
+ id: doing
+ attributes:
+ label: What were you doing when it crashed?
+ description: Game mode, track, character/vehicle, how many players, anything you pressed right before. The more specific, the better.
+ placeholder: "e.g. Retro Rewind online room, playing SNES Mario Circuit 3, crashed right as I crossed the finish line…"
+ validations:
+ required: true
+ - type: textarea
+ id: steps
+ attributes:
+ label: Steps to reproduce
+ description: If you can make it crash on purpose, list the steps.
+ placeholder: |
+ 1. Start the game
+ 2. …
+ - type: textarea
+ id: logs
+ attributes:
+ label: Logs
+ description: Drag & drop `console.log` and any `crash_*.txt` from your newest run folder here, or paste the tail end of them below.
+ render: text
+ validations:
+ required: true
+ - type: input
+ id: gpu
+ attributes:
+ label: GPU + driver version
+ placeholder: "e.g. RTX 3060, driver 560.94"
+ validations:
+ required: true
+ - type: input
+ id: os
+ attributes:
+ label: Windows version
+ placeholder: "e.g. Windows 11 24H2"
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/2-bug-report.yml b/.github/ISSUE_TEMPLATE/2-bug-report.yml
new file mode 100644
index 0000000..00608f0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/2-bug-report.yml
@@ -0,0 +1,91 @@
+name: In-game bug report
+description: Something looks, sounds, or behaves wrong (graphics, audio, input, online) - but the game doesn't crash.
+title: "[Bug] "
+labels: ["bug"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ **Where to find your logs** (paste the path into the File Explorer address bar):
+ - Playing through **WheelWizard** (most people): `%APPDATA%\CT-MKWII\Recomp\UserData\Logs`
+ - Installed with the standalone setup exe: `%LOCALAPPDATA%\WiiCompiled\Logs`
+
+ Every run gets its own folder. Open the **newest** one and attach `console.log`.
+
+ For visual bugs, a screenshot or short clip helps enormously.
+ - type: checkboxes
+ id: preflight
+ attributes:
+ label: Before you continue
+ options:
+ - label: I'm on the latest release of WiiCompiled.
+ required: true
+ - label: I searched the existing issues and nobody has reported this yet.
+ required: true
+ - label: The problem does NOT also happen on Dolphin or a real Wii (if it does, it's a game/mod issue, not a WiiCompiled issue).
+ required: true
+ - type: input
+ id: version
+ attributes:
+ label: WiiCompiled version
+ description: It's the very first line of `console.log` (see the log locations above) - it starts with `[runtime] WiiCompiled v…`.
+ placeholder: "e.g. v0.2.12"
+ validations:
+ required: true
+ - type: dropdown
+ id: category
+ attributes:
+ label: What kind of bug is it?
+ options:
+ - Graphics / visual glitch
+ - Audio
+ - Input / controllers
+ - Online / matchmaking
+ - Gameplay behaves differently than on Wii/Dolphin
+ - Save data / settings
+ - Other
+ validations:
+ required: true
+ - type: textarea
+ id: what
+ attributes:
+ label: What happens?
+ description: Describe the bug and what you expected instead.
+ placeholder: "e.g. The flag textures near the start line flicker on Coconut Mall…"
+ validations:
+ required: true
+ - type: textarea
+ id: doing
+ attributes:
+ label: What were you doing? / How to reproduce
+ description: Game mode, track, and exact steps if you can reproduce it.
+ placeholder: |
+ 1. Start a VS race on …
+ 2. …
+ validations:
+ required: true
+ - type: textarea
+ id: media
+ attributes:
+ label: Screenshot / video
+ description: Drag & drop images or clips here (very helpful for visual bugs).
+ - type: textarea
+ id: logs
+ attributes:
+ label: Logs
+ description: Attach `console.log` from your newest run folder, or paste the relevant part below.
+ render: text
+ - type: input
+ id: gpu
+ attributes:
+ label: GPU + driver version
+ placeholder: "e.g. RTX 3060, driver 560.94"
+ validations:
+ required: true
+ - type: input
+ id: os
+ attributes:
+ label: Windows version
+ placeholder: "e.g. Windows 11 24H2"
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/3-install-problem.yml b/.github/ISSUE_TEMPLATE/3-install-problem.yml
new file mode 100644
index 0000000..72f6828
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/3-install-problem.yml
@@ -0,0 +1,75 @@
+name: Install / update problem
+description: Installing or updating WiiCompiled through WheelWizard fails, or the game won't launch at all after installing.
+title: "[Install] "
+labels: ["setup"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ **Where to find the setup log** (same place whether you installed through WheelWizard or the setup exe):
+ - Press `Win+R`, type `%TEMP%` and press Enter, then find `WiiCompiled-setup.log` and attach it below.
+
+ If the install finished but the game won't start, also grab `console.log` from the newest folder in:
+ - WheelWizard: `%APPDATA%\CT-MKWII\Recomp\UserData\Logs`
+ - Standalone setup exe: `%LOCALAPPDATA%\WiiCompiled\Logs`
+ - type: checkboxes
+ id: preflight
+ attributes:
+ label: Before you continue
+ options:
+ - label: I'm on the latest version (latest WheelWizard, or the latest `MarioKartWiiCompiled-Setup.exe` from the Releases page).
+ required: true
+ - label: I searched the existing issues and nobody has reported this yet.
+ required: true
+ - label: I have a legally obtained copy of the game and it works in Dolphin.
+ required: true
+ - type: dropdown
+ id: stage
+ attributes:
+ label: Where does it go wrong?
+ options:
+ - Starting the install from WheelWizard
+ - Selecting the game file (ISO/WBFS/RVZ rejected)
+ - During compilation / translation
+ - Install finishes but the game won't launch
+ - Updating an existing install
+ - Other
+ validations:
+ required: true
+ - type: dropdown
+ id: installtype
+ attributes:
+ label: How did you install?
+ options:
+ - Through WheelWizard
+ - Downloaded the setup exe myself
+ - Not sure
+ validations:
+ required: true
+ - type: textarea
+ id: what
+ attributes:
+ label: What happens?
+ description: The exact error message (copy the full text or screenshot it) and what you were doing when it appeared.
+ validations:
+ required: true
+ - type: textarea
+ id: logs
+ attributes:
+ label: Setup log
+ description: Drag & drop `%TEMP%\WiiCompiled-setup.log` here, or paste the tail end of it below.
+ render: text
+ validations:
+ required: true
+ - type: input
+ id: os
+ attributes:
+ label: Windows version
+ placeholder: "e.g. Windows 11 24H2"
+ validations:
+ required: true
+ - type: input
+ id: av
+ attributes:
+ label: Antivirus (if any besides Windows Defender)
+ placeholder: "e.g. Avast, none"
diff --git a/.github/ISSUE_TEMPLATE/4-performance.yml b/.github/ISSUE_TEMPLATE/4-performance.yml
new file mode 100644
index 0000000..670fd1d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/4-performance.yml
@@ -0,0 +1,79 @@
+name: Performance problem
+description: Low FPS, stutters, or the game runs worse than expected.
+title: "[Perf] "
+labels: ["performance"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ **Where to find your logs** (paste the path into the File Explorer address bar):
+ - Playing through **WheelWizard** (most people): `%APPDATA%\CT-MKWII\Recomp\UserData\Logs`
+ - Installed with the standalone setup exe: `%LOCALAPPDATA%\WiiCompiled\Logs`
+
+ Every run gets its own folder. Open the **newest** one and attach `console.log`.
+ - type: checkboxes
+ id: preflight
+ attributes:
+ label: Before you continue
+ options:
+ - label: I'm on the latest release of WiiCompiled.
+ required: true
+ - label: I searched the existing issues and nobody has reported this yet.
+ required: true
+ - label: I checked that nothing else was hogging the GPU/CPU (recording software, browser video, etc.).
+ required: true
+ - type: input
+ id: version
+ attributes:
+ label: WiiCompiled version
+ description: It's the very first line of `console.log` (see the log locations above) - it starts with `[runtime] WiiCompiled v…`.
+ placeholder: "e.g. v0.2.12"
+ validations:
+ required: true
+ - type: dropdown
+ id: pattern
+ attributes:
+ label: What does the problem look like?
+ options:
+ - Constant low FPS everywhere
+ - Stutters / hitches (mostly smooth, brief freezes)
+ - Only certain tracks or situations are slow
+ - Got worse after an update
+ - Slow only while recording/streaming the game window
+ validations:
+ required: true
+ - type: textarea
+ id: doing
+ attributes:
+ label: When does it happen?
+ description: Which tracks/modes, offline or online, how many players, and roughly what FPS you see.
+ placeholder: "e.g. Drops to ~40 FPS on Rainbow Road with 12 players online, fine offline…"
+ validations:
+ required: true
+ - type: textarea
+ id: logs
+ attributes:
+ label: Logs
+ description: Attach `console.log` from your newest run folder.
+ render: text
+ - type: input
+ id: gpu
+ attributes:
+ label: GPU + driver version
+ placeholder: "e.g. RTX 3060, driver 560.94"
+ validations:
+ required: true
+ - type: input
+ id: cpu
+ attributes:
+ label: CPU
+ placeholder: "e.g. Ryzen 5 5600"
+ validations:
+ required: true
+ - type: input
+ id: os
+ attributes:
+ label: Windows version
+ placeholder: "e.g. Windows 11 24H2"
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/5-feature-request.yml b/.github/ISSUE_TEMPLATE/5-feature-request.yml
new file mode 100644
index 0000000..0532e0c
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/5-feature-request.yml
@@ -0,0 +1,26 @@
+name: Feature request
+description: Suggest an improvement or new feature.
+title: "[Feature] "
+labels: ["enhancement"]
+body:
+ - type: checkboxes
+ id: preflight
+ attributes:
+ label: Before you continue
+ options:
+ - label: I searched the existing issues and nobody has suggested this yet.
+ required: true
+ - label: This is about WiiCompiled itself, not WheelWizard or Retro Rewind content.
+ required: true
+ - type: textarea
+ id: idea
+ attributes:
+ label: What would you like to see?
+ description: Describe the feature and the problem it solves for you.
+ validations:
+ required: true
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Workarounds you've tried
+ description: Anything you currently do instead, if applicable.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..db8a4da
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+ - name: WheelWizard problem (launcher UI, mod downloads, Mii/save management)
+ url: https://github.com/TeamWheelWizard/WheelWizard/issues
+ about: If the problem is in the WheelWizard app itself rather than the game runtime, report it there instead.
+ - name: Retro Rewind / track questions
+ url: https://wiki.tockdom.com/wiki/Retro_Rewind
+ about: Questions about Retro Rewind content (tracks, unlocks, online events) that also happen on Dolphin or console belong with the Retro Rewind project, not here.
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..ad2796d
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,36 @@
+name: Build
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: build-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ translator:
+ name: Translator (build + test)
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Restore
+ run: dotnet restore translator/Translator.sln
+
+ - name: Build
+ run: dotnet build translator/Translator.sln -c Release --no-restore
+
+ - name: Test
+ run: dotnet test translator/Translator.sln -c Release --no-build --verbosity normal
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1c69c0a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,71 @@
+/generated/
+/Assets/
+/assets/
+/PulsarPacks/
+*.dol
+*.rel
+*.szs
+*.arc
+*.brres
+*.thp
+*.bmg
+*.brsar
+*.breff
+*.u8
+*.carc
+*.iso
+*.rvz
+*.wbfs
+*.nkit
+*.ciso
+*.gcm
+*.wad
+Code.pul
+
+# Build output
+/build/
+/build-*/
+/dist/
+/out/
+[Bb]in/
+[Oo]bj/
+*.o
+*.obj
+*.a
+*.lib
+*.pdb
+*.ilk
+*.map
+CMakeCache.txt
+CMakeFiles/
+cmake-build-*/
+
+
+# .NET
+/translator/TestResults/
+**/TestResults/
+*.user
+*.suo
+*.userprefs
+project.lock.json
+.packages/
+
+# Launcher artifacts
+/Launcher/artifacts/
+/Launcher/dist/
+/Launcher/WiiCompiled-Setup.exe
+*.exe
+*.msi
+
+
+# Local tooling and editors
+/.vs/
+/.vscode/
+/.idea/
+/tools/
+/TEMP/
+/tmp/
+/test_output/
+*.log
+output.txt
+
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..610cbe3
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,58 @@
+# Contributing to WiiCompiled
+
+Thanks for wanting to help! A few ground rules
+
+## The short version
+
+- Code is judged on quality, not where it came from
+- You must be able understand and be able to explain every line you submit.
+- PR descriptions and responses must be written by you, **not** generated.
+- Accuracy is the bar for anything touching game behavior.
+
+## Code quality
+
+We don't care how your code came into existence. What we care about is whether it meets the
+project's patterns and standards, and the only way we measure that is by
+reading it.
+
+Low-quality code won't be merged, regardless of origin. AI slop and human slop
+get the same treatment.
+
+## If you use AI tools
+
+That's ok, but rules apply:
+
+1. **You must be able to explain your changes.** If a reviewer asks why a line
+ exists or what a function does and you can't answer, the PR will be closed.
+ "The AI wrote it" is not an explanation.
+2. **Write your own PR description.** The description exists so reviewers know
+ what you changed and why, in your words. Generated descriptions tend to
+ describe everything and explain nothing, and they will get your PR closed.
+
+## Pull requests
+
+- Keep PRs focused. try and keep it at 1 change per PR.
+ Small PRs get reviewed fast.
+- Explain **what** and **why**. Reference the issue if there is one.
+- For anything affecting game behavior: identical behavior to real hardware is
+ the goal. Be prepared to show your change doesn't diverge from the original
+ game (hardware comparison, logs, whatever fits).
+- Review feedback. It's about the code, not about you ;).
+
+## Bug reports
+
+See the FAQ in the [README](README.md)
+
+## A note on related projects
+
+WiiCompiled, Wheel Wizard, and other projects in this ecosystem are developed
+independently and each has its **own** contribution rules and all have their own
+rules around AI usage. What applies here does not automatically apply there,
+and vice versa. Check each project's own CONTRIBUTING file.
+
+## Legal
+
+- Never!!! include Nintendo code, assets, or game data in a PR, an issue, or
+ anywhere else in this project. No exceptions.
+- By contributing, you agree your contributions are licensed under
+ [GPL v3.0](LICENSE), like the rest of the project.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..61d1860
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
\ No newline at end of file
diff --git a/Launcher/.gitignore b/Launcher/.gitignore
new file mode 100644
index 0000000..c9a6cc1
--- /dev/null
+++ b/Launcher/.gitignore
@@ -0,0 +1,4 @@
+/artifacts/
+/dist/
+**/bin/
+**/obj/
diff --git a/Launcher/Build-Installer.ps1 b/Launcher/Build-Installer.ps1
new file mode 100644
index 0000000..7792401
--- /dev/null
+++ b/Launcher/Build-Installer.ps1
@@ -0,0 +1,302 @@
+[CmdletBinding(PositionalBinding = $false)]
+param(
+ [string]$OutputDirectory = 'Launcher/dist',
+ [string]$DolphinToolPath,
+ [string]$PortableToolsDirectory = 'Launcher/artifacts/portable-tools',
+ [string]$DependencySourceDirectory = 'Launcher/artifacts/dependencies',
+ [string]$VcRuntimeDirectory,
+ [string]$ToolkitReleaseTag = $env:GITHUB_REF_NAME
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version 3.0
+
+# The canonical configure flags, and the Assert-File/Assert-Directory/Get-MkwFileSha256
+# helpers shared with LocalBuild.ps1 and Prepare-NativePrebuilt.ps1.
+. (Join-Path $PSScriptRoot 'NativeBuildFlags.ps1')
+
+if ([string]::IsNullOrWhiteSpace($DolphinToolPath)) {
+ throw 'Build-Installer.ps1 requires -DolphinToolPath pointing to DolphinTool.exe.'
+}
+
+$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
+$outputRoot = [IO.Path]::GetFullPath((Join-Path $repoRoot $OutputDirectory))
+$portableTools = [IO.Path]::GetFullPath((Join-Path $repoRoot $PortableToolsDirectory))
+$dependencySources = [IO.Path]::GetFullPath((Join-Path $repoRoot $DependencySourceDirectory))
+$workRoot = Join-Path $PSScriptRoot 'artifacts\installer-build'
+$publish = Join-Path $workRoot 'publish'
+$payloadRoot = Join-Path $workRoot 'payload'
+$setupProject = Join-Path $PSScriptRoot 'WiiCompiled.Setup\WiiCompiled.Setup.csproj'
+$translatorProject = Join-Path $repoRoot 'translator\src\Translator.Cli\Translator.Cli.csproj'
+$projectFile = Join-Path $repoRoot 'projects\mkwii\recomp.yml'
+
+# Everything Mario-Kart-specific in the manifest below is read from the project file rather than
+# restated here, and Test-PinnedFacts.ps1 checks the copies that cannot read it (the C++ entry
+# address, the C# endpoint constant, the lists this script and the installed host both carry).
+$pins = Get-MkwProjectPins $projectFile
+# The audit reports by throwing, which aborts this run; nothing native has been invoked yet, so
+# there is no exit code to inspect.
+& (Join-Path $PSScriptRoot 'Test-PinnedFacts.ps1') -RepositoryRoot $repoRoot
+
+function Reset-Directory([string]$Path) {
+ $full = [IO.Path]::GetFullPath($Path)
+ $allowed = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot 'artifacts')).TrimEnd('\') + '\'
+ if (-not $full.StartsWith($allowed, [StringComparison]::OrdinalIgnoreCase)) {
+ throw "Refusing to reset a directory outside Launcher/artifacts: $full"
+ }
+ if (Test-Path -LiteralPath $full) { Remove-Item -LiteralPath $full -Recurse -Force }
+ [IO.Directory]::CreateDirectory($full) | Out-Null
+}
+function Reset-OutputDirectory([string]$Path) {
+ $full = [IO.Path]::GetFullPath($Path)
+ $allowed = [IO.Path]::GetFullPath($PSScriptRoot).TrimEnd('\') + '\'
+ if (-not $full.StartsWith($allowed, [StringComparison]::OrdinalIgnoreCase)) {
+ throw "Refusing to reset an output directory outside Launcher: $full"
+ }
+ if (Test-Path -LiteralPath $full) { Remove-Item -LiteralPath $full -Recurse -Force }
+ [IO.Directory]::CreateDirectory($full) | Out-Null
+}
+function Resolve-VcRuntimeDirectory([string]$RequestedPath) {
+ if ($RequestedPath) {
+ $resolved = [IO.Path]::GetFullPath($RequestedPath)
+ Assert-File (Join-Path $resolved 'vcruntime140.dll') 'Visual C++ runtime'
+ Assert-File (Join-Path $resolved 'msvcp140.dll') 'Visual C++ standard library runtime'
+ return $resolved
+ }
+ $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
+ Assert-File $vswhere 'Visual Studio locator (or pass -VcRuntimeDirectory)'
+ $installation = (& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath | Select-Object -First 1)
+ if (-not $installation) { throw 'Visual C++ redistributable files were not found; pass -VcRuntimeDirectory.' }
+ $redistRoot = Join-Path $installation 'VC\Redist\MSVC'
+ $candidate = Get-ChildItem -LiteralPath $redistRoot -Directory -Recurse | Where-Object {
+ $_.Parent.Name -eq 'x64' -and $_.Name -match '^Microsoft\.VC\d+\.CRT$' -and
+ (Test-Path (Join-Path $_.FullName 'vcruntime140.dll')) -and (Test-Path (Join-Path $_.FullName 'msvcp140.dll'))
+ } | Sort-Object FullName -Descending | Select-Object -First 1
+ if (-not $candidate) { throw "No redistributable x64 Visual C++ runtime was found below $redistRoot." }
+ return $candidate.FullName
+}
+function Copy-Directory([string]$Source, [string]$Destination) {
+ Assert-Directory $Source "Source directory"
+ [IO.Directory]::CreateDirectory($Destination) | Out-Null
+ Copy-Item -Path (Join-Path $Source '*') -Destination $Destination -Recurse -Force
+}
+function Compress-Zip([string]$Source, [string]$Destination, [string[]]$Entries) {
+ $tar = Join-Path $env:SystemRoot 'System32\tar.exe'
+ Assert-File $tar 'Windows archive tool'
+ if (Test-Path -LiteralPath $Destination) { Remove-Item -LiteralPath $Destination -Force }
+ & $tar -a -c -f $Destination -C $Source @Entries
+ if ($LASTEXITCODE -ne 0) { throw "ZIP creation failed with exit code $LASTEXITCODE." }
+ Assert-File $Destination 'ZIP output'
+}
+
+Assert-File $setupProject '.NET setup project'
+Assert-File $translatorProject 'Translator CLI project'
+Assert-File $DolphinToolPath 'DolphinTool'
+
+if (-not (Test-Path -LiteralPath (Join-Path $portableTools 'llvm-mingw\bin\x86_64-w64-mingw32-clang++.exe'))) {
+ & (Join-Path $PSScriptRoot 'Prepare-PortableTools.ps1') -Destination $portableTools
+ if ($LASTEXITCODE -ne 0) { throw 'Portable tool preparation failed.' }
+}
+Assert-File (Join-Path $portableTools 'CMake\bin\cmake.exe') 'Portable CMake'
+Assert-File (Join-Path $portableTools 'Ninja\ninja.exe') 'Portable Ninja'
+Assert-Directory $dependencySources 'Pinned offline dependency sources'
+# native_prebuilt carries the aurora/third-party archives the user no longer has
+# to compile (launcher/Prepare-NativePrebuilt.ps1).
+# Kept in step with InstalledLayout.DependencyNames by Test-PinnedFacts.ps1: the installed host
+# refuses to call a toolkit complete unless every one of these directories is present.
+$requiredDependencies = @('abseil-cpp','cppwinrt','dawn_prebuilt','fmt','freetype','imgui','native_prebuilt','png','SDL','sqlite3','tracy','xxhash','zlib','zstd')
+
+# The precompiled archives are only interchangeable with what the user's machine
+# compiles if both came from this toolchain and this flag set, so a stale package
+# is rebuilt here rather than shipped. Without it every installation would fall
+# 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')
+function Test-NativePrebuiltCurrent([string]$PackageDirectory) {
+ $provenancePath = Join-Path $PackageDirectory 'provenance.json'
+ if (-not (Test-Path -LiteralPath $provenancePath -PathType Leaf)) { return $false }
+ $provenance = Get-Content -LiteralPath $provenancePath -Raw | ConvertFrom-Json
+ # Refreshing the pinned Dawn without re-harvesting would ship archives compiled
+ # against headers that no longer describe the shipped DLL. A package predating
+ # this field cannot prove it matches, so it is treated as stale.
+ $recordedDawn = $provenance.PSObject.Properties['DawnRuntimeSha256']
+ # Same reasoning for aurora itself: this release ships aurora-main sources
+ # whose headers the user's runtime compiles against, so archives harvested
+ # before an aurora change would be linked into call sites that no longer
+ # match them.
+ $recordedAurora = $provenance.PSObject.Properties['AuroraSourceFingerprint']
+ # And for the vendored Crypto++ whose archive the package now carries: this
+ # 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']
+ return ($provenance.CompilerSha256 -eq $portableClangHash) -and
+ ($provenance.FlagFingerprint -eq (Get-MkwNativeFlagFingerprint)) -and
+ ($null -ne $recordedDawn) -and
+ ($recordedDawn.Value -eq (Get-MkwDawnRuntimeSha256 $dependencySources)) -and
+ ($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')))
+}
+$nativePrebuiltPackage = Join-Path $dependencySources 'native_prebuilt'
+if (-not (Test-NativePrebuiltCurrent $nativePrebuiltPackage)) {
+ Write-Host '[0/6] Building the precompiled aurora and third-party package...'
+ & (Join-Path $PSScriptRoot 'Prepare-NativePrebuilt.ps1') `
+ -PortableToolsDirectory $portableTools -DependencySourceDirectory $dependencySources
+ if ($LASTEXITCODE -ne 0) { throw 'Precompiled aurora package preparation failed.' }
+ if (-not (Test-NativePrebuiltCurrent $nativePrebuiltPackage)) {
+ throw 'The precompiled aurora package still does not match this toolchain and flag set.'
+ }
+}
+foreach ($name in $requiredDependencies) { Assert-Directory (Join-Path $dependencySources $name) "Pinned dependency $name" }
+$vcRuntime = Resolve-VcRuntimeDirectory $VcRuntimeDirectory
+
+Reset-Directory $workRoot
+Reset-OutputDirectory $outputRoot
+foreach ($path in @($publish,$payloadRoot)) { [IO.Directory]::CreateDirectory($path) | Out-Null }
+
+Write-Host '[1/6] Publishing self-contained CLI host and translator...'
+& dotnet publish $setupProject -c Release -r win-x64 --self-contained true `
+ -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true `
+ -p:DebugType=None -o (Join-Path $publish 'setup')
+if ($LASTEXITCODE -ne 0) { throw "Setup publish failed with exit code $LASTEXITCODE." }
+& dotnet publish $translatorProject -c Release -r win-x64 --self-contained true `
+ -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true `
+ -p:DebugType=None -o (Join-Path $publish 'translator')
+if ($LASTEXITCODE -ne 0) { throw "Translator publish failed with exit code $LASTEXITCODE." }
+$setupHost = Join-Path $publish 'setup\WiiCompiled.Setup.exe'
+$translator = Join-Path $publish 'translator\Translator.Cli.exe'
+Assert-File $setupHost 'Published setup host'
+Assert-File $translator 'Self-contained translator'
+
+Write-Host '[2/6] Staging the explicit, game-code-free payload allowlist...'
+# The staged layout mirrors the installed layout exactly (Toolkit, BuildWorkspace): payload
+# identities hash relative paths, so the names here are part of the fingerprint contract.
+$toolkit = Join-Path $payloadRoot 'Toolkit'
+$workspace = Join-Path $payloadRoot 'BuildWorkspace'
+[IO.Directory]::CreateDirectory($toolkit) | Out-Null
+Copy-Directory (Join-Path $portableTools 'llvm-mingw') (Join-Path $toolkit 'llvm-mingw')
+Copy-Directory (Join-Path $portableTools 'CMake') (Join-Path $toolkit 'CMake')
+Copy-Directory (Join-Path $portableTools 'Ninja') (Join-Path $toolkit 'Ninja')
+# Debuggers, Python bindings, and the CMake GUI are not part of the local build graph.
+# Leaving them out reduces the attack/dependency surface without removing compiler support files.
+foreach ($unused in @(
+ (Join-Path $toolkit 'llvm-mingw\python'),
+ (Join-Path $toolkit 'CMake\bin\cmake-gui.exe')
+)) {
+ if (Test-Path -LiteralPath $unused) { Remove-Item -LiteralPath $unused -Recurse -Force }
+}
+Get-ChildItem -LiteralPath (Join-Path $toolkit 'llvm-mingw\bin') -File |
+ Where-Object { $_.Name -like 'lldb*' -or $_.Name -like 'liblldb*' } |
+ Remove-Item -Force
+[IO.Directory]::CreateDirectory((Join-Path $toolkit 'Translator')) | Out-Null
+Copy-Item -LiteralPath $translator -Destination (Join-Path $toolkit 'Translator\Translator.Cli.exe')
+Copy-Item -LiteralPath $DolphinToolPath -Destination (Join-Path $toolkit 'DolphinTool.exe')
+[IO.Directory]::CreateDirectory((Join-Path $toolkit 'Redist')) | Out-Null
+Copy-Item -Path (Join-Path $vcRuntime '*.dll') -Destination (Join-Path $toolkit 'Redist')
+Copy-Item -Path (Join-Path $vcRuntime '*.dll') -Destination (Join-Path $toolkit 'CMake\bin')
+Copy-Item -Path (Join-Path $vcRuntime '*.dll') -Destination (Join-Path $toolkit 'Ninja')
+Copy-Item -Path (Join-Path $vcRuntime '*.dll') -Destination $toolkit
+
+Copy-Directory (Join-Path $repoRoot 'runtime') (Join-Path $workspace 'runtime')
+Copy-Directory (Join-Path $repoRoot 'aurora-main') (Join-Path $workspace 'aurora-main')
+# Source trees can contain ignored developer build directories. They are never release inputs.
+$runtimeDeveloperBuild = Join-Path $workspace 'runtime\build'
+if (Test-Path -LiteralPath $runtimeDeveloperBuild) {
+ Remove-Item -LiteralPath $runtimeDeveloperBuild -Recurse -Force
+}
+$auroraExtern = Join-Path $workspace 'aurora-main\extern'
+Get-ChildItem -LiteralPath $auroraExtern -Directory -Force | Remove-Item -Recurse -Force
+Copy-Directory (Join-Path $repoRoot 'projects\mkwii') (Join-Path $workspace 'projects\mkwii')
+Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'LocalBuild.ps1') -Destination (Join-Path $workspace 'LocalBuild.ps1')
+# LocalBuild.ps1 dot-sources the canonical configure flags from this sibling.
+Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'NativeBuildFlags.ps1') -Destination (Join-Path $workspace 'NativeBuildFlags.ps1')
+[IO.Directory]::CreateDirectory((Join-Path $workspace 'Dependencies')) | Out-Null
+foreach ($name in $requiredDependencies) { Copy-Directory (Join-Path $dependencySources $name) (Join-Path $workspace "Dependencies\$name") }
+
+[IO.Directory]::CreateDirectory((Join-Path $payloadRoot 'host')) | Out-Null
+Copy-Item $setupHost (Join-Path $payloadRoot 'host\WiiCompiled-Setup.exe')
+
+Write-Host '[3/6] Writing manifests and third-party license inventory...'
+[IO.Directory]::CreateDirectory((Join-Path $payloadRoot 'licenses')) | Out-Null
+Copy-Item (Join-Path $portableTools 'README-LICENSES.txt') (Join-Path $payloadRoot 'licenses\Portable-build-tools.txt')
+Copy-Item (Join-Path $repoRoot 'aurora-main\LICENSE') (Join-Path $payloadRoot 'licenses\Aurora-LICENSE.txt')
+Copy-Item (Join-Path $dependencySources 'cppwinrt\LICENSE.txt') (Join-Path $payloadRoot 'licenses\CppWinRT-LICENSE.txt')
+# The precompiled aurora/third-party archives are built from the very sources
+# already shipped under build-workspace\Dependencies and aurora-main, so they add
+# no third-party component and therefore no new license obligation.
+$dolphinLicense = Join-Path (Split-Path -Parent $DolphinToolPath) 'COPYING'
+if (Test-Path $dolphinLicense) { Copy-Item $dolphinLicense (Join-Path $payloadRoot 'licenses\Dolphin-COPYING.txt') }
+@"
+DolphinTool source offer
+
+Project and complete corresponding source: https://github.com/dolphin-emu/dolphin
+Dolphin is licensed under GPLv2+ with additional per-file SPDX licenses.
+"@ | Set-Content (Join-Path $payloadRoot 'licenses\Dolphin-SOURCE.txt') -Encoding UTF8
+@"
+Microsoft Visual C++ Runtime
+
+Redistributable x64 runtime DLLs are included app-locally for DolphinTool and third-party renderer DLLs.
+Microsoft license terms: https://visualstudio.microsoft.com/license-terms/
+"@ | Set-Content (Join-Path $payloadRoot 'licenses\Microsoft-VC-Runtime.txt') -Encoding UTF8
+# Compute the payload's content identities once, here, with the same code every installed host
+# uses. Shipping them in the manifest is what keeps user machines from re-hashing the toolkit
+# during installation; only --repair-products re-derives content identity locally.
+Write-Host 'Computing the payload content identities (hashes the staged toolkit once)...'
+$identityJson = & $setupHost --emit-payload-identities --payload-root $payloadRoot
+if ($LASTEXITCODE -ne 0) { throw "Payload identity computation failed with exit code $LASTEXITCODE." }
+$identities = ($identityJson -join '') | ConvertFrom-Json
+foreach ($required in @('ToolkitFingerprint','TranslationFingerprint','NativeToolchainFingerprint',
+ 'ToolkitPackageFingerprint','RuntimeAssetsFingerprint')) {
+ if ([string]::IsNullOrWhiteSpace($identities.$required)) { throw "Payload identity output is missing $required." }
+}
+
+$manifest = [ordered]@{
+ SchemaVersion = 2
+ ProductVersion = '0.2.13'
+ ExpectedGameId = $pins.GameId
+ ExpectedDolSha256 = $pins.DolSha256
+ ExpectedRelSha256 = $pins.RelSha256
+ ToolkitReleaseTag = if ($ToolkitReleaseTag) { $ToolkitReleaseTag } else { '' }
+ BuildModel = 'local-translation-and-compilation'
+ RetroWfcPayloadUri = $pins.RetroWfcPayloadUri
+ ToolkitFingerprint = $identities.ToolkitFingerprint
+ ToolkitPackageFingerprint = $identities.ToolkitPackageFingerprint
+ RuntimeAssetsFingerprint = $identities.RuntimeAssetsFingerprint
+ TranslationFingerprint = $identities.TranslationFingerprint
+ NativeToolchainFingerprint = $identities.NativeToolchainFingerprint
+}
+$manifest | ConvertTo-Json | Set-Content (Join-Path $payloadRoot 'payload-manifest.json') -Encoding UTF8
+
+Write-Host '[4/6] Enforcing the copyright and generated-code boundary...'
+& (Join-Path $PSScriptRoot 'Test-PayloadBoundary.ps1') -PayloadRoot $payloadRoot
+if ($LASTEXITCODE -ne 0) { throw 'Payload boundary audit failed.' }
+& (Join-Path $PSScriptRoot 'Test-NativeDependencies.ps1') -PayloadRoot $payloadRoot -SetupHost $setupHost `
+ -LlvmReadobjPath (Join-Path $portableTools 'llvm-mingw\bin\llvm-readobj.exe')
+if ($LASTEXITCODE -ne 0) { throw 'Native dependency audit failed.' }
+
+Write-Host '[5/6] Creating the canonical installer payload...'
+$payloadZip = Join-Path $workRoot 'payload.zip'
+Compress-Zip $payloadRoot $payloadZip @(
+ 'Toolkit','BuildWorkspace','host','licenses','payload-manifest.json')
+
+Write-Host '[6/6] Producing the single-file setup executable...'
+$outputSetup = Join-Path $outputRoot 'WiiCompiled-Setup.exe'
+Copy-Item $setupHost $outputSetup -Force
+$outputStream = [IO.File]::Open($outputSetup, [IO.FileMode]::Append, [IO.FileAccess]::Write, [IO.FileShare]::None)
+try {
+ $offset = $outputStream.Position
+ $input = [IO.File]::OpenRead($payloadZip)
+ try { $input.CopyTo($outputStream) } finally { $input.Dispose() }
+ $length = $outputStream.Position - $offset
+ $writer = [IO.BinaryWriter]::new($outputStream, [Text.Encoding]::ASCII, $true)
+ try {
+ $writer.Write([Text.Encoding]::ASCII.GetBytes('MKWCPAY1'))
+ $writer.Write([Int64]$offset)
+ $writer.Write([Int64]$length)
+ } finally { $writer.Dispose() }
+} finally { $outputStream.Dispose() }
+
+$setupItem = Get-Item -LiteralPath $outputSetup
+Write-Host ("{0}: {1} MiB; SHA-256 {2}" -f $setupItem.Name,
+ [math]::Round($setupItem.Length / 1MB, 1), (Get-MkwFileSha256 $outputSetup))
diff --git a/Launcher/LocalBuild.ps1 b/Launcher/LocalBuild.ps1
new file mode 100644
index 0000000..4ecb371
--- /dev/null
+++ b/Launcher/LocalBuild.ps1
@@ -0,0 +1,460 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)] [string]$Workspace,
+ [Parameter(Mandatory)] [string]$Toolkit,
+ # 'both' translates once (retro-aware) and compiles the two products from one build
+ # graph, which shares every profile-neutral shard object between them. Building the
+ # legs separately recompiles all of mkw_base_shared in the second leg, because the
+ # retro-aware shard emission changes the content identity of every base_common shard.
+ [Parameter(Mandatory)] [ValidateSet('base', 'retro-rewind', 'both')] [string]$Profile,
+ [Parameter(Mandatory)] [string]$OutputDirectory,
+ [string]$BaseOutputDirectory,
+ [string]$RetroRewindPackageDirectory,
+ [string]$RetroWfcOfflineDirectory,
+ [ValidateSet('offline', 'downloaded')] [string]$RetroWfcPayloadOrigin = 'offline',
+ [switch]$SkipRetroWfcPayload,
+ # The caller's recomputed cache-reuse identities (see ToolkitFingerprint.ComputeComponents).
+ # They are compared against the provenance recorded beside the caches themselves; a missing or
+ # mismatched identity degrades that cache to a clean rebuild, never the other way around.
+ [string]$TranslationFingerprint = '',
+ [string]$NativeToolchainFingerprint = '',
+ # Discards every cache first. Used when a product was reported broken/blocked, so a possibly
+ # corrupted translation or build directory can never contribute to the repaired product.
+ [switch]$ForceCleanBuild,
+ [int]$Parallel = 0
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version 3.0
+
+# Canonical configure flags (plus Assert-File / Invoke-Checked / Get-MkwFileSha256),
+# shared with launcher/Prepare-NativePrebuilt.ps1 so the precompiled aurora/third-party
+# archives and the objects compiled here can never be produced with different settings.
+# See NativeBuildFlags.ps1; Build-Installer.ps1 stages it beside this script.
+. (Join-Path $PSScriptRoot 'NativeBuildFlags.ps1')
+
+function Resolve-NativePrebuiltDirectory([string]$DependenciesDirectory, [string]$ToolchainBin, [string]$AuroraDirectory, [string]$ThirdPartyDirectory) {
+ <#
+ The shipped precompiled aurora/third-party package, verified against its recorded provenance
+ (exact compiler, exact flags) before use. Anything unexpected falls back to building from source.
+ #>
+ $package = Join-Path $DependenciesDirectory 'native_prebuilt'
+ $provenancePath = Join-Path $package 'provenance.json'
+ if (-not (Test-Path -LiteralPath $provenancePath -PathType Leaf)) {
+ Write-Host 'MKWCBUILD: No precompiled aurora package was shipped; building it from source'
+ return ''
+ }
+ try {
+ $provenance = Get-Content -LiteralPath $provenancePath -Raw | ConvertFrom-Json
+ $compiler = Join-Path $ToolchainBin 'clang-22.exe'
+ $compilerHash = Get-MkwFileSha256 $compiler
+ if ($provenance.CompilerSha256 -ne $compilerHash) {
+ Write-Host 'MKWCBUILD: The precompiled aurora package was built by a different compiler; building it from source'
+ return ''
+ }
+ if ($provenance.FlagFingerprint -ne (Get-MkwNativeFlagFingerprint)) {
+ Write-Host 'MKWCBUILD: The precompiled aurora package was built with different flags; building it from source'
+ return ''
+ }
+ # The archives embed Dawn's headers and link its import library, so a
+ # dawn_prebuilt tree that is not the one they were harvested against is
+ # an ABI mismatch that only shows up as a crash inside the GPU backend.
+ $recordedDawn = $provenance.PSObject.Properties['DawnRuntimeSha256']
+ if ($null -eq $recordedDawn -or
+ $recordedDawn.Value -ne (Get-MkwDawnRuntimeSha256 $DependenciesDirectory)) {
+ Write-Host 'MKWCBUILD: The precompiled aurora package was built against a different Dawn; building it from source'
+ return ''
+ }
+ # The archives are aurora; the runtime compiled here calls its headers.
+ # If this workspace's aurora sources are not the ones that were harvested
+ # (a newer toolkit, or a locally modified tree), linking the package would
+ # mix two versions of aurora, so build it from source instead.
+ $recordedAurora = $provenance.PSObject.Properties['AuroraSourceFingerprint']
+ if ($null -eq $recordedAurora -or
+ $recordedAurora.Value -ne (Get-MkwAuroraSourceFingerprint $AuroraDirectory)) {
+ Write-Host 'MKWCBUILD: The precompiled aurora package was built from different aurora sources; building it from source'
+ return ''
+ }
+ # Same reasoning for the vendored Crypto++ the package also carries:
+ # first-party TUs compile against this workspace's third_party headers,
+ # so a package harvested from an older tree must not be linked here.
+ $recordedThirdParty = $provenance.PSObject.Properties['ThirdPartySourceFingerprint']
+ if ($null -eq $recordedThirdParty -or
+ $recordedThirdParty.Value -ne (Get-MkwThirdPartySourceFingerprint $ThirdPartyDirectory)) {
+ Write-Host 'MKWCBUILD: The precompiled aurora package was built from different vendored third-party sources; building it from source'
+ return ''
+ }
+ } catch {
+ Write-Host "MKWCBUILD: The precompiled aurora package could not be validated ($($_.Exception.Message)); building it from source"
+ return ''
+ }
+ Write-Host 'MKWCBUILD: Using the precompiled aurora and third-party libraries'
+ return $package
+}
+
+function Write-MkwBuildStep([string]$StepId, [string]$Message) {
+ <#
+ Announces a build step. The identifier is the contract (BuildStepIds in
+ WiiCompiled.Setup/InstallProgress.cs); the trailing sentence is only for the log.
+ #>
+ Write-Host "MKWCBUILD:STEP:${StepId} $Message"
+}
+
+function Reset-LocalDirectory([string]$Path) {
+ $full = [IO.Path]::GetFullPath($Path)
+ $root = [IO.Path]::GetFullPath($Workspace).TrimEnd('\') + '\'
+ $installRoot = [IO.Path]::GetFullPath((Split-Path -Parent $Workspace)).TrimEnd('\') + '\'
+ # The caller-supplied output destinations are legitimate reset targets by
+ # definition, wherever the caller placed them: a fresh install's operation
+ # scratch lives beside the installation directory rather than inside it.
+ $allowedDestinations = @($OutputDirectory, $BaseOutputDirectory) |
+ Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
+ ForEach-Object { [IO.Path]::GetFullPath($_).TrimEnd('\') }
+ $isAllowedDestination = $allowedDestinations |
+ Where-Object { $full.TrimEnd('\').Equals($_, [StringComparison]::OrdinalIgnoreCase) }
+ if (-not $isAllowedDestination -and
+ -not $full.StartsWith($root, [StringComparison]::OrdinalIgnoreCase) -and
+ -not $full.StartsWith($installRoot, [StringComparison]::OrdinalIgnoreCase)) {
+ throw "Refusing to reset a directory outside the local build workspace: $full"
+ }
+ if (Test-Path -LiteralPath $full) { Remove-Item -LiteralPath $full -Recurse -Force }
+ [IO.Directory]::CreateDirectory($full) | Out-Null
+}
+
+$Workspace = [IO.Path]::GetFullPath($Workspace)
+$Toolkit = [IO.Path]::GetFullPath($Toolkit)
+$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
+if (-not [string]::IsNullOrWhiteSpace($BaseOutputDirectory)) {
+ $BaseOutputDirectory = [IO.Path]::GetFullPath($BaseOutputDirectory)
+}
+if (-not [string]::IsNullOrWhiteSpace($RetroRewindPackageDirectory)) {
+ $RetroRewindPackageDirectory = [IO.Path]::GetFullPath($RetroRewindPackageDirectory)
+}
+if (-not [string]::IsNullOrWhiteSpace($RetroWfcOfflineDirectory)) {
+ $RetroWfcOfflineDirectory = [IO.Path]::GetFullPath($RetroWfcOfflineDirectory)
+}
+$hasOfflineRetroWfc = -not [string]::IsNullOrWhiteSpace($RetroWfcOfflineDirectory)
+$buildsRetro = $Profile -in @('retro-rewind', 'both')
+if (-not $buildsRetro -and ($hasOfflineRetroWfc -or $SkipRetroWfcPayload)) {
+ throw 'Retro-WFC payload options are valid only for a Retro Rewind build.'
+}
+if ($buildsRetro -and ($hasOfflineRetroWfc -eq [bool]$SkipRetroWfcPayload)) {
+ throw 'Choose exactly one Retro-WFC mode: -RetroWfcOfflineDirectory or -SkipRetroWfcPayload.'
+}
+if (-not $buildsRetro -and -not [string]::IsNullOrWhiteSpace($RetroRewindPackageDirectory)) {
+ throw '-RetroRewindPackageDirectory is valid only for a Retro Rewind build.'
+}
+if ($Profile -eq 'both' -and [string]::IsNullOrWhiteSpace($BaseOutputDirectory)) {
+ throw "-BaseOutputDirectory is required with -Profile both; -OutputDirectory receives the Retro Rewind product."
+}
+if ($Profile -ne 'both' -and -not [string]::IsNullOrWhiteSpace($BaseOutputDirectory)) {
+ throw '-BaseOutputDirectory is valid only with -Profile both.'
+}
+$translator = Join-Path $Toolkit 'Translator\Translator.Cli.exe'
+$cmake = Join-Path $Toolkit 'CMake\bin\cmake.exe'
+$ninja = Join-Path $Toolkit 'Ninja\ninja.exe'
+$toolchainBin = Join-Path $Toolkit 'llvm-mingw\bin'
+$cc = Join-Path $toolchainBin 'x86_64-w64-mingw32-clang.exe'
+$cxx = Join-Path $toolchainBin 'x86_64-w64-mingw32-clang++.exe'
+$windres = Join-Path $toolchainBin 'x86_64-w64-mingw32-windres.exe'
+$project = Join-Path $Workspace 'projects\mkwii\recomp.yml'
+$assets = Join-Path $Workspace 'Assets'
+$generated = Join-Path $Workspace 'generated'
+$functions = Join-Path $generated 'functions'
+$baseMetadata = Join-Path $generated 'base_translation_output.json'
+$baseManifestDir = Join-Path $Workspace 'build\base'
+$baseManifest = Join-Path $baseManifestDir 'mkwii_base_manifest.json'
+$shards = Join-Path $generated 'build_shards'
+$build = Join-Path $Workspace 'native-build'
+$retroRoot = if ([string]::IsNullOrWhiteSpace($RetroRewindPackageDirectory)) {
+ Join-Path $Workspace 'PulsarPacks\completed\RetroRewind\RetroRewind6'
+} else {
+ $RetroRewindPackageDirectory
+}
+
+foreach ($required in @(
+ @($translator, 'Bundled translator'), @($cmake, 'Bundled CMake'), @($ninja, 'Bundled Ninja'),
+ @($cc, 'Bundled C compiler'), @($cxx, 'Bundled C++ compiler'), @($windres, 'Bundled resource compiler'), @($project, 'Translation project'),
+ @((Join-Path $assets 'main.dol'), 'Extracted main.dol'), @((Join-Path $assets 'StaticR.rel'), 'Extracted StaticR.rel')
+)) { Assert-File $required[0] $required[1] }
+
+# Everything Mario-Kart-specific this script needs is read from the project file rather than
+# restated here: the translation entry point below, and (through the retro-rewind profile the
+# translator loads itself) the module guest/link bases the mod translation uses.
+$pins = Get-MkwProjectPins $project
+
+# Three independent knobs: $translatorThreads (the translator's own worker threads; peak memory is
+# one heap that barely moves with thread count, so clamping it only slows translation), $translatedJobs
+# (the real RAM guard, capping concurrent clang compiles of memory-hungry translated TUs via the Ninja
+# MKW_TRANSLATED_COMPILE_JOBS pool), and $globalJobs (Ninja's overall parallelism for everything else).
+# An explicit -Parallel pins all three.
+$memoryGiB = [math]::Max(1, [math]::Floor((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB))
+if ($Parallel -gt 0) {
+ $translatorThreads = $Parallel
+ $translatedJobs = $Parallel
+ $globalJobs = $Parallel
+} else {
+ $translatorThreads = [math]::Max(1, [math]::Min([Environment]::ProcessorCount, 16))
+ $translatedJobs = [math]::Max(1, [math]::Min([Environment]::ProcessorCount,
+ [math]::Floor($memoryGiB / 2)))
+ $globalJobs = [math]::Max($translatedJobs, [Environment]::ProcessorCount)
+}
+
+$oldPath = $env:PATH
+$oldDotnet = $env:DOTNET_ROOT
+try {
+ $env:PATH = Get-MkwToolchainPath $Toolkit
+ Remove-Item Env:DOTNET_ROOT -ErrorAction SilentlyContinue
+ Push-Location $Workspace
+ try {
+ # This script is the single owner of every cache-reuse decision: it is what would misbuild.
+ # The caller only supplies the identities to compare recorded provenance against.
+ $translationProvenance = Join-Path $generated 'translation-provenance.json'
+ $toolchainProvenance = Join-Path $build 'toolchain-provenance.json'
+
+ function Get-RecordedFingerprint([string]$Path, [string]$Property) {
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return '' }
+ try {
+ $record = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
+ $value = $record.PSObject.Properties[$Property]
+ if ($null -eq $value -or [string]::IsNullOrWhiteSpace($value.Value)) { return '' }
+ return [string]$value.Value
+ } catch { return '' }
+ }
+
+ if ($ForceCleanBuild) {
+ Write-Host 'MKWCBUILD: A clean build was requested; discarding every translation and build cache'
+ Reset-LocalDirectory $generated
+ if (Test-Path -LiteralPath $baseManifestDir) { Remove-Item -LiteralPath $baseManifestDir -Recurse -Force }
+ if (Test-Path -LiteralPath $build) { Remove-Item -LiteralPath $build -Recurse -Force }
+ }
+
+ # Reused only when its exact inputs match, its artifacts survived, and (for a modded build) it
+ # already knows this Code.pul. Otherwise it retranslates incrementally: content-addressed
+ # outputs plus --prune-stale mean Ninja only recompiles shards whose bytes actually moved.
+ $reuseBase = $false
+ if (-not $ForceCleanBuild -and -not [string]::IsNullOrWhiteSpace($TranslationFingerprint) -and
+ (Get-RecordedFingerprint $translationProvenance 'TranslationFingerprint') -eq $TranslationFingerprint) {
+ $artifacts = @($baseMetadata, $baseManifest, (Join-Path $generated 'base_translation_sources.bin'),
+ (Join-Path $generated 'base_translation_mod_awareness.json'))
+ $reuseBase = -not ($artifacts | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Leaf) })
+ }
+ if ($buildsRetro) {
+ # The translator discovers mods through the project file's workspace-relative profile
+ # paths, and both the base and the mod leg block leaf inlining at every address those
+ # profiles patch. A staged install workspace ships without any mod payload, so the
+ # selected Retro Rewind Code.pul must sit at the profile's mod_root before either leg
+ # runs - and it must be this build's pul, not the one a previous build left behind.
+ $awarenessBinaries = Join-Path $Workspace 'PulsarPacks\completed\RetroRewind\RetroRewind6\Binaries'
+ $sourcePul = Join-Path $retroRoot 'Binaries\Code.pul'
+ Assert-File $sourcePul 'Retro Rewind Code.pul'
+ [IO.Directory]::CreateDirectory($awarenessBinaries) | Out-Null
+ $stagedPul = Join-Path $awarenessBinaries 'Code.pul'
+ if (-not $stagedPul.Equals($sourcePul, [StringComparison]::OrdinalIgnoreCase)) {
+ Copy-Item -LiteralPath $sourcePul -Destination $stagedPul -Force
+ }
+ }
+
+ if ($reuseBase -and $buildsRetro) {
+ # The base translation bakes leaf-inlining and residency decisions around the mod patch
+ # sets it knew about when it ran, and it records that knowledge as a modPatchAwareness
+ # stamp. A base tree that never saw this Code.pul would silently bake vanilla code into
+ # the modded product, so it may only be reused once that has been ruled out.
+ $retroCodePul = Join-Path $retroRoot 'Binaries\Code.pul'
+ Assert-File $retroCodePul 'Retro Rewind Code.pul'
+ $pulSha = Get-MkwFileSha256 $retroCodePul
+ # The metadata file is tens of megabytes of machine-written JSON; a raw substring probe
+ # for the uniquely named stamp field avoids a multi-minute ConvertFrom-Json parse.
+ $rawMetadata = [IO.File]::ReadAllText($baseMetadata)
+ if (-not $rawMetadata.Contains('"codePulSha256":"' + $pulSha + '"')) {
+ # A different Code.pul is not automatically a different base translation: the base
+ # reads a pul for one thing, the addresses it patches, so a pul that patches the
+ # same translated functions produces the same base tree. Only the translator can
+ # decide that - it takes parsing the Kamek patch set against the recorded translated
+ # function ranges - and it fails closed, so anything but exit 0 retranslates.
+ & $translator @(
+ 'check-base-mod-awareness', '--project', $project, '--profile', 'retro-rewind',
+ '--translation-output-metadata', $baseMetadata, '--code-pul', $retroCodePul
+ ) | Write-Host
+ if ($LASTEXITCODE -ne 0) {
+ Write-MkwBuildStep 'retranslate-base' 'The base translation is stale; retranslating the base game for the new Code.pul'
+ $reuseBase = $false
+ }
+ }
+ }
+
+ if ($reuseBase) {
+ Write-MkwBuildStep 'reuse-base-translation' 'Reusing the completed base translation'
+ } else {
+ # A failed translation must never leave provenance claiming the old outputs are current.
+ if (Test-Path -LiteralPath $translationProvenance) { Remove-Item -LiteralPath $translationProvenance -Force }
+ [IO.Directory]::CreateDirectory($generated) | Out-Null
+ [IO.Directory]::CreateDirectory($baseManifestDir) | Out-Null
+
+ # No --clean-outdir: shard names are content-addressed and unchanged files keep their
+ # bytes and mtimes, which is exactly what lets Ninja skip them. --prune-stale (active
+ # only without --clean-outdir) removes everything the new translation no longer emits.
+ Invoke-Checked $translator @(
+ 'translate-recursive', $pins.EntryPoint, '--project', $project,
+ '--outdir', $functions, '--output-metadata', $baseMetadata,
+ '--production-source-bundle', (Join-Path $generated 'base_translation_sources.bin'),
+ '--no-function-files', '--prune-stale', '--threads', $translatorThreads
+ ) 'Translating the user-owned base game' -StepId 'translate-base'
+
+ Invoke-Checked $translator @(
+ 'emit-base-manifest', '--project', $project, '--out', $baseManifestDir,
+ '--functions-dir', $functions, '--translation-output-metadata', $baseMetadata, '--region', 'P'
+ ) 'Creating the local base translation manifest' -StepId 'emit-base-manifest'
+
+ if (-not [string]::IsNullOrWhiteSpace($TranslationFingerprint)) {
+ [ordered]@{ SchemaVersion = 1; TranslationFingerprint = $TranslationFingerprint } |
+ ConvertTo-Json | Set-Content -LiteralPath $translationProvenance -Encoding UTF8
+ }
+ }
+
+ if ($buildsRetro) {
+ $codePul = Join-Path $retroRoot 'Binaries\Code.pul'
+ Assert-File $codePul 'Retro Rewind Code.pul'
+ $retroOut = Join-Path $Workspace 'build\mods\retro_rewind_full_cpp'
+ $translateModArguments = @(
+ 'translate-mod', '--project', $project, '--profile', 'retro-rewind',
+ '--base-manifest', $baseManifest, '--base-translation-output-metadata', $baseMetadata,
+ '--code-pul', $codePul, '--mod-root', $retroRoot, '--mod-name', 'Retro Rewind',
+ '--region', 'P', '--out', $retroOut, '--prefer-cached-inputs', '--emit-cpp',
+ '--threads', $translatorThreads
+ )
+ if ($SkipRetroWfcPayload) {
+ $translateModArguments += '--skip-retro-wfc'
+ } elseif (-not [string]::IsNullOrWhiteSpace($RetroWfcOfflineDirectory)) {
+ $offlineRoot = [IO.Path]::GetFullPath($RetroWfcOfflineDirectory)
+ $offlinePayload = Join-Path $offlineRoot 'binary\payload.RMCPD00.bin'
+ Assert-File $offlinePayload 'Offline Retro-WFC shared payload'
+ $translateModArguments += @('--retro-wfc-payload', $offlinePayload)
+ } else {
+ throw 'Retro Rewind requires either -RetroWfcOfflineDirectory or -SkipRetroWfcPayload.'
+ }
+ Invoke-Checked $translator $translateModArguments 'Translating the selected Retro Rewind Code.pul' `
+ -StepId 'translate-mod'
+ }
+
+ Invoke-Checked $translator @('generate-data-init', '--project', $project) `
+ 'Generating local game data initialization' -StepId 'generate-data-init'
+
+ $shardArgs = @(
+ 'emit-build-shards', '--project', $project, '--base-metadata', $baseMetadata,
+ '--base-functions-dir', $functions,
+ '--native-source-dir', (Join-Path $Workspace 'runtime\src'), '--out', $shards
+ )
+ if ($buildsRetro) {
+ $retroOut = Join-Path $Workspace 'build\mods\retro_rewind_full_cpp'
+ $shardArgs += @('--resolved-profile', (Join-Path $retroOut 'resolved_dispatch_profile.json'),
+ '--retro-cpp-dir', (Join-Path $retroOut 'cpp'))
+ }
+ Invoke-Checked $translator $shardArgs 'Preparing local native build shards' -StepId 'emit-build-shards'
+
+ # The build directory is kept whenever the exact toolchain that filled it is the one
+ # installed now and its CMake cache still belongs to this workspace path. Object files are
+ # only ever unsafe to reuse when the compiler/flags changed - source changes are what
+ # CMake/Ninja exist to track, so a kept directory turns a small runtime or dependency
+ # change into a recompile of just the affected objects instead of every shard.
+ if (Test-Path -LiteralPath $build) {
+ $keepNativeBuild = -not [string]::IsNullOrWhiteSpace($NativeToolchainFingerprint) -and
+ (Get-RecordedFingerprint $toolchainProvenance 'NativeToolchainFingerprint') -eq $NativeToolchainFingerprint -and
+ (Test-Path -LiteralPath (Join-Path $build 'CMakeCache.txt') -PathType Leaf)
+ if ($keepNativeBuild) {
+ # A moved workspace invalidates every absolute path in the cache; CMake would hard
+ # error rather than reconfigure. Move healing already deletes the directory for
+ # portable installs, so this only fires for hand-moved trees.
+ $expectedHome = (Join-Path $Workspace 'runtime')
+ $cacheHome = Select-String -LiteralPath (Join-Path $build 'CMakeCache.txt') `
+ -Pattern '^CMAKE_HOME_DIRECTORY:INTERNAL=(.*)$' | Select-Object -First 1
+ if ($null -eq $cacheHome -or
+ -not [string]::Equals([IO.Path]::GetFullPath($cacheHome.Matches[0].Groups[1].Value),
+ [IO.Path]::GetFullPath($expectedHome), [StringComparison]::OrdinalIgnoreCase)) {
+ $keepNativeBuild = $false
+ }
+ }
+ if ($keepNativeBuild) {
+ Write-Host 'MKWCBUILD: Reusing the incremental native build directory'
+ } else {
+ Write-Host 'MKWCBUILD: The native build cache does not belong to this toolchain; rebuilding from scratch'
+ Remove-Item -LiteralPath $build -Recurse -Force
+ }
+ }
+ $dependencies = Join-Path $Workspace 'Dependencies'
+ $nativePrebuilt = Resolve-NativePrebuiltDirectory $dependencies $toolchainBin `
+ (Join-Path $Workspace 'aurora-main') (Join-Path $Workspace 'runtime\third_party')
+ $configure = Get-MkwNativeConfigureArguments `
+ -SourceDirectory (Join-Path $Workspace 'runtime') -BuildDirectory $build `
+ -Ninja $ninja -CCompiler $cc -CxxCompiler $cxx -ResourceCompiler $windres `
+ -DependenciesDirectory $dependencies -NativePrebuiltDirectory $nativePrebuilt `
+ -AdditionalArguments @("-DMKW_TRANSLATED_COMPILE_JOBS=$translatedJobs")
+ Invoke-Checked $cmake $configure 'Configuring the bundled native toolchain' -StepId 'configure-native'
+ if (-not [string]::IsNullOrWhiteSpace($NativeToolchainFingerprint)) {
+ [ordered]@{ SchemaVersion = 1; NativeToolchainFingerprint = $NativeToolchainFingerprint } |
+ ConvertTo-Json | Set-Content -LiteralPath $toolchainProvenance -Encoding UTF8
+ }
+ $targets = switch ($Profile) {
+ 'base' { @('WiiCompiled') }
+ 'retro-rewind' { @('RetroRewind') }
+ 'both' { @('WiiCompiled', 'RetroRewind') }
+ }
+ $buildArguments = @('--build', $build)
+ foreach ($target in $targets) { $buildArguments += @('--target', $target) }
+ $buildArguments += @('--parallel', $globalJobs)
+ Invoke-Checked $cmake $buildArguments "Compiling $($targets -join ' and ') locally" -StepId 'compile'
+
+ # Hashed once here; every published product records the same digests.
+ $dolSha = Get-MkwFileSha256 (Join-Path $assets 'main.dol')
+ $relSha = Get-MkwFileSha256 (Join-Path $assets 'StaticR.rel')
+ $compilerSha = Get-MkwFileSha256 (Join-Path $toolchainBin 'clang-22.exe')
+
+ function Publish-BuiltProduct([string]$Target, [string]$Destination, [string]$ProvenanceProfile) {
+ Reset-LocalDirectory $Destination
+ $exe = Join-Path $build "$Target.exe"
+ Assert-File $exe 'Locally compiled game executable'
+ Copy-Item -LiteralPath $exe -Destination (Join-Path $Destination "$Target.exe")
+ foreach ($name in @('dxcompiler.dll','dxil.dll','libc++.dll','libpng16.dll','libunwind.dll','libz.dll','SDL3.dll','sqlite3.dll','webgpu_dawn.dll','z.dll','noshaders.zip','dsp_coef.bin','initial_pipeline_cache.db')) {
+ $file = Join-Path $build $name
+ if (Test-Path -LiteralPath $file -PathType Leaf) { Copy-Item -LiteralPath $file -Destination $Destination }
+ }
+ $redist = Join-Path $Toolkit 'Redist'
+ if (Test-Path -LiteralPath $redist -PathType Container) {
+ Copy-Item -Path (Join-Path $redist '*.dll') -Destination $Destination -Force
+ }
+ foreach ($name in @('config','wii_bootstrap')) {
+ $directory = Join-Path $build $name
+ if (Test-Path -LiteralPath $directory -PathType Container) {
+ Copy-Item -LiteralPath $directory -Destination (Join-Path $Destination $name) -Recurse
+ }
+ }
+ $isRetro = $ProvenanceProfile -eq 'retro-rewind'
+ $provenance = [ordered]@{
+ SchemaVersion = 1
+ Profile = $ProvenanceProfile
+ BuiltUtc = [DateTime]::UtcNow.ToString('O')
+ DolSha256 = $dolSha
+ RelSha256 = $relSha
+ CodePulSha256 = if ($isRetro) { Get-MkwFileSha256 (Join-Path $retroRoot 'Binaries\Code.pul') } else { $null }
+ RetroWfcPayloadMode = if (-not $isRetro) { $null } elseif ($SkipRetroWfcPayload) { 'skipped' } else { $RetroWfcPayloadOrigin }
+ RetroWfcPayloadSha256 = if ($isRetro -and -not $SkipRetroWfcPayload) { Get-MkwFileSha256 (Join-Path $RetroWfcOfflineDirectory 'binary\payload.RMCPD00.bin') } else { $null }
+ RetroWfcPayloadLength = if ($isRetro -and -not $SkipRetroWfcPayload) { (Get-Item -LiteralPath (Join-Path $RetroWfcOfflineDirectory 'binary\payload.RMCPD00.bin')).Length } else { $null }
+ Compiler = "llvm-mingw clang-22 sha256:$compilerSha"
+ }
+ $provenance | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Destination 'local-build.json') -Encoding UTF8
+ }
+
+ if ($Profile -eq 'both') {
+ Publish-BuiltProduct 'WiiCompiled' $BaseOutputDirectory 'base'
+ Publish-BuiltProduct 'RetroRewind' $OutputDirectory 'retro-rewind'
+ } elseif ($Profile -eq 'retro-rewind') {
+ Publish-BuiltProduct 'RetroRewind' $OutputDirectory 'retro-rewind'
+ } else {
+ Publish-BuiltProduct 'WiiCompiled' $OutputDirectory 'base'
+ }
+ Write-Host "MKWCBUILD:OUTPUT=$OutputDirectory"
+ } finally { Pop-Location }
+} finally {
+ $env:PATH = $oldPath
+ if ($null -ne $oldDotnet) { $env:DOTNET_ROOT = $oldDotnet }
+}
diff --git a/Launcher/NativeBuildFlags.ps1 b/Launcher/NativeBuildFlags.ps1
new file mode 100644
index 0000000..f7adb3e
--- /dev/null
+++ b/Launcher/NativeBuildFlags.ps1
@@ -0,0 +1,264 @@
+# Canonical native (CMake) configure flags, plus the shared helpers (file assertions, checked
+# process spawn, file hashing) both LocalBuild.ps1 and Prepare-NativePrebuilt.ps1 dot-source.
+# They MUST stay byte-identical: the prebuilt package ships static archives linked into objects
+# the user's machine compiles, so any flag divergence is an ABI/ODR hazard, not just a rebuild.
+# The flag fingerprint below is recorded in the package provenance and re-checked before use.
+
+Set-StrictMode -Version 3.0
+
+function Assert-File([string]$Path, [string]$Description) {
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "$Description is missing: $Path" }
+}
+
+function Assert-Directory([string]$Path, [string]$Description) {
+ if (-not (Test-Path -LiteralPath $Path -PathType Container)) { throw "$Description is missing: $Path" }
+}
+
+function Get-MkwFileSha256([string]$Path) {
+ <#
+ Lower-case SHA-256 of one file. -LiteralPath is required: Get-FileHash treats a positional
+ path as a wildcard, so an install directory containing [, ] or * would hash the wrong file.
+ #>
+ return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
+}
+
+function Get-MkwToolchainPath([string]$ToolchainRoot) {
+ <#
+ The scrubbed PATH a bundled-toolchain build runs with. The bundled toolchain must be the only
+ one visible, or an ambient Visual Studio/MSYS install on PATH could pull in its own headers,
+ libraries, or linker. Shared by LocalBuild.ps1 and Prepare-NativePrebuilt.ps1 since the
+ installed Toolkit and the maintainer portable-tools tree share the same subdirectory layout.
+ #>
+ if ([string]::IsNullOrWhiteSpace($ToolchainRoot)) { throw 'A toolchain root is required.' }
+ $root = [IO.Path]::GetFullPath($ToolchainRoot)
+ return (@(
+ (Join-Path $root 'llvm-mingw\bin'),
+ (Join-Path $root 'CMake\bin'),
+ (Join-Path $root 'Ninja'),
+ (Join-Path $env:SystemRoot 'System32'),
+ $env:SystemRoot
+ ) -join ';')
+}
+
+function Get-MkwProjectPins([string]$ProjectFile) {
+ <#
+ The Mario Kart Wii facts pinned by projects/mkwii/recomp.yml (game identity, clean input
+ digests, translation entry point, Retro-WFC endpoint), read from one place instead of being
+ restated; Test-PinnedFacts.ps1 checks the copies that can't read YAML (C++ header, C#
+ constants) against these values. Parsing is literal, not a YAML dependency, since the file is
+ machine-written with a fixed shape.
+ #>
+ Assert-File $ProjectFile 'Translation project file'
+ $pins = [ordered]@{
+ GameId = ''; DolSha256 = ''; RelSha256 = ''; EntryPoint = ''; RetroWfcPayloadUri = ''
+ }
+ $section = ''
+ $inputKey = ''
+ foreach ($raw in [IO.File]::ReadAllLines($ProjectFile)) {
+ $line = ($raw -replace '#.*$', '')
+ if ([string]::IsNullOrWhiteSpace($line)) { continue }
+ if ($line -match '^([A-Za-z0-9_]+):') { $section = $Matches[1]; $inputKey = ''; continue }
+ if ($section -eq 'inputs' -and $line -match '^\s{2}([A-Za-z0-9_]+):\s*$') { $inputKey = $Matches[1]; continue }
+ if ($section -eq 'project' -and $line -match '^\s*game_id:\s*(\S+)\s*$') {
+ $pins.GameId = $Matches[1]
+ } elseif ($section -eq 'inputs' -and $line -match '^\s*sha256:\s*([0-9a-fA-F]{64})\s*$') {
+ if ($inputKey -eq 'dol') { $pins.DolSha256 = $Matches[1].ToLowerInvariant() }
+ elseif ($inputKey -eq 'rel') { $pins.RelSha256 = $Matches[1].ToLowerInvariant() }
+ } elseif ($section -eq 'translation' -and $pins.EntryPoint -eq '' -and
+ $line -match '^\s*-\s*(0[xX][0-9a-fA-F]+)\s*$') {
+ $pins.EntryPoint = $Matches[1].ToLowerInvariant()
+ } elseif ($line -match '^\s*retro_wfc_payload:\s*(\S+)\s*$') {
+ $pins.RetroWfcPayloadUri = $Matches[1]
+ }
+ }
+ foreach ($name in @($pins.Keys)) {
+ if ([string]::IsNullOrWhiteSpace($pins[$name])) {
+ throw "$ProjectFile does not pin $name; the project file is not the shape this build expects."
+ }
+ }
+ return $pins
+}
+
+function Invoke-Checked([string]$FilePath, [string[]]$Arguments, [string]$Description,
+ [string]$LogPrefix = 'MKWCBUILD', [string]$StepId = '') {
+ <#
+ Runs a build tool and turns a non-zero exit code into a described failure. Start-Process -Wait
+ is deliberate: it waits for the whole process tree, since a .NET single-file bundle host may
+ hand off to an extracted child that PowerShell's call operator would not wait for. Start-Process
+ doesn't publish $LASTEXITCODE, so this sets it manually for callers that check it.
+ -StepId emits the machine-readable form the installer's progress bar consumes (BuildStepIds in
+ WiiCompiled.Setup/InstallProgress.cs); the human sentence stays on the same log line.
+ #>
+ if ($StepId) { Write-Host "${LogPrefix}:STEP:${StepId} $Description" }
+ else { Write-Host "${LogPrefix}: $Description" }
+ $quotedArguments = @($Arguments | ForEach-Object {
+ if ($_.Contains('"')) { throw "A native build argument contains an unsupported quote: $_" }
+ '"' + $_ + '"'
+ })
+ $process = Start-Process -FilePath $FilePath -ArgumentList $quotedArguments `
+ -NoNewWindow -Wait -PassThru
+ $exitCode = $process.ExitCode
+ $global:LASTEXITCODE = $exitCode
+ if ($exitCode -ne 0) { throw "$Description failed with exit code $exitCode." }
+}
+
+function Get-MkwNativeFixedConfigureFlags {
+ <#
+ The path-independent half of the configure command line, identical on every machine, which is
+ what makes one precompiled aurora/third-party package valid for all users. Path-bearing
+ arguments are added separately by Get-MkwNativeConfigureArguments and excluded from the fingerprint.
+ #>
+ return @(
+ '-DCMAKE_BUILD_TYPE=Release',
+ '-DCMAKE_SYSTEM_PROCESSOR=x86_64',
+ '-DAURORA_DAWN_PROVIDER=package', '-DAURORA_SDL3_PROVIDER=vendor',
+ '-DCMAKE_POLICY_DEFAULT_CMP0168=NEW', '-DFETCHCONTENT_FULLY_DISCONNECTED=ON',
+ '-DAWK:FILEPATH='
+ )
+}
+
+function Get-MkwNativeFlagFingerprint {
+ <#
+ .SYNOPSIS
+ SHA-256 over the canonical flag list, used to reject a stale package.
+ #>
+ $canonical = (Get-MkwNativeFixedConfigureFlags) -join "`n"
+ $sha = [Security.Cryptography.SHA256]::Create()
+ try {
+ $bytes = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical))
+ } finally { $sha.Dispose() }
+ return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '')
+}
+
+function Get-MkwDawnRuntimeSha256([string]$DependenciesDirectory) {
+ <#
+ SHA-256 of the pinned Dawn runtime the prebuilt aurora must match. The precompiled aurora
+ archives are compiled and linked against the pinned dawn_prebuilt tree's headers/import
+ library, but the upstream release asset is mutable (the same version tag has already served
+ two different windows-amd64 archives), so the version string alone can't prove agreement.
+ A mismatch surfaces as an access violation at surface creation, not a link error, so the
+ digest is recorded at harvest and re-checked before use. Returns '' when the tree is absent.
+ #>
+ if ([string]::IsNullOrWhiteSpace($DependenciesDirectory)) { return '' }
+ $runtime = Join-Path $DependenciesDirectory 'dawn_prebuilt\bin\webgpu_dawn.dll'
+ if (-not (Test-Path -LiteralPath $runtime -PathType Leaf)) { return '' }
+ return Get-MkwFileSha256 $runtime
+}
+
+function Get-MkwThirdPartySourceFingerprint([string]$ThirdPartyDirectory) {
+ <#
+ SHA-256 over runtime/third_party, the vendored sources behind the prebuilt package. The
+ precompiled Crypto++ archive is linked while first-party TUs compile against the same headers,
+ so a workspace whose vendored tree moved on must not consume archives harvested from an older
+ one (same reasoning as the aurora fingerprint below). Unlike that scan this hashes hidden files
+ too, since Build-Installer stages this tree with Copy-Item -Force. Returns '' when absent.
+ #>
+ if ([string]::IsNullOrWhiteSpace($ThirdPartyDirectory)) { return '' }
+ if (-not (Test-Path -LiteralPath $ThirdPartyDirectory -PathType Container)) { return '' }
+ $root = [IO.Path]::GetFullPath($ThirdPartyDirectory).TrimEnd('\')
+ $files = Get-ChildItem -LiteralPath $root -Recurse -File |
+ Sort-Object { $_.FullName.Substring($root.Length + 1).Replace('\', '/') }
+ $sha = [Security.Cryptography.SHA256]::Create()
+ try {
+ $builder = [Text.StringBuilder]::new()
+ foreach ($file in $files) {
+ $relative = $file.FullName.Substring($root.Length + 1).Replace('\', '/')
+ $content = Get-MkwFileSha256 $file.FullName
+ [void]$builder.Append($relative).Append(' ').Append($content).Append("`n")
+ }
+ $bytes = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($builder.ToString()))
+ } finally { $sha.Dispose() }
+ return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '')
+}
+
+function Get-MkwAuroraSourceFingerprint([string]$AuroraDirectory) {
+ <#
+ SHA-256 over the aurora sources the precompiled archives were harvested from. The compiler,
+ flag, and Dawn digests only prove the package agrees with its target environment; none notice
+ aurora itself moving on, and since the runtime calls aurora headers directly, a package
+ harvested before an aurora change links old code against new call sites (undefined symbol at
+ best, silently stale renderer at worst). extern/ is excluded because the payload ships those
+ trees separately. Returns '' when the tree is absent.
+ #>
+ if ([string]::IsNullOrWhiteSpace($AuroraDirectory)) { return '' }
+ if (-not (Test-Path -LiteralPath $AuroraDirectory -PathType Container)) { return '' }
+ $root = [IO.Path]::GetFullPath($AuroraDirectory).TrimEnd('\')
+ $excludedPrefixes = @(
+ (Join-Path $root 'extern') + '\',
+ (Join-Path $root 'build') + '\'
+ )
+ # Hidden files are skipped deliberately: Build-Installer.ps1 stages this tree
+ # with Copy-Item -Recurse (no -Force), so they never reach the payload and
+ # hashing them would make the two sides disagree by construction.
+ $files = Get-ChildItem -LiteralPath $root -Recurse -File |
+ Where-Object {
+ $full = $_.FullName
+ -not ($excludedPrefixes | Where-Object { $full.StartsWith($_, [StringComparison]::OrdinalIgnoreCase) })
+ } |
+ Sort-Object { $_.FullName.Substring($root.Length + 1).Replace('\', '/') }
+ $sha = [Security.Cryptography.SHA256]::Create()
+ try {
+ $builder = [Text.StringBuilder]::new()
+ foreach ($file in $files) {
+ $relative = $file.FullName.Substring($root.Length + 1).Replace('\', '/')
+ $content = Get-MkwFileSha256 $file.FullName
+ [void]$builder.Append($relative).Append(' ').Append($content).Append("`n")
+ }
+ $bytes = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($builder.ToString()))
+ } finally { $sha.Dispose() }
+ return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '')
+}
+
+function ConvertTo-MkwCMakePath([string]$Path) {
+ return [IO.Path]::GetFullPath($Path).Replace('\', '/')
+}
+
+function Get-MkwNativeConfigureArguments {
+ <# The complete `cmake` configure argument list for a local runtime build. #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)] [string]$SourceDirectory,
+ [Parameter(Mandatory)] [string]$BuildDirectory,
+ [Parameter(Mandatory)] [string]$Ninja,
+ [Parameter(Mandatory)] [string]$CCompiler,
+ [Parameter(Mandatory)] [string]$CxxCompiler,
+ [Parameter(Mandatory)] [string]$ResourceCompiler,
+ [string]$DependenciesDirectory,
+ [string]$NativePrebuiltDirectory,
+ [string[]]$AdditionalArguments = @()
+ )
+
+ $arguments = @(
+ '-S', (ConvertTo-MkwCMakePath $SourceDirectory),
+ '-B', (ConvertTo-MkwCMakePath $BuildDirectory),
+ '-G', 'Ninja',
+ "-DCMAKE_MAKE_PROGRAM=$(ConvertTo-MkwCMakePath $Ninja)",
+ "-DCMAKE_C_COMPILER=$(ConvertTo-MkwCMakePath $CCompiler)",
+ "-DCMAKE_CXX_COMPILER=$(ConvertTo-MkwCMakePath $CxxCompiler)",
+ "-DCMAKE_RC_COMPILER=$(ConvertTo-MkwCMakePath $ResourceCompiler)"
+ )
+ $arguments += Get-MkwNativeFixedConfigureFlags
+
+ if (-not [string]::IsNullOrWhiteSpace($DependenciesDirectory) -and
+ (Test-Path -LiteralPath $DependenciesDirectory -PathType Container)) {
+ $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)"
+ }
+ foreach ($directory in Get-ChildItem -LiteralPath $DependenciesDirectory -Directory) {
+ # native_prebuilt holds compiled output, not a FetchContent source tree.
+ if ($directory.Name -eq 'native_prebuilt') { continue }
+ # FetchContent uppercases the declared name but preserves punctuation
+ # (for example abseil-cpp -> FETCHCONTENT_SOURCE_DIR_ABSEIL-CPP).
+ $name = $directory.Name.ToUpperInvariant()
+ $arguments += "-DFETCHCONTENT_SOURCE_DIR_$name=$(ConvertTo-MkwCMakePath $directory.FullName)"
+ }
+ }
+
+ if (-not [string]::IsNullOrWhiteSpace($NativePrebuiltDirectory)) {
+ $arguments += "-DMKW_NATIVE_PREBUILT_DIR=$(ConvertTo-MkwCMakePath $NativePrebuiltDirectory)"
+ }
+
+ $arguments += $AdditionalArguments
+ return $arguments
+}
diff --git a/Launcher/Prepare-NativePrebuilt.ps1 b/Launcher/Prepare-NativePrebuilt.ps1
new file mode 100644
index 0000000..10a36b6
--- /dev/null
+++ b/Launcher/Prepare-NativePrebuilt.ps1
@@ -0,0 +1,412 @@
+# 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.
+[CmdletBinding(PositionalBinding = $false)]
+param(
+ [string]$PortableToolsDirectory = 'launcher/artifacts/portable-tools',
+ [string]$DependencySourceDirectory = 'launcher/artifacts/dependencies',
+ [string]$OutputDirectory,
+ [string]$StageDirectory = 'build/native-prebuilt-stage',
+ [switch]$KeepStage,
+ # Maintainer iteration aid: keep an already configured staging build so a
+ # re-harvest does not recompile aurora from scratch.
+ [switch]$ReuseStage,
+ [int]$Parallel = 0
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version 3.0
+
+. (Join-Path $PSScriptRoot 'NativeBuildFlags.ps1')
+
+$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
+function Full([string]$Path) {
+ if ([IO.Path]::IsPathRooted($Path)) { return [IO.Path]::GetFullPath($Path) }
+ return [IO.Path]::GetFullPath((Join-Path $repoRoot $Path))
+}
+function Normalize([string]$Path) { return ([IO.Path]::GetFullPath($Path)).Replace('\', '/') }
+function StartsWithPath([string]$Candidate, [string]$Root) {
+ $rootSlash = $Root.TrimEnd('/') + '/'
+ return $Candidate.StartsWith($rootSlash, [StringComparison]::OrdinalIgnoreCase)
+}
+function RelativeTo([string]$Candidate, [string]$Root) {
+ $rootSlash = $Root.TrimEnd('/') + '/'
+ return $Candidate.Substring($rootSlash.Length)
+}
+
+$portableTools = Full $PortableToolsDirectory
+$dependencies = Full $DependencySourceDirectory
+$stage = Full $StageDirectory
+if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
+ $OutputDirectory = Join-Path $dependencies 'native_prebuilt'
+}
+$package = Full $OutputDirectory
+
+$toolchainBin = Join-Path $portableTools 'llvm-mingw\bin'
+$cmake = Join-Path $portableTools 'CMake\bin\cmake.exe'
+$ninja = Join-Path $portableTools 'Ninja\ninja.exe'
+$cc = Join-Path $toolchainBin 'x86_64-w64-mingw32-clang.exe'
+$cxx = Join-Path $toolchainBin 'x86_64-w64-mingw32-clang++.exe'
+$windres = Join-Path $toolchainBin 'x86_64-w64-mingw32-windres.exe'
+$clangBinary = Join-Path $toolchainBin 'clang-22.exe'
+$runtimeSource = Join-Path $repoRoot 'runtime'
+$auroraSource = Join-Path $repoRoot 'aurora-main'
+
+Assert-File $cmake 'Portable CMake'
+Assert-File $ninja 'Portable Ninja'
+Assert-File $cc 'Portable C compiler'
+Assert-File $cxx 'Portable C++ compiler'
+Assert-File $windres 'Portable resource compiler'
+Assert-File $clangBinary 'Portable clang driver binary'
+Assert-Directory $dependencies 'Pinned offline dependency sources'
+Assert-Directory $auroraSource 'aurora-main source tree'
+Assert-File (Join-Path $repoRoot 'generated\build_shards\shards.cmake') `
+ 'Translator shard manifest (run the developer build once so runtime/ can configure)'
+
+if ($Parallel -le 0) { $Parallel = [Environment]::ProcessorCount }
+
+# The package must never contain a stale mixture of two builds.
+if (Test-Path -LiteralPath $package) { Remove-Item -LiteralPath $package -Recurse -Force }
+[IO.Directory]::CreateDirectory($package) | Out-Null
+[IO.Directory]::CreateDirectory((Join-Path $package 'lib')) | Out-Null
+[IO.Directory]::CreateDirectory((Join-Path $package 'bin')) | Out-Null
+[IO.Directory]::CreateDirectory((Join-Path $package 'include')) | Out-Null
+
+# The staging build has to be configured without the package present, otherwise
+# the runtime would consume the very package this script is producing.
+if (-not $ReuseStage -and (Test-Path -LiteralPath $stage)) { Remove-Item -LiteralPath $stage -Recurse -Force }
+$exportDirectory = Join-Path $stage 'export'
+[IO.Directory]::CreateDirectory($exportDirectory) | Out-Null
+
+$oldPath = $env:PATH
+try {
+ $env:PATH = Get-MkwToolchainPath $portableTools
+
+ $configure = Get-MkwNativeConfigureArguments -SourceDirectory $runtimeSource -BuildDirectory $stage `
+ -Ninja $ninja -CCompiler $cc -CxxCompiler $cxx -ResourceCompiler $windres `
+ -DependenciesDirectory $dependencies `
+ -AdditionalArguments @("-DMKW_NATIVE_PREBUILT_EXPORT_DIR=$(ConvertTo-MkwCMakePath $exportDirectory)")
+ Invoke-Checked $cmake $configure 'Configuring the aurora/third-party staging build' -LogPrefix 'MKWNP'
+
+ # 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'
+} finally {
+ $env:PATH = $oldPath
+}
+
+# ---------------------------------------------------------------------------
+# Read the description CMake wrote, plus 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'
+$ninjaLines = [IO.File]::ReadAllLines($buildNinja)
+
+function Get-NinjaStatementVariables([string[]]$Lines, [string]$RulePrefix) {
+ $variables = $null
+ for ($i = 0; $i -lt $Lines.Length; $i++) {
+ $line = $Lines[$i]
+ if (-not $line.StartsWith('build ')) { continue }
+ $colon = $line.IndexOf(': ')
+ if ($colon -lt 0) { continue }
+ $rule = $line.Substring($colon + 2)
+ $space = $rule.IndexOf(' ')
+ if ($space -ge 0) { $rule = $rule.Substring(0, $space) }
+ if ($rule -ne $RulePrefix) { continue }
+ $variables = [ordered]@{}
+ for ($j = $i + 1; $j -lt $Lines.Length; $j++) {
+ $entry = $Lines[$j]
+ if ($entry -notmatch '^ ([A-Z_]+) = ?(.*)$') { break }
+ $variables[$Matches[1]] = $Matches[2]
+ }
+ break
+ }
+ if ($null -eq $variables) { throw "No Ninja statement used rule $RulePrefix." }
+ return $variables
+}
+function Get-NinjaValue($Variables, [string]$Name) {
+ if ($Variables.Contains($Name)) { return [string]$Variables[$Name] }
+ return ''
+}
+function Split-NinjaList([string]$Value) {
+ if ([string]::IsNullOrWhiteSpace($Value)) { return , [string[]]@() }
+ return , [string[]]@($Value -split '\s+' | Where-Object { $_ })
+}
+function Subtract-Ordered([string[]]$All, [string[]]$Remove) {
+ if ($null -eq $All) { return [string[]]@() }
+ $result = [Collections.Generic.List[string]]::new()
+ $pending = [Collections.Generic.List[string]]::new()
+ if ($null -ne $Remove) { foreach ($item in $Remove) { $pending.Add($item) } }
+ foreach ($item in $All) {
+ $index = $pending.IndexOf($item)
+ if ($index -ge 0) { $pending.RemoveAt($index); continue }
+ $result.Add($item)
+ }
+ return $result.ToArray()
+}
+
+$probeCompile = Get-NinjaStatementVariables $ninjaLines 'CXX_COMPILER__mkw_np_probe_unscanned_Release'
+$controlCompile = Get-NinjaStatementVariables $ninjaLines 'CXX_COMPILER__mkw_np_probe_control_unscanned_Release'
+$probeLink = Get-NinjaStatementVariables $ninjaLines 'CXX_EXECUTABLE_LINKER__mkw_np_probe_Release'
+$controlLink = Get-NinjaStatementVariables $ninjaLines 'CXX_EXECUTABLE_LINKER__mkw_np_probe_control_Release'
+
+$includeArguments = Subtract-Ordered (Split-NinjaList (Get-NinjaValue $probeCompile 'INCLUDES')) `
+ (Split-NinjaList (Get-NinjaValue $controlCompile 'INCLUDES'))
+$defineArguments = Subtract-Ordered (Split-NinjaList (Get-NinjaValue $probeCompile 'DEFINES')) `
+ (Split-NinjaList (Get-NinjaValue $controlCompile 'DEFINES'))
+$compileOptions = Subtract-Ordered (Split-NinjaList (Get-NinjaValue $probeCompile 'FLAGS')) `
+ (Split-NinjaList (Get-NinjaValue $controlCompile 'FLAGS'))
+$linkItems = Subtract-Ordered (Split-NinjaList (Get-NinjaValue $probeLink 'LINK_LIBRARIES')) `
+ (Split-NinjaList (Get-NinjaValue $controlLink 'LINK_LIBRARIES'))
+if ($includeArguments.Count -eq 0) { throw 'The aurora compile interface is empty; the probe did not link aurora.' }
+if ($linkItems.Count -eq 0) { throw 'The aurora link interface is empty; the probe did not link aurora.' }
+
+# ---------------------------------------------------------------------------
+# Harvest the built libraries.
+# ---------------------------------------------------------------------------
+$binaryRoot = Normalize $meta['binary_dir']
+$auroraRoot = Normalize $meta['aurora_dir']
+$runtimeRoot = Normalize $meta['runtime_dir']
+$dependencyRoot = Normalize $dependencies
+
+$targets = @{}
+foreach ($line in Get-Content -LiteralPath (Join-Path $exportDirectory 'targets.txt')) {
+ if ([string]::IsNullOrWhiteSpace($line)) { continue }
+ $parts = $line -split '\|'
+ if ($parts.Count -ne 4) { throw "Malformed exported target line: $line" }
+ $targets[$parts[0]] = [pscustomobject]@{
+ Name = $parts[0]; Type = $parts[1]
+ File = Normalize $parts[2]; LinkerFile = Normalize $parts[3]
+ }
+}
+
+$dawnLinkerFile = ''
+$dawnRuntimeFile = ''
+foreach ($line in Get-Content -LiteralPath (Join-Path $exportDirectory 'dawn.txt')) {
+ if ([string]::IsNullOrWhiteSpace($line)) { continue }
+ $parts = $line -split '\|'
+ $dawnRuntimeFile = Normalize $parts[1]
+ $dawnLinkerFile = Normalize $parts[2]
+}
+
+# linker-file path -> package reference, built only from libraries that were
+# actually produced by this build.
+$linkerFileToReference = @{}
+$sharedImports = [Collections.Generic.List[object]]::new()
+$copiedFiles = [Collections.Generic.List[string]]::new()
+function Copy-Harvested([string]$SourcePath, [string]$SubDirectory) {
+ $name = [IO.Path]::GetFileName($SourcePath)
+ $destination = Join-Path (Join-Path $package $SubDirectory) $name
+ if (Test-Path -LiteralPath $destination) {
+ throw "Two harvested files collide on the name ${name}: $SourcePath"
+ }
+ Copy-Item -LiteralPath $SourcePath -Destination $destination
+ $copiedFiles.Add("$SubDirectory/$name")
+ return "$SubDirectory/$name"
+}
+
+foreach ($name in ($targets.Keys | Sort-Object)) {
+ $target = $targets[$name]
+ if (-not (Test-Path -LiteralPath $target.LinkerFile -PathType Leaf)) {
+ # Not part of the closure mkw_np_probe pulled in; deliberately not shipped.
+ continue
+ }
+ $relativeLinker = Copy-Harvested $target.LinkerFile 'lib'
+ if ($target.Type -eq 'SHARED_LIBRARY') {
+ if (-not (Test-Path -LiteralPath $target.File -PathType Leaf)) {
+ throw "Shared library $name has no runtime file: $($target.File)"
+ }
+ $relativeRuntime = Copy-Harvested $target.File 'bin'
+ $sharedImports.Add([pscustomobject]@{
+ Target = "mkw_np::$name"; Runtime = $relativeRuntime; Implib = $relativeLinker })
+ $linkerFileToReference[$target.LinkerFile] = "mkw_np::$name"
+ } else {
+ $linkerFileToReference[$target.LinkerFile] = "`@PKG`@/$relativeLinker"
+ }
+}
+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' }
+
+# ---------------------------------------------------------------------------
+# Rewrite the compile/link interface into workspace-relative tokens.
+# ---------------------------------------------------------------------------
+$generatedIncludeIndex = 0
+function ConvertTo-Token([string]$AbsolutePath, [string]$Kind) {
+ $path = Normalize $AbsolutePath
+ if (StartsWithPath $path $auroraRoot) { return "`@AURORA`@/$(RelativeTo $path $auroraRoot)" }
+ if (StartsWithPath $path $runtimeRoot) { return "`@RUNTIME`@/$(RelativeTo $path $runtimeRoot)" }
+ if (StartsWithPath $path $dependencyRoot) { return "`@DEPS`@/$(RelativeTo $path $dependencyRoot)" }
+ if (StartsWithPath $path $binaryRoot) {
+ if ($Kind -ne 'include') { throw "Unhandled build-tree artifact: $path" }
+ # A generated header directory (SDL_revision.h and friends). The source
+ # trees ship, but these do not exist until something configures them, so
+ # they travel inside the package.
+ $script:generatedIncludeIndex++
+ $name = 'generated{0:d2}' -f $script:generatedIncludeIndex
+ $destination = Join-Path (Join-Path $package 'include') $name
+ [IO.Directory]::CreateDirectory($destination) | Out-Null
+ Copy-Item -Path (Join-Path $path '*') -Destination $destination -Recurse -Force
+ foreach ($file in Get-ChildItem -LiteralPath $destination -Recurse -File) {
+ $copiedFiles.Add(('include/{0}/{1}' -f $name,
+ $file.FullName.Substring($destination.Length + 1).Replace('\', '/')))
+ }
+ return "`@PKG`@/include/$name"
+ }
+ throw "Path escapes every shippable root ($Kind): $path"
+}
+
+# Ninja splits "-isystem " into two list entries (the vendored Crypto++
+# headers are declared SYSTEM, so they arrive that way), while plain aurora
+# includes stay glued to -I. Only the directory itself travels in the package;
+# the consumer re-emits it through INTERFACE_INCLUDE_DIRECTORIES and CMake
+# chooses the flag form per usage context.
+$packageIncludeDirectories = @()
+for ($i = 0; $i -lt $includeArguments.Count; $i++) {
+ $entry = $includeArguments[$i]
+ if ($entry.StartsWith('-I')) {
+ $packageIncludeDirectories += (ConvertTo-Token $entry.Substring(2) 'include')
+ } elseif ($entry -eq '-isystem' -and ($i + 1) -lt $includeArguments.Count) {
+ $i++
+ $packageIncludeDirectories += (ConvertTo-Token $includeArguments[$i] 'include')
+ } elseif ($entry.StartsWith('-isystem')) {
+ $packageIncludeDirectories += (ConvertTo-Token $entry.Substring('-isystem'.Length) 'include')
+ } else {
+ throw "Unexpected include argument: $entry"
+ }
+}
+
+$packageDefinitions = @($defineArguments | ForEach-Object {
+ if (-not $_.StartsWith('-D')) { throw "Unexpected define argument: $_" }
+ # Ninja carries the Windows command-line escaping; CMake re-applies it.
+ $_.Substring(2).Replace('\"', '"')
+})
+
+$packageLinkItems = @($linkItems | ForEach-Object {
+ $item = $_
+ if ($item.StartsWith('-')) { return $item }
+ $absolute = if ([IO.Path]::IsPathRooted($item)) { Normalize $item } else { Normalize (Join-Path $stage $item) }
+ if ($linkerFileToReference.ContainsKey($absolute)) { return $linkerFileToReference[$absolute] }
+ if (StartsWithPath $absolute $dependencyRoot) { return "`@DEPS`@/$(RelativeTo $absolute $dependencyRoot)" }
+ throw "Link item is neither a system library nor a harvested archive: $item"
+})
+
+# 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
+# through the same targets, and a forced C++ header include would break them.
+$packageCompileOptions = @($compileOptions | ForEach-Object { "`$<`$:$_>" })
+
+# ---------------------------------------------------------------------------
+# Emit the consumer-facing CMake description.
+# ---------------------------------------------------------------------------
+function Format-CMakeBlock([string]$Name, [string[]]$Items) {
+ if ($null -eq $Items -or $Items.Count -eq 0) { return "set($Name `"`")`n" }
+ $body = ($Items | ForEach-Object { ' "' + $_.Replace('\', '/').Replace('"', '\"') + '"' }) -join "`n"
+ return "set($Name`n$body)`n"
+}
+
+$generated = [Collections.Generic.List[string]]::new()
+$generated.Add('# Generated by launcher/Prepare-NativePrebuilt.ps1 - do not edit.')
+$generated.Add('#')
+$generated.Add('# Describes the precompiled aurora + third-party archives that replace a')
+$generated.Add('# from-source aurora-main build. Every path is a token resolved against the')
+$generated.Add('# consuming workspace, because the source trees still ship next to this')
+$generated.Add('# package and the runtime compiles against their headers.')
+$generated.Add('')
+$generated.Add('if(NOT MKW_NP_PACKAGE_DIR OR NOT MKW_NP_AURORA_DIR OR NOT MKW_NP_RUNTIME_DIR OR NOT MKW_NP_DEPS_DIR)')
+$generated.Add(' message(FATAL_ERROR "native_prebuilt.cmake must be included by runtime/cmake/NativePrebuilt.cmake")')
+$generated.Add('endif()')
+$generated.Add('')
+$generated.Add((Format-CMakeBlock 'MKW_NP_INCLUDE_DIRECTORIES' $packageIncludeDirectories).TrimEnd())
+$generated.Add((Format-CMakeBlock 'MKW_NP_COMPILE_DEFINITIONS' $packageDefinitions).TrimEnd())
+$generated.Add((Format-CMakeBlock 'MKW_NP_COMPILE_OPTIONS' $packageCompileOptions).TrimEnd())
+$generated.Add((Format-CMakeBlock 'MKW_NP_LINK_LIBRARIES' $packageLinkItems).TrimEnd())
+$generated.Add((Format-CMakeBlock 'MKW_NP_AURORA_TARGETS' @('aurora::gx','aurora::pad','aurora::si','aurora::vi','aurora::mtx')).TrimEnd())
+$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('# Shared third-party libraries keep imported SHARED targets so that')
+$generated.Add('# $ still copies them next to the game executable.')
+foreach ($import in $sharedImports) {
+ $generated.Add("add_library($($import.Target) SHARED IMPORTED GLOBAL)")
+ $generated.Add("set_target_properties($($import.Target) PROPERTIES")
+ $generated.Add(" IMPORTED_LOCATION `"`${MKW_NP_PACKAGE_DIR}/$($import.Runtime)`"")
+ $generated.Add(" IMPORTED_IMPLIB `"`${MKW_NP_PACKAGE_DIR}/$($import.Implib)`")")
+}
+$generatedText = ($generated -join "`r`n") + "`r`n"
+$generatedPath = Join-Path $package 'native_prebuilt.cmake'
+[IO.File]::WriteAllText($generatedPath, $generatedText, (New-Object Text.UTF8Encoding($false)))
+
+# --- Provenance ---
+# The pinned Dawn tree is what this harvest compiled against; without its digest the
+# consumer-side ABI check would silently pass on a mismatched Dawn.
+$dawnRuntimeHash = Get-MkwDawnRuntimeSha256 $dependencies
+if (-not $dawnRuntimeHash) {
+ throw "The pinned Dawn runtime is missing: $(Join-Path $dependencies 'dawn_prebuilt\bin\webgpu_dawn.dll')"
+}
+
+$auroraSourceFingerprint = Get-MkwAuroraSourceFingerprint $auroraSource
+if (-not $auroraSourceFingerprint) {
+ throw "The aurora source tree could not be fingerprinted: $auroraSource"
+}
+
+# The harvested Crypto++ archive is consumed against this tree's headers, so it
+# is recorded for the same reason as the aurora fingerprint above.
+$thirdPartyDirectory = Join-Path $runtimeSource 'third_party'
+$thirdPartySourceFingerprint = Get-MkwThirdPartySourceFingerprint $thirdPartyDirectory
+if (-not $thirdPartySourceFingerprint) {
+ throw "The vendored third-party tree could not be fingerprinted: $thirdPartyDirectory"
+}
+
+$contents = @(Get-ChildItem -LiteralPath $package -Recurse -File | ForEach-Object {
+ [pscustomobject][ordered]@{
+ Path = $_.FullName.Substring($package.Length + 1).Replace('\', '/')
+ Bytes = $_.Length
+ Sha256 = Get-MkwFileSha256 $_.FullName
+ }
+} | Sort-Object Path)
+
+$provenance = [ordered]@{
+ SchemaVersion = 1
+ BuiltUtc = [DateTime]::UtcNow.ToString('O')
+ # A precompiled archive is only interchangeable with locally compiled objects
+ # when the compiler and the flag set are bit-for-bit the ones the user's
+ # machine will use. LocalBuild.ps1 re-checks both before consuming this.
+ CompilerSha256 = Get-MkwFileSha256 $clangBinary
+ CompilerVersion = $meta['cxx_compiler_version']
+ FlagFingerprint = Get-MkwNativeFlagFingerprint
+ DawnVersion = $meta['aurora_dawn_version']
+ # The version tag is not a content identity: the upstream prebuilt asset is
+ # mutable, so record the digest of the Dawn runtime these archives were
+ # actually compiled and linked against. Consumers re-check it.
+ DawnRuntimeSha256 = $dawnRuntimeHash
+ # libaurora_*.a is compiled from this tree, while the runtime that calls into
+ # it is compiled on the user's machine. Record what was harvested so both the
+ # release build and the local build can refuse a package aurora has outgrown.
+ AuroraSourceFingerprint = $auroraSourceFingerprint
+ ThirdPartySourceFingerprint = $thirdPartySourceFingerprint
+ Sdl3Target = $meta['aurora_sdl3_target']
+ HarvestedLibraryCount = @($linkerFileToReference.Keys).Count
+ Contents = $contents
+}
+$provenance | ConvertTo-Json -Depth 6 |
+ Set-Content -LiteralPath (Join-Path $package 'provenance.json') -Encoding UTF8
+
+if (-not $KeepStage) { Remove-Item -LiteralPath $stage -Recurse -Force }
+
+$totalBytes = ($contents | Measure-Object -Property Bytes -Sum).Sum
+Write-Host ''
+Write-Host "Native prebuilt package: $package"
+Write-Host (" files: {0}; size: {1:n1} MiB" -f $contents.Count, ($totalBytes / 1MB))
+Write-Host (" archives: {0}; shared imports: {1}" -f (@(Get-ChildItem (Join-Path $package 'lib') -File).Count), $sharedImports.Count)
+Write-Host (" flag fingerprint: {0}" -f $provenance.FlagFingerprint)
diff --git a/Launcher/Prepare-PortableTools.ps1 b/Launcher/Prepare-PortableTools.ps1
new file mode 100644
index 0000000..30dda2a
--- /dev/null
+++ b/Launcher/Prepare-PortableTools.ps1
@@ -0,0 +1,74 @@
+[CmdletBinding()]
+param([string]$Destination)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version 3.0
+
+$packages = @(
+ [pscustomobject]@{
+ Name = 'llvm-mingw'; File = 'llvm-mingw-20260616-ucrt-x86_64.zip'
+ Uri = 'https://github.com/mstorsjo/llvm-mingw/releases/download/20260616/llvm-mingw-20260616-ucrt-x86_64.zip'
+ Sha256 = 'b9b68a4d276e16fa25802aaba458e4638f64b3884c290aaccdc2d87083b6ca35'
+ Root = 'llvm-mingw-20260616-ucrt-x86_64'
+ },
+ [pscustomobject]@{
+ Name = 'CMake'; File = 'cmake-4.3.3-windows-x86_64.zip'
+ Uri = 'https://github.com/Kitware/CMake/releases/download/v4.3.3/cmake-4.3.3-windows-x86_64.zip'
+ Sha256 = '935ade9e5e8723583c07f44c5592cea2a1c8f65c56ca7e07b34c025c880e0bd6'
+ Root = 'cmake-4.3.3-windows-x86_64'
+ },
+ [pscustomobject]@{
+ Name = 'Ninja'; File = 'ninja-win-1.13.2.zip'
+ Uri = 'https://github.com/ninja-build/ninja/releases/download/v1.13.2/ninja-win.zip'
+ Sha256 = '07fc8261b42b20e71d1720b39068c2e14ffcee6396b76fb7a795fb460b78dc65'
+ Root = $null
+ }
+)
+
+if (-not $Destination) { $Destination = Join-Path $PSScriptRoot 'artifacts\portable-tools' }
+$Destination = [IO.Path]::GetFullPath($Destination)
+$downloads = Join-Path $PSScriptRoot 'artifacts\downloads'
+[IO.Directory]::CreateDirectory($Destination) | Out-Null
+[IO.Directory]::CreateDirectory($downloads) | Out-Null
+
+foreach ($package in $packages) {
+ $archive = Join-Path $downloads $package.File
+ if (-not (Test-Path -LiteralPath $archive -PathType Leaf) -or
+ (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $package.Sha256) {
+ Write-Host "Downloading $($package.Name)..."
+ $temporary = $archive + '.partial'
+ Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue
+ Invoke-WebRequest -UseBasicParsing -Uri $package.Uri -OutFile $temporary
+ $actual = (Get-FileHash -LiteralPath $temporary -Algorithm SHA256).Hash.ToLowerInvariant()
+ if ($actual -ne $package.Sha256) { throw "$($package.Name) hash mismatch: $actual" }
+ Move-Item -LiteralPath $temporary -Destination $archive -Force
+ }
+
+ $target = Join-Path $Destination $package.Name
+ if (Test-Path -LiteralPath $target -PathType Container) { continue }
+ $extract = Join-Path $Destination ('.extract-' + $package.Name)
+ if (Test-Path -LiteralPath $extract) { Remove-Item -LiteralPath $extract -Recurse -Force }
+ Expand-Archive -LiteralPath $archive -DestinationPath $extract
+ $source = if ($package.Root) { Join-Path $extract $package.Root } else { $extract }
+ if (-not (Test-Path -LiteralPath $source -PathType Container)) { throw "$($package.Name) archive layout changed." }
+ Move-Item -LiteralPath $source -Destination $target
+ if (Test-Path -LiteralPath $extract) { Remove-Item -LiteralPath $extract -Recurse -Force }
+}
+
+$license = @"
+Portable build tools bundled by WiiCompiled
+
+LLVM-MinGW 20260616 (LLVM/Clang/LLD, mingw-w64, libc++)
+ https://github.com/mstorsjo/llvm-mingw
+ Licenses are included in the llvm-mingw directory.
+
+CMake 4.3.3
+ https://cmake.org/
+ License is included in the CMake directory.
+
+Ninja 1.13.2
+ https://github.com/ninja-build/ninja
+ Apache License 2.0; see the installer license inventory.
+"@
+$license | Set-Content -LiteralPath (Join-Path $Destination 'README-LICENSES.txt') -Encoding UTF8
+Write-Host "Portable tools ready: $Destination"
diff --git a/Launcher/Prepare-Release.ps1 b/Launcher/Prepare-Release.ps1
new file mode 100644
index 0000000..8e51414
--- /dev/null
+++ b/Launcher/Prepare-Release.ps1
@@ -0,0 +1,30 @@
+[CmdletBinding(PositionalBinding = $false)]
+param(
+ [string]$DolphinToolPath,
+ [string]$PortableToolsDirectory = 'Launcher/artifacts/portable-tools',
+ [string]$DependencySourceDirectory = 'Launcher/artifacts/dependencies',
+ [string]$VcRuntimeDirectory,
+ [string]$ToolkitReleaseTag = $env:GITHUB_REF_NAME
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version 3.0
+
+if ([string]::IsNullOrWhiteSpace($DolphinToolPath)) {
+ throw 'Prepare-Release.ps1 requires -DolphinToolPath pointing to DolphinTool.exe.'
+}
+
+$arguments = @{
+ DolphinToolPath = $DolphinToolPath
+ PortableToolsDirectory = $PortableToolsDirectory
+ DependencySourceDirectory = $DependencySourceDirectory
+ ToolkitReleaseTag = $ToolkitReleaseTag
+}
+if (-not [string]::IsNullOrWhiteSpace($VcRuntimeDirectory)) {
+ $arguments.VcRuntimeDirectory = $VcRuntimeDirectory
+}
+
+& (Join-Path $PSScriptRoot 'Build-Installer.ps1') @arguments
+if ($LASTEXITCODE -ne 0) { throw "Build-Installer.ps1 failed with exit code $LASTEXITCODE." }
+
+Write-Host 'The release artifact is ready. It contains no translated game executable, game image, Code.pul, or Retro-WFC payload.'
diff --git a/Launcher/Test-NativeDependencies.ps1 b/Launcher/Test-NativeDependencies.ps1
new file mode 100644
index 0000000..a8bbe8f
--- /dev/null
+++ b/Launcher/Test-NativeDependencies.ps1
@@ -0,0 +1,69 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$PayloadRoot,
+ [Parameter(Mandatory = $true)]
+ [string]$SetupHost,
+ [Parameter(Mandatory = $true)]
+ [string]$LlvmReadobjPath
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version 3.0
+
+if (-not (Test-Path -LiteralPath $LlvmReadobjPath -PathType Leaf)) {
+ throw "Bundled llvm-readobj.exe is missing: $LlvmReadobjPath"
+}
+$llvmReadobj = [System.IO.Path]::GetFullPath($LlvmReadobjPath)
+
+$systemDlls = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
+@(
+ 'advapi32.dll', 'authz.dll', 'bcrypt.dll', 'combase.dll', 'comdlg32.dll', 'crypt32.dll',
+ 'd3d11.dll', 'd3d12.dll', 'dbghelp.dll', 'dcomp.dll', 'dwrite.dll', 'dwmapi.dll',
+ 'dxgi.dll', 'gdi32.dll', 'imm32.dll',
+ 'iphlpapi.dll', 'kernel32.dll', 'mf.dll', 'mfplat.dll', 'mfreadwrite.dll', 'mfuuid.dll',
+ 'ntdll.dll', 'ole32.dll', 'oleaut32.dll', 'opengl32.dll', 'powrprof.dll', 'rpcrt4.dll',
+ 'secur32.dll', 'setupapi.dll', 'shell32.dll', 'shlwapi.dll', 'user32.dll', 'userenv.dll',
+ 'version.dll', 'winhttp.dll', 'winmm.dll', 'wtsapi32.dll', 'ws2_32.dll', 'msvcrt.dll',
+ 'netapi32.dll', 'normaliz.dll', 'psapi.dll', 'ucrtbase.dll', 'uxtheme.dll'
+) | ForEach-Object { [void]$systemDlls.Add($_) }
+
+$redistributableDlls = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
+$redistDirectory = Join-Path ([System.IO.Path]::GetFullPath($PayloadRoot)) 'Toolkit\Redist'
+if (Test-Path -LiteralPath $redistDirectory -PathType Container) {
+ Get-ChildItem -LiteralPath $redistDirectory -Filter '*.dll' -File |
+ ForEach-Object { [void]$redistributableDlls.Add($_.Name) }
+}
+
+$binaries = @(
+ Get-ChildItem -LiteralPath ([System.IO.Path]::GetFullPath($PayloadRoot)) -File -Recurse |
+ Where-Object { $_.Extension -in @('.exe', '.dll') }
+)
+$binaries += Get-Item -LiteralPath ([System.IO.Path]::GetFullPath($SetupHost))
+$missing = [Collections.Generic.List[string]]::new()
+$checkedImports = 0
+
+foreach ($binary in $binaries) {
+ $output = & $llvmReadobj --coff-imports $binary.FullName 2>&1
+ if ($LASTEXITCODE -ne 0) { throw "llvm-readobj failed for $($binary.FullName).`n$output" }
+ $dependencies = @($output | ForEach-Object {
+ if ($_ -match '^\s*Name:\s+([A-Za-z0-9._-]+\.dll)\s*$') { $Matches[1] }
+ } | Sort-Object -Unique)
+ foreach ($dependency in $dependencies) {
+ $checkedImports++
+ if ($dependency.StartsWith('api-ms-', [StringComparison]::OrdinalIgnoreCase) -or
+ $dependency.StartsWith('ext-ms-', [StringComparison]::OrdinalIgnoreCase) -or
+ $systemDlls.Contains($dependency) -or
+ $redistributableDlls.Contains($dependency) -or
+ (Test-Path -LiteralPath (Join-Path $binary.DirectoryName $dependency) -PathType Leaf)) {
+ continue
+ }
+ $missing.Add("$($binary.FullName) -> $dependency")
+ }
+}
+
+if ($missing.Count -gt 0) {
+ throw "Native dependency audit found imports that are neither Windows system DLLs nor app-local:`n$($missing -join "`n")"
+}
+
+Write-Host "Native dependency audit passed: $($binaries.Count) binaries, $checkedImports imports."
diff --git a/Launcher/Test-PayloadBoundary.ps1 b/Launcher/Test-PayloadBoundary.ps1
new file mode 100644
index 0000000..79bdc3f
--- /dev/null
+++ b/Launcher/Test-PayloadBoundary.ps1
@@ -0,0 +1,43 @@
+[CmdletBinding()]
+param([Parameter(Mandatory)] [string]$PayloadRoot)
+
+$ErrorActionPreference = 'Stop'
+$PayloadRoot = [IO.Path]::GetFullPath($PayloadRoot)
+if (-not (Test-Path -LiteralPath $PayloadRoot -PathType Container)) { throw "Payload does not exist: $PayloadRoot" }
+
+$forbiddenNames = @(
+ 'WiiCompiled.exe', 'RetroRewind.exe', 'mkw_recompiled.exe',
+ 'main.dol', 'StaticR.rel', 'Code.pul', 'base_translation_sources.bin',
+ 'data_sections_init.cpp', 'data_sections_init_blobs.S', 'mkwii_base_manifest.json',
+ 'generated_func_aliases.cpp', 'translated_function_decls.h'
+)
+$forbiddenExtensions = @('.iso', '.gcm', '.gcz', '.ciso', '.wbfs', '.wia', '.rvz', '.dol', '.rel', '.pul')
+$forbiddenSegments = @(
+ '\BuildWorkspace\generated\', '\BuildWorkspace\runtime\build\', '\GameAssets\', '\BuildWorkspace\Assets\',
+ '\BuildWorkspace\PulsarPacks\', '\BuildWorkspace\build\base\', '\BuildWorkspace\build\mods\'
+)
+$violations = [Collections.Generic.List[string]]::new()
+foreach ($file in Get-ChildItem -LiteralPath $PayloadRoot -Recurse -File) {
+ $rootPrefix = $PayloadRoot.TrimEnd('\') + '\'
+ if (-not $file.FullName.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) {
+ throw "Payload enumeration escaped its root: $($file.FullName)"
+ }
+ $relative = '\' + $file.FullName.Substring($rootPrefix.Length).Replace('/', '\')
+ $underCompiler = $relative.StartsWith('\Toolkit\llvm-mingw\', [StringComparison]::OrdinalIgnoreCase)
+ $underPinnedDependency = $relative.StartsWith('\BuildWorkspace\Dependencies\', [StringComparison]::OrdinalIgnoreCase)
+ if ($forbiddenNames -contains $file.Name -or $forbiddenExtensions -contains $file.Extension.ToLowerInvariant()) {
+ $violations.Add($relative)
+ continue
+ }
+ if ($forbiddenSegments | Where-Object { $relative.IndexOf($_, [StringComparison]::OrdinalIgnoreCase) -ge 0 }) {
+ $violations.Add($relative)
+ continue
+ }
+ if (-not $underCompiler -and -not $underPinnedDependency -and $file.Extension -in @('.obj','.o','.lib','.a')) {
+ $violations.Add("$relative (precompiled object/library outside the licensed compiler payload)")
+ }
+}
+if ($violations.Count -gt 0) {
+ throw "Release payload contains forbidden game-derived or precompiled artifacts:`n$($violations -join "`n")"
+}
+Write-Host "Payload boundary audit passed: no translated game executable, game input, generated shard, or generated data was packaged."
diff --git a/Launcher/Test-PinnedFacts.ps1 b/Launcher/Test-PinnedFacts.ps1
new file mode 100644
index 0000000..4d97c9c
--- /dev/null
+++ b/Launcher/Test-PinnedFacts.ps1
@@ -0,0 +1,133 @@
+# Fails the release build when a fact duplicated across the repo stops agreeing with the copy
+# that owns it (recomp.yml). Scripts read pinned facts through Get-MkwProjectPins, but three
+# consumers can't read YAML (the C++ runtime header, the C# constants, hand-written lists on
+# both sides of the C#/PowerShell boundary), so those are checked here instead.
+[CmdletBinding()]
+param([string]$RepositoryRoot)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version 3.0
+
+. (Join-Path $PSScriptRoot 'NativeBuildFlags.ps1')
+
+if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) {
+ $RepositoryRoot = Join-Path $PSScriptRoot '..'
+}
+$repoRoot = [IO.Path]::GetFullPath($RepositoryRoot)
+$launcher = Join-Path $repoRoot 'Launcher'
+$setup = Join-Path $launcher 'WiiCompiled.Setup'
+
+$failures = [Collections.Generic.List[string]]::new()
+function Add-Failure([string]$Message) { $failures.Add($Message) }
+
+function Read-SourceFile([string]$Path, [string]$Description) {
+ Assert-File $Path $Description
+ return [IO.File]::ReadAllText($Path)
+}
+
+function Get-CapturedValue([string]$Text, [string]$Pattern, [string]$Description) {
+ $match = [regex]::Match($Text, $Pattern)
+ if (-not $match.Success) { throw "$Description could not be located; update Test-PinnedFacts.ps1." }
+ return $match.Groups[1].Value
+}
+
+function Get-QuotedList([string]$Text, [string]$BlockPattern, [string]$ItemPattern, [string]$Description) {
+ $block = Get-CapturedValue $Text $BlockPattern $Description
+ $items = [regex]::Matches($block, $ItemPattern) | ForEach-Object { $_.Groups[1].Value }
+ if (@($items).Count -eq 0) { throw "$Description is empty; update Test-PinnedFacts.ps1." }
+ return [string[]]@($items)
+}
+
+function Compare-Set([string[]]$Expected, [string[]]$Actual, [string]$ExpectedName, [string]$ActualName) {
+ $missing = @($Expected | Where-Object { $Actual -notcontains $_ })
+ $extra = @($Actual | Where-Object { $Expected -notcontains $_ })
+ if ($missing.Count -gt 0) { Add-Failure "$ActualName is missing: $($missing -join ', ') (present in $ExpectedName)." }
+ if ($extra.Count -gt 0) { Add-Failure "$ActualName has entries $ExpectedName does not: $($extra -join ', ')." }
+}
+
+$pins = Get-MkwProjectPins (Join-Path $repoRoot 'projects\mkwii\recomp.yml')
+
+# --- The Retro-WFC endpoint: recomp.yml owns it; the installer host pins the same string so a
+# --- redirected or rewritten endpoint cannot be fetched from.
+$inputValidation = Read-SourceFile (Join-Path $setup 'InputValidation.cs') 'InputValidation.cs'
+$hostUri = Get-CapturedValue $inputValidation 'CurrentRetroWfcPayloadUri\s*=\s*"([^"]+)"' `
+ 'The host Retro-WFC endpoint constant'
+if ($hostUri -cne $pins.RetroWfcPayloadUri) {
+ Add-Failure "InputValidation.CurrentRetroWfcPayloadUri is '$hostUri' but recomp.yml pins '$($pins.RetroWfcPayloadUri)'."
+}
+
+# --- The game identity: the manifest carries it, but the host also compiles a fallback for a
+# --- manifest that predates the field, and that fallback decides which disc is accepted.
+$models = Read-SourceFile (Join-Path $setup 'Models.cs') 'Models.cs'
+$hostGameId = Get-CapturedValue $models 'ExpectedGameId\s*\{\s*get;\s*set;\s*\}\s*=\s*"([^"]+)"' `
+ 'The host expected game id'
+if ($hostGameId -cne $pins.GameId) {
+ Add-Failure "PayloadManifest.ExpectedGameId defaults to '$hostGameId' but recomp.yml pins '$($pins.GameId)'."
+}
+
+# --- The translation entry point: the translator is told it by LocalBuild.ps1 (which reads
+# --- recomp.yml), while the runtime enters the translated image at its own constant.
+$systemBridge = Read-SourceFile (Join-Path $repoRoot 'runtime\include\system_bridge.h') 'system_bridge.h'
+$runtimeEntry = (Get-CapturedValue $systemBridge 'kDefaultEntryAddress\s*=\s*(0[xX][0-9a-fA-F]+)' `
+ 'The runtime default entry address').ToLowerInvariant()
+if ($runtimeEntry -ne $pins.EntryPoint) {
+ Add-Failure "system_bridge.h enters at $runtimeEntry but recomp.yml translates from $($pins.EntryPoint)."
+}
+
+# --- The pinned dependency set: Build-Installer.ps1 stages it, the installed host requires it.
+$installedLayout = Read-SourceFile (Join-Path $setup 'InstalledLayout.cs') 'InstalledLayout.cs'
+$buildInstaller = Read-SourceFile (Join-Path $launcher 'Build-Installer.ps1') 'Build-Installer.ps1'
+Compare-Set (Get-QuotedList $installedLayout '(?s)DependencyNames\s*=\s*\[(.*?)\]' '"([^"]+)"' `
+ 'InstalledLayout.DependencyNames') `
+ (Get-QuotedList $buildInstaller '(?s)\$requiredDependencies\s*=\s*@\((.*?)\)' "'([^']+)'" `
+ 'Build-Installer.ps1 $requiredDependencies') `
+ 'InstalledLayout.DependencyNames' 'Build-Installer.ps1 $requiredDependencies'
+
+# --- The runtime assets copied beside a product: the host hashes exactly these names, so the build
+# --- script must publish every one of them or every product reports its support files as stale.
+$localBuild = Read-SourceFile (Join-Path $launcher 'LocalBuild.ps1') 'LocalBuild.ps1'
+$productAssets = @(Get-CapturedValue $installedLayout 'ProductBootstrapDirectoryName\s*=\s*"([^"]+)"' `
+ 'The product bootstrap directory name') +
+ @(Get-QuotedList $installedLayout '(?s)ProductFileName\)\[\]\s*Files\s*=\s*\[(.*?)\n\s*\];' `
+ '\]\s*,\s*"([^"]+)"\s*\)' 'ProductRuntimeAssets.Files')
+foreach ($asset in $productAssets) {
+ if ($localBuild.IndexOf("'$asset'", [StringComparison]::Ordinal) -lt 0) {
+ Add-Failure "LocalBuild.ps1 never publishes the product runtime asset '$asset'."
+ }
+}
+
+# --- The command-line contract between the host and the build script.
+$localBuildService = Read-SourceFile (Join-Path $setup 'LocalBuildService.cs') 'LocalBuildService.cs'
+$parameterBlock = Get-CapturedValue $localBuild '(?sm)^param\((.*?)\r?\n\)' 'The LocalBuild.ps1 param block'
+$scriptParameters = [regex]::Matches($parameterBlock, '\$(\w+)') | ForEach-Object { $_.Groups[1].Value }
+# powershell.exe's own switches travel in the same argument list and are not script parameters.
+$hostSwitches = @('NoLogo', 'NoProfile', 'NonInteractive', 'ExecutionPolicy', 'File')
+$passedSwitches = [regex]::Matches($localBuildService, '"-([A-Za-z]\w*)"') |
+ ForEach-Object { $_.Groups[1].Value } | Where-Object { $hostSwitches -notcontains $_ } |
+ Sort-Object -Unique
+foreach ($switch in $passedSwitches) {
+ if ($scriptParameters -notcontains $switch) {
+ Add-Failure "LocalBuildService.cs passes -$switch, which LocalBuild.ps1 does not declare."
+ }
+}
+foreach ($mandatory in ([regex]::Matches($parameterBlock,
+ '\[Parameter\(Mandatory\)\][^$]*\$(\w+)') | ForEach-Object { $_.Groups[1].Value })) {
+ if ($passedSwitches -notcontains $mandatory) {
+ Add-Failure "LocalBuild.ps1 requires -$mandatory, which LocalBuildService.cs never passes."
+ }
+}
+
+# --- The build steps the installer's progress bar acts on.
+$installProgress = Read-SourceFile (Join-Path $setup '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+'([^']+)'") |
+ ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
+Compare-Set $knownSteps $emittedSteps 'BuildStepIds' 'the steps LocalBuild.ps1 emits'
+
+if ($failures.Count -gt 0) {
+ throw "Pinned facts disagree between their copies:`n $($failures -join "`n ")"
+}
+Write-Host ("Pinned-fact audit passed: game {0}, entry {1}, endpoint {2}; dependency, runtime-asset, " -f
+ $pins.GameId, $pins.EntryPoint, $pins.RetroWfcPayloadUri) -NoNewline
+Write-Host 'build-step, and build-script parameter contracts all agree.'
diff --git a/Launcher/WiiCompiled.Setup/CancellationSignal.cs b/Launcher/WiiCompiled.Setup/CancellationSignal.cs
new file mode 100644
index 0000000..dd4848b
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/CancellationSignal.cs
@@ -0,0 +1,75 @@
+namespace WiiCompiled.Setup;
+
+///
+/// Bridges a frontend-owned, named Windows event into the cancellation token used by setup.
+///
+internal sealed class CancellationSignal : IDisposable
+{
+ public const string EnvironmentVariable = "MKWCOMPILED_CANCEL_EVENT";
+
+ private readonly EventWaitHandle? _event;
+ private readonly CancellationTokenSource? _source;
+ private readonly RegisteredWaitHandle? _registration;
+
+ private CancellationSignal(EventWaitHandle? @event, CancellationTokenSource? source,
+ RegisteredWaitHandle? registration)
+ {
+ _event = @event;
+ _source = source;
+ _registration = registration;
+ }
+
+ public CancellationToken Token => _source?.Token ?? CancellationToken.None;
+
+ public static CancellationSignal ObserveEnvironment()
+ {
+ var eventName = Environment.GetEnvironmentVariable(EnvironmentVariable);
+ if (eventName is null)
+ return new CancellationSignal(null, null, null);
+ if (string.IsNullOrWhiteSpace(eventName))
+ throw new InvalidOperationException(
+ $"The cancellation event named by {EnvironmentVariable} is empty.");
+
+ EventWaitHandle signal;
+ try
+ {
+ signal = EventWaitHandle.OpenExisting(eventName.Trim());
+ }
+ catch (Exception ex) when (ex is WaitHandleCannotBeOpenedException or UnauthorizedAccessException
+ or IOException or ArgumentException)
+ {
+ throw new InvalidOperationException(
+ $"The cancellation event named by {EnvironmentVariable} could not be opened.", ex);
+ }
+
+ var source = new CancellationTokenSource();
+ RegisteredWaitHandle registration;
+ try
+ {
+ registration = ThreadPool.RegisterWaitForSingleObject(signal, static (state, _) =>
+ {
+ try { ((CancellationTokenSource)state!).Cancel(); }
+ catch (ObjectDisposedException) { }
+ }, source, Timeout.Infinite, executeOnlyOnce: true);
+
+ // Registering and checking explicitly closes the race where the event was already set
+ // before RegisterWaitForSingleObject installed its wait.
+ if (signal.WaitOne(0)) source.Cancel();
+ }
+ catch
+ {
+ source.Dispose();
+ signal.Dispose();
+ throw;
+ }
+
+ return new CancellationSignal(signal, source, registration);
+ }
+
+ public void Dispose()
+ {
+ _registration?.Unregister(null);
+ _event?.Dispose();
+ _source?.Dispose();
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup/CommandLine.cs b/Launcher/WiiCompiled.Setup/CommandLine.cs
new file mode 100644
index 0000000..ef573d6
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/CommandLine.cs
@@ -0,0 +1,191 @@
+namespace WiiCompiled.Setup;
+
+internal enum AppMode
+{
+ Help,
+ SilentInstall,
+ VerifyInputs,
+ Uninstall,
+ SilentUninstall,
+ UninstallWorker,
+ SelfTest,
+ LaunchBase,
+ LaunchRetro,
+ CheckProducts,
+ RepairProducts,
+ Version,
+ EmitPayloadIdentities
+}
+
+///
+/// What each mode accepts. Every cross-flag rule below is expressed here once instead of as a
+/// per-mode exception list, so adding a mode cannot silently inherit another mode's inputs.
+///
+internal sealed record ModeRules
+{
+ public string Flag { get; init; } = "this command";
+ public bool AcceptsProgressJson { get; init; }
+ public bool AcceptsGame { get; init; }
+ public bool RequiresGame { get; init; }
+ public bool AcceptsRetroDirectory { get; init; }
+ public bool RequiresRetroDirectory { get; init; }
+ public bool AcceptsPayloadMode { get; init; }
+ public bool RequiresInstallDirectory { get; init; }
+ public bool RequiresPayloadRoot { get; init; }
+ public bool AcceptsPortable { get; init; }
+}
+
+internal sealed class CommandLine
+{
+ public AppMode Mode { get; private set; } = AppMode.Help;
+ public string? GamePath { get; private set; }
+ public string? RetroDirectoryPath { get; private set; }
+ public RetroWfcPayloadMode RetroWfcPayloadMode { get; private set; }
+ public string? InstallDirectory { get; private set; }
+ public bool Quiet { get; private set; }
+ public bool ProgressJson { get; private set; }
+ public string? PayloadRootPath { get; private set; }
+ public bool Portable { get; private set; }
+
+ public static bool WantsProgressJson(string[] args) =>
+ args.Any(argument => argument.Equals("--progress-json", StringComparison.OrdinalIgnoreCase));
+
+ public static CommandLine Parse(string[] args)
+ {
+ var result = new CommandLine();
+ for (var i = 0; i < args.Length; i++)
+ {
+ switch (args[i].ToLowerInvariant())
+ {
+ case "--version": result.Mode = AppMode.Version; break;
+ case "--silent": result.Mode = AppMode.SilentInstall; break;
+ case "--verify-inputs": result.Mode = AppMode.VerifyInputs; break;
+ case "--uninstall": result.Mode = AppMode.Uninstall; break;
+ case "--silent-uninstall": result.Mode = AppMode.SilentUninstall; result.Quiet = true; break;
+ case "--uninstall-worker": result.Mode = AppMode.UninstallWorker; break;
+ case "--self-test": result.Mode = AppMode.SelfTest; break;
+ case "--launch-base": result.Mode = AppMode.LaunchBase; break;
+ case "--launch-retro": result.Mode = AppMode.LaunchRetro; break;
+ case "--check-products": result.Mode = AppMode.CheckProducts; break;
+ case "--repair-products": result.Mode = AppMode.RepairProducts; break;
+ case "--portable": result.Portable = true; break;
+ case "--emit-payload-identities": result.Mode = AppMode.EmitPayloadIdentities; break;
+ case "--payload-root": result.PayloadRootPath = RequireValue(args, ref i); break;
+ case "--quiet": result.Quiet = true; break;
+ case "--progress-json": result.ProgressJson = true; break;
+ case "--game": result.GamePath = RequireValue(args, ref i); break;
+ case "--retro-dir": result.RetroDirectoryPath = RequireValue(args, ref i); break;
+ case "--download-retro-wfc-payload":
+ if (result.RetroWfcPayloadMode != RetroWfcPayloadMode.NotApplicable)
+ throw new ArgumentException("Choose only one Retro-WFC payload mode.");
+ result.RetroWfcPayloadMode = RetroWfcPayloadMode.Online;
+ break;
+ case "--skip-retro-wfc-payload":
+ if (result.RetroWfcPayloadMode != RetroWfcPayloadMode.NotApplicable)
+ throw new ArgumentException("Choose only one Retro-WFC payload mode.");
+ result.RetroWfcPayloadMode = RetroWfcPayloadMode.Skipped;
+ break;
+ case "--install-dir": result.InstallDirectory = RequireValue(args, ref i); break;
+ default:
+ throw new ArgumentException($"Unknown CLI option: {args[i]}");
+ }
+ }
+
+ result.Validate();
+ return result;
+ }
+
+ private static readonly Dictionary Rules = new()
+ {
+ [AppMode.SilentInstall] = new ModeRules
+ {
+ Flag = "--silent",
+ AcceptsProgressJson = true, AcceptsGame = true, RequiresGame = true,
+ AcceptsRetroDirectory = true, AcceptsPayloadMode = true,
+ AcceptsPortable = true
+ },
+ [AppMode.VerifyInputs] = new ModeRules
+ {
+ Flag = "--verify-inputs",
+ AcceptsProgressJson = true, AcceptsGame = true, RequiresGame = true,
+ AcceptsRetroDirectory = true
+ },
+ [AppMode.CheckProducts] = new ModeRules
+ {
+ Flag = "--check-products", AcceptsProgressJson = true, AcceptsRetroDirectory = true
+ },
+ [AppMode.RepairProducts] = new ModeRules
+ {
+ Flag = "--repair-products",
+ AcceptsProgressJson = true, AcceptsRetroDirectory = true, RequiresRetroDirectory = true,
+ AcceptsPayloadMode = true, RequiresInstallDirectory = true
+ },
+ [AppMode.LaunchBase] = new ModeRules { Flag = "--launch-base" },
+ [AppMode.LaunchRetro] = new ModeRules { Flag = "--launch-retro" },
+ [AppMode.Uninstall] = new ModeRules { Flag = "--uninstall", RequiresInstallDirectory = true },
+ [AppMode.SilentUninstall] = new ModeRules
+ {
+ Flag = "--silent-uninstall", RequiresInstallDirectory = true
+ },
+ [AppMode.UninstallWorker] = new ModeRules
+ {
+ Flag = "--uninstall-worker", RequiresInstallDirectory = true
+ },
+ [AppMode.EmitPayloadIdentities] = new ModeRules
+ {
+ Flag = "--emit-payload-identities", RequiresPayloadRoot = true
+ }
+ };
+
+ private void Validate()
+ {
+ var rules = Rules.GetValueOrDefault(Mode) ?? new ModeRules();
+
+ void Reject(bool present, string option)
+ {
+ if (present) throw new ArgumentException($"{option} is not valid with {rules.Flag}.");
+ }
+
+ Reject(GamePath is not null && !rules.AcceptsGame, "--game");
+ Reject(RetroDirectoryPath is not null && !rules.AcceptsRetroDirectory, "--retro-dir");
+ Reject(ProgressJson && !rules.AcceptsProgressJson, "--progress-json");
+ Reject(PayloadRootPath is not null && !rules.RequiresPayloadRoot, "--payload-root");
+ Reject(Portable && !rules.AcceptsPortable, "--portable");
+ Reject(RetroWfcPayloadMode != RetroWfcPayloadMode.NotApplicable && !rules.AcceptsPayloadMode,
+ "A Retro-WFC payload option");
+
+ if (rules.RequiresGame && string.IsNullOrWhiteSpace(GamePath))
+ throw new ArgumentException("--game is required.");
+ if (rules.RequiresRetroDirectory && string.IsNullOrWhiteSpace(RetroDirectoryPath))
+ throw new ArgumentException(
+ $"--retro-dir is required with {rules.Flag}. Supply Wheel Wizard's canonical Retro Rewind folder.");
+ if (rules.RequiresPayloadRoot && string.IsNullOrWhiteSpace(PayloadRootPath))
+ throw new ArgumentException($"--payload-root is required with {rules.Flag}.");
+
+ // A portable installation has no default location so the caller must state where that root is.
+ if (Portable && string.IsNullOrWhiteSpace(InstallDirectory))
+ throw new ArgumentException(
+ "--install-dir is required with --portable. Its parent folder becomes the portable root.");
+ if (Mode == AppMode.SilentInstall && string.IsNullOrWhiteSpace(InstallDirectory))
+ InstallDirectory = ProductInfo.DefaultInstallDirectory;
+ if (rules.RequiresInstallDirectory && string.IsNullOrWhiteSpace(InstallDirectory))
+ throw new ArgumentException($"--install-dir is required with {rules.Flag}.");
+
+ // The Retro Rewind source and a payload decision are a pair. a base-only operation
+ // has no payload to decide.
+ if (rules.AcceptsPayloadMode && RetroDirectoryPath is not null &&
+ RetroWfcPayloadMode == RetroWfcPayloadMode.NotApplicable)
+ throw new ArgumentException(
+ "Retro Rewind requires --download-retro-wfc-payload or --skip-retro-wfc-payload.");
+ if (RetroDirectoryPath is null && RetroWfcPayloadMode != RetroWfcPayloadMode.NotApplicable)
+ throw new ArgumentException(
+ "--retro-dir is required when selecting a Retro-WFC payload option.");
+ }
+
+ private static string RequireValue(string[] args, ref int index)
+ {
+ if (++index >= args.Length || string.IsNullOrWhiteSpace(args[index]))
+ throw new ArgumentException($"A value is required after {args[index - 1]}.");
+ return args[index];
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup/CompileInputsFingerprint.cs b/Launcher/WiiCompiled.Setup/CompileInputsFingerprint.cs
new file mode 100644
index 0000000..8b8dc0a
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/CompileInputsFingerprint.cs
@@ -0,0 +1,183 @@
+using System.Buffers.Binary;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace WiiCompiled.Setup;
+
+internal sealed record RetroRewindCompileInputs(
+ string RetroRewindRoot,
+ string CodePulSha256,
+ string CompileInputsSha256);
+
+internal static class CompileInputsFingerprint
+{
+ private const string FormatVersion = "mkwc-retro-compile-inputs-v3";
+
+ /// The exact directory name a build snapshot uses, because it is the mod root name
+ /// the translator turns into the product's relative DVD overlay root.
+ public const string PackageDirectoryName = "RetroRewind6";
+
+ public static RetroRewindCompileInputs Compute(string selectedDirectory,
+ CancellationToken cancellationToken = default)
+ {
+ var root = RetroRewindSource.ResolveRetroRewind6(selectedDirectory);
+ return new RetroRewindCompileInputs(root, ComputeCodePulSha256(root, cancellationToken),
+ ComputeCompileInputsSha256(root, cancellationToken));
+ }
+
+ public static string ComputeCodePulSha256(string retroRewindRoot,
+ CancellationToken cancellationToken = default)
+ {
+ var codePul = CodePulPath(Path.GetFullPath(retroRewindRoot));
+ if (!File.Exists(codePul)) throw MissingCodePul(retroRewindRoot);
+ cancellationToken.ThrowIfCancellationRequested();
+ return InputValidation.Sha256File(codePul);
+ }
+
+
+ /// Hashes only the translator inputs without relying on timestamps.
+ public static string ComputeCompileInputsSha256(string retroRewindRoot,
+ CancellationToken cancellationToken = default)
+ {
+ var root = Path.GetFullPath(retroRewindRoot);
+ if (!Directory.Exists(root))
+ throw new DirectoryNotFoundException($"The Retro Rewind folder is missing: {root}");
+ var codePul = CodePulPath(root);
+ if (!File.Exists(codePul)) throw MissingCodePul(root);
+
+ using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
+ AppendField(hash, FormatVersion);
+ AppendFile(hash, root, codePul, required: true, cancellationToken);
+
+ AppendTopology(hash, "topology:mod-root", root);
+ AppendTopology(hash, "topology:files", Path.Combine(root, "files"));
+
+ return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
+ }
+
+ ///
+ /// Copies just the compile inputs of a Wheel Wizard-owned Retro Rewind folder into
+ /// operation-owned scratch and proves the copy is a faithful snapshot. This is the Code.pul
+ ///
+ public static RetroRewindCompileInputs Snapshot(string selectedDirectory, string scratchDirectory,
+ CancellationToken cancellationToken = default)
+ {
+ var source = Compute(selectedDirectory, cancellationToken);
+ var destination = Path.Combine(Path.GetFullPath(scratchDirectory), PackageDirectoryName);
+ if (Directory.Exists(destination) || File.Exists(destination))
+ throw new IOException($"The compile-input snapshot destination already exists: {destination}");
+
+ CopyCompileInputs(source.RetroRewindRoot, destination, cancellationToken);
+ var snapshot = new RetroRewindCompileInputs(destination,
+ ComputeCodePulSha256(destination, cancellationToken),
+ ComputeCompileInputsSha256(destination, cancellationToken));
+ // A snapshot that hashes exactly like its source is that source; anything else means the
+ // folder was being written while it was read, which is the frontend's lease to hold.
+ if (!snapshot.CodePulSha256.Equals(source.CodePulSha256, StringComparison.OrdinalIgnoreCase) ||
+ !snapshot.CompileInputsSha256.Equals(source.CompileInputsSha256,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ throw new IOException(
+ "The Retro Rewind compile inputs changed while they were being copied. " +
+ "Wait for its update to finish and retry.");
+ }
+ return snapshot;
+ }
+
+ private static void CopyCompileInputs(string root, string destination,
+ CancellationToken cancellationToken)
+ {
+ Directory.CreateDirectory(destination);
+ CopyInput(root, destination, CodePulPath(root), required: true, cancellationToken);
+ ReproduceTopology(root, destination, "files", cancellationToken);
+ }
+
+ private static void CopyInput(string root, string destination, string path, bool required,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!File.Exists(path))
+ {
+ if (required) throw MissingCompileInput(path);
+ return;
+ }
+ RejectLink(path);
+ var output = Path.Combine(destination, RelativePath(root, path)
+ .Replace('/', Path.DirectorySeparatorChar));
+ Directory.CreateDirectory(Path.GetDirectoryName(output)!);
+ File.Copy(path, output, overwrite: true);
+ }
+
+
+ private static void ReproduceTopology(string root, string destination, string relative,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var source = Path.Combine(root, relative);
+ if (!File.Exists(source) && !Directory.Exists(source)) return;
+ RejectLink(source);
+ var output = Path.Combine(destination, relative);
+ if (Directory.Exists(source)) Directory.CreateDirectory(output);
+ else File.Copy(source, output, overwrite: true);
+ }
+
+ private static void AppendFile(IncrementalHash hash, string root, string path, bool required,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var relative = RelativePath(root, path);
+ if (!File.Exists(path))
+ {
+ if (required) throw MissingCompileInput(path);
+ AppendField(hash, "file:" + relative);
+ AppendField(hash, "absent");
+ return;
+ }
+
+ RejectLink(path);
+ AppendField(hash, "file:" + relative);
+ AppendField(hash, InputValidation.Sha256File(path));
+ }
+
+ private static void AppendTopology(IncrementalHash hash, string name, string path)
+ {
+ AppendField(hash, name);
+ if (!File.Exists(path) && !Directory.Exists(path))
+ {
+ AppendField(hash, "absent");
+ return;
+ }
+
+ var attributes = File.GetAttributes(path);
+ var kind = Directory.Exists(path) ? "directory" : "file";
+ if ((attributes & FileAttributes.ReparsePoint) != 0) kind += "+link";
+ AppendField(hash, kind);
+ }
+
+ private static void AppendField(IncrementalHash hash, string value)
+ {
+ var bytes = Encoding.UTF8.GetBytes(value);
+ Span length = stackalloc byte[sizeof(int)];
+ BinaryPrimitives.WriteInt32LittleEndian(length, bytes.Length);
+ hash.AppendData(length);
+ hash.AppendData(bytes);
+ }
+
+ private static string RelativePath(string root, string path) =>
+ Path.GetRelativePath(root, path).Replace('\\', '/');
+
+ private static string CodePulPath(string root) => Path.Combine(root, "Binaries", "Code.pul");
+
+ private static void RejectLink(string path)
+ {
+ if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0)
+ throw new InvalidDataException(
+ $"The Retro Rewind compile inputs contain a link instead of a regular entry: {path}");
+ }
+
+ private static InvalidDataException MissingCompileInput(string path) =>
+ new($"The Retro Rewind compile input is missing: {path}");
+
+ private static InvalidDataException MissingCodePul(string retroRewindRoot) =>
+ new($"The Retro Rewind folder is missing Binaries\\Code.pul: {retroRewindRoot}");
+}
diff --git a/Launcher/WiiCompiled.Setup/ConsoleCommands.cs b/Launcher/WiiCompiled.Setup/ConsoleCommands.cs
new file mode 100644
index 0000000..499b591
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/ConsoleCommands.cs
@@ -0,0 +1,328 @@
+using System.Text.Json;
+
+namespace WiiCompiled.Setup;
+
+internal static class ConsoleCommands
+{
+ public static int Help()
+ {
+ Console.Out.WriteLine($"{ProductInfo.Name} command-line setup {ProductInfo.Version}");
+ Console.Out.WriteLine("Wheel Wizard is the graphical interface for installing and launching WiiCompiled.");
+ Console.Out.WriteLine();
+ Console.Out.WriteLine("Commands:");
+ Console.Out.WriteLine(" --silent --game --install-dir [--retro-dir ] [--portable]");
+ Console.Out.WriteLine(" --verify-inputs --game [--retro-dir ]");
+ Console.Out.WriteLine(" --check-products [--install-dir ] [--retro-dir ] [--progress-json]");
+ Console.Out.WriteLine(" --repair-products --install-dir --retro-dir " +
+ "(--download-retro-wfc-payload | --skip-retro-wfc-payload) [--progress-json]");
+ Console.Out.WriteLine(" --launch-retro | --launch-base");
+ Console.Out.WriteLine(" --uninstall --install-dir ");
+ Console.Out.WriteLine(" --version");
+ return 0;
+ }
+
+ public static int Version()
+ {
+ Console.Out.WriteLine(ProductInfo.Version);
+ return 0;
+ }
+
+
+ public static int EmitPayloadIdentities(CommandLine command)
+ {
+ var root = Path.GetFullPath(command.PayloadRootPath!);
+ if (!Directory.Exists(InstalledLayout.Toolkit(root)) ||
+ !Directory.Exists(InstalledLayout.Workspace(root)))
+ {
+ throw new InvalidDataException($"{root} is not a staged payload root: expected " +
+ $"{InstalledLayout.ToolkitDirectoryName} and {InstalledLayout.WorkspaceDirectoryName} " +
+ "directories.");
+ }
+
+ var components = ToolkitFingerprint.ComputeComponents(root);
+ var identities = new PayloadIdentities(
+ components.Compile,
+ components.Translation,
+ components.NativeToolchain,
+ ToolkitFingerprint.ComputePackage(root),
+ ToolkitFingerprint.ComputeRuntimeAssets(root));
+ Console.Out.WriteLine(JsonSerializer.Serialize(identities));
+ return 0;
+ }
+
+ private sealed record PayloadIdentities(string ToolkitFingerprint, string TranslationFingerprint,
+ string NativeToolchainFingerprint, string ToolkitPackageFingerprint,
+ string RuntimeAssetsFingerprint);
+
+ public static int VerifyInputs(CommandLine command)
+ {
+ var reporter = command.ProgressJson ? new NdjsonInstallReporter() : null;
+ var temp = Path.Combine(Path.GetTempPath(), "mkwc-verify-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(temp);
+ try
+ {
+ using var payload = PayloadArchive.OpenCurrent();
+ var manifest = payload.ReadManifest();
+ var tool = Path.Combine(temp, "DolphinTool.exe");
+ payload.ExtractEntry(InstalledLayout.ToolkitEntryPrefix + "DolphinTool.exe", tool);
+ payload.ExtractDirectory(InstalledLayout.ToolkitEntryPrefix + "Redist", temp);
+ reporter?.Progress(InstallStages.Validate, "Checking the Wii disc image...", 10);
+ var header = InputValidation.ReadDiscHeaderAsync(tool, command.GamePath!).GetAwaiter().GetResult();
+ InputValidation.EnsureCompatibleDisc(header, manifest);
+
+ if (command.RetroDirectoryPath is not null)
+ {
+ reporter?.Progress(InstallStages.Validate, "Checking the Retro Rewind folder...", 80);
+ _ = CompileInputsFingerprint.Compute(command.RetroDirectoryPath);
+ }
+ reporter?.Success(command.InstallDirectory is null
+ ? ProductInfo.DefaultInstallDirectory
+ : Path.GetFullPath(command.InstallDirectory));
+ return 0;
+ }
+ catch (Exception ex) when (reporter is not null)
+ {
+ reporter.Failure(ex.Message);
+ Console.Error.WriteLine(ex);
+ return 1;
+ }
+ finally
+ {
+ reporter?.EnsureFinished("Input verification stopped before it reached a result.");
+ if (Directory.Exists(temp)) Directory.Delete(temp, recursive: true);
+ }
+ }
+
+ public static async Task Install(CommandLine command, CancellationToken cancellationToken = default)
+ {
+ var logPath = Path.Combine(Path.GetTempPath(), "WiiCompiled-setup.log");
+ var logLock = new object();
+ void Log(string message)
+ {
+ lock (logLock)
+ {
+ File.AppendAllText(logPath, $"[{DateTime.Now:O}] {message}{Environment.NewLine}");
+ }
+ }
+ File.WriteAllText(logPath, $"{ProductInfo.Name} setup {ProductInfo.Version}{Environment.NewLine}");
+
+ var ndjson = command.ProgressJson ? new NdjsonInstallReporter(Log) : null;
+ IInstallReporter reporter = ndjson ?? (IInstallReporter)new ConsoleInstallReporter(Log);
+ var installDirectory = Path.GetFullPath(command.InstallDirectory!);
+ try
+ {
+ var engine = new InstallerEngine(reporter);
+ await engine.InstallAsync(new InstallOptions
+ {
+ GamePath = command.GamePath!,
+ RetroDirectoryPath = command.RetroDirectoryPath,
+ RetroWfcPayloadMode = command.RetroWfcPayloadMode,
+ InstallDirectory = installDirectory,
+ Portable = command.Portable
+ }, cancellationToken);
+ ndjson?.Success(installDirectory);
+ return 0;
+ }
+ catch (Exception ex) when (ndjson is not null)
+ {
+ // The caller reads stdout as a protocol, so the failure has to arrive as the terminal
+ // result line; the exception detail belongs on stderr and in the setup log.
+ ndjson.Failure(ex.Message);
+ Console.Error.WriteLine(ex);
+ return 1;
+ }
+ finally
+ {
+ // Nothing - not a cancellation, not a process-level abort path - may leave the caller
+ // without the one line it waits for.
+ ndjson?.EnsureFinished("The installer stopped before it reached a result.");
+ }
+ }
+
+ /// Reports whether the installed products are current, without building, mutating, networking,
+ /// or walking the Retro Rewind asset tree. Exit code 2 means work is required, not a protocol failure.
+ public static int CheckProducts(CommandLine command,
+ CancellationToken cancellationToken = default)
+ {
+ var reporter = command.ProgressJson ? new NdjsonInstallReporter() : null;
+ var installation = new Installation(string.IsNullOrWhiteSpace(command.InstallDirectory)
+ ? AppContext.BaseDirectory
+ : Path.GetFullPath(command.InstallDirectory));
+ try
+ {
+ // The lock also performs exact journal/scratch recovery. Acquire it for absent and
+ // partial roots too: a killed first install can have recoverable state even when the
+ // product executable/state file has not reached the live directory yet.
+ using var operationLock = InstallOperationLock.Acquire(installation.Root, reporter);
+ PortableInstallHealing.HealMovedInstall(installation, reporter);
+ var report = InspectProducts(installation, command.RetroDirectoryPath,
+ cancellationToken: cancellationToken);
+ Console.Out.WriteLine(command.ProgressJson
+ ? SerializeProductsReport(report)
+ : $"base: {report.Base.Reason} {report.Base.Detail}".TrimEnd());
+ if (!command.ProgressJson)
+ Console.Out.WriteLine(
+ $"retro-rewind: {report.RetroRewind.Reason} {report.RetroRewind.Detail}".TrimEnd());
+ Console.Out.Flush();
+ reporter?.Success(installation.Root);
+ return report.RebuildRequired ? 2 : 0;
+ }
+ catch (Exception ex) when (reporter is not null)
+ {
+ reporter.Failure(ex.Message);
+ Console.Error.WriteLine(ex);
+ return 1;
+ }
+ finally
+ {
+ reporter?.EnsureFinished("The product check stopped before it reached a result.");
+ }
+ }
+
+ ///
+ /// Reconciles only the product work identified by a preceding health check. Every invocation
+ /// carries the canonical Retro Rewind folder and one payload option; the final inspection is
+ /// authoritative, so success is emitted only when every required product is current.
+ ///
+ public static async Task RepairProducts(CommandLine command,
+ CancellationToken cancellationToken = default)
+ {
+ var logPath = Path.Combine(Path.GetTempPath(), "WiiCompiled-repair.log");
+ var logLock = new object();
+ void Log(string message)
+ {
+ lock (logLock)
+ {
+ File.AppendAllText(logPath, $"[{DateTime.Now:O}] {message}{Environment.NewLine}");
+ }
+ }
+
+ var ndjson = command.ProgressJson ? new NdjsonInstallReporter(Log) : null;
+ IInstallReporter reporter = ndjson is null ? new ConsoleInstallReporter(Log) : ndjson;
+ var installDirectory = Path.GetFullPath(command.InstallDirectory!);
+ var installation = new Installation(installDirectory);
+ try
+ {
+ if (!installation.IsPresent)
+ throw new InvalidOperationException(
+ $"WiiCompiled is not installed at {installDirectory}.");
+
+ using var operationLock = InstallOperationLock.Acquire(installDirectory, reporter);
+ PortableInstallHealing.HealMovedInstall(installation, reporter);
+
+ cancellationToken.ThrowIfCancellationRequested();
+ var service = new ProductRepairService(installation, reporter);
+ using var scratch = InstallScratchSpace.CreateInsideInstall(installDirectory, reporter);
+ // Only compile inputs are copied out of the canonical installation, under the frontend's
+ // source lease. Its assets stay where they are and are read live at launch.
+ var canonicalRoot = RetroRewindSource.ResolveRetroRewind6(command.RetroDirectoryPath!);
+ var snapshot = CompileInputsFingerprint.Snapshot(canonicalRoot,
+ Path.Combine(scratch.Root, "compile-inputs"), cancellationToken);
+ var options = new ProductRepairService.ReconcileOptions
+ {
+ ScratchRoot = scratch.Root,
+ CanonicalRetroRewindRoot = canonicalRoot
+ };
+ var reconciliation = await service.RepairRetroAsync(snapshot, command.RetroWfcPayloadMode,
+ options, cancellationToken);
+ // ReconcileAsync is the completion barrier. Cancellation is observed throughout
+ // preparation and immediately before publication; a request racing with or following
+ // its uncancellable Publish/Commit must not turn committed state into a failure result.
+ // Reconciliation returns the exact payload-cache observation it proved or published, so
+ // terminal health does not hash the same payload a second time.
+ var after = InspectProducts(installation, snapshot,
+ reconciliation.CachedRetroWfcPayloadMatches);
+ if (after.RebuildRequired)
+ throw new InvalidOperationException(
+ "Product repair completed without producing a current installation: " +
+ after.ActionRequiredDetail);
+
+ ndjson?.Success(installDirectory);
+ return 0;
+ }
+ catch (Exception ex) when (ndjson is not null)
+ {
+ ndjson.Failure(ex is OperationCanceledException ? "Operation canceled." : ex.Message);
+ Console.Error.WriteLine(ex);
+ return 1;
+ }
+ finally
+ {
+ ndjson?.EnsureFinished("Product repair stopped before it reached a result.");
+ }
+ }
+
+ ///
+ /// The products record exactly as the v1 contract defines it: a typed status and a
+ /// human-readable detail per product, plus one aggregate. Nothing else is derived, because
+ /// nothing else is reported.
+ ///
+ internal sealed record ProductsReport(string InstallDirectory,
+ ProductState Base, ProductState RetroRewind)
+ {
+ public bool RebuildRequired => Base.ActionRequired || RetroRewind.ActionRequired;
+
+ public string ActionRequiredDetail => string.Join(" ",
+ new[] { Base, RetroRewind }
+ .Where(state => state.ActionRequired)
+ .Select(state => state.Detail)
+ .Where(detail => !string.IsNullOrWhiteSpace(detail)));
+ }
+
+ ///
+ /// Inspects both products against the canonical Retro Rewind installation: the caller's explicit
+ /// folder when it supplied one, otherwise the recorded retro_rewind_root.
+ ///
+ internal static ProductsReport InspectProducts(Installation installation,
+ string? canonicalRetroDirectory = null, bool? cachedRetroWfcPayloadMatches = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (!installation.IsPresent) return AbsentReport(installation);
+ var canonical = installation.ResolveCanonicalCompileInputs(canonicalRetroDirectory,
+ out var canonicalError, cancellationToken);
+ return BuildReport(installation, canonical, canonicalError, cachedRetroWfcPayloadMatches);
+ }
+
+ ///
+ /// Builds the report from a compile-input identity the operation already observed under its
+ /// lock, so a repair does not hash the same canonical inputs at every decision boundary.
+ ///
+ internal static ProductsReport InspectProducts(Installation installation,
+ RetroRewindCompileInputs canonical, bool? cachedRetroWfcPayloadMatches = null) =>
+ installation.IsPresent
+ ? BuildReport(installation, canonical, null, cachedRetroWfcPayloadMatches)
+ : AbsentReport(installation);
+
+ private static ProductsReport AbsentReport(Installation installation) =>
+ // Probing a path nothing was installed to is a valid answer. It must not create a directory
+ // or turn the frontend's first-install probe into a repair request.
+ new(installation.Root,
+ new ProductState(ProductStatus.Absent, "WiiCompiled is not installed here."),
+ new ProductState(ProductStatus.Absent, "Retro Rewind is not installed."));
+
+ private static ProductsReport BuildReport(Installation installation,
+ RetroRewindCompileInputs? canonical, string? canonicalError,
+ bool? cachedRetroWfcPayloadMatches)
+ {
+ var toolkitFingerprint = installation.ResolveToolkitFingerprint();
+ return new ProductsReport(installation.Root,
+ installation.CheckBase(toolkitFingerprint),
+ installation.CheckRetroRewind(toolkitFingerprint, canonical, canonicalError,
+ cachedRetroWfcPayloadMatches));
+ }
+
+ ///
+ /// Emits the stable products record. The frontend branches on status only, never
+ /// on the detail text, and an unrecognized status fails closed on its side.
+ ///
+ internal static string SerializeProductsReport(ProductsReport report) =>
+ System.Text.Json.JsonSerializer.Serialize(new
+ {
+ type = "products",
+ setupVersion = ProductInfo.Version,
+ installDir = report.InstallDirectory,
+ rebuildRequired = report.RebuildRequired,
+ @base = new { status = report.Base.Reason, detail = report.Base.Detail },
+ retroRewind = new { status = report.RetroRewind.Reason, detail = report.RetroRewind.Detail }
+ });
+}
diff --git a/Launcher/WiiCompiled.Setup/FileSystemUtilities.cs b/Launcher/WiiCompiled.Setup/FileSystemUtilities.cs
new file mode 100644
index 0000000..1b662f9
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/FileSystemUtilities.cs
@@ -0,0 +1,233 @@
+namespace WiiCompiled.Setup;
+
+///
+/// One entry of an exact regular directory tree. Directory topology is part of the content
+/// contract everywhere this walker is used: an empty directory can be a runtime-visible asset just
+/// as a regular file can be, so it must not disappear from a content identity or a staged copy.
+///
+internal sealed record RegularTreeEntry(string RelativePath, string FullPath, bool IsDirectory,
+ bool IsEmptyDirectory, long Length);
+
+internal static class FileSystemUtilities
+{
+ public static void CopyDirectory(string source, string destination,
+ CancellationToken cancellationToken = default)
+ {
+ Directory.CreateDirectory(destination);
+ foreach (var directory in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Directory.CreateDirectory(Path.Combine(destination, Path.GetRelativePath(source, directory)));
+ }
+ foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var output = Path.Combine(destination, Path.GetRelativePath(source, file));
+ Directory.CreateDirectory(Path.GetDirectoryName(output)!);
+ File.Copy(file, output, overwrite: true);
+ }
+ }
+
+ ///
+ /// Enumerates an exact tree without following links, sorted by forward-slash relative path.
+ /// Every directory appears exactly once and is flagged when it has no children at all.
+ ///
+ public static IReadOnlyList EnumerateRegularTree(string root,
+ CancellationToken cancellationToken = default, string description = "The directory tree")
+ {
+ var rootInfo = new DirectoryInfo(Path.GetFullPath(root));
+ if (!rootInfo.Exists)
+ throw new DirectoryNotFoundException($"{description} is missing: {rootInfo.FullName}");
+ RejectReparsePoint(rootInfo, description);
+
+ var entries = new List();
+ var pending = new Stack();
+ pending.Push(rootInfo);
+ while (pending.Count > 0)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var directory = pending.Pop();
+ var hasChild = false;
+ foreach (var entry in directory.EnumerateFileSystemInfos())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ RejectReparsePoint(entry, description);
+ hasChild = true;
+ switch (entry)
+ {
+ case DirectoryInfo child:
+ pending.Push(child);
+ break;
+ case FileInfo file:
+ entries.Add(new RegularTreeEntry(Relative(rootInfo, file.FullName), file.FullName,
+ false, false, file.Length));
+ break;
+ default:
+ throw new InvalidDataException(
+ $"{description} contains an unsupported file-system entry: {entry.FullName}");
+ }
+ }
+
+ if (directory != rootInfo)
+ entries.Add(new RegularTreeEntry(Relative(rootInfo, directory.FullName), directory.FullName,
+ true, !hasChild, 0));
+ }
+
+ entries.Sort((left, right) => StringComparer.Ordinal.Compare(left.RelativePath, right.RelativePath));
+ return entries;
+ }
+
+ /// Removes exactly this directory, clearing attributes that would refuse deletion.
+ public static void DeleteDirectoryIfExists(string path)
+ {
+ if (!Directory.Exists(path)) return;
+ foreach (var entry in Directory.EnumerateFileSystemEntries(path, "*", SearchOption.AllDirectories))
+ {
+ try { File.SetAttributes(entry, FileAttributes.Normal); }
+ catch { }
+ }
+ try { File.SetAttributes(path, FileAttributes.Normal); }
+ catch { }
+ Directory.Delete(path, recursive: true);
+ }
+
+ ///
+ /// One comparable spelling of a path. Normalization happens before the trailing separator is
+ /// dropped, because Path.GetFullPath("C:") is the current directory on that drive rather
+ /// than the drive root.
+ ///
+ public static string NormalizePath(string path) =>
+ Path.TrimEndingDirectorySeparator(Path.GetFullPath(path));
+
+ /// Whether two paths name the same location, after normalization.
+ public static bool PathsEqual(string left, string right) =>
+ NormalizePath(left).Equals(NormalizePath(right), StringComparison.OrdinalIgnoreCase);
+
+ /// Whether is or inside it, using a
+ /// normalized prefix check so C:\Games2 doesn't match inside C:\Games.
+ public static bool PathContains(string container, string candidate)
+ {
+ container = NormalizePath(container);
+ candidate = NormalizePath(candidate);
+ return candidate.Equals(container, StringComparison.OrdinalIgnoreCase) ||
+ candidate.StartsWith(container + Path.DirectorySeparatorChar,
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ /// Whether two paths are equal or one contains the other, after normalization.
+ public static bool PathsOverlap(string left, string right) =>
+ PathContains(left, right) || PathContains(right, left);
+
+ /// Location rules shared by install directories and portable roots (both are trees setup may
+ /// replace and uninstall may delete). only names what's being rejected.
+ public static void EnsureUsableLocation(string path, string subject)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ throw new InvalidOperationException($"{subject} is required.");
+ if (path.StartsWith(@"\\", StringComparison.Ordinal))
+ throw new InvalidOperationException($"{subject} must be on a local drive, not a network path.");
+ if (path.Length > MaximumLocationLength)
+ throw new InvalidOperationException(
+ $"{subject} path is too long. Choose a path shorter than {MaximumLocationLength} characters.");
+
+ var full = NormalizePath(path);
+ var drive = Path.GetPathRoot(full);
+ if (string.IsNullOrWhiteSpace(drive) ||
+ full.Equals(NormalizePath(drive), StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException(
+ $"{subject} must be a subfolder, not the root of a drive. " +
+ "Choose a dedicated folder such as D:\\Games\\WiiCompiled\\Install.");
+ }
+
+ var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
+ if (!string.IsNullOrEmpty(windows) && PathContains(windows, full))
+ throw new InvalidOperationException($"{subject} cannot be inside the Windows directory.");
+
+ foreach (var folder in ReservedLocations)
+ {
+ var reserved = Environment.GetFolderPath(folder);
+ if (string.IsNullOrEmpty(reserved)) continue;
+ if (full.Equals(NormalizePath(reserved), StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException(
+ $"{subject} cannot be {reserved}. Choose a dedicated folder.");
+ }
+ }
+
+ ///
+ /// The local build nests deep paths (llvm-mingw headers, CMake object directories) below the
+ /// installation, so the root itself has to stay well inside the classic path limit.
+ ///
+ private const int MaximumLocationLength = 180;
+
+ /// Well-known folders that must never be an installation or portable root.
+ private static readonly Environment.SpecialFolder[] ReservedLocations =
+ [
+ Environment.SpecialFolder.ProgramFiles,
+ Environment.SpecialFolder.ProgramFilesX86,
+ Environment.SpecialFolder.CommonProgramFiles,
+ Environment.SpecialFolder.System,
+ Environment.SpecialFolder.SystemX86,
+ Environment.SpecialFolder.UserProfile,
+ Environment.SpecialFolder.LocalApplicationData,
+ Environment.SpecialFolder.ApplicationData,
+ Environment.SpecialFolder.CommonApplicationData,
+ Environment.SpecialFolder.DesktopDirectory,
+ Environment.SpecialFolder.MyDocuments
+ ];
+
+ ///
+ /// Writes a file so that a reader only ever sees the old contents or the new ones: the bytes go
+ /// to a sibling temporary and are renamed over the destination, which is atomic on NTFS.
+ /// Interrupting the write leaves the temporary behind, never a truncated document.
+ ///
+ public static void WriteAtomic(string path, byte[] contents)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
+ var temporary = path + $".tmp-{Guid.NewGuid():N}";
+ try
+ {
+ File.WriteAllBytes(temporary, contents);
+ File.Move(temporary, path, overwrite: true);
+ }
+ finally
+ {
+ try { File.Delete(temporary); } catch { }
+ }
+ }
+
+ public static void WriteAtomic(string path, string contents) =>
+ WriteAtomic(path, System.Text.Encoding.UTF8.GetBytes(contents));
+
+ ///
+ /// Refuses an operation that cannot fit on the destination drive. The caller owns the allowance
+ /// it needs; this owns the one message and the one drive-readiness rule both paths report.
+ ///
+ public static void EnsureFreeSpace(string path, long required, string operationDescription)
+ {
+ var root = Path.GetPathRoot(path)
+ ?? throw new InvalidOperationException(
+ "The installation directory is not on a local drive.");
+ var drive = new DriveInfo(root);
+ if (!drive.IsReady)
+ throw new IOException($"The destination drive {root} is not ready.");
+ if (drive.AvailableFreeSpace < required)
+ {
+ throw new IOException(
+ $"Not enough free space on {root}. {operationDescription} needs approximately " +
+ $"{FormatGiB(required)} free, but only {FormatGiB(drive.AvailableFreeSpace)} is available.");
+ }
+ }
+
+ private static string FormatGiB(long bytes) => $"{bytes / (1024d * 1024 * 1024):0.0} GiB";
+
+ public static void RejectReparsePoint(FileSystemInfo entry, string description)
+ {
+ if ((entry.Attributes & FileAttributes.ReparsePoint) != 0)
+ throw new InvalidDataException(
+ $"{description} contains a reparse point instead of a regular entry: {entry.FullName}");
+ }
+
+ private static string Relative(DirectoryInfo root, string fullPath) =>
+ Path.GetRelativePath(root.FullName, fullPath).Replace('\\', '/');
+}
diff --git a/Launcher/WiiCompiled.Setup/GameLaunchService.cs b/Launcher/WiiCompiled.Setup/GameLaunchService.cs
new file mode 100644
index 0000000..58d10d0
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/GameLaunchService.cs
@@ -0,0 +1,64 @@
+using System.Diagnostics;
+
+namespace WiiCompiled.Setup;
+
+internal static class GameLaunchService
+{
+ public static Task LaunchAsync(BuildProfile profile)
+ {
+ var installation = new Installation(AppContext.BaseDirectory);
+ using var operation = InstallOperationLock.Acquire(installation.Root);
+ // A portable installation the user moved is reconciled before anything is read from its
+ // recorded location. Launch never needs the discarded native build tree.
+ PortableInstallHealing.HealMovedInstall(installation,
+ new DelegatingInstallReporter(Console.Error.WriteLine));
+ EnsureProductIsCurrent(installation, profile);
+ return Task.FromResult(LaunchInstalledProduct(installation, profile));
+ }
+
+ private static void EnsureProductIsCurrent(Installation installation, BuildProfile profile)
+ {
+ var state = InspectProductForLaunch(installation, profile);
+
+ if (state.Status == ProductStatus.Current) return;
+ if (state.Status == ProductStatus.Absent)
+ throw new InvalidDataException(
+ "Retro Rewind is not installed. Install Retro Rewind through Wheel Wizard first.");
+
+ throw new InvalidDataException(string.IsNullOrWhiteSpace(state.Detail)
+ ? "This product must be repaired through Wheel Wizard before it can be launched."
+ : state.Detail);
+ }
+
+ /// Validates the executable, inputs, build provenance, runtime assets, payload cache, and the
+ /// canonical Retro Rewind root, so a missing/invalid root fails launch instead of starting a vanilla-looking game.
+ internal static ProductState InspectProductForLaunch(Installation installation, BuildProfile profile)
+ {
+ var toolkitFingerprint = installation.ResolveToolkitFingerprint();
+ if (profile == BuildProfile.Base)
+ return installation.CheckBase(toolkitFingerprint);
+ var canonical = installation.ResolveCanonicalCompileInputs(null, out var canonicalError);
+ return installation.CheckRetroRewind(toolkitFingerprint, canonical, canonicalError);
+ }
+
+ private static int LaunchInstalledProduct(Installation installation, BuildProfile profile)
+ {
+ var runtimeRoot = profile == BuildProfile.Base ? installation.BaseDirectory : installation.RetroDirectory;
+ var runtime = profile == BuildProfile.Base ? installation.BaseExecutable : installation.RetroExecutable;
+ if (!File.Exists(runtime))
+ throw new FileNotFoundException("The installed recomp is missing. Run the installer again.", runtime);
+ if (!File.Exists(Path.Combine(installation.GameDataDirectory, "sys", "fst.bin")))
+ throw new InvalidDataException("The installed Mario Kart Wii game data is missing. Run the installer again.");
+
+ var info = new ProcessStartInfo
+ {
+ FileName = runtime,
+ WorkingDirectory = runtimeRoot,
+ UseShellExecute = false
+ };
+ using var process = Process.Start(info) ?? throw new InvalidOperationException("Could not start the recomp.");
+ process.WaitForExit();
+ return process.ExitCode;
+ }
+
+}
diff --git a/Launcher/WiiCompiled.Setup/InputValidation.cs b/Launcher/WiiCompiled.Setup/InputValidation.cs
new file mode 100644
index 0000000..854bd37
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/InputValidation.cs
@@ -0,0 +1,304 @@
+using System.Diagnostics;
+using System.Buffers.Binary;
+using System.Net;
+using System.Security.Cryptography;
+using System.Text.Json;
+
+namespace WiiCompiled.Setup;
+
+internal static class InputValidation
+{
+ private const long MaximumRetroWfcPayloadBytes = 16L * 1024 * 1024;
+ // The payload is tens of kilobytes from a single fixed endpoint 30s is good.
+ private static readonly TimeSpan RetroWfcDownloadTimeout = TimeSpan.FromSeconds(30);
+ private static readonly TimeSpan RetroWfcRetryDelay = TimeSpan.FromSeconds(1);
+ private static readonly HashSet SupportedDiscImageExtensions = new(
+ [".iso", ".gcm", ".gcz", ".ciso", ".wbfs", ".wia", ".rvz"],
+ StringComparer.OrdinalIgnoreCase);
+
+ public const string CurrentRetroWfcPayloadUri = "http://nas.play.rwfc.net/payload?g=RMCPD00";
+ private static readonly string RetroWfcOfflinePayloadFile =
+ Path.Combine("binary", "payload.RMCPD00.bin");
+
+ /// Retro-WFC production payload signing key (PROD PayloadPublicKey). Verifies
+ /// the payload past the 0x110 header; on-console stage1 pins the same key, so
+ /// rotating it upstream is a breaking release there too.
+ private static readonly byte[] RetroWfcPayloadSigningModulus = Convert.FromHexString(
+ "e6e6ce416f350422cbe26c36a67eba613dddcd27d79afd077dcc593e5319eaa6" +
+ "080293400033876d3dbdfda12c15f46ac8e4f5b40c56e7b5f67e91647d618cb9" +
+ "99c041581b86d103bd7723fceac03ad3ad5134bf611cd47dc527002596821e94" +
+ "1c9470938fea07238a84767323e4a610bd996465e59d04dae4febd915c96fc07" +
+ "39e4e818300829d78f3f2275e1f3fbd2507f1bde74f24a5285e61007b959a583" +
+ "b4820d75eca76680866efe5d79590b82c3577b796155899530e305b94b4ceef4" +
+ "428644b719df3d8540c9588f5bb02d83d3938255d1a1e073d3408163ff93a615" +
+ "a2106a03923a397aad6a29ebb43031ed06de1575c8ee2b54678fa059e025f455");
+
+ private const int RetroWfcPayloadSignedRegionOffset = 0x110;
+ private const int RetroWfcPayloadSignatureOffset = 0x10;
+ private const int RetroWfcPayloadMinimumBytes = 0x130;
+
+ public static void ValidateExtension(string gamePath)
+ {
+ if (!File.Exists(gamePath))
+ throw new FileNotFoundException("The selected game image does not exist.", gamePath);
+ var extension = Path.GetExtension(gamePath);
+ if (!SupportedDiscImageExtensions.Contains(extension))
+ throw new InvalidDataException(
+ "Select a complete Wii disc image in ISO, GCM, GCZ, CISO, WBFS, WIA, or RVZ format.");
+ }
+
+ public static async Task ReadDiscHeaderAsync(string dolphinTool, string gamePath,
+ CancellationToken cancellationToken = default)
+ {
+ ValidateExtension(gamePath);
+ var result = await ProcessRunner.RunAsync(dolphinTool,
+ ["header", "-i", Path.GetFullPath(gamePath), "-j"], null, cancellationToken);
+ if (result.ExitCode != 0)
+ throw new InvalidDataException("DolphinTool could not read this disc image. " + result.CombinedOutput.Trim());
+
+ var json = result.StandardOutput.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
+ .FirstOrDefault(line => line.TrimStart().StartsWith('{'));
+ if (json is null)
+ throw new InvalidDataException("DolphinTool did not return disc metadata.");
+ return JsonSerializer.Deserialize(json)
+ ?? throw new InvalidDataException("DolphinTool returned invalid disc metadata.");
+ }
+
+ public static void EnsureCompatibleDisc(DiscHeader header, PayloadManifest manifest)
+ {
+ if (!header.GameId.Equals(manifest.ExpectedGameId, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidDataException(
+ $"This build supports Mario Kart Wii PAL ({manifest.ExpectedGameId}). " +
+ $"The selected image is {header.GameId} ({header.InternalName}, {header.Region}).");
+ }
+ }
+
+ public static string ValidateStagedRetroWfcPayloadDirectory(string stagedDirectory,
+ RSAParameters? signingKey = null)
+ {
+ if (string.IsNullOrWhiteSpace(stagedDirectory))
+ throw new InvalidDataException("The staged Retro-WFC payload directory is missing.");
+
+ var root = Path.GetFullPath(stagedDirectory);
+ var payload = Path.Combine(root, RetroWfcOfflinePayloadFile);
+ if (!File.Exists(payload))
+ throw new InvalidDataException(
+ "The staged Retro-WFC payload directory does not contain binary\\payload.RMCPD00.bin.");
+ ValidateRetroWfcPayloadFile(payload, signingKey);
+ return root;
+ }
+
+ public static string ResolveRetroWfcPayloadFile(string stagedDirectory,
+ RSAParameters? signingKey = null) =>
+ Path.Combine(ValidateStagedRetroWfcPayloadDirectory(stagedDirectory, signingKey),
+ RetroWfcOfflinePayloadFile);
+
+ public static string ComputeRetroWfcPayloadSha256(string stagedDirectory,
+ RSAParameters? signingKey = null) =>
+ Sha256File(ResolveRetroWfcPayloadFile(stagedDirectory, signingKey));
+
+ public static void ValidateRetroWfcPayloadUri(string uriText)
+ {
+ if (!string.Equals(uriText, CurrentRetroWfcPayloadUri, StringComparison.Ordinal))
+ throw new InvalidDataException("The installer does not define the fixed Retro-WFC payload endpoint.");
+ }
+
+ public static async Task DownloadRetroWfcPayloadAsync(string uriText,
+ string destinationDirectory,
+ CancellationToken cancellationToken)
+ {
+ ValidateRetroWfcPayloadUri(uriText);
+ var uri = new Uri(uriText, UriKind.Absolute);
+
+ var root = Path.GetFullPath(destinationDirectory);
+ var destination = Path.Combine(root, RetroWfcOfflinePayloadFile);
+ Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
+ using var handler = new HttpClientHandler { AllowAutoRedirect = false };
+ using var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan };
+
+ for (var attempt = 1; attempt <= 2; attempt++)
+ {
+ try
+ {
+ return await DownloadRetroWfcPayloadAttemptAsync(client, uri, root, destination,
+ cancellationToken);
+ }
+ catch (Exception ex) when (attempt == 1 &&
+ IsTransientRetroWfcDownloadFailure(ex, cancellationToken))
+ {
+ await Task.Delay(RetroWfcRetryDelay, cancellationToken);
+ }
+ }
+
+ throw new InvalidOperationException("The Retro-WFC download retry loop ended unexpectedly.");
+ }
+
+ private static async Task DownloadRetroWfcPayloadAttemptAsync(
+ HttpClient client, Uri uri, string root, string destination, CancellationToken cancellationToken)
+ {
+ var temporary = destination + $".tmp-{Guid.NewGuid():N}";
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(RetroWfcDownloadTimeout);
+ var attemptToken = timeout.Token;
+ try
+ {
+ using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, attemptToken);
+ if ((int)response.StatusCode is >= 300 and < 400)
+ throw new InvalidDataException(
+ "The fixed Retro-WFC payload endpoint redirected; refusing to fetch from a different target.");
+ response.EnsureSuccessStatusCode();
+ if (response.Content.Headers.ContentLength is > MaximumRetroWfcPayloadBytes)
+ throw new InvalidDataException("The Retro-WFC payload is unexpectedly large.");
+ await using var input = await response.Content.ReadAsStreamAsync(attemptToken);
+ long total = 0;
+ string actualSha256;
+ await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write,
+ FileShare.None, 64 * 1024, FileOptions.Asynchronous))
+ {
+ var buffer = new byte[64 * 1024];
+ using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
+ while (true)
+ {
+ var read = await input.ReadAsync(buffer, attemptToken);
+ if (read == 0) break;
+ total = checked(total + read);
+ if (total > MaximumRetroWfcPayloadBytes)
+ throw new InvalidDataException("The Retro-WFC payload is unexpectedly large.");
+ hash.AppendData(buffer, 0, read);
+ await output.WriteAsync(buffer.AsMemory(0, read), attemptToken);
+ }
+ actualSha256 = Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
+ }
+ ValidateRetroWfcPayloadFile(temporary);
+ File.Move(temporary, destination, overwrite: true);
+ return new RetroWfcPayloadSnapshot(root, actualSha256, total);
+ }
+ catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
+ {
+ throw new TimeoutException(
+ $"The Retro-WFC payload download did not finish within {RetroWfcDownloadTimeout.TotalMinutes:0} minutes.",
+ ex);
+ }
+ finally
+ {
+ try { File.Delete(temporary); } catch { }
+ }
+ }
+
+ internal static bool IsTransientRetroWfcDownloadFailure(Exception exception,
+ CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested) return false;
+ if (exception is TimeoutException) return true;
+ if (exception is not HttpRequestException request) return false;
+ return request.StatusCode is null or HttpStatusCode.RequestTimeout or
+ HttpStatusCode.TooManyRequests ||
+ request.StatusCode is { } status && (int)status >= 500;
+ }
+
+ private static void ValidateRetroWfcPayloadFile(string payload, RSAParameters? signingKey = null)
+ {
+ byte[] image;
+ using (var stream = File.OpenRead(payload))
+ {
+ if (stream.Length > MaximumRetroWfcPayloadBytes)
+ throw new InvalidDataException("The Retro-WFC payload is unexpectedly large.");
+ if (stream.Length < RetroWfcPayloadMinimumBytes)
+ throw new InvalidDataException("The Retro-WFC payload has an invalid header.");
+ image = new byte[stream.Length];
+ stream.ReadExactly(image);
+ }
+
+ if (!image.AsSpan(0, 12).SequenceEqual("WWFC/Payload"u8))
+ throw new InvalidDataException("The Retro-WFC payload has an invalid header.");
+ var declaredSize = BinaryPrimitives.ReadUInt32BigEndian(image.AsSpan(0x0C));
+ if (declaredSize != image.Length)
+ throw new InvalidDataException(
+ $"The Retro-WFC payload declares {declaredSize} bytes but contains {image.Length}.");
+
+ using var rsa = RSA.Create();
+ rsa.ImportParameters(signingKey ?? new RSAParameters
+ {
+ Modulus = RetroWfcPayloadSigningModulus,
+ Exponent = [0x01, 0x00, 0x01],
+ });
+ if (!rsa.VerifyData(
+ image.AsSpan(RetroWfcPayloadSignedRegionOffset),
+ image.AsSpan(RetroWfcPayloadSignatureOffset,
+ RetroWfcPayloadSignedRegionOffset - RetroWfcPayloadSignatureOffset),
+ HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1))
+ throw new InvalidDataException(
+ "The Retro-WFC payload is not signed by the pinned Retro-WFC signing key.");
+ }
+
+ public static string Sha256File(string path)
+ {
+ using var stream = File.OpenRead(path);
+ return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
+ }
+}
+
+internal sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError)
+{
+ public string CombinedOutput => StandardOutput + Environment.NewLine + StandardError;
+}
+
+internal static class ProcessRunner
+{
+ /// Runs a redirected child process to completion. sets up a
+ /// working directory or scrubbed environment; is off for callers that only
+ /// forward output live, so a build's output isn't buffered in memory for nobody to read.
+ public static async Task RunAsync(string executable, IReadOnlyList arguments,
+ Action? output, CancellationToken cancellationToken,
+ Action? configure = null, bool capture = true,
+ Action? onTerminationFailure = null)
+ {
+ var info = new ProcessStartInfo
+ {
+ FileName = executable,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true
+ };
+ foreach (var argument in arguments) info.ArgumentList.Add(argument);
+ configure?.Invoke(info);
+
+ using var process = new Process { StartInfo = info, EnableRaisingEvents = true };
+ var stdout = new List();
+ var stderr = new List();
+ process.OutputDataReceived += (_, e) => { if (e.Data is not null) { if (capture) stdout.Add(e.Data); output?.Invoke(e.Data); } };
+ process.ErrorDataReceived += (_, e) => { if (e.Data is not null) { if (capture) stderr.Add(e.Data); output?.Invoke(e.Data); } };
+ if (!process.Start()) throw new InvalidOperationException($"Could not start {executable}.");
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+ await WaitForExitAsync(process, cancellationToken, onTerminationFailure);
+ return new ProcessResult(process.ExitCode, string.Join(Environment.NewLine, stdout),
+ string.Join(Environment.NewLine, stderr));
+ }
+
+ public static async Task WaitForExitAsync(Process process, CancellationToken cancellationToken,
+ Action? onTerminationFailure = null)
+ {
+ try
+ {
+ await process.WaitForExitAsync(cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ try
+ {
+ if (!process.HasExited) process.Kill(entireProcessTree: true);
+ }
+ catch (Exception ex)
+ {
+ onTerminationFailure?.Invoke(ex);
+ }
+ await process.WaitForExitAsync(CancellationToken.None);
+ process.WaitForExit();
+ throw;
+ }
+ process.WaitForExit();
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup/InstallOperationLock.cs b/Launcher/WiiCompiled.Setup/InstallOperationLock.cs
new file mode 100644
index 0000000..44f1c78
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/InstallOperationLock.cs
@@ -0,0 +1,86 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace WiiCompiled.Setup;
+
+///
+/// A fail-fast, cross-process lock covering install, repair and launch operations for one install
+/// root. The lock file lives beside the installation, so replacing the installation cannot replace
+/// or release the lock that protects it.
+///
+internal sealed class InstallOperationLock : IDisposable
+{
+ private readonly FileStream _stream;
+
+ private InstallOperationLock(FileStream stream) => _stream = stream;
+
+ public string LockPath => _stream.Name;
+
+ public static InstallOperationLock Acquire(string installDirectory, IInstallReporter? reporter = null)
+ {
+ var root = InstallOperationPaths.NormalizeRoot(installDirectory);
+ var parent = InstallOperationPaths.GetParent(root);
+ Directory.CreateDirectory(parent);
+ var path = InstallOperationPaths.GetLockPath(root);
+
+ FileStream stream;
+ try
+ {
+ stream = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None,
+ bufferSize: 1, FileOptions.WriteThrough);
+ }
+ catch (IOException ex)
+ {
+ throw new InvalidOperationException(
+ "Another WiiCompiled operation is already using this installation. " +
+ "Wait for the current game, install, or repair operation to finish.", ex);
+ }
+
+ try
+ {
+ InstallTransaction.Recover(root, reporter);
+ InstallScratchSpace.Recover(root, reporter);
+ return new InstallOperationLock(stream);
+ }
+ catch
+ {
+ stream.Dispose();
+ throw;
+ }
+ }
+
+ public void Dispose() => _stream.Dispose();
+}
+
+/// Deterministic paths shared by the operation lock and its recovery journal.
+internal static class InstallOperationPaths
+{
+ public static string NormalizeRoot(string installDirectory)
+ {
+ if (string.IsNullOrWhiteSpace(installDirectory))
+ throw new ArgumentException("The installation directory is required.", nameof(installDirectory));
+ return FileSystemUtilities.NormalizePath(installDirectory);
+ }
+
+ public static string GetParent(string normalizedRoot) =>
+ Directory.GetParent(normalizedRoot)?.FullName
+ ?? throw new InvalidOperationException("The installation directory must have a parent directory.");
+
+
+ public static string GetScopeId(string normalizedRoot) =>
+ HashId(16, normalizedRoot.ToUpperInvariant());
+
+ public static string HashId(int hexLength, params string[] canonicalParts) =>
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join('\n', canonicalParts))))
+ .ToLowerInvariant()[..hexLength];
+
+ public static string GetLockPath(string normalizedRoot) =>
+ Path.Combine(GetParent(normalizedRoot), $".mkwc-operation-{GetScopeId(normalizedRoot)}.lock");
+
+ public static string GetJournalPath(string normalizedRoot) =>
+ Path.Combine(GetParent(normalizedRoot), $".mkwc-transaction-{GetScopeId(normalizedRoot)}.json");
+
+ public static string GetWorkRoot(string normalizedRoot, Guid transactionId) =>
+ Path.Combine(GetParent(normalizedRoot),
+ $".mkwc-transaction-{GetScopeId(normalizedRoot)}-{transactionId:N}");
+}
diff --git a/Launcher/WiiCompiled.Setup/InstallProgress.cs b/Launcher/WiiCompiled.Setup/InstallProgress.cs
new file mode 100644
index 0000000..ac1e1fb
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/InstallProgress.cs
@@ -0,0 +1,235 @@
+using System.Text.Json;
+
+namespace WiiCompiled.Setup;
+
+///
+/// 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";
+}
+
+///
+/// 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);
+}
+
+/// Reports to a caller-supplied sink; used by console and maintenance commands.
+internal sealed class DelegatingInstallReporter : IInstallReporter
+{
+ private readonly Action _sink;
+
+ public DelegatingInstallReporter(Action sink) => _sink = sink;
+
+ public void Progress(string stage, string message, int percent) => _sink(message);
+ public void Diagnostic(string line) => _sink(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 Action? _log;
+ private readonly object _gate = new();
+ private int _lastPercent;
+ private bool _finished;
+
+ public NdjsonInstallReporter(Action? log = null) => _log = log;
+
+ 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 });
+ }
+ _log?.Invoke(message);
+ }
+
+ public void Diagnostic(string line)
+ {
+ Console.Error.WriteLine(line);
+ _log?.Invoke(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 });
+ }
+ _log?.Invoke("FAILED: " + 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 silent install without --progress-json.
+internal sealed class ConsoleInstallReporter : IInstallReporter
+{
+ private readonly Action? _log;
+
+ public ConsoleInstallReporter(Action? log = null) => _log = log;
+
+ public void Progress(string stage, string message, int percent)
+ {
+ Console.Out.WriteLine($"[{percent,3}%] {message}");
+ _log?.Invoke(message);
+ }
+
+ public void Diagnostic(string line)
+ {
+ Console.Out.WriteLine(line);
+ _log?.Invoke(line);
+ }
+}
+
+/// Build step identifiers from the bundled script's 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 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 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.
+///
+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.ReuseBaseTranslation, 0.30, "Reusing the completed base translation"),
+ (BuildStepIds.RetranslateBase, 0.05, "The base translation is stale; retranslating it"),
+ (BuildStepIds.TranslateBase, 0.08, "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 bundled 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 - 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.
+ _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)
+ {
+ _fraction = Math.Min(0.97, _fraction + 0.0015);
+ Emit();
+ }
+ }
+
+ 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/InstallScratchSpace.cs b/Launcher/WiiCompiled.Setup/InstallScratchSpace.cs
new file mode 100644
index 0000000..49aa053
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/InstallScratchSpace.cs
@@ -0,0 +1,81 @@
+namespace WiiCompiled.Setup;
+
+///
+/// Owns one temporary directory for an install operation. The name carries the installation's scope
+/// id, so the next locked operation can remove trees left by forced process termination without
+/// touching scratch belonging to another installation.
+///
+internal sealed class InstallScratchSpace : IDisposable
+{
+ private readonly IInstallReporter? _reporter;
+ private bool _disposed;
+
+ private InstallScratchSpace(string root, IInstallReporter? reporter)
+ {
+ Root = root;
+ _reporter = reporter;
+ }
+
+ public string Root { get; }
+
+ /// Creates staging beside the installation, suitable even when the install is absent.
+ public static InstallScratchSpace CreateSibling(string installDirectory, IInstallReporter? reporter = null) =>
+ Create(InstallOperationPaths.GetParent(InstallOperationPaths.NormalizeRoot(installDirectory)),
+ installDirectory, reporter);
+
+ /// Creates build/update output inside an existing installation.
+ public static InstallScratchSpace CreateInsideInstall(string installDirectory,
+ IInstallReporter? reporter = null)
+ {
+ var installRoot = InstallOperationPaths.NormalizeRoot(installDirectory);
+ if (!Directory.Exists(installRoot))
+ throw new DirectoryNotFoundException(
+ "An in-install scratch directory requires an existing installation root.");
+ return Create(installRoot, installDirectory, reporter);
+ }
+
+ /// Deletes scratch left by an interrupted operation. Call only while locked.
+ public static void Recover(string installDirectory, IInstallReporter? reporter = null)
+ {
+ var installRoot = InstallOperationPaths.NormalizeRoot(installDirectory);
+ foreach (var container in new[] { InstallOperationPaths.GetParent(installRoot), installRoot })
+ {
+ if (!Directory.Exists(container)) continue;
+ foreach (var leftover in Directory.EnumerateDirectories(container, Pattern(installRoot)))
+ {
+ reporter?.Diagnostic("Removing scratch space from an interrupted operation: " + leftover);
+ FileSystemUtilities.DeleteDirectoryIfExists(leftover);
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ try
+ {
+ FileSystemUtilities.DeleteDirectoryIfExists(Root);
+ }
+ catch (Exception ex)
+ {
+ // The next locked operation removes this exact tree. A transient AV/file-indexer handle
+ // must not turn an already committed installation into a reported failure.
+ _reporter?.Diagnostic("Scratch cleanup is pending and will be retried: " + ex.Message);
+ }
+ }
+
+ private static InstallScratchSpace Create(string container, string installDirectory,
+ IInstallReporter? reporter)
+ {
+ var installRoot = InstallOperationPaths.NormalizeRoot(installDirectory);
+ var root = Path.Combine(container,
+ $".mkwc-scratch-{InstallOperationPaths.GetScopeId(installRoot)}-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(root);
+ reporter?.Diagnostic("Prepared exact update scratch space at " + root);
+ return new InstallScratchSpace(root, reporter);
+ }
+
+ private static string Pattern(string installRoot) =>
+ $".mkwc-scratch-{InstallOperationPaths.GetScopeId(installRoot)}-*";
+}
diff --git a/Launcher/WiiCompiled.Setup/InstallTransaction.cs b/Launcher/WiiCompiled.Setup/InstallTransaction.cs
new file mode 100644
index 0000000..230050f
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/InstallTransaction.cs
@@ -0,0 +1,369 @@
+namespace WiiCompiled.Setup;
+
+internal enum InstallTransactionEntryKind
+{
+ Directory,
+ File
+}
+
+/// An exact prepared-path to live-path replacement made by an install transaction.
+internal sealed record InstallTransactionEntry(
+ string PreparedPath,
+ string DestinationPath,
+ InstallTransactionEntryKind Kind)
+{
+ public static InstallTransactionEntry Directory(string preparedPath, string destinationPath) =>
+ new(preparedPath, destinationPath, InstallTransactionEntryKind.Directory);
+
+ public static InstallTransactionEntry File(string preparedPath, string destinationPath) =>
+ new(preparedPath, destinationPath, InstallTransactionEntryKind.File);
+}
+
+///
+/// Publishes prepared files/directories as one recoverable operation. Destinations are journaled before the
+/// first move, so an interrupted operation rolls back on disposal or the next lock acquisition. Because every
+/// move is a same-volume rename, rollback needs no per-entry record, just backup/destination/prepared-path presence.
+///
+internal sealed class InstallTransaction : IDisposable
+{
+ // v3 renamed the journal's version field from "Schema" to "SchemaVersion", so that every
+ // persisted document in an installation spells it the same way. A journal only exists between
+ // the first move of an update and its commit, and a document from another version has always
+ // been rejected rather than interpreted, so the rename needs no read compatibility - it only
+ // needs to be visible as a version change.
+ private const int SchemaVersion = 3;
+
+ private readonly string _journalPath;
+ private readonly IInstallReporter? _reporter;
+ private readonly TransactionJournal _journal;
+ private bool _finished;
+
+ private InstallTransaction(string journalPath, TransactionJournal journal, IInstallReporter? reporter)
+ {
+ _journalPath = journalPath;
+ _journal = journal;
+ _reporter = reporter;
+ }
+
+ public static InstallTransaction Begin(string installDirectory, IInstallReporter? reporter = null,
+ params InstallTransactionEntry[] entries)
+ {
+ var root = InstallOperationPaths.NormalizeRoot(installDirectory);
+ if (entries is null || entries.Length == 0)
+ throw new ArgumentException("At least one publish entry is required.", nameof(entries));
+
+ var journalPath = InstallOperationPaths.GetJournalPath(root);
+ if (File.Exists(journalPath))
+ throw new InvalidOperationException(
+ "This installation has an unfinished transaction. Acquire the installation operation lock " +
+ "so it can recover before beginning another transaction.");
+
+ var transactionId = Guid.NewGuid();
+ var workRoot = InstallOperationPaths.GetWorkRoot(root, transactionId);
+ var journal = new TransactionJournal
+ {
+ SchemaVersion = SchemaVersion,
+ TransactionId = transactionId,
+ InstallRoot = root,
+ WorkRoot = workRoot,
+ State = TransactionState.Prepared,
+ Entries = NormalizeEntries(root, workRoot, entries)
+ };
+
+ Directory.CreateDirectory(Path.Combine(workRoot, "backup"));
+ JsonState.Write(journalPath, journal);
+ return new InstallTransaction(journalPath, journal, reporter);
+ }
+
+ /// Moves every old destination aside and publishes every prepared entry.
+ public void Publish()
+ {
+ EnsureActive(TransactionState.Prepared);
+ try
+ {
+ foreach (var entry in _journal.Entries)
+ {
+ if (entry.OriginalExisted)
+ Move(entry.DestinationPath, entry.BackupPath, entry.Kind);
+ Move(entry.PreparedPath, entry.DestinationPath, entry.Kind);
+ }
+
+ _journal.State = TransactionState.Published;
+ WriteJournal();
+ }
+ catch (Exception publishFailure)
+ {
+ try
+ {
+ RollBack();
+ }
+ catch (Exception rollbackFailure)
+ {
+ throw new AggregateException(
+ "Publishing failed and the previous installation could not be fully restored. " +
+ "The recovery journal was retained for the next operation.",
+ publishFailure, rollbackFailure);
+ }
+ throw;
+ }
+ }
+
+ ///
+ /// Durably records the runtime configuration preimage before the caller mutates it. Recovery of
+ /// any uncommitted transaction restores this exact preimage; committed transactions retain the
+ /// new configuration. Call after and before the first config write.
+ ///
+ public void RecordRuntimeConfigurationMutation(RuntimeConfigSnapshot snapshot)
+ {
+ EnsureActive(TransactionState.Published);
+ _journal.RuntimeConfigExisted = snapshot.Existed;
+ _journal.RuntimeConfigContents = snapshot.Contents;
+ _journal.RuntimeConfigMutationPlanned = true;
+ WriteJournal();
+ }
+
+ ///
+ /// Makes the published paths authoritative. Cleanup failure is non-fatal: the committed journal
+ /// makes the next lock acquisition finish deleting only this transaction's exact backup paths.
+ ///
+ public void Commit()
+ {
+ EnsureActive(TransactionState.Published);
+ _journal.State = TransactionState.Committed;
+ WriteJournal();
+ _finished = true;
+ TryCleanCommitted(_journalPath, _journal, _reporter);
+ }
+
+ public void Dispose()
+ {
+ if (_finished) return;
+ RollBack();
+ }
+
+ /// Recovers the one deterministic journal for an installation. Call only while locked.
+ public static void Recover(string installDirectory, IInstallReporter? reporter = null)
+ {
+ var root = InstallOperationPaths.NormalizeRoot(installDirectory);
+ var journalPath = InstallOperationPaths.GetJournalPath(root);
+ if (!File.Exists(journalPath)) return;
+
+ var journal = JsonState.TryRead(journalPath)
+ ?? throw new InvalidDataException(
+ $"The interrupted-install journal is unreadable: {journalPath}");
+ ValidateJournal(root, journal);
+
+ if (journal.State is TransactionState.Committed or TransactionState.RolledBack)
+ {
+ reporter?.Diagnostic("Finishing cleanup from the previous completed update operation...");
+ TryCleanCommitted(journalPath, journal, reporter);
+ if (File.Exists(journalPath))
+ throw new IOException("The previous committed update's exact backup could not be removed.");
+ return;
+ }
+
+ reporter?.Diagnostic("Restoring the installation after an interrupted update...");
+ new InstallTransaction(journalPath, journal, reporter).RollBack();
+ }
+
+ private void RollBack()
+ {
+ if (_finished) return;
+ ValidateJournal(InstallOperationPaths.NormalizeRoot(_journal.InstallRoot), _journal);
+
+ foreach (var entry in _journal.Entries.AsEnumerable().Reverse())
+ RollBackEntry(entry);
+
+ if (_journal.RuntimeConfigMutationPlanned)
+ {
+ // The journal records the installation root, which is what decides whether this
+ // installation's configuration lives in its portable root or in per-user data.
+ RuntimeConfiguration.Restore(
+ RuntimeConfiguration.ResolveConfigPath(_journal.InstallRoot),
+ new RuntimeConfigSnapshot(_journal.RuntimeConfigExisted,
+ _journal.RuntimeConfigContents ?? []));
+ }
+
+ _journal.State = TransactionState.RolledBack;
+ WriteJournal();
+ _finished = true;
+ FileSystemUtilities.DeleteDirectoryIfExists(_journal.WorkRoot);
+ File.Delete(_journalPath);
+ }
+
+ private static void RollBackEntry(TransactionJournalEntry entry)
+ {
+ var destinationExists = Exists(entry.DestinationPath, entry.Kind);
+ if (Exists(entry.BackupPath, entry.Kind))
+ {
+ if (destinationExists) Delete(entry.DestinationPath, entry.Kind);
+ Move(entry.BackupPath, entry.DestinationPath, entry.Kind);
+ return;
+ }
+
+ // No backup: either the original was never moved aside, or it has already been restored.
+ // Both leave the original in place, so only a vanished original is a failure.
+ if (entry.OriginalExisted)
+ {
+ if (!destinationExists)
+ throw new IOException($"The transaction backup is missing for {entry.DestinationPath}.");
+ return;
+ }
+
+ if (destinationExists && Exists(entry.PreparedPath, entry.Kind))
+ throw new IOException(
+ $"Both prepared and published paths exist for {entry.DestinationPath}; recovery is ambiguous.");
+ if (destinationExists) Delete(entry.DestinationPath, entry.Kind);
+ }
+
+ private static List NormalizeEntries(string root, string workRoot,
+ IReadOnlyList entries)
+ {
+ var normalized = new List(entries.Count);
+ for (var index = 0; index < entries.Count; index++)
+ {
+ var requested = entries[index];
+ var prepared = Path.GetFullPath(requested.PreparedPath);
+ var destination = Path.GetFullPath(requested.DestinationPath);
+ EnsureDestinationInScope(root, destination);
+ if (FileSystemUtilities.PathsOverlap(prepared, destination))
+ throw new InvalidOperationException("A prepared path and its destination must not overlap.");
+
+ if (!Exists(prepared, requested.Kind))
+ throw new FileNotFoundException("A prepared transaction entry is missing.", prepared);
+ if (ExistsAsOtherKind(prepared, requested.Kind))
+ throw new InvalidDataException($"The prepared path has the wrong entry type: {prepared}");
+ if (ExistsAsOtherKind(destination, requested.Kind))
+ throw new InvalidDataException($"The destination has the wrong entry type: {destination}");
+
+ foreach (var previous in normalized)
+ {
+ if (FileSystemUtilities.PathsOverlap(destination, previous.DestinationPath))
+ throw new InvalidOperationException("Transaction destinations must not overlap.");
+ if (FileSystemUtilities.PathsOverlap(prepared, previous.PreparedPath))
+ throw new InvalidOperationException("Transaction prepared paths must not overlap.");
+ if (FileSystemUtilities.PathsOverlap(prepared, previous.DestinationPath) ||
+ FileSystemUtilities.PathsOverlap(destination, previous.PreparedPath))
+ throw new InvalidOperationException("Transaction sources and destinations must not overlap.");
+ }
+
+ normalized.Add(new TransactionJournalEntry
+ {
+ PreparedPath = prepared,
+ DestinationPath = destination,
+ BackupPath = Path.Combine(workRoot, "backup", index.ToString("D4")),
+ Kind = requested.Kind,
+ OriginalExisted = Exists(destination, requested.Kind)
+ });
+ }
+ return normalized;
+ }
+
+ private static void ValidateJournal(string expectedRoot, TransactionJournal journal)
+ {
+ if (journal.SchemaVersion != SchemaVersion || journal.TransactionId == Guid.Empty)
+ throw new InvalidDataException("The interrupted-install journal has an unsupported format.");
+
+ var root = InstallOperationPaths.NormalizeRoot(journal.InstallRoot);
+ if (!root.Equals(expectedRoot, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidDataException("The interrupted-install journal belongs to another installation.");
+
+ var expectedWorkRoot = InstallOperationPaths.GetWorkRoot(root, journal.TransactionId);
+ if (!Path.GetFullPath(journal.WorkRoot).Equals(expectedWorkRoot, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidDataException("The interrupted-install journal has an invalid work directory.");
+ if (journal.Entries.Count == 0 || journal.Entries.Count > 64)
+ throw new InvalidDataException("The interrupted-install journal has an invalid entry count.");
+
+ for (var index = 0; index < journal.Entries.Count; index++)
+ {
+ var entry = journal.Entries[index];
+ EnsureDestinationInScope(root, Path.GetFullPath(entry.DestinationPath));
+ var expectedBackup = Path.Combine(expectedWorkRoot, "backup", index.ToString("D4"));
+ if (!Path.GetFullPath(entry.BackupPath).Equals(expectedBackup, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidDataException("The interrupted-install journal has an invalid backup path.");
+ }
+ }
+
+ private static void EnsureDestinationInScope(string root, string destination)
+ {
+ if (!FileSystemUtilities.PathContains(root, destination))
+ throw new InvalidOperationException("A transaction destination must be inside its installation root.");
+ }
+
+ private void EnsureActive(TransactionState expected)
+ {
+ if (_finished || _journal.State != expected)
+ throw new InvalidOperationException("The install transaction is not in the required state.");
+ }
+
+ private void WriteJournal() => JsonState.Write(_journalPath, _journal);
+
+ private static bool Exists(string path, InstallTransactionEntryKind kind) =>
+ kind == InstallTransactionEntryKind.Directory ? Directory.Exists(path) : File.Exists(path);
+
+ private static bool ExistsAsOtherKind(string path, InstallTransactionEntryKind kind) =>
+ kind == InstallTransactionEntryKind.Directory ? File.Exists(path) : Directory.Exists(path);
+
+ private static void Move(string source, string destination, InstallTransactionEntryKind kind)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
+ if (kind == InstallTransactionEntryKind.Directory) Directory.Move(source, destination);
+ else File.Move(source, destination);
+ }
+
+ private static void Delete(string path, InstallTransactionEntryKind kind)
+ {
+ if (kind == InstallTransactionEntryKind.Directory) FileSystemUtilities.DeleteDirectoryIfExists(path);
+ else File.Delete(path);
+ }
+
+ private static void TryCleanCommitted(string journalPath, TransactionJournal journal,
+ IInstallReporter? reporter)
+ {
+ try
+ {
+ FileSystemUtilities.DeleteDirectoryIfExists(journal.WorkRoot);
+ File.Delete(journalPath);
+ }
+ catch (Exception ex)
+ {
+ reporter?.Diagnostic(
+ "The update committed successfully, but its exact previous-version backup could not yet be " +
+ "removed: " + ex.Message);
+ }
+ }
+
+ private sealed class TransactionJournal
+ {
+ public TransactionJournal() { }
+
+ public int SchemaVersion { get; set; }
+ public Guid TransactionId { get; set; }
+ public string InstallRoot { get; set; } = "";
+ public string WorkRoot { get; set; } = "";
+ public TransactionState State { get; set; }
+ public List Entries { get; set; } = [];
+ public bool RuntimeConfigMutationPlanned { get; set; }
+ public bool RuntimeConfigExisted { get; set; }
+ public byte[]? RuntimeConfigContents { get; set; }
+ }
+
+ private sealed class TransactionJournalEntry
+ {
+ public TransactionJournalEntry() { }
+
+ public string PreparedPath { get; set; } = "";
+ public string DestinationPath { get; set; } = "";
+ public string BackupPath { get; set; } = "";
+ public InstallTransactionEntryKind Kind { get; set; }
+ public bool OriginalExisted { get; set; }
+ }
+
+ private enum TransactionState
+ {
+ Prepared,
+ Published,
+ Committed,
+ RolledBack
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup/Installation.cs b/Launcher/WiiCompiled.Setup/Installation.cs
new file mode 100644
index 0000000..0b621ff
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/Installation.cs
@@ -0,0 +1,591 @@
+namespace WiiCompiled.Setup;
+
+/// Provenance written by the bundled build script next to every product it produces.
+internal sealed class LocalBuildProvenance
+{
+ public int SchemaVersion { get; set; }
+ public string Profile { get; set; } = "";
+ public string DolSha256 { get; set; } = "";
+ public string RelSha256 { get; set; } = "";
+ public string? CodePulSha256 { get; set; }
+ public string? RetroWfcPayloadMode { get; set; }
+ public string? RetroWfcPayloadSha256 { get; set; }
+ public long? RetroWfcPayloadLength { get; set; }
+ public string Compiler { get; set; } = "";
+
+ public const string FileName = "local-build.json";
+}
+
+///
+/// The typed product status of the --check-products contract. These are the only nine answers
+/// the backend gives; see docs/WHEELWIZARD_CONTRACT.md. A frontend branches on the status alone.
+///
+internal enum ProductStatus
+{
+ Absent,
+
+ /// The binary matches the installed toolkit and the canonical compile inputs.
+ Current,
+
+ /// The toolkit that produced the binary is no longer the installed toolkit.
+ ToolkitChanged,
+
+ /// The canonical Code.pul is not the one this binary statically embeds.
+ CodePulChanged,
+
+ /// Another translator-consumed Retro Rewind input changed.
+ CompileInputsChanged,
+
+ /// Only the cached Retro-WFC payload needs repair; no compilation.
+ PayloadChanged,
+
+ ///
+ /// The canonical Retro Rewind installation is missing or structurally invalid, so the product
+ /// can neither be trusted nor rebuilt until the frontend supplies it.
+ ///
+ InputsMissing,
+
+ ///
+ /// The product exists, but the installation cannot prove its provenance or provide the required
+ /// toolkit. A caller must repair the installation before launching it.
+ ///
+ Blocked,
+
+ /// The binary or the support files it needs are missing or unusable.
+ Broken
+}
+
+///
+/// The result of a non-mutating product inspection. Status and detail are exactly what the wire
+/// carries: nothing is derived that is not also reported.
+///
+internal sealed record ProductState(ProductStatus Status, string Detail)
+{
+ /// The stable machine-readable status string of the command-line contract.
+ public string Reason => Status switch
+ {
+ ProductStatus.Absent => "absent",
+ ProductStatus.Current => "current",
+ ProductStatus.ToolkitChanged => "toolkit-changed",
+ ProductStatus.CodePulChanged => "code-pul-changed",
+ ProductStatus.CompileInputsChanged => "compile-inputs-changed",
+ ProductStatus.PayloadChanged => "payload-changed",
+ ProductStatus.InputsMissing => "inputs-missing",
+ ProductStatus.Blocked => "blocked",
+ _ => "broken"
+ };
+
+ /// Whether a caller must do something before this product may be launched.
+ public bool ActionRequired => Status is not ProductStatus.Current and not ProductStatus.Absent;
+}
+
+///
+/// The on-disk shape of an installation, plus the cheap checks a launch performs before starting a
+/// binary. Everything here is O(1), a single file hash, or the compile-input identity of the
+/// canonical Retro Rewind installation; the multi-gigabyte asset tree is never walked.
+///
+internal sealed class Installation
+{
+ public Installation(string root) => Root = FileSystemUtilities.NormalizePath(root);
+
+ public string Root { get; }
+ public string ToolkitDirectory => InstalledLayout.Toolkit(Root);
+ public string WorkspaceDirectory => InstalledLayout.Workspace(Root);
+ public string WorkspaceAssetsDirectory => Path.Combine(WorkspaceDirectory, "Assets");
+ public string WorkspaceRetroWfcPayload => Path.Combine(WorkspaceAssetsDirectory, "OfflinePayload");
+ public string BaseDirectory => Path.Combine(Root, "Base");
+ public string BaseExecutable => Path.Combine(BaseDirectory, "WiiCompiled.exe");
+ public string RetroDirectory => Path.Combine(Root, "RetroRewind");
+ public string RetroExecutable => Path.Combine(RetroDirectory, "RetroRewind.exe");
+ public string GameDataDirectory => Path.Combine(Root, "GameAssets", "DATA");
+ public string InstallStatePath => Path.Combine(Root, InstalledLayout.InstallStateFileName);
+ public string ToolkitStatePath => Path.Combine(Root, ToolkitState.FileName);
+
+ /// The portable root this installation lives in, or null for an ordinary installation.
+ public string? PortableRootDirectory => PortableRoot.TryFind(Root);
+
+ ///
+ /// The runtime configuration this installation reads: <portable root>\UserData\Config.toml
+ /// when portable, otherwise the shared per-user file.
+ ///
+ public string ConfigPath => RuntimeConfiguration.ResolveConfigPath(Root);
+
+ /// The install directory copy of setup that acts as the CLI launcher.
+ public string SetupCopyPath => Path.Combine(Root, ProductInfo.SetupCopyName);
+
+ private const string Toolkit = InstalledLayout.ToolkitDirectoryName + "\\";
+ private const string Workspace = InstalledLayout.WorkspaceDirectoryName + "\\";
+
+ private static readonly string[] RequiredToolkitFiles =
+ [
+ Toolkit + @"Translator\Translator.Cli.exe",
+ Toolkit + @"CMake\bin\cmake.exe",
+ Toolkit + @"Ninja\ninja.exe",
+ Toolkit + @"llvm-mingw\bin\clang-22.exe",
+ Toolkit + @"llvm-mingw\bin\ld.lld.exe",
+ Toolkit + @"llvm-mingw\bin\x86_64-w64-mingw32-clang.exe",
+ Toolkit + @"llvm-mingw\bin\x86_64-w64-mingw32-clang++.exe",
+ Toolkit + @"llvm-mingw\bin\x86_64-w64-mingw32-windres.exe",
+ Workspace + "LocalBuild.ps1",
+ Workspace + "NativeBuildFlags.ps1",
+ Workspace + @"projects\mkwii\recomp.yml",
+ Workspace + @"runtime\CMakeLists.txt",
+ Workspace + @"runtime\assets\dsp\dsp_coef.bin"
+ ];
+
+ // These are the source trees and pinned dependency roots LocalBuild.ps1 consumes during every
+ // native configure/build. Presence is intentionally a cheap structural test, not another full
+ // toolkit hash on every status check. The dependency names come from the one shipped list, so a
+ // dependency added to the payload cannot be missed here.
+ private static readonly string[] RequiredNonEmptyToolkitDirectories =
+ [
+ Workspace + @"runtime\src",
+ Workspace + @"runtime\assets\wii",
+ Workspace + "aurora-main",
+ .. InstalledLayout.DependencyNames.Select(name => Workspace + @"Dependencies\" + name)
+ ];
+
+ public bool Exists => File.Exists(InstallStatePath);
+
+ ///
+ /// Whether this directory holds an installation at all. A missing or empty directory is not a
+ /// broken installation, it is simply nothing - a frontend probing a path it has not installed to
+ /// yet must get "absent", not an error.
+ ///
+ public bool IsPresent => Directory.Exists(Root) &&
+ (Exists || File.Exists(BaseExecutable) || File.Exists(RetroExecutable));
+ ///
+ /// A cheap structural check for the programs and workspace inputs LocalBuild.ps1 actually
+ /// invokes. Product inspection intentionally does not rehash the full toolkit every time, but
+ /// it must not claim a product current when its compiler, linker, or native runtime is gone.
+ ///
+ public bool HasToolkit => RequiredToolkitFiles.All(relative => File.Exists(Path.Combine(Root, relative))) &&
+ RequiredNonEmptyToolkitDirectories.All(relative =>
+ IsNonEmptyRegularDirectory(Path.Combine(Root, relative)));
+ public bool HasRetroProduct => File.Exists(RetroExecutable);
+
+ public InstallState? ReadInstallState() => JsonState.TryRead(InstallStatePath);
+
+ internal ToolkitState? ReadToolkitState() => JsonState.TryRead(ToolkitStatePath);
+
+ ///
+ /// The identity of the installed toolkit. Inspection never writes or adopts state: a missing
+ /// toolkit provenance record must not silently make an existing product look trustworthy.
+ ///
+ public string ResolveToolkitFingerprint()
+ {
+ var state = ReadToolkitState();
+ if (state is { SchemaVersion: 2 } && !string.IsNullOrEmpty(state.ToolkitFingerprint) &&
+ !string.IsNullOrEmpty(state.ToolkitPackageFingerprint) && HasToolkit)
+ return state.ToolkitFingerprint;
+
+ // Do not derive a replacement identity from whatever happens to be on disk. A missing or
+ // unsupported toolkit-state.json has no authoritative chain to the installed products and
+ // is therefore blocked until setup repairs it.
+ return "";
+ }
+
+ /// The recorded package identity of the installed Toolkit directory, under the same
+ /// fail-closed rules as .
+ public string ResolveToolkitPackageFingerprint()
+ {
+ var state = ReadToolkitState();
+ if (state is { SchemaVersion: 2 } && !string.IsNullOrEmpty(state.ToolkitFingerprint) &&
+ !string.IsNullOrEmpty(state.ToolkitPackageFingerprint) && HasToolkit)
+ return state.ToolkitPackageFingerprint;
+
+ return "";
+ }
+
+ public void WriteToolkitState(string fingerprint, string packageFingerprint, string releaseTag,
+ string runtimeAssetsFingerprint = "", string translationFingerprint = "",
+ string nativeToolchainFingerprint = "") =>
+ JsonState.Write(ToolkitStatePath, new ToolkitState
+ {
+ ToolkitFingerprint = fingerprint,
+ ToolkitPackageFingerprint = packageFingerprint,
+ RuntimeAssetsFingerprint = runtimeAssetsFingerprint,
+ TranslationFingerprint = translationFingerprint,
+ NativeToolchainFingerprint = nativeToolchainFingerprint,
+ ToolkitReleaseTag = releaseTag
+ });
+
+ ///
+ /// Reads the complete product provenance. A product without the current fingerprint schema is
+ /// intentionally untrusted rather than guessed from an older, partial provenance document.
+ ///
+ public ProductFingerprint? ReadProductFingerprint(string productDirectory, string toolkitFingerprint)
+ {
+ var recorded = JsonState.TryRead(
+ Path.Combine(productDirectory, ProductFingerprint.FileName));
+ return recorded is { SchemaVersion: 1 } ? recorded : null;
+ }
+
+ ///
+ /// The canonical Retro Rewind install to compare against: explicit --retro-dir if given, else the
+ /// recorded retro_rewind_root. Reads only compile inputs, so it stays cheap for every status check and launch.
+ ///
+ internal RetroRewindCompileInputs? ResolveCanonicalCompileInputs(string? explicitDirectory,
+ out string? error, CancellationToken cancellationToken = default)
+ {
+ error = null;
+ var selected = string.IsNullOrWhiteSpace(explicitDirectory)
+ ? ConfiguredRetroRewindRoot
+ : explicitDirectory;
+ if (string.IsNullOrWhiteSpace(selected))
+ {
+ if (HasRetroProduct || ReadInstallState()?.RetroRewindInstalled == true)
+ error = "No canonical Retro Rewind folder is recorded for this installation. " +
+ "Repair it with Wheel Wizard's current Retro Rewind folder.";
+ return null;
+ }
+
+ try
+ {
+ return CompileInputsFingerprint.Compute(selected, cancellationToken);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException
+ or ArgumentException)
+ {
+ error = "The canonical Retro Rewind folder cannot be read: " + ex.Message;
+ return null;
+ }
+ }
+
+ /// The canonical Retro Rewind root the runtime reads its assets from, if one is set.
+ public string? ConfiguredRetroRewindRoot => RuntimeConfiguration.GetRetroRewindRoot(ConfigPath);
+
+ public ProductState CheckBase(string toolkitFingerprint)
+ {
+ var state = CheckBaseCore(toolkitFingerprint);
+ if (state.Status != ProductStatus.Current) return state;
+ var runtimeAssetsError = ValidateCopiedRuntimeAssets(BaseDirectory, "base", toolkitFingerprint);
+ return runtimeAssetsError is null ? state : new ProductState(ProductStatus.Broken, runtimeAssetsError);
+ }
+
+ ///
+ /// Everything about the base product except its copied support files. Repair inspects those
+ /// separately because they are republished from the installed workspace without recompiling.
+ ///
+ internal ProductState CheckBaseCore(string toolkitFingerprint)
+ {
+ if (!IsUsableExecutable(BaseExecutable))
+ return new ProductState(ProductStatus.Broken, "The base recomp executable is missing.");
+ if (!HasUsableToolkit(toolkitFingerprint))
+ return MissingToolkit();
+ var installState = ReadCurrentInstallState();
+ if (installState is null)
+ return new ProductState(ProductStatus.Blocked,
+ "The installation state is missing, unsupported, or belongs to another directory.");
+ var fingerprint = ReadProductFingerprint(BaseDirectory, toolkitFingerprint);
+ if (fingerprint is null)
+ return new ProductState(ProductStatus.Blocked,
+ "The base recomp has no current build provenance. Repair it with the current setup.");
+ if (!fingerprint.ToolkitFingerprint.Equals(toolkitFingerprint, StringComparison.Ordinal))
+ return new ProductState(ProductStatus.ToolkitChanged,
+ "The base recomp was produced by a different recompilation toolkit.");
+ var provenanceError = ValidateProductProvenance(BaseDirectory, "base", fingerprint, installState);
+ return provenanceError is null
+ ? new ProductState(ProductStatus.Current, "")
+ : new ProductState(ProductStatus.Blocked, provenanceError);
+ }
+
+ ///
+ /// Classifies the Retro Rewind product against the canonical installation's compile inputs. The
+ /// asset tree is never inspected: the runtime reads it live, so an asset-only Retro Rewind
+ /// update leaves this product current.
+ ///
+ public ProductState CheckRetroRewind(string toolkitFingerprint,
+ RetroRewindCompileInputs? canonical, string? canonicalError = null,
+ bool? cachedRetroWfcPayloadMatches = null)
+ {
+ var state = CheckRetroRewindCore(toolkitFingerprint, canonical, canonicalError,
+ cachedRetroWfcPayloadMatches);
+ if (state.Status != ProductStatus.Current) return state;
+ var runtimeAssetsError = ValidateCopiedRuntimeAssets(RetroDirectory, "retro-rewind",
+ toolkitFingerprint);
+ return runtimeAssetsError is null ? state : new ProductState(ProductStatus.Broken, runtimeAssetsError);
+ }
+
+ internal ProductState CheckRetroRewindCore(string toolkitFingerprint,
+ RetroRewindCompileInputs? canonical, string? canonicalError = null,
+ bool? cachedRetroWfcPayloadMatches = null)
+ {
+ var installState = ReadCurrentInstallState();
+ var recordedAsInstalled = installState?.RetroRewindInstalled == true;
+ if ((HasRetroProduct || recordedAsInstalled) && !HasUsableToolkit(toolkitFingerprint))
+ return MissingToolkit();
+
+ if (!HasRetroProduct)
+ {
+ return recordedAsInstalled
+ ? new ProductState(ProductStatus.Broken,
+ "Retro Rewind is recorded as installed, but its executable is missing.")
+ : new ProductState(ProductStatus.Absent, "Retro Rewind is not installed.");
+ }
+
+ if (canonicalError is not null)
+ return new ProductState(ProductStatus.InputsMissing, canonicalError);
+
+ var fingerprint = ReadProductFingerprint(RetroDirectory, toolkitFingerprint);
+ if (fingerprint is null)
+ return new ProductState(ProductStatus.Blocked,
+ "The Retro Rewind product has no current build provenance. " +
+ "Repair it with Wheel Wizard's current Retro Rewind folder.");
+ if (!fingerprint.ToolkitFingerprint.Equals(toolkitFingerprint, StringComparison.Ordinal))
+ return new ProductState(ProductStatus.ToolkitChanged,
+ "Retro Rewind was produced by a different recompilation toolkit.");
+ if (installState is null)
+ return new ProductState(ProductStatus.Blocked,
+ "The installation state is missing, unsupported, or belongs to another directory.");
+
+ var provenanceError = ValidateProductProvenance(RetroDirectory, "retro-rewind", fingerprint,
+ installState);
+ if (provenanceError is not null)
+ return new ProductState(ProductStatus.Blocked, provenanceError);
+
+ if (string.IsNullOrWhiteSpace(fingerprint.CodePulSha256) ||
+ string.IsNullOrWhiteSpace(fingerprint.RetroRewindCompileInputsSha256) ||
+ string.IsNullOrWhiteSpace(installState.RetroRewindCodePulSha256) ||
+ string.IsNullOrWhiteSpace(installState.RetroRewindCompileInputsSha256) ||
+ !Same(installState.RetroRewindCodePulSha256, fingerprint.CodePulSha256) ||
+ !Same(installState.RetroRewindCompileInputsSha256,
+ fingerprint.RetroRewindCompileInputsSha256))
+ return new ProductState(ProductStatus.Blocked,
+ "Retro Rewind's compile-input provenance is incomplete or inconsistent. " +
+ "Repair it with Wheel Wizard's current Retro Rewind folder.");
+
+ if (canonical is not null)
+ {
+ if (!Same(canonical.CodePulSha256, fingerprint.CodePulSha256))
+ return new ProductState(ProductStatus.CodePulChanged,
+ "The canonical Retro Rewind Code.pul changed since WiiCompiled was translated.");
+ if (!Same(canonical.CompileInputsSha256, fingerprint.RetroRewindCompileInputsSha256))
+ return new ProductState(ProductStatus.CompileInputsChanged,
+ "Translator-consumed Retro Rewind inputs changed since WiiCompiled was built.");
+ }
+
+ if (fingerprint.RetroWfcPayloadMode == "downloaded" &&
+ CheckRetroWfcPayloadCache(fingerprint, installState, cachedRetroWfcPayloadMatches)
+ is { } payloadError)
+ return payloadError;
+
+ return new ProductState(ProductStatus.Current, "");
+ }
+
+ ///
+ /// Compares the workspace payload cache with the identity this product embeds.
+ /// carries an observation the operation already paid for:
+ /// true/false skip the hash, null means nothing is known yet.
+ ///
+ private ProductState? CheckRetroWfcPayloadCache(ProductFingerprint fingerprint, InstallState installState,
+ bool? cachedMatches)
+ {
+ if (string.IsNullOrWhiteSpace(fingerprint.RetroWfcPayloadSha256) ||
+ fingerprint.RetroWfcPayloadLength <= 0 ||
+ string.IsNullOrWhiteSpace(installState.RetroWfcPayloadSha256) ||
+ installState.RetroWfcPayloadLength <= 0)
+ return new ProductState(ProductStatus.Blocked,
+ "The Retro-WFC payload provenance is incomplete.");
+ if (cachedMatches == false)
+ return new ProductState(ProductStatus.PayloadChanged,
+ "The cached Retro-WFC payload is missing or does not match the current snapshot.");
+ if (cachedMatches == true) return null;
+
+ try
+ {
+ var payload = InputValidation.ResolveRetroWfcPayloadFile(WorkspaceRetroWfcPayload);
+ if (!InputValidation.Sha256File(payload).Equals(fingerprint.RetroWfcPayloadSha256,
+ StringComparison.OrdinalIgnoreCase) ||
+ new FileInfo(payload).Length != fingerprint.RetroWfcPayloadLength)
+ return new ProductState(ProductStatus.PayloadChanged,
+ "The cached Retro-WFC payload is not the one this product was built with.");
+ return null;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
+ {
+ return new ProductState(ProductStatus.PayloadChanged,
+ "The cached Retro-WFC payload is missing or invalid: " + ex.Message);
+ }
+ }
+
+ private static bool Same(string left, string right) =>
+ left.Equals(right, StringComparison.OrdinalIgnoreCase);
+
+ internal bool ProductUsesGameInputs(string productDirectory, string toolkitFingerprint,
+ string expectedDolSha256, string expectedRelSha256)
+ {
+ var fingerprint = ReadProductFingerprint(productDirectory, toolkitFingerprint);
+ return fingerprint is not null &&
+ fingerprint.DolSha256.Equals(expectedDolSha256, StringComparison.OrdinalIgnoreCase) &&
+ fingerprint.RelSha256.Equals(expectedRelSha256, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private bool HasUsableToolkit(string toolkitFingerprint) =>
+ HasToolkit && !string.IsNullOrWhiteSpace(toolkitFingerprint);
+
+ private static ProductState MissingToolkit() =>
+ new(ProductStatus.Blocked,
+ "The recompilation toolkit is missing or incomplete. Repair the setup before launching or updating products.");
+
+ private InstallState? ReadCurrentInstallState()
+ {
+ var state = ReadInstallState();
+ if (state is not { SchemaVersion: 1 } || string.IsNullOrWhiteSpace(state.InstallDir)) return null;
+ try
+ {
+ return FileSystemUtilities.PathsEqual(state.InstallDir, Root) ? state : null;
+ }
+ catch { return null; }
+ }
+
+ private string? ValidateProductProvenance(string productDirectory, string expectedProfile,
+ ProductFingerprint fingerprint, InstallState state)
+ {
+ if (!fingerprint.Profile.Equals(expectedProfile, StringComparison.Ordinal) ||
+ string.IsNullOrWhiteSpace(fingerprint.DolSha256) ||
+ string.IsNullOrWhiteSpace(fingerprint.RelSha256) ||
+ string.IsNullOrWhiteSpace(fingerprint.ExecutableSha256))
+ return $"The {expectedProfile} product provenance is incomplete.";
+
+ var executable = expectedProfile == "base" ? BaseExecutable : RetroExecutable;
+ if (!IsUsableExecutable(executable))
+ return $"The {expectedProfile} executable is missing or incomplete.";
+ try
+ {
+ if (!InputValidation.Sha256File(executable).Equals(fingerprint.ExecutableSha256,
+ StringComparison.OrdinalIgnoreCase))
+ return $"The {expectedProfile} executable no longer matches its recorded build identity.";
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return $"The {expectedProfile} executable cannot be verified: {ex.Message}";
+ }
+
+ var dol = Path.Combine(GameDataDirectory, "sys", "main.dol");
+ var rel = Path.Combine(GameDataDirectory, "files", "rel", "StaticR.rel");
+ var fst = Path.Combine(GameDataDirectory, "sys", "fst.bin");
+ if (!File.Exists(dol) || !File.Exists(rel) || !File.Exists(fst))
+ return "The installed game assets are incomplete. Apply setup again with the disc image.";
+ try
+ {
+ var dolSha = InputValidation.Sha256File(dol);
+ var relSha = InputValidation.Sha256File(rel);
+ if (!dolSha.Equals(fingerprint.DolSha256, StringComparison.OrdinalIgnoreCase) ||
+ !relSha.Equals(fingerprint.RelSha256, StringComparison.OrdinalIgnoreCase) ||
+ !dolSha.Equals(state.DolSha256, StringComparison.OrdinalIgnoreCase) ||
+ !relSha.Equals(state.RelSha256, StringComparison.OrdinalIgnoreCase))
+ return "The installed game inputs do not match the product provenance.";
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return "The installed game inputs cannot be verified: " + ex.Message;
+ }
+
+ var local = JsonState.TryRead(
+ Path.Combine(productDirectory, LocalBuildProvenance.FileName));
+ if (local is not { SchemaVersion: 1 } ||
+ !local.Profile.Equals(expectedProfile, StringComparison.Ordinal) ||
+ !local.DolSha256.Equals(fingerprint.DolSha256, StringComparison.OrdinalIgnoreCase) ||
+ !local.RelSha256.Equals(fingerprint.RelSha256, StringComparison.OrdinalIgnoreCase))
+ return $"The {expectedProfile} executable has invalid local-build provenance.";
+ if (expectedProfile == "retro-rewind" &&
+ (!string.Equals(local.RetroWfcPayloadMode, fingerprint.RetroWfcPayloadMode,
+ StringComparison.Ordinal) ||
+ !string.Equals(local.RetroWfcPayloadSha256 ?? "", fingerprint.RetroWfcPayloadSha256,
+ StringComparison.OrdinalIgnoreCase) ||
+ (local.RetroWfcPayloadLength ?? 0) != fingerprint.RetroWfcPayloadLength))
+ {
+ return "The Retro Rewind executable has invalid Retro-WFC payload provenance.";
+ }
+
+ return null;
+ }
+
+ ///
+ /// Runtime support files are copied beside products rather than embedded in the executable.
+ /// The installed toolkit-state is their authoritative release identity; products with missing
+ /// or altered copies cannot be declared launchable merely because their .exe still hashes.
+ ///
+ internal string? ValidateCopiedRuntimeAssets(string productDirectory, string expectedProfile,
+ string toolkitFingerprint)
+ {
+ var toolkitState = ReadToolkitState();
+ if (toolkitState is not { SchemaVersion: 2 } ||
+ !string.Equals(toolkitState.ToolkitFingerprint, toolkitFingerprint, StringComparison.Ordinal) ||
+ string.IsNullOrWhiteSpace(toolkitState.RuntimeAssetsFingerprint))
+ {
+ return "The installed runtime-asset provenance is missing or does not belong to the current toolkit.";
+ }
+
+ string? failure = null;
+ if (!ToolkitFingerprint.ProductRuntimeAssetsMatch(productDirectory,
+ toolkitState.RuntimeAssetsFingerprint, diagnostic: line => failure = line))
+ {
+ // An access-denied or half-deleted tree must not masquerade as an ordinary hash mismatch.
+ return failure ??
+ $"The {expectedProfile} product's copied runtime assets are missing, corrupt, or stale.";
+ }
+ return null;
+ }
+
+ private static bool IsUsableExecutable(string path)
+ {
+ try { return File.Exists(path) && new FileInfo(path).Length >= 64 * 1024; }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { return false; }
+ }
+
+ private static bool IsNonEmptyRegularDirectory(string path)
+ {
+ try
+ {
+ if (!Directory.Exists(path)) return false;
+ var directory = new DirectoryInfo(path);
+ if ((directory.Attributes & FileAttributes.ReparsePoint) != 0) return false;
+ using var entries = Directory.EnumerateFileSystemEntries(path).GetEnumerator();
+ return entries.MoveNext();
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return false;
+ }
+ }
+
+ /// The Retro-WFC payload choice this installation was built with.
+ public RetroWfcPayloadMode ResolveRetroWfcPayloadMode(string toolkitFingerprint)
+ {
+ var recorded = ReadProductFingerprint(RetroDirectory, toolkitFingerprint)?.RetroWfcPayloadMode;
+ recorded = string.IsNullOrEmpty(recorded) ? ReadInstallState()?.RetroWfcPayloadMode : recorded;
+ return recorded switch
+ {
+ "downloaded" => RetroWfcPayloadMode.Online,
+ "skipped" => RetroWfcPayloadMode.Skipped,
+ _ => RetroWfcPayloadMode.NotApplicable
+ };
+ }
+
+ public bool MatchesRetroWfcPayloadSnapshot(string toolkitFingerprint,
+ RetroWfcPayloadSnapshot snapshot)
+ {
+ var fingerprint = ReadProductFingerprint(RetroDirectory, toolkitFingerprint);
+ var state = ReadCurrentInstallState();
+ return fingerprint is { RetroWfcPayloadMode: "downloaded" } &&
+ state is { RetroWfcPayloadMode: "downloaded" } &&
+ string.Equals(fingerprint.RetroWfcPayloadSha256, snapshot.Sha256, StringComparison.Ordinal) &&
+ string.Equals(state.RetroWfcPayloadSha256, snapshot.Sha256, StringComparison.Ordinal);
+ }
+
+ public bool CachedRetroWfcPayloadMatches(RetroWfcPayloadSnapshot snapshot)
+ {
+ try
+ {
+ var payload = InputValidation.ResolveRetroWfcPayloadFile(WorkspaceRetroWfcPayload);
+ return new FileInfo(payload).Length == snapshot.ByteLength &&
+ InputValidation.Sha256File(payload).Equals(snapshot.Sha256, StringComparison.Ordinal);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
+ {
+ return false;
+ }
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup/InstalledLayout.cs b/Launcher/WiiCompiled.Setup/InstalledLayout.cs
new file mode 100644
index 0000000..73d8efb
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/InstalledLayout.cs
@@ -0,0 +1,54 @@
+namespace WiiCompiled.Setup;
+
+///
+/// Names of the installed/staged layout. Not cosmetic: payload and toolkit identities hash relative paths
+/// that begin with these strings, so changing one (including casing) invalidates every shipped fingerprint.
+/// Build-Installer.ps1 stages the payload with exactly these names.
+///
+internal static class InstalledLayout
+{
+ public const string ToolkitDirectoryName = "Toolkit";
+ public const string WorkspaceDirectoryName = "BuildWorkspace";
+ public const string InstallStateFileName = "install-state.json";
+ public const string PayloadManifestFileName = "payload-manifest.json";
+
+ /// Payload entry names use forward slashes; the archive is written by tar.
+ public const string ToolkitEntryPrefix = ToolkitDirectoryName + "/";
+ public const string WorkspaceEntryPrefix = WorkspaceDirectoryName + "/";
+
+ public static string Toolkit(string root) => Path.Combine(root, ToolkitDirectoryName);
+ public static string Workspace(string root) => Path.Combine(root, WorkspaceDirectoryName);
+
+ ///
+ /// The pinned offline dependency sources shipped under BuildWorkspace\Dependencies. This
+ /// is the one list: Build-Installer.ps1 stages exactly these directories and
+ /// Launcher/Test-PinnedFacts.ps1 fails the release build if its copy and this one disagree.
+ ///
+ public static readonly string[] DependencyNames =
+ [
+ "abseil-cpp", "cppwinrt", "dawn_prebuilt", "fmt", "freetype", "imgui", "native_prebuilt",
+ "png", "SDL", "sqlite3", "tracy", "xxhash", "zlib", "zstd"
+ ];
+}
+
+///
+/// Source-owned files copied verbatim beside every product. One inventory drives identity
+/// (), verification, and publication. Renaming
+/// an identity key below requires bumping the fingerprint's version salt.
+///
+internal static class ProductRuntimeAssets
+{
+ /// The bootstrap tree, copied from runtime/assets/wii into every product.
+ public const string SourceBootstrapDirectoryName = "wii";
+ public const string ProductBootstrapDirectoryName = "wii_bootstrap";
+
+ /// Single files, given as their path below runtime/assets and their product name.
+ public static readonly (string[] SourceRelativePath, string ProductFileName)[] Files =
+ [
+ (["dsp", "dsp_coef.bin"], "dsp_coef.bin"),
+ (["pipeline", "initial_pipeline_cache.db"], "initial_pipeline_cache.db")
+ ];
+
+ public static string SourceFile(string sourceAssets, string[] relativePath) =>
+ Path.Combine([sourceAssets, .. relativePath]);
+}
diff --git a/Launcher/WiiCompiled.Setup/InstallerEngine.cs b/Launcher/WiiCompiled.Setup/InstallerEngine.cs
new file mode 100644
index 0000000..cfdb9d4
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/InstallerEngine.cs
@@ -0,0 +1,523 @@
+namespace WiiCompiled.Setup;
+
+internal sealed class InstallerEngine
+{
+ private readonly IInstallReporter _reporter;
+
+ public InstallerEngine(IInstallReporter reporter) => _reporter = reporter;
+
+ public async Task InstallAsync(InstallOptions options, CancellationToken cancellationToken = default)
+ {
+ var installDirectory = Path.GetFullPath(options.InstallDirectory);
+ ValidateInstallDirectory(installDirectory);
+
+ InputValidation.ValidateExtension(options.GamePath);
+ var parent = Directory.GetParent(installDirectory)?.FullName
+ ?? throw new InvalidOperationException("The installation directory has no parent.");
+ Directory.CreateDirectory(parent);
+
+ using var operationLock = InstallOperationLock.Acquire(installDirectory, _reporter);
+ var existing = new Installation(installDirectory);
+
+ PortableInstallHealing.HealMovedInstall(existing, _reporter);
+ var previousState = existing.ReadInstallState();
+ using var scratch = Directory.Exists(installDirectory)
+ ? InstallScratchSpace.CreateInsideInstall(installDirectory, _reporter)
+ : InstallScratchSpace.CreateSibling(installDirectory, _reporter);
+ var staging = scratch.Root;
+
+
+ const long payloadStagingAllowance = 2L * 1024 * 1024 * 1024;
+ FileSystemUtilities.EnsureFreeSpace(installDirectory, payloadStagingAllowance, "Setup");
+
+ using var payload = PayloadArchive.OpenCurrent();
+ var manifest = payload.ReadManifest();
+ var toolkit = InstalledLayout.Toolkit(staging);
+ var workspace = InstalledLayout.Workspace(staging);
+
+ var candidateToolkitFingerprint = manifest.ToolkitFingerprint;
+ var candidateToolkitPackageFingerprint = manifest.ToolkitPackageFingerprint;
+ var candidateRuntimeAssetsFingerprint = manifest.RuntimeAssetsFingerprint;
+ var installedToolkitFingerprint = existing.IsPresent
+ ? existing.ResolveToolkitFingerprint()
+ : "";
+ var installedToolkitPackageFingerprint = existing.IsPresent
+ ? existing.ResolveToolkitPackageFingerprint()
+ : "";
+ var reusableGameAssets = FindReusableGameAssets(existing, manifest);
+
+ var sameToolkit = existing.IsPresent && reusableGameAssets is not null &&
+ installedToolkitFingerprint.Equals(candidateToolkitFingerprint,
+ StringComparison.Ordinal);
+ var samePackageContent = existing.IsPresent &&
+ installedToolkitPackageFingerprint.Equals(
+ candidateToolkitPackageFingerprint, StringComparison.Ordinal);
+ var sameToolkitPackage = sameToolkit && samePackageContent;
+ var hadRetro = existing.HasRetroProduct || previousState?.RetroRewindInstalled == true;
+
+ var runtimeAssetsCurrent = sameToolkit && RuntimeAssetsAreCurrent(existing,
+ candidateRuntimeAssetsFingerprint, cancellationToken);
+ var installedDolphinTool = Path.Combine(existing.ToolkitDirectory, "DolphinTool.exe");
+ var extractToolkit = MustRefreshToolkit(sameToolkit, samePackageContent,
+ File.Exists(installedDolphinTool));
+ var extractWorkspace = !sameToolkit || !runtimeAssetsCurrent;
+
+ _reporter.Progress(InstallStages.ExtractToolkit,
+ "Inspecting the release's local recompilation toolkit...", 1);
+ if (extractToolkit) payload.ExtractDirectory(InstalledLayout.ToolkitDirectoryName, toolkit);
+ if (extractWorkspace)
+ {
+ payload.ExtractDirectory(InstalledLayout.WorkspaceDirectoryName, workspace);
+ WorkspaceTimestamps.MarkChangedFiles(existing.WorkspaceDirectory, workspace,
+ _reporter.Diagnostic, cancellationToken);
+ }
+ payload.ExtractEntry("host/WiiCompiled-Setup.exe",
+ Path.Combine(staging, ProductInfo.SetupCopyName));
+ payload.ExtractDirectory("licenses", Path.Combine(staging, "licenses"));
+ payload.ExtractEntry(InstalledLayout.PayloadManifestFileName,
+ Path.Combine(staging, InstalledLayout.PayloadManifestFileName));
+
+ var dolphinTool = extractToolkit ? Path.Combine(toolkit, "DolphinTool.exe") : installedDolphinTool;
+ _reporter.Progress(InstallStages.Validate, "Checking the Wii disc image...", 2);
+ var header = await InputValidation.ReadDiscHeaderAsync(dolphinTool, options.GamePath,
+ cancellationToken);
+ InputValidation.EnsureCompatibleDisc(header, manifest);
+ var canonicalRetroRoot = options.RetroDirectoryPath is null
+ ? null
+ : RetroRewindSource.ResolveRetroRewind6(options.RetroDirectoryPath);
+ ValidateRetroOptions(canonicalRetroRoot, options.RetroWfcPayloadMode, manifest);
+
+ var retroCompileInputs = canonicalRetroRoot is null
+ ? null
+ : SnapshotRetroRewindCompileInputs(canonicalRetroRoot,
+ Path.Combine(staging, "compile-inputs"), cancellationToken);
+ if (options.Portable)
+ {
+ var portableRoot = PortableRoot.Create(parent);
+ _reporter.Diagnostic($"Installing as a portable installation under {portableRoot}.");
+ }
+
+ if (sameToolkit)
+ {
+ var desiredInputDrift = ProductRepairService.FindDesiredInputDrift(existing,
+ candidateToolkitFingerprint, manifest.ExpectedDolSha256, manifest.ExpectedRelSha256);
+ if (desiredInputDrift.Retro && retroCompileInputs is null)
+ throw new InvalidOperationException(
+ "This release changes the clean game inputs used by the installed Retro Rewind product. " +
+ "Supply Wheel Wizard's current Retro Rewind folder explicitly so it can be rebuilt safely.");
+
+ var reconciliation = await ReconcileSameToolkitAsync(existing, retroCompileInputs,
+ canonicalRetroRoot, options.RetroWfcPayloadMode, Path.Combine(staging, "repair"),
+ manifest.ExpectedDolSha256, manifest.ExpectedRelSha256, cancellationToken);
+ var remainingCancellation = reconciliation.PublicationCommitted
+ ? CancellationToken.None
+ : cancellationToken;
+
+
+ if (!runtimeAssetsCurrent)
+ runtimeAssetsCurrent = RuntimeAssetsAreCurrent(existing,
+ candidateRuntimeAssetsFingerprint, remainingCancellation);
+
+ remainingCancellation.ThrowIfCancellationRequested();
+ EnsureReconciliationCurrent(existing, candidateToolkitFingerprint, retroCompileInputs,
+ manifest.ExpectedDolSha256, manifest.ExpectedRelSha256,
+ reconciliation.CachedRetroWfcPayloadMatches);
+
+ var updatedState = BuildInstallState(existing.ReadInstallState(), installDirectory, manifest,
+ retroCompileInputs, canonicalRetroRoot, options.RetroWfcPayloadMode);
+ PrepareMetadata(staging, manifest, updatedState);
+ remainingCancellation.ThrowIfCancellationRequested();
+
+ var releaseEntries = new List();
+ if (!sameToolkitPackage)
+ AddComponent(releaseEntries, staging, installDirectory,
+ InstalledLayout.ToolkitDirectoryName);
+ if (!runtimeAssetsCurrent)
+ AddRuntimeAssetPublicationEntries(releaseEntries, staging, installDirectory,
+ updatedState.RetroRewindInstalled, candidateRuntimeAssetsFingerprint,
+ remainingCancellation);
+ Publish(staging, installDirectory, canonicalRetroRoot, updatedState,
+ releaseEntries, remainingCancellation);
+ return;
+ }
+
+ if (hadRetro && retroCompileInputs is null)
+ {
+ throw new InvalidOperationException(
+ "The installed Retro Rewind product must be produced again. Supply Wheel Wizard's " +
+ "current Retro Rewind folder explicitly so it can be rebuilt safely.");
+ }
+
+ EnsureSufficientRemainingDiskSpace(installDirectory,
+ needsExtractedDisc: reusableGameAssets is null, needsLocalBuild: true);
+
+ if (reusableGameAssets is null)
+ {
+ await ExtractGameAssetsAsync(dolphinTool, options.GamePath,
+ Path.Combine(staging, "GameAssets"), manifest, cancellationToken);
+ }
+
+ await PublishToolkitAndReconcileProductsAsync(existing, staging, workspace, manifest,
+ previousState, options, canonicalRetroRoot, retroCompileInputs,
+ publishGameAssets: reusableGameAssets is null, cancellationToken);
+ }
+
+ ///
+ /// Whether this operation must replace the installed Toolkit. The shipped translator and translation
+ /// project are one unit, so samePackageContent alone isn't enough: sameToolkit tracks the compile
+ /// identity separately, since gating on package identity alone let the two disagree after the v0.2.5 healing salt bump.
+ ///
+ internal static bool MustRefreshToolkit(bool sameToolkit, bool samePackageContent,
+ bool dolphinToolPresent) =>
+ !sameToolkit || !samePackageContent || !dolphinToolPresent;
+
+ private static void AddComponent(List entries, string staging,
+ string installDirectory, string name) =>
+ entries.Add(InstallTransactionEntry.Directory(Path.Combine(staging, name),
+ Path.Combine(installDirectory, name)));
+
+ ///
+ /// Build-produced workspace directories (not shipped in a release) grafted from the installed workspace into
+ /// the staged one, carrying the base translation, native build dir, verified inputs, and Retro-WFC payload cache
+ /// across a toolkit update. Each is re-validated by the build, so stale caches degrade to a clean rebuild, never reuse.
+ ///
+ private static readonly string[] WorkspaceCacheDirectories =
+ ["generated", "build", "native-build", "Assets", "PulsarPacks"];
+
+ private async Task PublishToolkitAndReconcileProductsAsync(Installation existing, string staging,
+ string stagedWorkspace, PayloadManifest manifest, InstallState? previousState,
+ InstallOptions options, string? canonicalRetroRoot,
+ RetroRewindCompileInputs? retroCompileInputs,
+ bool publishGameAssets, CancellationToken cancellationToken)
+ {
+ var installDirectory = existing.Root;
+
+
+ foreach (var cacheDirectory in WorkspaceCacheDirectories)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var source = Path.Combine(existing.WorkspaceDirectory, cacheDirectory);
+ if (!Directory.Exists(source)) continue;
+ var destination = Path.Combine(stagedWorkspace, cacheDirectory);
+ if (Directory.Exists(destination)) Directory.Delete(destination, recursive: true);
+ Directory.Move(source, destination);
+ }
+
+
+ var state = BuildInstallState(previousState, installDirectory, manifest,
+ compileInputs: null, canonicalRetroRoot, options.RetroWfcPayloadMode);
+ PrepareMetadata(staging, manifest, state);
+
+ cancellationToken.ThrowIfCancellationRequested();
+ var entries = new List();
+
+ AddComponent(entries, staging, installDirectory, InstalledLayout.ToolkitDirectoryName);
+ AddComponent(entries, staging, installDirectory, InstalledLayout.WorkspaceDirectoryName);
+ if (publishGameAssets) AddComponent(entries, staging, installDirectory, "GameAssets");
+
+ Publish(staging, installDirectory, canonicalRetroRoot, state,
+ entries, cancellationToken, progressPercent: 8, completionPercent: 10);
+
+ _reporter.Progress(InstallStages.BuildBase,
+ "Producing the installed products with the published toolkit...", 11);
+
+ var reconciliation = await ReconcileSameToolkitAsync(existing, retroCompileInputs,
+ canonicalRetroRoot, options.RetroWfcPayloadMode, repairScratch: null,
+ manifest.ExpectedDolSha256, manifest.ExpectedRelSha256, cancellationToken);
+ EnsureReconciliationCurrent(existing, manifest.ToolkitFingerprint, retroCompileInputs,
+ manifest.ExpectedDolSha256, manifest.ExpectedRelSha256,
+ reconciliation.CachedRetroWfcPayloadMatches);
+ }
+
+ private Task ReconcileSameToolkitAsync(Installation existing,
+ RetroRewindCompileInputs? retroCompileInputs, string? canonicalRetroRoot,
+ RetroWfcPayloadMode payloadMode, string? repairScratch, string expectedDolSha256,
+ string expectedRelSha256, CancellationToken cancellationToken)
+ {
+ var repair = new ProductRepairService(existing, _reporter);
+ var options = new ProductRepairService.ReconcileOptions
+ {
+ ScratchRoot = repairScratch,
+ ExpectedDolSha256 = expectedDolSha256,
+ ExpectedRelSha256 = expectedRelSha256,
+ CanonicalRetroRewindRoot = canonicalRetroRoot
+ };
+
+ return retroCompileInputs is null
+ ? repair.RepairBaseAsync(options, cancellationToken)
+ : repair.RepairRetroAsync(retroCompileInputs, payloadMode, options, cancellationToken);
+ }
+
+ private static void EnsureReconciliationCurrent(Installation installation, string toolkitFingerprint,
+ RetroRewindCompileInputs? retroCompileInputs, string expectedDolSha256, string expectedRelSha256,
+ bool? cachedRetroWfcPayloadMatches)
+ {
+ var baseState = installation.CheckBase(toolkitFingerprint);
+ var retroState = installation.CheckRetroRewind(toolkitFingerprint, retroCompileInputs,
+ canonicalError: null, cachedRetroWfcPayloadMatches);
+ var desiredInputsCurrent = installation.ProductUsesGameInputs(installation.BaseDirectory,
+ toolkitFingerprint, expectedDolSha256, expectedRelSha256) &&
+ (!installation.HasRetroProduct ||
+ installation.ProductUsesGameInputs(installation.RetroDirectory,
+ toolkitFingerprint, expectedDolSha256, expectedRelSha256));
+ if (baseState.Status == ProductStatus.Current && !retroState.ActionRequired && desiredInputsCurrent) return;
+
+ var details = string.Join(" ", new[] { baseState, retroState }
+ .Where(state => state.ActionRequired)
+ .Select(state => state.Detail)
+ .Where(detail => !string.IsNullOrWhiteSpace(detail)));
+ throw new InvalidOperationException(
+ "Product reconciliation did not produce a current installation with the release's game inputs; " +
+ "release metadata was not advanced." +
+ (string.IsNullOrWhiteSpace(details) ? "" : " " + details));
+ }
+
+ ///
+ /// Publishes the caller's component entries together with the release metadata every install and
+ /// update advances, then applies the runtime configuration this installation owns.
+ ///
+ private void Publish(string staging, string installDirectory,
+ string? canonicalRetroRoot, InstallState state,
+ List entries, CancellationToken cancellationToken,
+ int progressPercent = 95, int completionPercent = 99)
+ {
+ entries.Add(InstallTransactionEntry.Directory(Path.Combine(staging, "licenses"),
+ Path.Combine(installDirectory, "licenses")));
+ entries.Add(InstallTransactionEntry.File(Path.Combine(staging, ProductInfo.SetupCopyName),
+ Path.Combine(installDirectory, ProductInfo.SetupCopyName)));
+ entries.Add(InstallTransactionEntry.File(
+ Path.Combine(staging, InstalledLayout.PayloadManifestFileName),
+ Path.Combine(installDirectory, InstalledLayout.PayloadManifestFileName)));
+ entries.Add(InstallTransactionEntry.File(Path.Combine(staging, ToolkitState.FileName),
+ Path.Combine(installDirectory, ToolkitState.FileName)));
+ entries.Add(InstallTransactionEntry.File(
+ Path.Combine(staging, InstalledLayout.InstallStateFileName),
+ Path.Combine(installDirectory, InstalledLayout.InstallStateFileName)));
+
+ cancellationToken.ThrowIfCancellationRequested();
+ _reporter.Progress(InstallStages.Publish, "Publishing the completed installation...", progressPercent);
+ var configPath = RuntimeConfiguration.ResolveConfigPath(installDirectory);
+ var configSnapshot = RuntimeConfiguration.Capture(configPath);
+ using var transaction = InstallTransaction.Begin(installDirectory, _reporter, entries.ToArray());
+ transaction.Publish();
+ transaction.RecordRuntimeConfigurationMutation(configSnapshot);
+ RuntimeConfiguration.SetDvdRoot(configPath, Path.Combine(installDirectory, "GameAssets", "DATA"));
+ // The runtime scans this directory live for its asset overlay, so it is recorded on every
+ // operation that receives one and is never rewritten by a base-only operation.
+ if (canonicalRetroRoot is not null)
+ RuntimeConfiguration.SetRetroRewindRoot(configPath, canonicalRetroRoot);
+ transaction.Commit();
+
+ // A portable installation is owned by the folder it lives in, not by this machine. Adding a
+ // machine-wide uninstall entry for it would outlive the folder and, worse, would overwrite
+ // the entry of a normal installation on the same account.
+ if (PortableRoot.TryFind(installDirectory) is not null)
+ {
+ _reporter.Diagnostic(
+ "Portable installation: no Windows uninstall entry was registered. Remove the folder, " +
+ "or run the copied setup with --uninstall, to uninstall it.");
+ }
+ else
+ {
+ try
+ {
+ ShellIntegration.RegisterUninstaller(installDirectory, state.RetroRewindInstalled);
+ ShellIntegration.RemoveAllShortcuts();
+ }
+ catch (Exception ex)
+ {
+ _reporter.Diagnostic("The installation succeeded, but Windows uninstall registration failed: " +
+ ex.Message);
+ }
+ }
+
+ _reporter.Progress(InstallStages.Publish, "Installation complete.", completionPercent);
+ }
+
+ private static void PrepareMetadata(string staging, PayloadManifest manifest, InstallState state)
+ {
+ JsonState.Write(Path.Combine(staging, ToolkitState.FileName), new ToolkitState
+ {
+ ToolkitFingerprint = manifest.ToolkitFingerprint,
+ ToolkitPackageFingerprint = manifest.ToolkitPackageFingerprint,
+ RuntimeAssetsFingerprint = manifest.RuntimeAssetsFingerprint,
+ TranslationFingerprint = manifest.TranslationFingerprint,
+ NativeToolchainFingerprint = manifest.NativeToolchainFingerprint,
+ ToolkitReleaseTag = manifest.ToolkitReleaseTag
+ });
+ JsonState.Write(Path.Combine(staging, InstalledLayout.InstallStateFileName), state);
+ }
+
+ private static InstallState BuildInstallState(InstallState? previousState, string installDirectory,
+ PayloadManifest manifest, RetroRewindCompileInputs? compileInputs, string? canonicalRetroRoot,
+ RetroWfcPayloadMode payloadMode,
+ RetroWfcPayloadSnapshot? retroWfcPayloadSnapshot = null)
+ {
+ // Preserve the pre-update object for runtime-configuration rollback/ownership decisions.
+ var state = new InstallState
+ {
+ SchemaVersion = 1,
+ SetupVersion = ProductInfo.Version,
+ ProductVersion = manifest.ProductVersion,
+ InstallDir = installDirectory,
+ InstalledUtc = previousState?.InstalledUtc ?? DateTime.UtcNow,
+ RetroRewindInstalled = previousState?.RetroRewindInstalled ?? false,
+ ToolkitReleaseTag = manifest.ToolkitReleaseTag,
+ DolSha256 = manifest.ExpectedDolSha256,
+ RelSha256 = manifest.ExpectedRelSha256,
+ RetroRewindCodePulSha256 = previousState?.RetroRewindCodePulSha256 ?? "",
+ RetroRewindCompileInputsSha256 = previousState?.RetroRewindCompileInputsSha256 ?? "",
+ RetroWfcPayloadMode = previousState?.RetroWfcPayloadMode ?? "",
+ RetroWfcPayloadSha256 = previousState?.RetroWfcPayloadSha256 ?? "",
+ RetroWfcPayloadLength = previousState?.RetroWfcPayloadLength ?? 0,
+ RetroRewindRoot = canonicalRetroRoot is null
+ ? previousState?.RetroRewindRoot ?? ""
+ : Path.GetFullPath(canonicalRetroRoot)
+ };
+ if (compileInputs is not null)
+ {
+ state.RetroRewindInstalled = true;
+ state.RetroRewindCodePulSha256 = compileInputs.CodePulSha256;
+ state.RetroRewindCompileInputsSha256 = compileInputs.CompileInputsSha256;
+ state.RetroWfcPayloadMode = payloadMode == RetroWfcPayloadMode.Online
+ ? "downloaded"
+ : "skipped";
+ state.RetroWfcPayloadSha256 = payloadMode == RetroWfcPayloadMode.Online
+ ? retroWfcPayloadSnapshot?.Sha256 ?? state.RetroWfcPayloadSha256
+ : "";
+ state.RetroWfcPayloadLength = payloadMode == RetroWfcPayloadMode.Online
+ ? retroWfcPayloadSnapshot?.ByteLength ?? state.RetroWfcPayloadLength
+ : 0;
+ if (payloadMode == RetroWfcPayloadMode.Online &&
+ (string.IsNullOrWhiteSpace(state.RetroWfcPayloadSha256) || state.RetroWfcPayloadLength <= 0))
+ throw new InvalidDataException("The downloaded Retro-WFC payload snapshot is missing.");
+ }
+ return state;
+ }
+
+ ///
+ /// Captures the canonical installation's compile inputs into operation-owned staging. Only
+ /// Code.pul and the bundled Pulsar sources are copied; the asset tree stays where Wheel Wizard
+ /// owns it and is read live by the runtime through retro_rewind_root.
+ ///
+ private RetroRewindCompileInputs SnapshotRetroRewindCompileInputs(string canonicalRetroRoot,
+ string destination, CancellationToken cancellationToken)
+ {
+ _reporter.Progress(InstallStages.Validate, "Snapshotting the Retro Rewind compile inputs...", 3);
+ return CompileInputsFingerprint.Snapshot(canonicalRetroRoot, destination, cancellationToken);
+ }
+
+ private bool RuntimeAssetsAreCurrent(Installation installation, string expectedFingerprint,
+ CancellationToken cancellationToken)
+ {
+ var installedSource = ToolkitFingerprint.TryComputeRuntimeAssets(installation.Root, cancellationToken,
+ _reporter.Diagnostic);
+ if (!expectedFingerprint.Equals(installedSource, StringComparison.Ordinal)) return false;
+ if (File.Exists(installation.BaseExecutable) &&
+ !ToolkitFingerprint.ProductRuntimeAssetsMatch(installation.BaseDirectory, expectedFingerprint,
+ cancellationToken, _reporter.Diagnostic))
+ return false;
+ return !installation.HasRetroProduct ||
+ ToolkitFingerprint.ProductRuntimeAssetsMatch(installation.RetroDirectory,
+ expectedFingerprint, cancellationToken, _reporter.Diagnostic);
+ }
+
+ private void AddRuntimeAssetPublicationEntries(List entries,
+ string staging, string installDirectory, bool includeRetro, string expectedFingerprint,
+ CancellationToken cancellationToken)
+ {
+ // The identity was computed from this exact operation-owned payload extraction, so the
+ // staged assets are published as they are; only repair has an installed source to re-verify.
+ var sourceAssets = Path.Combine(InstalledLayout.Workspace(staging), "runtime", "assets");
+ var products = (includeRetro ? new[] { "Base", "RetroRewind" } : new[] { "Base" })
+ .Select(product => (product, Path.Combine(installDirectory, product)));
+ RuntimeAssetPublication.AddEntries(entries, sourceAssets,
+ Path.Combine(staging, "runtime-asset-publication"), products, expectedFingerprint,
+ "publication", _reporter.Diagnostic, cancellationToken);
+
+ entries.Add(InstallTransactionEntry.Directory(sourceAssets,
+ Path.Combine(InstalledLayout.Workspace(installDirectory), "runtime", "assets")));
+ }
+
+ private static void ValidateRetroOptions(string? canonicalRetroRoot, RetroWfcPayloadMode mode,
+ PayloadManifest manifest)
+ {
+ if (canonicalRetroRoot is null && mode != RetroWfcPayloadMode.NotApplicable)
+ throw new InvalidOperationException(
+ "A Retro-WFC payload option requires the canonical Retro Rewind folder.");
+ if (canonicalRetroRoot is not null && mode == RetroWfcPayloadMode.NotApplicable)
+ throw new InvalidOperationException(
+ "Download the Retro-WFC payload, or explicitly skip the optional payload.");
+ if (canonicalRetroRoot is not null && mode == RetroWfcPayloadMode.Online)
+ InputValidation.ValidateRetroWfcPayloadUri(manifest.RetroWfcPayloadUri);
+ }
+
+ private string? FindReusableGameAssets(Installation existing, PayloadManifest manifest)
+ {
+ var dataRoot = existing.GameDataDirectory;
+ // No fst.bin simply means nothing was extracted here before; anything past that gate is an
+ // existing extraction that fails reuse, which the log must say before the expensive
+ // re-extraction quietly repairs it.
+ if (!File.Exists(Path.Combine(dataRoot, "sys", "fst.bin"))) return null;
+ try
+ {
+ ValidateExtractedGame(dataRoot, manifest);
+ return dataRoot;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
+ {
+ _reporter.Diagnostic(
+ "The installed game assets cannot be reused; the disc will be extracted again: " + ex.Message);
+ return null;
+ }
+ }
+
+ private async Task ExtractGameAssetsAsync(string dolphinTool, string gamePath, string destination,
+ PayloadManifest manifest, CancellationToken cancellationToken)
+ {
+ _reporter.Progress(InstallStages.ExtractDisc,
+ "Extracting the game disc. This is the longest preparation step...", 6);
+ var extraction = await ProcessRunner.RunAsync(dolphinTool,
+ ["extract", "-i", Path.GetFullPath(gamePath), "-o", destination, "-g", "-q"],
+ line => { if (!string.IsNullOrWhiteSpace(line)) _reporter.Diagnostic(line); },
+ cancellationToken);
+ if (extraction.ExitCode != 0)
+ throw new InvalidDataException("Game extraction failed. " + extraction.CombinedOutput.Trim());
+ ValidateExtractedGame(Path.Combine(destination, "DATA"), manifest);
+ }
+
+ private static void ValidateExtractedGame(string dataRoot, PayloadManifest manifest)
+ {
+ var dol = Path.Combine(dataRoot, "sys", "main.dol");
+ var rel = Path.Combine(dataRoot, "files", "rel", "StaticR.rel");
+ var fst = Path.Combine(dataRoot, "sys", "fst.bin");
+ if (!File.Exists(dol) || !File.Exists(rel) || !File.Exists(fst))
+ throw new InvalidDataException("The disc extraction is missing required Mario Kart Wii files.");
+ var dolHash = InputValidation.Sha256File(dol);
+ var relHash = InputValidation.Sha256File(rel);
+ if (!dolHash.Equals(manifest.ExpectedDolSha256, StringComparison.OrdinalIgnoreCase) ||
+ !relHash.Equals(manifest.ExpectedRelSha256, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidDataException(
+ "The disc is RMCP01 but does not match the supported clean PAL revision. " +
+ "Patched or otherwise modified game code cannot be installed safely.");
+ }
+ }
+
+ private static void ValidateInstallDirectory(string path) =>
+ FileSystemUtilities.EnsureUsableLocation(path, "The installation folder");
+
+ private static void EnsureSufficientRemainingDiskSpace(string installDirectory,
+ bool needsExtractedDisc, bool needsLocalBuild)
+ {
+ const long extractedDiscAllowance = 5L * 1024 * 1024 * 1024;
+ const long localBuildAllowance = 14L * 1024 * 1024 * 1024;
+ const long safetyAllowance = 2L * 1024 * 1024 * 1024;
+ // This preflight runs after the release payload and the compile-input snapshot have already
+ // been staged. Count only work that can still allocate space from this point.
+ var required = checked((needsExtractedDisc ? extractedDiscAllowance : 0) +
+ (needsLocalBuild ? localBuildAllowance : 0) + safetyAllowance);
+
+ FileSystemUtilities.EnsureFreeSpace(installDirectory, required, "Setup");
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup/JsonState.cs b/Launcher/WiiCompiled.Setup/JsonState.cs
new file mode 100644
index 0000000..5b51fc6
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/JsonState.cs
@@ -0,0 +1,27 @@
+using System.Text.Json;
+
+namespace WiiCompiled.Setup;
+
+/// Reads and atomically writes the small JSON state documents kept inside an installation.
+internal static class JsonState
+{
+ private static readonly JsonSerializerOptions ReadOptions = new() { PropertyNameCaseInsensitive = true };
+ private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
+
+ public static T? TryRead(string path) where T : class
+ {
+ try
+ {
+ return File.Exists(path) ? JsonSerializer.Deserialize(File.ReadAllText(path), ReadOptions) : null;
+ }
+ catch
+ {
+ // A truncated or hand-edited state document must degrade into "unknown", which every
+ // caller already treats as "assume stale and rebuild", not into a failed launch.
+ return null;
+ }
+ }
+
+ public static void Write(string path, T value) =>
+ FileSystemUtilities.WriteAtomic(path, JsonSerializer.Serialize(value, WriteOptions));
+}
diff --git a/Launcher/WiiCompiled.Setup/LocalBuildService.cs b/Launcher/WiiCompiled.Setup/LocalBuildService.cs
new file mode 100644
index 0000000..439c594
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/LocalBuildService.cs
@@ -0,0 +1,197 @@
+using System.Diagnostics;
+
+namespace WiiCompiled.Setup;
+
+///
+/// runs one retro-aware translation and compiles the two products from a single
+/// build graph, sharing every profile-neutral shard object. Building the legs separately re-emits
+/// the shared base shards with retro-aware content in the second leg and recompiles all of them.
+///
+internal enum BuildProfile { Base, RetroRewind, Both }
+
+internal sealed class LocalBuildService
+{
+ private readonly IInstallReporter _reporter;
+
+ public LocalBuildService(IInstallReporter reporter) => _reporter = reporter;
+
+ ///
+ /// Runs the bundled translate-and-compile script against , which
+ /// holds a complete Toolkit and BuildWorkspace pair - either an installation being
+ /// staged or an existing installation being repaired in place.
+ ///
+ public async Task BuildAsync(string installStaging, BuildProfile profile, string outputDirectory,
+ RetroWfcPayloadMode retroWfcPayloadMode, string? retroWfcOfflinePayloadDirectory,
+ CancellationToken cancellationToken, ToolkitFingerprintComponents? toolkitComponents = null,
+ bool forceCleanBuild = false, BuildProgressWindow? progress = null,
+ string? retroRewindPackageDirectory = null, string? baseOutputDirectory = null)
+ {
+ var workspace = InstalledLayout.Workspace(installStaging);
+ var toolkit = InstalledLayout.Toolkit(installStaging);
+ var script = Path.Combine(workspace, "LocalBuild.ps1");
+ if (!File.Exists(script)) throw new FileNotFoundException("The local build script is missing.", script);
+
+ var buildsRetro = profile is BuildProfile.RetroRewind or BuildProfile.Both;
+ if (!buildsRetro && retroWfcPayloadMode != RetroWfcPayloadMode.NotApplicable)
+ throw new ArgumentException("The base build cannot select a Retro-WFC payload.");
+ if (buildsRetro && retroWfcPayloadMode == RetroWfcPayloadMode.NotApplicable)
+ throw new ArgumentException("The Retro Rewind build must select or explicitly skip the Retro-WFC payload.");
+ if (retroRewindPackageDirectory is not null && !buildsRetro)
+ throw new ArgumentException("Only Retro Rewind can select an explicit package snapshot.");
+ if ((profile == BuildProfile.Both) != (baseOutputDirectory is not null))
+ throw new ArgumentException("A combined build takes exactly one base output directory.");
+ if (retroRewindPackageDirectory is not null)
+ retroRewindPackageDirectory = RetroRewindSource.ResolveRetroRewind6(
+ retroRewindPackageDirectory);
+ if (retroWfcPayloadMode == RetroWfcPayloadMode.Online)
+ retroWfcOfflinePayloadDirectory =
+ InputValidation.ValidateStagedRetroWfcPayloadDirectory(retroWfcOfflinePayloadDirectory!);
+
+ var arguments = new List
+ {
+ "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script,
+ "-Workspace", workspace, "-Toolkit", toolkit,
+ "-Profile", profile switch
+ {
+ BuildProfile.Base => "base",
+ BuildProfile.RetroRewind => "retro-rewind",
+ _ => "both"
+ },
+ "-OutputDirectory", outputDirectory
+ };
+ if (baseOutputDirectory is not null)
+ {
+ arguments.Add("-BaseOutputDirectory");
+ arguments.Add(baseOutputDirectory);
+ }
+ // The script is the single owner of every cache-reuse decision; these identities are the
+ // evidence it compares against the provenance it recorded with the caches themselves.
+ // Without them (or with -ForceCleanBuild) it degrades to a full clean translate and build.
+ if (toolkitComponents is not null)
+ {
+ arguments.Add("-TranslationFingerprint");
+ arguments.Add(toolkitComponents.Translation);
+ arguments.Add("-NativeToolchainFingerprint");
+ arguments.Add(toolkitComponents.NativeToolchain);
+ }
+ if (forceCleanBuild)
+ arguments.Add("-ForceCleanBuild");
+ if (retroRewindPackageDirectory is not null)
+ {
+ arguments.Add("-RetroRewindPackageDirectory");
+ arguments.Add(retroRewindPackageDirectory);
+ }
+ if (retroWfcPayloadMode == RetroWfcPayloadMode.Online)
+ {
+ arguments.Add("-RetroWfcOfflineDirectory");
+ arguments.Add(retroWfcOfflinePayloadDirectory!);
+ arguments.Add("-RetroWfcPayloadOrigin");
+ arguments.Add("downloaded");
+ }
+ else if (retroWfcPayloadMode == RetroWfcPayloadMode.Skipped)
+ {
+ arguments.Add("-SkipRetroWfcPayload");
+ }
+ // Scrubs the ambient VS environment so the bundled clang-mingw toolchain is the only one visible.
+ // The actual search path is set by LocalBuild.ps1's one canonical definition (Get-MkwToolchainPath,
+ // shared with Prepare-NativePrebuilt.ps1), not duplicated here, to avoid drift.
+ void ScrubEnvironment(ProcessStartInfo start)
+ {
+ start.WorkingDirectory = workspace;
+ foreach (var inherited in new[]
+ { "INCLUDE", "LIB", "LIBPATH", "VSINSTALLDIR", "VCToolsInstallDir", "WindowsSdkDir" })
+ start.Environment.Remove(inherited);
+ }
+
+ void Observe(string line)
+ {
+ if (string.IsNullOrWhiteSpace(line)) return;
+ if (progress is not null) progress.Observe(line);
+ else _reporter.Diagnostic(line);
+ }
+
+ var build = await ProcessRunner.RunAsync(
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),
+ "WindowsPowerShell", "v1.0", "powershell.exe"),
+ arguments, Observe, cancellationToken, ScrubEnvironment, capture: false,
+ ex => _reporter.Diagnostic(
+ "The cancelled build process could not be terminated immediately: " + ex.Message));
+ if (build.ExitCode != 0)
+ throw new InvalidOperationException($"Local recompilation failed with exit code {build.ExitCode}. See the setup log for the failed translator or compiler command.");
+
+ var expectedOutputs = profile switch
+ {
+ BuildProfile.Base => [(outputDirectory, "WiiCompiled.exe")],
+ BuildProfile.RetroRewind => [(outputDirectory, "RetroRewind.exe")],
+ _ => new[] { (baseOutputDirectory!, "WiiCompiled.exe"), (outputDirectory, "RetroRewind.exe") }
+ };
+ foreach (var (directory, executableName) in expectedOutputs)
+ {
+ var executable = Path.Combine(directory, executableName);
+ if (!File.Exists(executable) || new FileInfo(executable).Length < 64 * 1024)
+ throw new InvalidDataException("The local build completed without producing a valid game executable.");
+ if (!File.Exists(Path.Combine(directory, "local-build.json")))
+ throw new InvalidDataException("The local build did not produce provenance information.");
+ }
+ }
+
+ ///
+ /// Records what this product was built from: ties it to the
+ /// translator/workspace, to Retro Rewind's embedded Code.pul, so a
+ /// later launch or update can tell whether the binary is still correct.
+ ///
+ public static void WriteFingerprint(string outputDirectory, BuildProfile profile, string toolkitFingerprint,
+ string dolSha256, string relSha256, string codePulSha256, RetroWfcPayloadMode retroWfcPayloadMode,
+ string retroRewindCompileInputsSha256 = "", string retroWfcPayloadSha256 = "",
+ long retroWfcPayloadLength = 0)
+ {
+ var executable = Path.Combine(outputDirectory,
+ profile == BuildProfile.Base ? "WiiCompiled.exe" : "RetroRewind.exe");
+ if (!File.Exists(executable))
+ throw new FileNotFoundException("Cannot record build provenance because the compiled executable is missing.",
+ executable);
+ if (profile == BuildProfile.RetroRewind)
+ {
+ var local = JsonState.TryRead(
+ Path.Combine(outputDirectory, LocalBuildProvenance.FileName));
+ var expectedMode = retroWfcPayloadMode == RetroWfcPayloadMode.Online
+ ? "downloaded"
+ : "skipped";
+ if (local is not { SchemaVersion: 1 } ||
+ !string.Equals(local.DolSha256, dolSha256, StringComparison.OrdinalIgnoreCase) ||
+ !string.Equals(local.RelSha256, relSha256, StringComparison.OrdinalIgnoreCase) ||
+ !string.Equals(local.CodePulSha256, codePulSha256,
+ StringComparison.OrdinalIgnoreCase) ||
+ !string.Equals(local.RetroWfcPayloadMode, expectedMode, StringComparison.Ordinal) ||
+ !string.Equals(local.RetroWfcPayloadSha256,
+ retroWfcPayloadMode == RetroWfcPayloadMode.Online ? retroWfcPayloadSha256 : null,
+ StringComparison.OrdinalIgnoreCase) ||
+ (local.RetroWfcPayloadLength ?? 0) !=
+ (retroWfcPayloadMode == RetroWfcPayloadMode.Online ? retroWfcPayloadLength : 0))
+ {
+ throw new InvalidDataException(
+ "The local Retro Rewind build did not consume the selected Retro-WFC payload snapshot.");
+ }
+ }
+
+ var fingerprint = new ProductFingerprint
+ {
+ Profile = profile == BuildProfile.Base ? "base" : "retro-rewind",
+ ToolkitFingerprint = toolkitFingerprint,
+ DolSha256 = dolSha256,
+ RelSha256 = relSha256,
+ ExecutableSha256 = InputValidation.Sha256File(executable),
+ CodePulSha256 = codePulSha256,
+ RetroRewindCompileInputsSha256 = retroRewindCompileInputsSha256,
+ RetroWfcPayloadMode = retroWfcPayloadMode switch
+ {
+ RetroWfcPayloadMode.Online => "downloaded",
+ RetroWfcPayloadMode.Skipped => "skipped",
+ _ => ""
+ },
+ RetroWfcPayloadSha256 = retroWfcPayloadSha256,
+ RetroWfcPayloadLength = retroWfcPayloadLength
+ };
+ JsonState.Write(Path.Combine(outputDirectory, ProductFingerprint.FileName), fingerprint);
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup/Models.cs b/Launcher/WiiCompiled.Setup/Models.cs
new file mode 100644
index 0000000..0ba2fae
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/Models.cs
@@ -0,0 +1,172 @@
+using System.Text.Json.Serialization;
+
+namespace WiiCompiled.Setup;
+
+internal enum RetroWfcPayloadMode
+{
+ NotApplicable,
+ Online,
+ Skipped
+}
+
+internal sealed class InstallOptions
+{
+ public required string GamePath { get; init; }
+
+ ///
+ /// The canonical Retro Rewind installation Wheel Wizard owns. Its resolved path is recorded as
+ /// retro_rewind_root; only its compile inputs are ever snapshotted.
+ ///
+ public string? RetroDirectoryPath { get; init; }
+
+ public RetroWfcPayloadMode RetroWfcPayloadMode { get; init; }
+ public required string InstallDirectory { get; init; }
+
+ ///
+ /// Establish a portable installation: the parent of becomes the
+ /// portable root, gets the portable.txt marker and a UserData directory, and all
+ /// runtime user state and [paths] settings stay inside it.
+ ///
+ public bool Portable { get; init; }
+
+ public bool HasRetroRewind => RetroDirectoryPath is not null;
+}
+
+internal sealed class PayloadManifest
+{
+ public int SchemaVersion { get; set; }
+ public string ProductVersion { get; set; } = "";
+ public string ExpectedGameId { get; set; } = "RMCP01";
+ public string ExpectedDolSha256 { get; set; } = "";
+ public string ExpectedRelSha256 { get; set; } = "";
+ public string ToolkitReleaseTag { get; set; } = "";
+ public string BuildModel { get; set; } = "";
+ public string RetroWfcPayloadUri { get; set; } = "";
+
+ ///
+ /// Content identities computed once at release-build time (Build-Installer.ps1 --emit-payload-identities)
+ /// so install doesn't re-hash gigabytes per user; compilation alone re-derives the toolkit identity from disk.
+ ///
+ public string ToolkitFingerprint { get; set; } = "";
+ public string ToolkitPackageFingerprint { get; set; } = "";
+ public string RuntimeAssetsFingerprint { get; set; } = "";
+
+ ///
+ /// Cache-reuse subsets of . Empty in a pre-0.2.0 manifest,
+ /// which every consumer treats as "changed" and therefore as a full rebuild - never as reuse.
+ ///
+ public string TranslationFingerprint { get; set; } = "";
+ public string NativeToolchainFingerprint { get; set; } = "";
+}
+
+///
+/// One validated, content-identified download in operation-owned scratch space. Callers use this
+/// exact directory for both the update decision and any resulting build.
+///
+internal sealed record RetroWfcPayloadSnapshot(string Directory, string Sha256, long ByteLength);
+
+internal sealed class DiscHeader
+{
+ [JsonPropertyName("game_id")]
+ public string GameId { get; set; } = "";
+
+ [JsonPropertyName("internal_name")]
+ public string InternalName { get; set; } = "";
+
+ [JsonPropertyName("region")]
+ public string Region { get; set; } = "";
+
+ [JsonPropertyName("revision")]
+ public int Revision { get; set; }
+}
+
+internal sealed class InstallState
+{
+ [JsonRequired]
+ public int SchemaVersion { get; set; } = 1;
+
+ ///
+ /// The setup version that produced this installation. This is the field WheelWizard compares
+ /// against the latest published v* release tag; see docs/WHEELWIZARD_CONTRACT.md.
+ ///
+ public string SetupVersion { get; set; } = ProductInfo.Version;
+
+ public string ProductVersion { get; set; } = ProductInfo.Version;
+
+ /// The directory this state describes, so a frontend can confirm what it found.
+ public string InstallDir { get; set; } = "";
+
+ public DateTime InstalledUtc { get; set; } = DateTime.UtcNow;
+ public bool RetroRewindInstalled { get; set; }
+ public string ToolkitReleaseTag { get; set; } = "";
+ public string DolSha256 { get; set; } = "";
+ public string RelSha256 { get; set; } = "";
+ public string RetroRewindCodePulSha256 { get; set; } = "";
+ public string RetroRewindCompileInputsSha256 { get; set; } = "";
+ public string RetroWfcPayloadMode { get; set; } = "";
+ public string RetroWfcPayloadSha256 { get; set; } = "";
+ public long RetroWfcPayloadLength { get; set; }
+
+ ///
+ /// The RetroRewind6 directory this installation was pointed at. The runtime's actual copy is
+ /// [paths] retro_rewind_root; this mirror lets uninstall drop only the setting it owns.
+ ///
+ public string RetroRewindRoot { get; set; } = "";
+}
+
+///
+/// Written next to every locally produced product (Base, RetroRewind): the exact inputs
+/// it was built from, so launch/update can decide without re-running anything whether it's still correct.
+///
+internal sealed class ProductFingerprint
+{
+ [JsonRequired]
+ public int SchemaVersion { get; set; } = 1;
+ public string Profile { get; set; } = "";
+ public string SetupVersion { get; set; } = ProductInfo.Version;
+ public string ToolkitFingerprint { get; set; } = "";
+ public string DolSha256 { get; set; } = "";
+ public string RelSha256 { get; set; } = "";
+ ///
+ /// Identity of the exact executable produced by this build. A valid input provenance is not
+ /// enough if a partial copy, third-party replacement, or disk corruption changed the product
+ /// after it was built.
+ ///
+ public string ExecutableSha256 { get; set; } = "";
+ public string CodePulSha256 { get; set; } = "";
+ ///
+ /// Identity of every translator-consumed Retro Rewind input this product was built from. An
+ /// asset-only change to the canonical installation does not alter it, which is exactly why an
+ /// asset-only Retro Rewind update needs no backend work at all.
+ ///
+ public string RetroRewindCompileInputsSha256 { get; set; } = "";
+ public string RetroWfcPayloadMode { get; set; } = "";
+ public string RetroWfcPayloadSha256 { get; set; } = "";
+ public long RetroWfcPayloadLength { get; set; }
+ public DateTimeOffset BuiltUtc { get; set; } = DateTimeOffset.UtcNow;
+
+ public const string FileName = "build-fingerprint.json";
+}
+
+///
+/// Written whenever the toolkit (translator + build workspace + compiler) is installed or replaced;
+/// records compile, packaged-tool, and copied-runtime identities. Launch checks compare the compile
+/// identity against each product's in O(1).
+///
+internal sealed class ToolkitState
+{
+ [JsonRequired]
+ public int SchemaVersion { get; set; } = 2;
+ public string ToolkitFingerprint { get; set; } = "";
+ public string ToolkitPackageFingerprint { get; set; } = "";
+ /// Identity of the runtime assets copied verbatim beside each product.
+ public string RuntimeAssetsFingerprint { get; set; } = "";
+ /// Cache-reuse subsets of the compile identity; empty on a pre-0.2.0 installation.
+ public string TranslationFingerprint { get; set; } = "";
+ public string NativeToolchainFingerprint { get; set; } = "";
+ public string ToolkitReleaseTag { get; set; } = "";
+ public string SetupVersion { get; set; } = ProductInfo.Version;
+ public DateTimeOffset UpdatedUtc { get; set; } = DateTimeOffset.UtcNow;
+
+ public const string FileName = "toolkit-state.json";
+}
diff --git a/Launcher/WiiCompiled.Setup/PayloadArchive.cs b/Launcher/WiiCompiled.Setup/PayloadArchive.cs
new file mode 100644
index 0000000..3fb9055
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/PayloadArchive.cs
@@ -0,0 +1,167 @@
+using System.IO.Compression;
+using System.Text;
+using System.Text.Json;
+
+namespace WiiCompiled.Setup;
+
+internal sealed class PayloadArchive : IDisposable
+{
+ private static readonly byte[] FooterMagic = Encoding.ASCII.GetBytes("MKWCPAY1");
+ private static readonly DateTime NormalizedPayloadTimestampUtc =
+ new(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ private const int FooterSize = 24;
+ private readonly FileStream _executable;
+ private readonly SliceStream _payloadStream;
+ private readonly ZipArchive _zip;
+
+ private PayloadArchive(FileStream executable, SliceStream payloadStream, ZipArchive zip)
+ {
+ _executable = executable;
+ _payloadStream = payloadStream;
+ _zip = zip;
+ }
+
+ public static PayloadArchive OpenCurrent()
+ {
+ var path = Environment.ProcessPath ?? throw new InvalidOperationException("Cannot locate the setup executable.");
+ var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
+ try
+ {
+ if (stream.Length < FooterSize)
+ throw new InvalidDataException("This setup executable does not contain an installation payload.");
+
+ stream.Position = stream.Length - FooterSize;
+ using var reader = new BinaryReader(stream, Encoding.ASCII, leaveOpen: true);
+ var magic = reader.ReadBytes(FooterMagic.Length);
+ var offset = reader.ReadInt64();
+ var length = reader.ReadInt64();
+ if (!magic.SequenceEqual(FooterMagic) || offset < 0 || length <= 0 || offset + length != stream.Length - FooterSize)
+ throw new InvalidDataException("The installation payload is missing or damaged.");
+
+ var slice = new SliceStream(stream, offset, length);
+ var zip = new ZipArchive(slice, ZipArchiveMode.Read, leaveOpen: true);
+ return new PayloadArchive(stream, slice, zip);
+ }
+ catch
+ {
+ stream.Dispose();
+ throw;
+ }
+ }
+
+ public PayloadManifest ReadManifest()
+ {
+ var entry = FindEntry(InstalledLayout.PayloadManifestFileName)
+ ?? throw new InvalidDataException("The payload manifest is missing.");
+ using var stream = entry.Open();
+ var manifest = JsonSerializer.Deserialize(stream,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
+ ?? throw new InvalidDataException("The payload manifest is invalid.");
+ if (manifest.SchemaVersion != 2)
+ throw new InvalidDataException($"Unsupported payload schema {manifest.SchemaVersion}.");
+ // The payload identities are the release-computed replacement for hashing the whole
+ // toolkit on the user's machine; a payload without them was packaged incorrectly.
+ if (string.IsNullOrWhiteSpace(manifest.ToolkitFingerprint) ||
+ string.IsNullOrWhiteSpace(manifest.ToolkitPackageFingerprint) ||
+ string.IsNullOrWhiteSpace(manifest.RuntimeAssetsFingerprint))
+ throw new InvalidDataException("The payload manifest is missing its content identities.");
+ InputValidation.ValidateRetroWfcPayloadUri(manifest.RetroWfcPayloadUri);
+ return manifest;
+ }
+
+ public void ExtractEntry(string entryName, string destination)
+ {
+ var entry = FindEntry(entryName)
+ ?? throw new InvalidDataException($"Payload entry is missing: {entryName}");
+ Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
+ using (var input = entry.Open())
+ using (var output = new FileStream(destination, FileMode.Create, FileAccess.Write, FileShare.None))
+ input.CopyTo(output);
+ NormalizeExtractedTimestamp(destination);
+ }
+
+ public void ExtractDirectory(string prefix, string destination)
+ {
+ prefix = NormalizeEntryName(prefix).TrimEnd('/') + "/";
+ var destinationRoot = Path.GetFullPath(destination).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
+ foreach (var entry in _zip.Entries)
+ {
+ var normalizedEntry = NormalizeEntryName(entry.FullName);
+ if (!normalizedEntry.StartsWith(prefix, StringComparison.Ordinal) || normalizedEntry.EndsWith('/'))
+ continue;
+ var relative = normalizedEntry[prefix.Length..].Replace('/', Path.DirectorySeparatorChar);
+ var outputPath = Path.GetFullPath(Path.Combine(destinationRoot, relative));
+ if (!outputPath.StartsWith(destinationRoot, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidDataException($"Unsafe payload path: {entry.FullName}");
+ Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
+ entry.ExtractToFile(outputPath, overwrite: true);
+ NormalizeExtractedTimestamp(outputPath);
+ }
+ }
+
+ internal static void NormalizeExtractedTimestamp(string path) =>
+ File.SetLastWriteTimeUtc(path, NormalizedPayloadTimestampUtc);
+
+ private static string NormalizeEntryName(string value) => value.Replace('\\', '/').TrimStart('/');
+ private ZipArchiveEntry? FindEntry(string name)
+ {
+ var normalized = NormalizeEntryName(name);
+ return _zip.Entries.FirstOrDefault(entry =>
+ NormalizeEntryName(entry.FullName).Equals(normalized, StringComparison.Ordinal));
+ }
+
+ public void Dispose()
+ {
+ _zip.Dispose();
+ _payloadStream.Dispose();
+ _executable.Dispose();
+ }
+
+ private sealed class SliceStream : Stream
+ {
+ private readonly Stream _inner;
+ private readonly long _offset;
+ private readonly long _length;
+ private long _position;
+
+ public SliceStream(Stream inner, long offset, long length)
+ {
+ _inner = inner;
+ _offset = offset;
+ _length = length;
+ }
+
+ public override bool CanRead => true;
+ public override bool CanSeek => true;
+ public override bool CanWrite => false;
+ public override long Length => _length;
+ public override long Position { get => _position; set => Seek(value, SeekOrigin.Begin); }
+ public override void Flush() { }
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ if (_position >= _length) return 0;
+ count = (int)Math.Min(count, _length - _position);
+ lock (_inner)
+ {
+ _inner.Position = _offset + _position;
+ var read = _inner.Read(buffer, offset, count);
+ _position += read;
+ return read;
+ }
+ }
+ public override long Seek(long offset, SeekOrigin origin)
+ {
+ var next = origin switch
+ {
+ SeekOrigin.Begin => offset,
+ SeekOrigin.Current => _position + offset,
+ SeekOrigin.End => _length + offset,
+ _ => throw new ArgumentOutOfRangeException(nameof(origin))
+ };
+ if (next < 0 || next > _length) throw new IOException("Attempted to seek outside the payload.");
+ return _position = next;
+ }
+ public override void SetLength(long value) => throw new NotSupportedException();
+ public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup/PortableRoot.cs b/Launcher/WiiCompiled.Setup/PortableRoot.cs
new file mode 100644
index 0000000..eab3dcd
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/PortableRoot.cs
@@ -0,0 +1,143 @@
+namespace WiiCompiled.Setup;
+
+///
+/// A portable installation is a self-contained directory tree the user can move or carry on removable media:
+///
+/// <root>\portable.txt marker; its contents are irrelevant
+/// <root>\Install\ the installation directory
+/// <root>\UserData\ runtime user state (Config.toml, NAND, Cache, Logs)
+///
+/// The runtime finds the same root independently (runtime/include/runtime_config.h,
+/// PortableRootDirectory); this class must keep the same marker name, layout, and depth bound.
+///
+internal static class PortableRoot
+{
+ public const string MarkerFileName = "portable.txt";
+ public const string UserDataDirectoryName = "UserData";
+ public const string InstallDirectoryName = "Install";
+
+ ///
+ /// Parents searched above the start directory. Kept small (installs sit 2 levels below root,
+ /// <root>\Install\Base\game.exe) so an unrelated marker far up a drive can't capture it.
+ ///
+ public const int MaximumSearchDepth = 4;
+
+ ///
+ /// The portable root belongs to, or null otherwise. Callers pass the
+ /// installation directory (not the running executable's location), since a downloaded setup runs from Downloads.
+ ///
+ public static string? TryFind(string startDirectory)
+ {
+ if (string.IsNullOrWhiteSpace(startDirectory)) return null;
+ string current;
+ try
+ {
+ // Normalize before trimming: "C:\" trimmed to "C:" is the current directory on that
+ // drive, not the drive root.
+ current = Normalize(startDirectory);
+ }
+ catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
+ {
+ return null;
+ }
+
+ for (var level = 0; level <= MaximumSearchDepth; level++)
+ {
+ if (File.Exists(Path.Combine(current, MarkerFileName))) return current;
+ var parent = Path.GetDirectoryName(current);
+ if (string.IsNullOrEmpty(parent) || parent.Equals(current, StringComparison.Ordinal)) break;
+ current = parent;
+ }
+ return null;
+ }
+
+ public static string UserDataDirectory(string root) => Path.Combine(root, UserDataDirectoryName);
+
+ /// Whether is or lives inside it.
+ public static bool Contains(string root, string candidate) =>
+ FileSystemUtilities.PathContains(root, candidate);
+
+ ///
+ /// Establishes the root an install is about to publish into: the marker and the user-state
+ /// directory must exist before anything resolves a configuration path, because that resolution is
+ /// what decides whether the installation writes portable or machine-wide settings.
+ ///
+ public static string Create(string root)
+ {
+ var full = Normalize(root);
+ EnsureUsableRoot(full);
+ Directory.CreateDirectory(full);
+ Directory.CreateDirectory(UserDataDirectory(full));
+ var marker = Path.Combine(full, MarkerFileName);
+ if (!File.Exists(marker))
+ {
+ File.WriteAllText(marker,
+ $"{ProductInfo.Name} portable installation." + Environment.NewLine +
+ "This marker makes the runtime keep Config.toml, NAND, Cache, and Logs in UserData\\ " +
+ "beside it instead of in %LOCALAPPDATA%." + Environment.NewLine +
+ "Delete it to make this installation use per-user application data again." +
+ Environment.NewLine);
+ }
+ return full;
+ }
+
+ ///
+ /// A portable root is deleted wholesale by the user, so like an install directory it may never be a
+ /// drive root or well-known system location; both share .
+ ///
+ public static void EnsureUsableRoot(string root) =>
+ FileSystemUtilities.EnsureUsableLocation(root, "A portable installation root");
+
+ private static string Normalize(string path) => FileSystemUtilities.NormalizePath(path);
+}
+
+///
+/// A portable root can be moved or renamed between operations. Every installed-host operation that
+/// reads install-state.json passes through here first so exactly one place decides what a
+/// moved installation means, and so a non-portable installation is never touched.
+///
+internal static class PortableInstallHealing
+{
+ ///
+ /// Reconciles a moved portable installation with its recorded location: the state file adopts the
+ /// directory it was actually found in, and the native build tree is discarded because its
+ /// CMake cache holds absolute paths from the old location. Returns whether anything was healed.
+ ///
+ public static bool HealMovedInstall(Installation installation, IInstallReporter? reporter = null)
+ {
+ // Guard: an ordinary installation that disagrees with its state file is a real problem for
+ // the operation to report, not something to silently rewrite.
+ if (PortableRoot.TryFind(installation.Root) is null) return false;
+
+ var state = installation.ReadInstallState();
+ if (state is not { SchemaVersion: 1 } || string.IsNullOrWhiteSpace(state.InstallDir)) return false;
+
+ string recorded;
+ try
+ {
+ recorded = FileSystemUtilities.NormalizePath(state.InstallDir);
+ }
+ catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
+ {
+ recorded = state.InstallDir;
+ }
+ if (recorded.Equals(installation.Root, StringComparison.OrdinalIgnoreCase)) return false;
+
+ var previous = state.InstallDir;
+ state.InstallDir = installation.Root;
+ JsonState.Write(installation.InstallStatePath, state);
+
+ // The configured native build directory bakes absolute source, toolchain, and output paths
+ // into CMakeCache.txt. After a move it is unusable and would fail the next configure rather
+ // than being reused, so it is removed and reconfigured from scratch on the next build.
+ var nativeBuild = Path.Combine(installation.WorkspaceDirectory, "native-build");
+ var hadNativeBuild = Directory.Exists(nativeBuild);
+ if (hadNativeBuild) FileSystemUtilities.DeleteDirectoryIfExists(nativeBuild);
+
+ reporter?.Diagnostic(
+ $"This portable installation moved from {previous} to {installation.Root}. " +
+ "The recorded location was updated" +
+ (hadNativeBuild ? " and the location-bound native build cache was discarded." : "."));
+ return true;
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup/ProductRepairService.cs b/Launcher/WiiCompiled.Setup/ProductRepairService.cs
new file mode 100644
index 0000000..57769d1
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/ProductRepairService.cs
@@ -0,0 +1,646 @@
+namespace WiiCompiled.Setup;
+
+///
+/// Reconciles installed products against the canonical Retro Rewind install Wheel Wizard owns: the
+/// toolkit compiles, the source only contributes inputs and its path (retro_rewind_root).
+///
+internal sealed class ProductRepairService
+{
+ internal enum RetroWfcPayloadAction { None, PublishCacheOnly, RebuildRetro }
+
+ ///
+ /// Inputs every reconciliation entry point shares. Everything here is optional context the
+ /// caller already owns under the operation lock; the entry point decides what work is possible.
+ ///
+ internal sealed record ReconcileOptions
+ {
+ /// Operation scratch the caller owns, or null to own one for this call.
+ public string? ScratchRoot { get; init; }
+
+ /// The release's authoritative clean-disc inputs, supplied together or not at all.
+ public string? ExpectedDolSha256 { get; init; }
+ public string? ExpectedRelSha256 { get; init; }
+
+ ///
+ /// The frontend's own canonical RetroRewind6 directory, recorded as
+ /// retro_rewind_root. Required by every Retro Rewind operation: the compile-input
+ /// snapshot lives in operation scratch, so it can never be the durable value.
+ ///
+ public string? CanonicalRetroRewindRoot { get; init; }
+ }
+
+ internal sealed record ReconciliationResult(
+ RetroRewindCompileInputs? CompileInputs,
+ bool PublicationCommitted,
+ ///
+ /// true when this operation proved or published the online payload cache, null when it made
+ /// no observation and the next inspection must check for itself. false is never produced
+ /// here; product inspection reserves it for a cache already proven stale.
+ ///
+ bool? CachedRetroWfcPayloadMatches);
+
+ private readonly Installation _installation;
+ private readonly IInstallReporter _reporter;
+
+ public ProductRepairService(Installation installation, IInstallReporter reporter)
+ {
+ _installation = installation;
+ _reporter = reporter;
+ }
+
+ ///
+ /// Repairs the base product and any copy-only drift the installation can fix from its own
+ /// authoritative workspace. The caller must hold .
+ ///
+ public Task RepairBaseAsync(ReconcileOptions options,
+ CancellationToken cancellationToken = default) =>
+ ReconcileAsync(null, RetroWfcPayloadMode.NotApplicable, options, cancellationToken);
+
+ ///
+ /// Reconciles Retro Rewind against one immutable compile-input snapshot of the canonical
+ /// installation, recompiling only what those inputs actually change.
+ ///
+ public Task RepairRetroAsync(RetroRewindCompileInputs snapshot,
+ RetroWfcPayloadMode payloadMode, ReconcileOptions options,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+ if (payloadMode == RetroWfcPayloadMode.NotApplicable)
+ throw new InvalidOperationException(
+ "A Retro Rewind operation requires a Retro-WFC payload mode.");
+ return ReconcileAsync(snapshot, payloadMode, options, cancellationToken);
+ }
+
+ private async Task ReconcileAsync(RetroRewindCompileInputs? snapshot,
+ RetroWfcPayloadMode requestedPayloadMode, ReconcileOptions options,
+ CancellationToken cancellationToken)
+ {
+ if (!_installation.IsPresent)
+ throw new InvalidOperationException("WiiCompiled is not installed here.");
+ if (!_installation.HasToolkit)
+ throw new InvalidOperationException(
+ "The installed recompilation toolkit is missing. Apply the current setup release before repairing products.");
+ var expectedDolSha256 = options.ExpectedDolSha256;
+ var expectedRelSha256 = options.ExpectedRelSha256;
+ if (string.IsNullOrWhiteSpace(expectedDolSha256) != string.IsNullOrWhiteSpace(expectedRelSha256))
+ throw new ArgumentException("Both authoritative game-input hashes must be supplied together.");
+
+ var toolkitFingerprint = _installation.ResolveToolkitFingerprint();
+ if (string.IsNullOrWhiteSpace(toolkitFingerprint))
+ throw new InvalidDataException("The installed recompilation toolkit cannot be identified safely.");
+
+ using var ownedScratch = options.ScratchRoot is null
+ ? InstallScratchSpace.CreateInsideInstall(_installation.Root, _reporter)
+ : null;
+ var scratchRoot = options.ScratchRoot ?? ownedScratch!.Root;
+ Directory.CreateDirectory(scratchRoot);
+
+ RetroWfcPayloadSnapshot? payloadSnapshot = null;
+ if (requestedPayloadMode == RetroWfcPayloadMode.Online)
+ {
+ _reporter.Progress(InstallStages.PrepareRetro,
+ "Downloading the current shared Retro-WFC payload snapshot...", 3);
+ var payloadScratch = Path.Combine(scratchRoot, "retro-wfc-payload");
+ try
+ {
+ payloadSnapshot = await InputValidation.DownloadRetroWfcPayloadAsync(
+ InputValidation.CurrentRetroWfcPayloadUri, payloadScratch, cancellationToken);
+ }
+ catch (Exception ex) when (!cancellationToken.IsCancellationRequested &&
+ ex is HttpRequestException or IOException or InvalidDataException
+ or InvalidOperationException or OperationCanceledException)
+ {
+ payloadSnapshot = RecoverInstalledRetroWfcPayload(toolkitFingerprint,
+ Path.Combine(scratchRoot, "retro-wfc-payload-recovered"), ex, cancellationToken);
+ }
+ }
+
+
+ _reporter.Progress(InstallStages.PrepareRetro,
+ "Checking which installed products are still current...", 3);
+ var downloadedPayloadMatchesProduct = payloadSnapshot is not null &&
+ _installation.MatchesRetroWfcPayloadSnapshot(
+ toolkitFingerprint, payloadSnapshot);
+ var cachedPayloadMatchesSnapshot = payloadSnapshot is null
+ ? (bool?)null
+ : _installation.CachedRetroWfcPayloadMatches(payloadSnapshot);
+ var payloadAction = payloadSnapshot is null
+ ? RetroWfcPayloadAction.None
+ : PlanRetroWfcPayloadReconciliation(downloadedPayloadMatchesProduct,
+ cachedPayloadMatchesSnapshot == true);
+
+ // The canonical root the runtime must read assets from. A base-only operation never changes
+ // it, so its recorded value stays exactly as the last Retro Rewind operation left it.
+ var canonicalRoot = snapshot is null
+ ? null
+ : Path.GetFullPath(options.CanonicalRetroRewindRoot ?? throw new InvalidOperationException(
+ "A Retro Rewind operation must record the canonical Retro Rewind folder."));
+ var canonicalRootChanged = canonicalRoot is not null && !RecordedCanonicalRootMatches(canonicalRoot);
+
+ var baseState = _installation.CheckBaseCore(toolkitFingerprint);
+ var retroState = _installation.CheckRetroRewindCore(toolkitFingerprint, snapshot,
+ canonicalError: null, cachedPayloadMatchesSnapshot);
+ var desiredInputDrift = FindDesiredInputDrift(_installation, toolkitFingerprint,
+ expectedDolSha256, expectedRelSha256);
+ var rebuildBase = RequiresCompilation(baseState.Status) || desiredInputDrift.Base;
+ var rebuildRetro = false;
+ var syncRetroWfcPayload = false;
+
+ if (snapshot is null)
+ {
+ if (desiredInputDrift.Retro)
+ throw new InvalidOperationException(
+ "The release changes the clean game inputs used by the installed Retro Rewind product. " +
+ "Supply Wheel Wizard's current Retro Rewind folder explicitly so it can be rebuilt safely.");
+ if (retroState.ActionRequired)
+ throw new InvalidOperationException(
+ "Retro Rewind needs repair, but Wheel Wizard's current Retro Rewind folder was not supplied. " +
+ retroState.Detail);
+ }
+ else
+ {
+ rebuildRetro = desiredInputDrift.Retro || RequiresCompilation(retroState.Status) ||
+ retroState.Status == ProductStatus.Absent;
+ syncRetroWfcPayload = retroState.Status == ProductStatus.PayloadChanged;
+
+ if (payloadAction == RetroWfcPayloadAction.RebuildRetro)
+ {
+ rebuildRetro = true;
+ syncRetroWfcPayload = true;
+ }
+ else if (payloadAction == RetroWfcPayloadAction.PublishCacheOnly)
+ {
+ syncRetroWfcPayload = true;
+ }
+
+ var installedMode = _installation.ResolveRetroWfcPayloadMode(toolkitFingerprint);
+ if (_installation.HasRetroProduct && installedMode != RetroWfcPayloadMode.NotApplicable &&
+ installedMode != requestedPayloadMode)
+ {
+ rebuildRetro = true;
+ syncRetroWfcPayload = requestedPayloadMode == RetroWfcPayloadMode.Online;
+ }
+ if (rebuildRetro && requestedPayloadMode == RetroWfcPayloadMode.Online)
+ syncRetroWfcPayload = true;
+ }
+
+ // Copied support files are republished from the installed workspace, never recompiled. A
+ // rebuild already stages fresh copies, so only a product that is not being rebuilt needs it.
+ var syncBaseRuntimeAssets = !rebuildBase && File.Exists(_installation.BaseExecutable) &&
+ _installation.ValidateCopiedRuntimeAssets(_installation.BaseDirectory,
+ "base", toolkitFingerprint) is not null;
+ var syncRetroRuntimeAssets = !rebuildRetro && _installation.HasRetroProduct &&
+ _installation.ValidateCopiedRuntimeAssets(_installation.RetroDirectory,
+ "retro-rewind", toolkitFingerprint) is not null;
+
+ if (!rebuildBase && !rebuildRetro && !syncRetroWfcPayload && !syncBaseRuntimeAssets &&
+ !syncRetroRuntimeAssets && !canonicalRootChanged)
+ {
+ _reporter.Progress(InstallStages.Publish, "The installed products are already current.", 99);
+ return new ReconciliationResult(snapshot, PublicationCommitted: false,
+ VerifiedOnlinePayloadCache(toolkitFingerprint));
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+ var builder = new LocalBuildService(_reporter);
+ string? baseOutput = null;
+ string? retroOutput = null;
+
+ if (rebuildBase || rebuildRetro)
+ {
+ EnsureSufficientRepairDiskSpace();
+ _reporter.Progress(InstallStages.PrepareRetro,
+ "Verifying the installed recompilation toolkit...", 4);
+ var toolkitComponents = VerifyToolkitForCompilation(toolkitFingerprint, cancellationToken);
+ RefreshTranslationAssets(expectedDolSha256, expectedRelSha256, cancellationToken);
+
+ // A broken or blocked product may mean the caches themselves are damaged, so nothing
+ // recorded on disk is allowed to contribute to the repaired product.
+ var forceCleanBuild =
+ (rebuildBase && baseState.Status is ProductStatus.Broken or ProductStatus.Blocked) ||
+ (rebuildRetro && retroState.Status is ProductStatus.Broken or ProductStatus.Blocked);
+ var (dolSha, relSha) = TranslationInputHashes();
+
+ if (rebuildBase && rebuildRetro)
+ {
+ // One retro-aware translation, one build graph, both products - the same reason the
+ // install path uses BuildProfile.Both: sequential legs would re-emit every shared
+ // base shard with retro-aware content and recompile all of them twice.
+ var compileInputs = snapshot ?? throw new InvalidOperationException(
+ "Retro Rewind recompilation requires a compile-input snapshot.");
+ baseOutput = Path.Combine(scratchRoot, "base-output");
+ retroOutput = Path.Combine(scratchRoot, "retro-output");
+ _reporter.Progress(InstallStages.BuildBase,
+ "Recompiling Mario Kart Wii and Retro Rewind with the installed toolkit...", 5);
+ await builder.BuildAsync(_installation.Root, BuildProfile.Both, retroOutput,
+ requestedPayloadMode, payloadSnapshot?.Directory, cancellationToken,
+ toolkitComponents, forceCleanBuild,
+ progress: new BuildProgressWindow(_reporter, InstallStages.BuildBase, 5, 92),
+ retroRewindPackageDirectory: compileInputs.RetroRewindRoot,
+ baseOutputDirectory: baseOutput);
+ LocalBuildService.WriteFingerprint(baseOutput, BuildProfile.Base, toolkitFingerprint,
+ dolSha, relSha, "", RetroWfcPayloadMode.NotApplicable);
+ LocalBuildService.WriteFingerprint(retroOutput, BuildProfile.RetroRewind,
+ toolkitFingerprint, dolSha, relSha, compileInputs.CodePulSha256,
+ requestedPayloadMode, compileInputs.CompileInputsSha256,
+ payloadSnapshot?.Sha256 ?? "", payloadSnapshot?.ByteLength ?? 0);
+ }
+ else if (rebuildBase)
+ {
+ baseOutput = Path.Combine(scratchRoot, "base-output");
+ _reporter.Progress(InstallStages.BuildBase,
+ "Recompiling Mario Kart Wii with the installed toolkit...", 5);
+ await builder.BuildAsync(_installation.Root, BuildProfile.Base, baseOutput,
+ RetroWfcPayloadMode.NotApplicable, null, cancellationToken,
+ toolkitComponents, forceCleanBuild,
+ progress: new BuildProgressWindow(_reporter, InstallStages.BuildBase, 5, 92));
+ LocalBuildService.WriteFingerprint(baseOutput, BuildProfile.Base, toolkitFingerprint,
+ dolSha, relSha, "", RetroWfcPayloadMode.NotApplicable);
+ }
+ else
+ {
+ var compileInputs = snapshot ?? throw new InvalidOperationException(
+ "Retro Rewind recompilation requires a compile-input snapshot.");
+ retroOutput = Path.Combine(scratchRoot, "retro-output");
+ _reporter.Progress(InstallStages.BuildRetro,
+ "Recompiling Retro Rewind for the canonical Code.pul...", 5);
+ await builder.BuildAsync(_installation.Root, BuildProfile.RetroRewind, retroOutput,
+ requestedPayloadMode, payloadSnapshot?.Directory, cancellationToken,
+ toolkitComponents, forceCleanBuild,
+ progress: new BuildProgressWindow(_reporter, InstallStages.BuildRetro, 5, 92),
+ retroRewindPackageDirectory: compileInputs.RetroRewindRoot);
+ LocalBuildService.WriteFingerprint(retroOutput, BuildProfile.RetroRewind,
+ toolkitFingerprint, dolSha, relSha, compileInputs.CodePulSha256,
+ requestedPayloadMode, compileInputs.CompileInputsSha256,
+ payloadSnapshot?.Sha256 ?? "", payloadSnapshot?.ByteLength ?? 0);
+ }
+ }
+
+ if (retroOutput is not null)
+ PreserveProductConfig(_installation.RetroDirectory, retroOutput);
+ if (baseOutput is not null)
+ PreserveProductConfig(_installation.BaseDirectory, baseOutput);
+
+ cancellationToken.ThrowIfCancellationRequested();
+ PublishProducts(new PublicationPlan
+ {
+ BaseOutput = baseOutput,
+ RetroOutput = retroOutput,
+ CompileInputs = snapshot,
+ CanonicalRetroRewindRoot = canonicalRoot,
+ SyncBaseRuntimeAssets = syncBaseRuntimeAssets,
+ SyncRetroRuntimeAssets = syncRetroRuntimeAssets,
+ SyncRetroWfcPayload = syncRetroWfcPayload,
+ ToolkitFingerprint = toolkitFingerprint,
+ ScratchRoot = scratchRoot,
+ RequestedPayloadMode = requestedPayloadMode,
+ PayloadSnapshot = payloadSnapshot,
+ ExpectedDolSha256 = expectedDolSha256,
+ ExpectedRelSha256 = expectedRelSha256
+ }, cancellationToken);
+ _reporter.Progress(InstallStages.Publish, "Product repair complete.", 99);
+ return new ReconciliationResult(snapshot, PublicationCommitted: true,
+ VerifiedOnlinePayloadCache(toolkitFingerprint));
+ }
+
+
+ private RetroWfcPayloadSnapshot RecoverInstalledRetroWfcPayload(string toolkitFingerprint,
+ string destinationDirectory, Exception downloadFailure, CancellationToken cancellationToken)
+ {
+ InvalidOperationException Unavailable(string reason) => new(
+ "The Retro-WFC payload service is unavailable" +
+ $" ({downloadFailure.Message.TrimEnd('.')}), and {reason}" +
+ " Try again once the service is reachable.", downloadFailure);
+
+ var fingerprint = _installation.ReadProductFingerprint(_installation.RetroDirectory,
+ toolkitFingerprint);
+ var state = _installation.ReadInstallState();
+ if (fingerprint is not { RetroWfcPayloadMode: "downloaded" } ||
+ state is not { RetroWfcPayloadMode: "downloaded" } ||
+ string.IsNullOrWhiteSpace(fingerprint.RetroWfcPayloadSha256) ||
+ !fingerprint.RetroWfcPayloadSha256.Equals(state.RetroWfcPayloadSha256,
+ StringComparison.Ordinal) ||
+ fingerprint.RetroWfcPayloadLength <= 0)
+ {
+ throw Unavailable("this installation has no verified payload snapshot to fall back to.");
+ }
+
+ string cachedFile;
+ try
+ {
+ cachedFile = InputValidation.ResolveRetroWfcPayloadFile(
+ _installation.WorkspaceRetroWfcPayload);
+ cancellationToken.ThrowIfCancellationRequested();
+ if (new FileInfo(cachedFile).Length != fingerprint.RetroWfcPayloadLength ||
+ !InputValidation.Sha256File(cachedFile).Equals(fingerprint.RetroWfcPayloadSha256,
+ StringComparison.Ordinal))
+ {
+ throw Unavailable("the installed payload snapshot no longer matches its recorded identity.");
+ }
+
+ var destination = Path.Combine(Path.GetFullPath(destinationDirectory), "binary",
+ "payload.RMCPD00.bin");
+ Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
+ File.Copy(cachedFile, destination, overwrite: true);
+ var root = InputValidation.ValidateStagedRetroWfcPayloadDirectory(destinationDirectory);
+ _reporter.Diagnostic(
+ "The Retro-WFC payload service is unavailable; reusing the installation's verified " +
+ $"payload snapshot (sha256 {fingerprint.RetroWfcPayloadSha256[..12]}..., " +
+ $"{fingerprint.RetroWfcPayloadLength} bytes).");
+ return new RetroWfcPayloadSnapshot(root, fingerprint.RetroWfcPayloadSha256,
+ fingerprint.RetroWfcPayloadLength);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
+ {
+ throw Unavailable($"the installed payload snapshot could not be reused ({ex.Message.TrimEnd('.')}).");
+ }
+ }
+
+ /// The statuses that can only be resolved by producing the product again.
+ private static bool RequiresCompilation(ProductStatus status) =>
+ status is ProductStatus.ToolkitChanged or ProductStatus.CodePulChanged or
+ ProductStatus.CompileInputsChanged or ProductStatus.InputsMissing or
+ ProductStatus.Blocked or ProductStatus.Broken;
+
+ private bool? VerifiedOnlinePayloadCache(string toolkitFingerprint) =>
+ _installation.HasRetroProduct &&
+ _installation.ResolveRetroWfcPayloadMode(toolkitFingerprint) == RetroWfcPayloadMode.Online
+ ? true
+ : null;
+
+ internal static RetroWfcPayloadAction PlanRetroWfcPayloadReconciliation(bool productMatchesSnapshot,
+ bool cacheMatchesSnapshot) =>
+ !productMatchesSnapshot ? RetroWfcPayloadAction.RebuildRetro
+ : cacheMatchesSnapshot ? RetroWfcPayloadAction.None
+ : RetroWfcPayloadAction.PublishCacheOnly;
+
+ private bool RecordedCanonicalRootMatches(string canonicalRoot)
+ {
+ var configured = _installation.ConfiguredRetroRewindRoot;
+ var recorded = _installation.ReadInstallState()?.RetroRewindRoot;
+ return configured is not null && recorded is not null &&
+ SamePath(configured, canonicalRoot) && SamePath(recorded, canonicalRoot);
+ }
+
+ private static bool SamePath(string left, string right)
+ {
+ try { return FileSystemUtilities.PathsEqual(left, right); }
+ catch { return false; }
+ }
+
+ /// Everything one reconciliation decided to publish as a single transaction.
+ private sealed record PublicationPlan
+ {
+ public string? BaseOutput { get; init; }
+ public string? RetroOutput { get; init; }
+ public RetroRewindCompileInputs? CompileInputs { get; init; }
+ public string? CanonicalRetroRewindRoot { get; init; }
+ public bool SyncBaseRuntimeAssets { get; init; }
+ public bool SyncRetroRuntimeAssets { get; init; }
+ public bool SyncRetroWfcPayload { get; init; }
+
+ public required string ToolkitFingerprint { get; init; }
+ public required string ScratchRoot { get; init; }
+ public RetroWfcPayloadMode RequestedPayloadMode { get; init; }
+ public RetroWfcPayloadSnapshot? PayloadSnapshot { get; init; }
+ public string? ExpectedDolSha256 { get; init; }
+ public string? ExpectedRelSha256 { get; init; }
+ }
+
+ private void PublishProducts(PublicationPlan plan, CancellationToken cancellationToken)
+ {
+ var entries = new List();
+ if (plan.BaseOutput is not null)
+ entries.Add(InstallTransactionEntry.Directory(plan.BaseOutput, _installation.BaseDirectory));
+ if (plan.RetroOutput is not null)
+ entries.Add(InstallTransactionEntry.Directory(plan.RetroOutput, _installation.RetroDirectory));
+
+ if (plan.SyncBaseRuntimeAssets || plan.SyncRetroRuntimeAssets)
+ AddRuntimeAssetPublicationEntries(entries, plan.ScratchRoot, plan.SyncBaseRuntimeAssets,
+ plan.SyncRetroRuntimeAssets, plan.ToolkitFingerprint, cancellationToken);
+
+ if (plan.SyncRetroWfcPayload)
+ {
+ if (plan.PayloadSnapshot is null)
+ throw new InvalidDataException("The downloaded Retro-WFC payload was not staged.");
+ entries.Add(InstallTransactionEntry.Directory(plan.PayloadSnapshot.Directory,
+ _installation.WorkspaceRetroWfcPayload));
+ }
+ if (plan.RetroOutput is not null && plan.RequestedPayloadMode == RetroWfcPayloadMode.Skipped)
+ {
+ var emptyPayload = Path.Combine(plan.ScratchRoot, "retro-wfc-payload-removed");
+ Directory.CreateDirectory(emptyPayload);
+ entries.Add(InstallTransactionEntry.Directory(emptyPayload,
+ _installation.WorkspaceRetroWfcPayload));
+ }
+
+ // Copied-runtime repair does not change product or input provenance. Publish only the
+ // authoritative support files in that case, leaving install-state byte-for-byte intact.
+ var publishState = plan.BaseOutput is not null || plan.RetroOutput is not null ||
+ plan.CanonicalRetroRewindRoot is not null;
+ if (publishState)
+ {
+ var statePath = Path.Combine(plan.ScratchRoot, InstalledLayout.InstallStateFileName);
+ JsonState.Write(statePath, BuildUpdatedInstallState(plan.ToolkitFingerprint,
+ plan.CompileInputs, plan.CanonicalRetroRewindRoot,
+ plan.RetroOutput is null ? null : plan.RequestedPayloadMode,
+ plan.RetroOutput is null ? null : plan.PayloadSnapshot, plan.ExpectedDolSha256,
+ plan.ExpectedRelSha256));
+ entries.Add(InstallTransactionEntry.File(statePath, _installation.InstallStatePath));
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+ var configPath = RuntimeConfiguration.ResolveConfigPath(_installation.Root);
+ var configSnapshot = RuntimeConfiguration.Capture(configPath);
+ using var transaction = InstallTransaction.Begin(_installation.Root, _reporter, entries.ToArray());
+ transaction.Publish();
+ if (plan.CanonicalRetroRewindRoot is not null)
+ {
+ transaction.RecordRuntimeConfigurationMutation(configSnapshot);
+ RuntimeConfiguration.SetRetroRewindRoot(configPath, plan.CanonicalRetroRewindRoot);
+ }
+ transaction.Commit();
+ }
+
+ private void EnsureSufficientRepairDiskSpace()
+ {
+ const long localBuildAllowance = 14L * 1024 * 1024 * 1024;
+ const long safetyAllowance = 2L * 1024 * 1024 * 1024;
+ FileSystemUtilities.EnsureFreeSpace(_installation.Root,
+ localBuildAllowance + safetyAllowance, "Local recompilation");
+ }
+
+ internal ToolkitFingerprintComponents VerifyToolkitForCompilation(string authoritativeFingerprint,
+ CancellationToken cancellationToken = default)
+ {
+ var state = _installation.ReadToolkitState();
+ if (state is not { SchemaVersion: 2 } ||
+ string.IsNullOrWhiteSpace(state.ToolkitFingerprint) ||
+ !state.ToolkitFingerprint.Equals(authoritativeFingerprint, StringComparison.Ordinal))
+ {
+ throw new InvalidDataException(
+ "The installed toolkit provenance is missing or does not match this repair operation.");
+ }
+
+ var components = ToolkitFingerprint.ComputeComponents(_installation.Root, cancellationToken);
+ if (!components.Compile.Equals(authoritativeFingerprint, StringComparison.Ordinal))
+ {
+ throw new InvalidDataException(
+ "The installed recompilation toolkit was modified or is incomplete. Apply the current setup release before compiling products.");
+ }
+ return components;
+ }
+
+ private void AddRuntimeAssetPublicationEntries(List entries,
+ string scratchRoot, bool includeBase, bool includeRetro, string toolkitFingerprint,
+ CancellationToken cancellationToken)
+ {
+ var state = _installation.ReadToolkitState();
+ if (state is not { SchemaVersion: 2 } ||
+ !state.ToolkitFingerprint.Equals(toolkitFingerprint, StringComparison.Ordinal) ||
+ string.IsNullOrWhiteSpace(state.RuntimeAssetsFingerprint))
+ {
+ throw new InvalidDataException(
+ "The installed runtime-asset provenance is missing or does not belong to the current toolkit.");
+ }
+
+ var expectedFingerprint = state.RuntimeAssetsFingerprint;
+ var sourceFingerprint = ToolkitFingerprint.ComputeRuntimeAssets(_installation.Root, cancellationToken);
+ if (!sourceFingerprint.Equals(expectedFingerprint, StringComparison.Ordinal))
+ {
+ throw new InvalidDataException(
+ "The installed BuildWorkspace runtime assets do not match their authoritative toolkit provenance.");
+ }
+
+ var products = new[]
+ {
+ (Include: includeBase, Name: "Base", Destination: _installation.BaseDirectory),
+ (Include: includeRetro, Name: "RetroRewind", Destination: _installation.RetroDirectory)
+ }
+ .Where(product => product.Include)
+ .Select(product => (product.Name, product.Destination));
+ RuntimeAssetPublication.AddEntries(entries,
+ Path.Combine(_installation.WorkspaceDirectory, "runtime", "assets"),
+ Path.Combine(scratchRoot, "runtime-asset-repair"), products, expectedFingerprint,
+ "repair", _reporter.Diagnostic, cancellationToken);
+
+ cancellationToken.ThrowIfCancellationRequested();
+ var sourceAfter = ToolkitFingerprint.ComputeRuntimeAssets(_installation.Root, cancellationToken);
+ if (!sourceAfter.Equals(sourceFingerprint, StringComparison.Ordinal))
+ throw new IOException("The installed BuildWorkspace runtime assets changed while repair was being prepared.");
+ }
+
+ private InstallState BuildUpdatedInstallState(string toolkitFingerprint,
+ RetroRewindCompileInputs? compileInputs, string? canonicalRetroRewindRoot,
+ RetroWfcPayloadMode? payloadMode = null,
+ RetroWfcPayloadSnapshot? retroWfcPayloadSnapshot = null, string? expectedDolSha256 = null,
+ string? expectedRelSha256 = null)
+ {
+ var state = _installation.ReadInstallState() ?? new InstallState { InstallDir = _installation.Root };
+ state.InstallDir = _installation.Root;
+ state.SchemaVersion = 1;
+
+ if (string.IsNullOrWhiteSpace(state.SetupVersion)) state.SetupVersion = ProductInfo.Version;
+ if (string.IsNullOrWhiteSpace(state.ProductVersion)) state.ProductVersion = state.SetupVersion;
+ if (!string.IsNullOrWhiteSpace(expectedDolSha256)) state.DolSha256 = expectedDolSha256;
+ if (!string.IsNullOrWhiteSpace(expectedRelSha256)) state.RelSha256 = expectedRelSha256;
+ state.RetroRewindInstalled = compileInputs is not null || _installation.HasRetroProduct;
+ if (compileInputs is not null)
+ {
+ state.RetroRewindCodePulSha256 = compileInputs.CodePulSha256;
+ state.RetroRewindCompileInputsSha256 = compileInputs.CompileInputsSha256;
+ }
+ if (canonicalRetroRewindRoot is not null)
+ state.RetroRewindRoot = Path.GetFullPath(canonicalRetroRewindRoot);
+ if (payloadMode is not null)
+ {
+ state.RetroWfcPayloadMode = payloadMode == RetroWfcPayloadMode.Online
+ ? "downloaded"
+ : "skipped";
+ state.RetroWfcPayloadSha256 = payloadMode == RetroWfcPayloadMode.Online
+ ? retroWfcPayloadSnapshot?.Sha256 ?? throw new InvalidDataException(
+ "The downloaded Retro-WFC payload snapshot is missing.")
+ : "";
+ state.RetroWfcPayloadLength = payloadMode == RetroWfcPayloadMode.Online
+ ? retroWfcPayloadSnapshot!.ByteLength
+ : 0;
+ }
+ else
+ {
+ var retro = _installation.ReadProductFingerprint(_installation.RetroDirectory,
+ toolkitFingerprint);
+ if (retro is not null && !string.IsNullOrWhiteSpace(retro.RetroWfcPayloadMode))
+ {
+ state.RetroWfcPayloadMode = retro.RetroWfcPayloadMode;
+ state.RetroWfcPayloadSha256 = retro.RetroWfcPayloadSha256;
+ state.RetroWfcPayloadLength = retro.RetroWfcPayloadLength;
+ }
+ }
+ return state;
+ }
+
+ internal static (bool Base, bool Retro) FindDesiredInputDrift(Installation installation,
+ string toolkitFingerprint, string? expectedDolSha256, string? expectedRelSha256)
+ {
+ if (string.IsNullOrWhiteSpace(expectedDolSha256) || string.IsNullOrWhiteSpace(expectedRelSha256))
+ return (false, false);
+ return (
+ File.Exists(installation.BaseExecutable) &&
+ !installation.ProductUsesGameInputs(installation.BaseDirectory, toolkitFingerprint,
+ expectedDolSha256, expectedRelSha256),
+ installation.HasRetroProduct &&
+ !installation.ProductUsesGameInputs(installation.RetroDirectory, toolkitFingerprint,
+ expectedDolSha256, expectedRelSha256));
+ }
+
+
+ private void RefreshTranslationAssets(string? expectedDolSha256, string? expectedRelSha256,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(expectedDolSha256) && string.IsNullOrWhiteSpace(expectedRelSha256))
+ {
+ var state = _installation.ReadInstallState();
+ expectedDolSha256 = state is { SchemaVersion: 1 } ? state.DolSha256 : null;
+ expectedRelSha256 = state is { SchemaVersion: 1 } ? state.RelSha256 : null;
+ }
+ if (string.IsNullOrWhiteSpace(expectedDolSha256) || string.IsNullOrWhiteSpace(expectedRelSha256))
+ throw new InvalidDataException(
+ "The installed game-input provenance is missing or unsupported. Apply the current setup with the disc image.");
+
+ Directory.CreateDirectory(_installation.WorkspaceAssetsDirectory);
+ foreach (var (name, source, expectedSha256) in new[]
+ {
+ ("main.dol", Path.Combine(_installation.GameDataDirectory, "sys", "main.dol"), expectedDolSha256),
+ ("StaticR.rel", Path.Combine(_installation.GameDataDirectory, "files", "rel", "StaticR.rel"),
+ expectedRelSha256)
+ })
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!File.Exists(source))
+ throw new InvalidDataException(
+ $"The installed Mario Kart Wii {name} is missing. Apply setup again with the disc image.");
+ if (!InputValidation.Sha256File(source).Equals(expectedSha256, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidDataException(
+ $"The installed Mario Kart Wii {name} does not match its recorded clean-disc identity. " +
+ "Apply the current setup with the disc image.");
+
+ var workspaceInput = Path.Combine(_installation.WorkspaceAssetsDirectory, name);
+ if (File.Exists(workspaceInput) &&
+ InputValidation.Sha256File(workspaceInput)
+ .Equals(expectedSha256, StringComparison.OrdinalIgnoreCase))
+ continue;
+ File.Copy(source, workspaceInput, overwrite: true);
+ }
+ }
+
+ private (string DolSha, string RelSha) TranslationInputHashes() =>
+ (InputValidation.Sha256File(Path.Combine(_installation.WorkspaceAssetsDirectory, "main.dol")),
+ InputValidation.Sha256File(Path.Combine(_installation.WorkspaceAssetsDirectory, "StaticR.rel")));
+
+ private static void PreserveProductConfig(string currentProduct, string preparedProduct)
+ {
+ var config = Path.Combine(currentProduct, "config");
+ if (Directory.Exists(config))
+ FileSystemUtilities.CopyDirectory(config, Path.Combine(preparedProduct, "config"));
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup/Program.cs b/Launcher/WiiCompiled.Setup/Program.cs
new file mode 100644
index 0000000..7863336
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/Program.cs
@@ -0,0 +1,136 @@
+namespace WiiCompiled.Setup;
+
+using System.Runtime.InteropServices;
+
+internal static class Program
+{
+ private static int Main(string[] args)
+ {
+ var progressJson = CommandLine.WantsProgressJson(args);
+
+ try
+ {
+ PlatformChecks.EnsureSupportedHost();
+
+ if (args.Length == 0 && GetConsoleProcessList(new uint[1], 1) == 1)
+ {
+ Console.Out.WriteLine(
+ "Mario Kart WiiCompiled is installed through Wheel Wizard - download it from " +
+ "https://github.com/TeamWheelWizard/WheelWizard");
+ Console.ReadKey(intercept: true);
+ return 0;
+ }
+
+ CommandLine command;
+ try
+ {
+ command = CommandLine.Parse(args);
+ }
+ catch (ArgumentException ex) when (args.Length > 0)
+ {
+ // 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);
+ else Console.Error.WriteLine(ex.Message);
+ return 1;
+ }
+
+ if (command.Mode == AppMode.Version)
+ return ConsoleCommands.Version();
+
+ if (command.Mode == AppMode.Help)
+ return ConsoleCommands.Help();
+
+ if (command.Mode == AppMode.SelfTest)
+ return SelfTests.Run();
+
+ if (command.Mode == AppMode.VerifyInputs)
+ return ConsoleCommands.VerifyInputs(command);
+
+ if (command.Mode == AppMode.EmitPayloadIdentities)
+ return ConsoleCommands.EmitPayloadIdentities(command);
+
+ if (command.Mode == AppMode.CheckProducts)
+ {
+ using var cancellationSignal = CancellationSignal.ObserveEnvironment();
+ return ConsoleCommands.CheckProducts(command, cancellationSignal.Token);
+ }
+
+ if (command.Mode is AppMode.LaunchBase or AppMode.LaunchRetro)
+ return GameLaunchService.LaunchAsync(
+ command.Mode == AppMode.LaunchBase ? BuildProfile.Base : BuildProfile.RetroRewind)
+ .GetAwaiter().GetResult();
+
+ if (command.Mode is AppMode.SilentInstall or AppMode.RepairProducts)
+ {
+ using var cancellationSignal = CancellationSignal.ObserveEnvironment();
+ return command.Mode == AppMode.SilentInstall
+ ? ConsoleCommands.Install(command, cancellationSignal.Token).GetAwaiter().GetResult()
+ : ConsoleCommands.RepairProducts(command, cancellationSignal.Token)
+ .GetAwaiter().GetResult();
+ }
+
+ if (command.Mode is AppMode.Uninstall or AppMode.SilentUninstall)
+ {
+ UninstallService.StartWorker(command.InstallDirectory!,
+ quiet: command.Mode == AppMode.SilentUninstall || command.Quiet);
+ return 0;
+ }
+
+ if (command.Mode == AppMode.UninstallWorker)
+ return UninstallService.RunWorker(command.InstallDirectory!, command.Quiet);
+
+ return ConsoleCommands.Help();
+ }
+ catch (Exception ex)
+ {
+ // A caller reading the NDJSON protocol must always receive a terminal result line, even
+ // when the failure happened before the installer was reached (a bad command line, for
+ // instance). ConsoleCommands already reports its own failures, so this only fires for
+ // errors raised outside it.
+ if (progressJson)
+ {
+ new NdjsonInstallReporter().Failure(ex.Message);
+ Console.Error.WriteLine(ex);
+ }
+ else
+ {
+ Console.Error.WriteLine(ex);
+ }
+ return 1;
+ }
+ }
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern uint GetConsoleProcessList(
+ [Out] uint[] processList,
+ uint processCount);
+}
+
+internal static class PlatformChecks
+{
+ public static void EnsureSupportedHost()
+ {
+ if (!OperatingSystem.IsWindows() || !Environment.Is64BitOperatingSystem)
+ throw new PlatformNotSupportedException("WiiCompiled requires 64-bit Windows.");
+ if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 18362))
+ throw new PlatformNotSupportedException("WiiCompiled requires Windows 10 version 1903 or newer.");
+ }
+}
+
+internal static class ProductInfo
+{
+ public const string Name = "WiiCompiled";
+ public const string Version = "0.2.13";
+
+ ///
+ /// The setup executable is copied into the installation under this name. It is the launcher and
+ /// launch entry point named by the Wheel Wizard contract, so the name is part of that interface.
+ ///
+ public const string SetupCopyName = "WiiCompiled-Setup.exe";
+
+ public const string UninstallKey = @"Software\Microsoft\Windows\CurrentVersion\Uninstall\WiiCompiled";
+ public static string DefaultInstallDirectory =>
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Programs", "WiiCompiled");
+}
diff --git a/Launcher/WiiCompiled.Setup/RetroRewindSource.cs b/Launcher/WiiCompiled.Setup/RetroRewindSource.cs
new file mode 100644
index 0000000..75ea9b8
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/RetroRewindSource.cs
@@ -0,0 +1,59 @@
+namespace WiiCompiled.Setup;
+
+///
+/// Resolves the one canonical Retro Rewind install. Wheel Wizard owns and passes it as
+/// --retro-dir; the backend only resolves, reads, and records it, never packages or copies it.
+///
+internal static class RetroRewindSource
+{
+ ///
+ /// Resolves the RetroRewind6 folder from a selection that may be the folder itself or a
+ /// parent containing exactly one RetroRewind6/Binaries/Code.pul.
+ ///
+ internal static string ResolveRetroRewind6(string selected)
+ {
+ if (string.IsNullOrWhiteSpace(selected))
+ throw new InvalidDataException("Choose the canonical Retro Rewind folder.");
+ var root = Path.GetFullPath(selected);
+ if (!Directory.Exists(root))
+ throw new DirectoryNotFoundException($"The Retro Rewind folder does not exist: {root}");
+
+ var candidates = new List { root, Path.Combine(root, "RetroRewind6") };
+ // A folder produced by unpacking the published distribution keeps an extra wrapper directory.
+ foreach (var child in Directory.EnumerateDirectories(root))
+ candidates.Add(Path.Combine(child, "RetroRewind6"));
+
+ var matches = candidates
+ .Where(candidate => File.Exists(Path.Combine(candidate, "Binaries", "Code.pul")))
+ .Select(Path.GetFullPath)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ return matches.Count switch
+ {
+ 1 => ResolveFinalDirectory(matches[0]),
+ 0 => throw new InvalidDataException(
+ $"{root} does not contain RetroRewind6\\Binaries\\Code.pul. " +
+ "Select the canonical Retro Rewind folder, or the folder that contains it."),
+ _ => throw new InvalidDataException(
+ $"{root} contains more than one RetroRewind6\\Binaries\\Code.pul. " +
+ "Select the exact Retro Rewind folder to use.")
+ };
+ }
+
+ ///
+ /// Must resolve a junction/symlink RetroRewind6 to its real directory before fingerprinting: the
+ /// compile-input identity records on-disk kind, and a snapshot copy is always a real directory, so an
+ /// unresolved link can never match its own copy and installs fail forever with a misleading error.
+ ///
+ private static string ResolveFinalDirectory(string path)
+ {
+ var directory = new DirectoryInfo(path);
+ if (directory.LinkTarget is null)
+ return path;
+ var target = directory.ResolveLinkTarget(returnFinalTarget: true)?.FullName;
+ if (target is null || !Directory.Exists(target))
+ throw new InvalidDataException($"The Retro Rewind folder is a link to a missing target: {path}");
+ return target;
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup/RuntimeAssetPublication.cs b/Launcher/WiiCompiled.Setup/RuntimeAssetPublication.cs
new file mode 100644
index 0000000..beb4186
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/RuntimeAssetPublication.cs
@@ -0,0 +1,46 @@
+namespace WiiCompiled.Setup;
+
+///
+/// The one path by which a product receives its copied runtime assets, shared by install and repair.
+/// Each product gets its own moved (not copied) entry, re-verified against the authoritative identity
+/// in case the source changed mid-preparation.
+///
+internal static class RuntimeAssetPublication
+{
+ public static void AddEntries(List entries, string sourceAssets,
+ string preparedRoot, IEnumerable<(string Name, string Destination)> products,
+ string expectedFingerprint, string operationDescription, Action diagnostic,
+ CancellationToken cancellationToken)
+ {
+ foreach (var (name, destination) in products)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var preparedProduct = Path.Combine(preparedRoot, name);
+ Directory.CreateDirectory(preparedProduct);
+ FileSystemUtilities.CopyDirectory(
+ Path.Combine(sourceAssets, ProductRuntimeAssets.SourceBootstrapDirectoryName),
+ Path.Combine(preparedProduct, ProductRuntimeAssets.ProductBootstrapDirectoryName),
+ cancellationToken);
+ foreach (var (relativePath, fileName) in ProductRuntimeAssets.Files)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ File.Copy(ProductRuntimeAssets.SourceFile(sourceAssets, relativePath),
+ Path.Combine(preparedProduct, fileName));
+ }
+
+ if (!ToolkitFingerprint.ProductRuntimeAssetsMatch(preparedProduct, expectedFingerprint,
+ cancellationToken, diagnostic))
+ {
+ throw new IOException(
+ $"A copied product runtime asset changed while {operationDescription} was being prepared.");
+ }
+
+ entries.Add(InstallTransactionEntry.Directory(
+ Path.Combine(preparedProduct, ProductRuntimeAssets.ProductBootstrapDirectoryName),
+ Path.Combine(destination, ProductRuntimeAssets.ProductBootstrapDirectoryName)));
+ foreach (var (_, fileName) in ProductRuntimeAssets.Files)
+ entries.Add(InstallTransactionEntry.File(Path.Combine(preparedProduct, fileName),
+ Path.Combine(destination, fileName)));
+ }
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup/RuntimeConfiguration.cs b/Launcher/WiiCompiled.Setup/RuntimeConfiguration.cs
new file mode 100644
index 0000000..cc7a546
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/RuntimeConfiguration.cs
@@ -0,0 +1,296 @@
+using System.Globalization;
+using System.Text;
+
+namespace WiiCompiled.Setup;
+
+internal sealed record RuntimeConfigSnapshot(bool Existed, byte[] Contents);
+
+///
+/// Reads and writes the runtime's Config.toml. Every entry point takes the file it operates on
+/// since its location depends on the installation (portable UserData vs. per-user app data);
+/// callers obtain it once via .
+///
+internal static class RuntimeConfiguration
+{
+ public const string ConfigFileName = "Config.toml";
+
+ ///
+ /// Ordinal, matching the runtime parser/writer (runtime_config.h) exactly. Case-insensitive
+ /// matching here would let setup rewrite a [Paths]/Dvd_Root spelling the runtime ignores.
+ ///
+ private const StringComparison KeyComparison = StringComparison.Ordinal;
+
+ /// The per-user configuration an ordinary (non-portable) installation shares.
+ public static string ApplicationDataConfigPath => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "WiiCompiled", ConfigFileName);
+
+ ///
+ /// The configuration file that governs . This mirrors
+ /// RuntimeConfigFile::ResolveConfigPath in the runtime: portable roots win, everything
+ /// else is per-user application data.
+ ///
+ public static string ResolveConfigPath(string? installDirectory)
+ {
+ if (!string.IsNullOrWhiteSpace(installDirectory) &&
+ PortableRoot.TryFind(installDirectory) is { } portableRoot)
+ return Path.Combine(PortableRoot.UserDataDirectory(portableRoot), ConfigFileName);
+ return ApplicationDataConfigPath;
+ }
+
+ public static RuntimeConfigSnapshot Capture(string configPath) =>
+ File.Exists(configPath)
+ ? new RuntimeConfigSnapshot(true, File.ReadAllBytes(configPath))
+ : new RuntimeConfigSnapshot(false, []);
+
+ public static void Restore(string configPath, RuntimeConfigSnapshot snapshot)
+ {
+ if (snapshot.Existed)
+ FileSystemUtilities.WriteAtomic(configPath, snapshot.Contents);
+ else
+ File.Delete(configPath);
+ }
+
+ internal static void SetDvdRoot(string configPath, string dvdRoot) =>
+ SetPath(configPath, "dvd_root", dvdRoot);
+
+ ///
+ /// Records the one canonical Retro Rewind installation Wheel Wizard owns. The runtime builds its
+ /// asset overlay by scanning this directory live at launch, so an asset-only Retro Rewind update
+ /// needs no backend work: the next launch simply reads the new files.
+ ///
+ internal static void SetRetroRewindRoot(string configPath, string retroRewindRoot) =>
+ SetPath(configPath, "retro_rewind_root", retroRewindRoot);
+
+ /// The canonical Retro Rewind root, or null when no installation has recorded one.
+ public static string? GetRetroRewindRoot(string configPath) =>
+ GetResolvedPath(configPath, "retro_rewind_root");
+
+ internal static void RemoveRetroRewindRootIfOwned(string configPath, string retroRewindRoot) =>
+ RemovePathIfOwned(configPath, "retro_rewind_root", retroRewindRoot);
+
+ internal static void RemoveDvdRootIfOwned(string configPath, string dvdRoot) =>
+ RemovePathIfOwned(configPath, "dvd_root", dvdRoot);
+
+ /// The raw stored text of a [paths] key, exactly as the file holds it.
+ internal static string? GetPath(string configPath, string key) =>
+ TryUnquoteToml(GetRawValue(configPath, "paths", key) ?? "", out var value) ? value : null;
+
+ ///
+ /// A [paths] value as the runtime will actually use it. The runtime resolves every relative
+ /// [paths] value against the directory holding Config.toml (never the working
+ /// directory), so the host must resolve it the same way before comparing or reading it.
+ ///
+ internal static string? GetResolvedPath(string configPath, string key)
+ {
+ var stored = GetPath(configPath, key);
+ return string.IsNullOrWhiteSpace(stored) ? null : ResolveAgainstConfig(configPath, stored);
+ }
+
+ internal static string ConfigDirectory(string configPath) =>
+ Path.GetDirectoryName(Path.GetFullPath(configPath))
+ ?? throw new InvalidOperationException($"{configPath} has no containing directory.");
+
+ internal static string ResolveAgainstConfig(string configPath, string value) =>
+ Path.GetFullPath(value, ConfigDirectory(configPath));
+
+ private static void SetPath(string configPath, string key, string value)
+ {
+ var lines = ReadLinesOrDefault(configPath);
+ SetSectionValue(lines, "paths", key, QuoteToml(FormatPathValue(configPath, value)));
+ WriteLines(configPath, lines);
+ }
+
+ ///
+ /// Paths inside the portable root are stored relative to the config directory (forward slashes,
+ /// needing no TOML escaping) so the tree keeps working if the user moves or renames the root.
+ /// Everything else, including all non-portable paths, is stored absolute.
+ ///
+ private static string FormatPathValue(string configPath, string value)
+ {
+ var full = Path.GetFullPath(value);
+ var configDirectory = ConfigDirectory(configPath);
+ if (PortableRoot.TryFind(configDirectory) is not { } root || !PortableRoot.Contains(root, full))
+ return full;
+ var relative = Path.GetRelativePath(configDirectory, full);
+ return Path.IsPathRooted(relative)
+ ? full
+ : relative.Replace(Path.DirectorySeparatorChar, '/');
+ }
+
+ private static void RemovePathIfOwned(string configPath, string key, string expectedValue)
+ {
+ if (!File.Exists(configPath)) return;
+ var lines = File.ReadAllLines(configPath).ToList();
+ var inPaths = false;
+ var changed = false;
+ for (var index = 0; index < lines.Count; index++)
+ {
+ var trimmed = RemoveComment(lines[index]).Trim();
+ if (IsSection(trimmed))
+ {
+ inPaths = trimmed.Equals("[paths]", KeyComparison);
+ continue;
+ }
+ if (!inPaths || trimmed.Length == 0) continue;
+ var equals = trimmed.IndexOf('=');
+ if (equals < 0 || !trimmed[..equals].Trim().Equals(key, KeyComparison))
+ continue;
+ if (!TryUnquoteToml(trimmed[(equals + 1)..].Trim(), out var configured)) return;
+ // A portable installation stores this relative, so ownership is decided on the path the
+ // runtime would actually resolve, not on the stored text.
+ if (!ResolveAgainstConfig(configPath, configured)
+ .Equals(Path.GetFullPath(expectedValue), StringComparison.OrdinalIgnoreCase))
+ return;
+ lines.RemoveAt(index);
+ changed = true;
+ break;
+ }
+ if (changed) WriteLines(configPath, lines);
+ }
+
+ /// The raw TOML literal stored for a key, or null when the section or key is absent.
+ internal static string? GetRawValue(string configPath, string section, string key)
+ {
+ if (!File.Exists(configPath)) return null;
+ var header = $"[{section}]";
+ var inSection = false;
+ foreach (var line in File.ReadLines(configPath))
+ {
+ var trimmed = RemoveComment(line).Trim();
+ if (IsSection(trimmed))
+ {
+ inSection = trimmed.Equals(header, KeyComparison);
+ continue;
+ }
+ if (!inSection || trimmed.Length == 0) continue;
+ var equals = trimmed.IndexOf('=');
+ if (equals < 0 || !trimmed[..equals].Trim().Equals(key, KeyComparison)) continue;
+ return trimmed[(equals + 1)..].Trim();
+ }
+ return null;
+ }
+
+ ///
+ /// Replaces one key in one section, preserving comments, ordering, and every unrelated setting -
+ /// this file belongs to the user and to the in-game settings bar as much as to setup. A
+ /// commented-out key is deliberately not matched, matching the runtime's own writer.
+ ///
+ private static void SetSectionValue(List lines, string section, string key, string value)
+ {
+ var header = $"[{section}]";
+ var sectionStart = -1;
+ var sectionEnd = lines.Count;
+ for (var index = 0; index < lines.Count; index++)
+ {
+ var trimmed = RemoveComment(lines[index]).Trim();
+ if (!IsSection(trimmed)) continue;
+ if (sectionStart >= 0)
+ {
+ sectionEnd = index;
+ break;
+ }
+ if (trimmed.Equals(header, KeyComparison)) sectionStart = index;
+ }
+ if (sectionStart < 0)
+ {
+ if (lines.Count > 0 && lines[^1].Length != 0) lines.Add("");
+ lines.Add(header);
+ lines.Add($"{key} = {value}");
+ return;
+ }
+
+ for (var index = sectionStart + 1; index < sectionEnd; index++)
+ {
+ var trimmed = RemoveComment(lines[index]).Trim();
+ if (trimmed.Length == 0) continue;
+ var equals = trimmed.IndexOf('=');
+ if (equals >= 0 && trimmed[..equals].Trim().Equals(key, KeyComparison))
+ {
+ lines[index] = $"{key} = {value}";
+ return;
+ }
+ }
+
+ // Append after the section's last real line, not after the blank line that separates it from
+ // the next header, so a generated file stays readable for the user who also edits it by hand.
+ var insertAt = sectionEnd;
+ while (insertAt > sectionStart + 1 && lines[insertAt - 1].Trim().Length == 0) insertAt--;
+ lines.Insert(insertAt, $"{key} = {value}");
+ }
+
+ private static List ReadLinesOrDefault(string configPath) =>
+ File.Exists(configPath) ? File.ReadAllLines(configPath).ToList() : DefaultConfigLines();
+
+ private static void WriteLines(string configPath, List lines) =>
+ FileSystemUtilities.WriteAtomic(configPath,
+ Encoding.UTF8.GetBytes(string.Join(Environment.NewLine, lines) + Environment.NewLine));
+
+ private static List DefaultConfigLines() =>
+ [
+ "# WiiCompiled user configuration",
+ "",
+ "[paths]"
+ ];
+
+ private static bool IsSection(string value) =>
+ value.Length >= 2 && value[0] == '[' && value[^1] == ']';
+
+ ///
+ /// Line-for-line port of RemoveComment in runtime_config.h, including its backslash
+ /// escaping inside basic strings: without it a Windows path ending in \\" reads as
+ /// re-opening the quote, and the two implementations disagree about which # starts a comment.
+ ///
+ private static string RemoveComment(string line)
+ {
+ var inSingle = false;
+ var inDouble = false;
+ var escaped = false;
+ for (var index = 0; index < line.Length; index++)
+ {
+ var character = line[index];
+ if (inDouble && character == '\\' && !escaped)
+ {
+ escaped = true;
+ continue;
+ }
+ if (character == '\'' && !inDouble) inSingle = !inSingle;
+ else if (character == '"' && !inSingle && !escaped) inDouble = !inDouble;
+ else if (character == '#' && !inSingle && !inDouble) return line[..index];
+ escaped = false;
+ }
+ return line;
+ }
+
+ private static string QuoteToml(string value) =>
+ "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
+
+ internal static bool TryUnquoteToml(string value, out string result)
+ {
+ result = "";
+ if (value.Length < 2) return false;
+ // Literal strings carry no escapes; the runtime's parser treats them the same way.
+ if (value[0] == '\'' && value[^1] == '\'')
+ {
+ result = value[1..^1];
+ return true;
+ }
+ if (value[0] != '"' || value[^1] != '"') return false;
+ var builder = new StringBuilder(value.Length - 2);
+ for (var index = 1; index < value.Length - 1; index++)
+ {
+ if (value[index] == '\\' && index + 1 < value.Length - 1)
+ {
+ var escaped = value[++index];
+ builder.Append(escaped switch { '\\' => '\\', '"' => '"', _ => escaped });
+ }
+ else
+ {
+ builder.Append(value[index]);
+ }
+ }
+ result = builder.ToString();
+ return true;
+ }
+
+}
diff --git a/Launcher/WiiCompiled.Setup/SelfTests.cs b/Launcher/WiiCompiled.Setup/SelfTests.cs
new file mode 100644
index 0000000..cb54093
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/SelfTests.cs
@@ -0,0 +1,1494 @@
+using System.Buffers.Binary;
+using System.Security.Cryptography;
+
+namespace WiiCompiled.Setup;
+
+internal static class SelfTests
+{
+ public static int Run()
+ {
+ var failures = new List();
+ Test("ISO extension", () => TestAcceptedExtension(".iso"), failures);
+ Test("GCM extension", () => TestAcceptedExtension(".gcm"), failures);
+ Test("GCZ extension", () => TestAcceptedExtension(".gcz"), failures);
+ Test("CISO extension", () => TestAcceptedExtension(".ciso"), failures);
+ Test("WBFS extension", () => TestAcceptedExtension(".wbfs"), failures);
+ Test("WIA extension", () => TestAcceptedExtension(".wia"), failures);
+ Test("RVZ extension", () => TestAcceptedExtension(".RVZ"), failures);
+ Test("Unsupported extension", () =>
+ {
+ var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".nfs");
+ File.WriteAllBytes(path, [0]);
+ try
+ {
+ try { InputValidation.ValidateExtension(path); throw new Exception("NFS was accepted."); }
+ catch (InvalidDataException) { }
+ }
+ finally { File.Delete(path); }
+ }, failures);
+ Test("Disc compatibility", () =>
+ InputValidation.EnsureCompatibleDisc(new DiscHeader { GameId = "RMCP01" },
+ new PayloadManifest { ExpectedGameId = "RMCP01" }), failures);
+ Test("Disc rejection", () =>
+ {
+ try
+ {
+ InputValidation.EnsureCompatibleDisc(new DiscHeader { GameId = "RMCJ01" },
+ new PayloadManifest { ExpectedGameId = "RMCP01" });
+ throw new Exception("Wrong region was accepted.");
+ }
+ catch (InvalidDataException) { }
+ }, failures);
+ Test("Payload timestamp normalization", TestPayloadTimestampNormalization, failures);
+ Test("Workspace timestamp reconciliation", TestWorkspaceTimestampReconciliation, failures);
+ Test("Toolkit accompanies the workspace", TestToolkitRefreshDecision, failures);
+ Test("Retro-WFC payload snapshot reconciliation", TestRetroWfcPayloadSnapshotReconciliation, failures);
+ Test("Retro-WFC transient retry policy", TestRetroWfcTransientRetryPolicy, failures);
+ Test("Shared runtime configuration", () => TestPathSettingRoundTrip("dvd_root",
+ RuntimeConfiguration.SetDvdRoot, RuntimeConfiguration.RemoveDvdRootIfOwned), failures);
+ Test("Canonical Retro Rewind configuration", () => TestPathSettingRoundTrip("retro_rewind_root",
+ RuntimeConfiguration.SetRetroRewindRoot, RuntimeConfiguration.RemoveRetroRewindRootIfOwned),
+ failures);
+ Test("Runtime configuration key matching", TestRuntimeConfigurationKeyMatching, failures);
+ Test("Install location validation", TestInstallLocationValidation, failures);
+ Test("Command line contract", TestCommandLineContract, failures);
+ Test("Portable root discovery", TestPortableRootDiscovery, failures);
+ Test("Portable install validation failure leaves no marker",
+ TestPortableValidationFailureLeavesNoMarker, failures);
+ Test("Portable configuration path resolution", TestPortableConfigPathResolution, failures);
+ Test("Portable relative path settings", TestPortableRelativePathSettings, failures);
+ Test("Portable move healing", TestPortableMoveHealing, failures);
+ Test("Install transaction rollback", TestInstallTransactionRollback, failures);
+ Test("Install scratch recovery", TestInstallScratchRecovery, failures);
+ Test("Shortcut cleanup is best effort", TestShortcutCleanupIsBestEffort, failures);
+ Test("Canonical Retro Rewind resolution", TestCanonicalRetroRewindResolution, failures);
+ Test("Retro Rewind compile-inputs golden vector",
+ TestCompileInputsFingerprintGoldenVector, failures);
+ Test("Retro Rewind compile-inputs fingerprint", TestCompileInputsFingerprint, failures);
+ Test("Retro Rewind compile-inputs snapshot", TestCompileInputsSnapshot, failures);
+ Test("Toolkit fingerprint", TestToolkitFingerprint, failures);
+ Test("Retro Rewind inspection state machine", TestCodePulStateMachine, failures);
+ Test("Products report contract", TestProductsReport, failures);
+ foreach (var failure in failures) Console.Error.WriteLine("FAILED " + failure);
+ return failures.Count == 0 ? 0 : 1;
+ }
+
+ private static void TestAcceptedExtension(string extension)
+ {
+ var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + extension);
+ File.WriteAllBytes(path, [0]);
+ try { InputValidation.ValidateExtension(path); } finally { File.Delete(path); }
+ }
+
+ private static void TestPayloadTimestampNormalization()
+ {
+ var path = Path.Combine(Path.GetTempPath(), "mkwc-timestamp-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ File.WriteAllText(path, "timestamp");
+ File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddDays(1));
+ PayloadArchive.NormalizeExtractedTimestamp(path);
+ if (File.GetLastWriteTimeUtc(path).Year != 2000)
+ throw new Exception("Extracted payload timestamps were not normalized.");
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ private static void TestWorkspaceTimestampReconciliation()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "mkwc-wsstamp-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ var installed = Path.Combine(root, "Install", "BuildWorkspace");
+ var staged = Path.Combine(root, "staging", "BuildWorkspace");
+ Directory.CreateDirectory(Path.Combine(installed, "runtime", "src"));
+ Directory.CreateDirectory(Path.Combine(staged, "runtime", "src"));
+ File.WriteAllText(Path.Combine(installed, "runtime", "src", "same.cpp"), "int same();");
+ File.WriteAllText(Path.Combine(staged, "runtime", "src", "same.cpp"), "int same();");
+ File.WriteAllText(Path.Combine(installed, "runtime", "src", "edited.cpp"), "int old();");
+ File.WriteAllText(Path.Combine(staged, "runtime", "src", "edited.cpp"), "int new_();");
+ File.WriteAllText(Path.Combine(staged, "runtime", "src", "added.cpp"), "int added();");
+ foreach (var file in Directory.EnumerateFiles(staged, "*", SearchOption.AllDirectories))
+ PayloadArchive.NormalizeExtractedTimestamp(file);
+
+ WorkspaceTimestamps.MarkChangedFiles(installed, staged, _ => { });
+
+ if (File.GetLastWriteTimeUtc(Path.Combine(staged, "runtime", "src", "same.cpp")).Year != 2000)
+ throw new Exception("An unchanged workspace file lost its normalized timestamp.");
+ if (File.GetLastWriteTimeUtc(Path.Combine(staged, "runtime", "src", "edited.cpp")).Year == 2000)
+ throw new Exception("A changed workspace file kept its normalized timestamp; " +
+ "Ninja would never recompile it.");
+ if (File.GetLastWriteTimeUtc(Path.Combine(staged, "runtime", "src", "added.cpp")).Year == 2000)
+ throw new Exception("A new workspace file kept its normalized timestamp.");
+
+ // A fresh installation has no workspace to compare against; the pass must be a no-op.
+ WorkspaceTimestamps.MarkChangedFiles(Path.Combine(root, "absent"), staged, _ => { });
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
+ }
+
+ ///
+ /// The translator and the project file it parses must never come from different releases. The
+ /// first case is the v0.2.5 regression: a compile-identity change republished the workspace
+ /// while an unchanged package identity retained the older toolkit.
+ ///
+ private static void TestToolkitRefreshDecision()
+ {
+ if (!InstallerEngine.MustRefreshToolkit(sameToolkit: false, samePackageContent: true,
+ dolphinToolPresent: true))
+ throw new Exception("A republished workspace kept the installed toolkit; the shipped " +
+ "translator and project file could come from different releases.");
+ if (!InstallerEngine.MustRefreshToolkit(sameToolkit: true, samePackageContent: false,
+ dolphinToolPresent: true))
+ throw new Exception("Changed toolkit package content was not extracted.");
+ if (!InstallerEngine.MustRefreshToolkit(sameToolkit: true, samePackageContent: true,
+ dolphinToolPresent: false))
+ throw new Exception("A missing DolphinTool.exe did not force toolkit extraction.");
+ if (InstallerEngine.MustRefreshToolkit(sameToolkit: true, samePackageContent: true,
+ dolphinToolPresent: true))
+ throw new Exception("An unchanged toolkit was needlessly re-extracted.");
+ }
+
+ ///
+ /// Every [paths] key follows the same contract: writing preserves other settings, reads
+ /// back resolved, and uninstall removes it only while it still points at the recorded location.
+ ///
+ private static void TestPathSettingRoundTrip(string key, Action set,
+ Action removeIfOwned)
+ {
+ var root = Path.Combine(Path.GetTempPath(), $"mkwc-{key}-" + Guid.NewGuid().ToString("N"));
+ var config = Path.Combine(root, "Config.toml");
+ var owned = Path.Combine(root, "Owned Location");
+ try
+ {
+ Directory.CreateDirectory(owned);
+ File.WriteAllText(config,
+ "[video]\nwidescreen = true\n\n[paths]\nunrelated_root = \"D:\\\\Elsewhere\"\n");
+ set(config, owned);
+ var updated = File.ReadAllText(config);
+ if (!updated.Contains("widescreen = true", StringComparison.Ordinal) ||
+ !updated.Contains("unrelated_root = \"D:\\\\Elsewhere\"", StringComparison.Ordinal) ||
+ !updated.Contains(key + " = ", StringComparison.Ordinal))
+ throw new Exception($"Writing {key} discarded existing runtime settings.");
+
+ var read = RuntimeConfiguration.GetResolvedPath(config, key);
+ if (read is null || !read.Equals(Path.GetFullPath(owned), StringComparison.OrdinalIgnoreCase))
+ throw new Exception($"The configured {key} did not round-trip.");
+
+ removeIfOwned(config, Path.Combine(root, "Someone Else"));
+ if (RuntimeConfiguration.GetPath(config, key) is null)
+ throw new Exception($"A {key} owned by someone else was removed.");
+ removeIfOwned(config, owned);
+ if (RuntimeConfiguration.GetPath(config, key) is not null)
+ throw new Exception($"Uninstall did not remove its owned {key} setting.");
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
+ }
+
+ ///
+ /// TOML keys are case-sensitive and the runtime compares them exactly, so the host must match
+ /// case too, or it silently writes a key the runtime never reads.
+ ///
+ private static void TestRuntimeConfigurationKeyMatching()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "mkwc-tomlcase-" + Guid.NewGuid().ToString("N"));
+ var config = Path.Combine(root, RuntimeConfiguration.ConfigFileName);
+ var owned = Path.Combine(root, "Owned Location");
+ try
+ {
+ Directory.CreateDirectory(owned);
+ File.WriteAllText(config, "[Paths]\r\nDvd_Root = \"D:\\\\Wrong\"\r\n");
+ RuntimeConfiguration.SetDvdRoot(config, owned);
+ if (!File.ReadAllText(config).Contains("Dvd_Root = \"D:\\\\Wrong\"", StringComparison.Ordinal))
+ throw new Exception("A differently-cased key the runtime ignores was rewritten.");
+ var written = RuntimeConfiguration.GetResolvedPath(config, "dvd_root");
+ if (written is null || !written.Equals(Path.GetFullPath(owned), StringComparison.OrdinalIgnoreCase))
+ throw new Exception("dvd_root was not written as the exactly-cased key the runtime reads.");
+
+ // An escaped quote does not end the value, so the '#' after it is data rather than the
+ // start of a comment - exactly what RemoveComment in runtime_config.h decides.
+ File.WriteAllText(config, "[paths]\r\ndvd_root = \"a\\\"#b\"\r\n");
+ if (RuntimeConfiguration.GetPath(config, "dvd_root") != "a\"#b")
+ throw new Exception("A '#' inside an escaped quoted value was treated as a comment.");
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
+ }
+
+ ///
+ /// One location rule set covers both an installation directory and a portable root, so the
+ /// rejections a portable install performs are the ones an ordinary install performs too.
+ ///
+ private static void TestInstallLocationValidation()
+ {
+ void Rejects(string description, string path)
+ {
+ try
+ {
+ FileSystemUtilities.EnsureUsableLocation(path, "The installation folder");
+ throw new Exception(description + " was accepted as an install location.");
+ }
+ catch (InvalidOperationException) { }
+ }
+
+ FileSystemUtilities.EnsureUsableLocation("C:\\Games\\WiiCompiled\\Install", "The installation folder");
+ Rejects("A network path", "\\\\server\\share\\WiiCompiled");
+ Rejects("An over-long path", "C:\\" + new string('w', 200));
+ Rejects("A drive root", "C:\\");
+ Rejects("A folder inside Windows",
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "WiiCompiled"));
+ Rejects("The user profile itself",
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));
+ // The two entry points must not have separate opinions about any of the above.
+ foreach (var rejected in new[] { "\\\\server\\share\\WiiCompiled", "C:\\" })
+ {
+ try
+ {
+ PortableRoot.EnsureUsableRoot(rejected);
+ throw new Exception($"{rejected} was accepted as a portable root.");
+ }
+ catch (InvalidOperationException) { }
+ }
+ }
+
+ private static void TestCommandLineContract()
+ {
+ void Rejects(string description, params string[] args)
+ {
+ try
+ {
+ CommandLine.Parse(args);
+ throw new Exception(description + " was accepted.");
+ }
+ catch (ArgumentException) { }
+ }
+
+ var version = CommandLine.Parse(["--version"]);
+ if (version.Mode != AppMode.Version) throw new Exception("--version was not recognised.");
+
+ var silent = CommandLine.Parse([
+ "--silent", "--game", "game.iso", "--install-dir", "C:\\Games\\MKW",
+ "--progress-json", "--retro-dir", "D:\\RetroRewind", "--skip-retro-wfc-payload"
+ ]);
+
+ if (silent.Mode != AppMode.SilentInstall || !silent.ProgressJson ||
+ silent.RetroDirectoryPath != "D:\\RetroRewind" ||
+ silent.RetroWfcPayloadMode != RetroWfcPayloadMode.Skipped)
+ throw new Exception("The silent install contract did not parse.");
+
+ var launch = CommandLine.Parse(["--launch-base"]);
+ if (launch.Mode != AppMode.LaunchBase)
+ throw new Exception("The launch contract did not parse.");
+ Rejects("runtime argument forwarding", "--launch-base", "--", "--windowed");
+
+ if (CommandLine.Parse(["--launch-retro"]).Mode != AppMode.LaunchRetro)
+ throw new Exception("--launch-retro was not recognised.");
+
+ var check = CommandLine.Parse([
+ "--check-products", "--install-dir", "C:\\Games\\MKW", "--retro-dir", "D:\\RetroRewind",
+ "--progress-json"
+ ]);
+ if (check.Mode != AppMode.CheckProducts || check.RetroDirectoryPath != "D:\\RetroRewind" ||
+ check.RetroWfcPayloadMode != RetroWfcPayloadMode.NotApplicable)
+ throw new Exception("The product check contract did not parse.");
+
+ var retroRepair = CommandLine.Parse([
+ "--repair-products", "--install-dir", "C:\\Games\\MKW", "--retro-dir", "D:\\RetroRewind",
+ "--download-retro-wfc-payload", "--progress-json"
+ ]);
+ if (retroRepair.Mode != AppMode.RepairProducts ||
+ retroRepair.RetroDirectoryPath != "D:\\RetroRewind" ||
+ retroRepair.RetroWfcPayloadMode != RetroWfcPayloadMode.Online)
+ throw new Exception("The Retro Rewind repair contract did not parse.");
+
+ Rejects("An unknown Retro Rewind source flag", "--silent", "--game", "g.iso", "--retro-zip",
+ "a.zip", "--skip-retro-wfc-payload");
+ Rejects("--progress-json outside an install", "--launch-base", "--progress-json");
+ // The NAND flag is gone: Wheel Wizard writes paths.nand_root into Config.toml itself.
+ Rejects("The removed NAND flag", "--silent", "--game", "g.iso", "--nand-dir", "D:\\Wii");
+ Rejects("A payload option without the canonical Retro Rewind folder", "--silent", "--game",
+ "g.iso", "--skip-retro-wfc-payload");
+ Rejects("A Retro Rewind folder without a payload decision", "--silent", "--game", "g.iso",
+ "--retro-dir", "b");
+ Rejects("Repair without an installation", "--repair-products", "--retro-dir", "rr",
+ "--skip-retro-wfc-payload", "--progress-json");
+ Rejects("Repair without the canonical Retro Rewind folder", "--repair-products",
+ "--install-dir", "fixed", "--progress-json");
+ Rejects("Retro Rewind repair without a payload decision", "--repair-products", "--install-dir", "fixed",
+ "--retro-dir", "rr", "--progress-json");
+ Rejects("A repair payload option without the canonical folder", "--repair-products",
+ "--install-dir", "fixed", "--skip-retro-wfc-payload", "--progress-json");
+
+ var portable = CommandLine.Parse([
+ "--silent", "--game", "game.iso", "--install-dir", "D:\\Games\\Recomp\\Install",
+ "--portable", "--progress-json"
+ ]);
+ if (portable.Mode != AppMode.SilentInstall || !portable.Portable)
+ throw new Exception("The portable install contract did not parse.");
+
+ Rejects("--portable without an install directory", "--silent", "--game", "g.iso", "--portable");
+ Rejects("--portable outside an install", "--check-products", "--install-dir", "fixed", "--portable");
+ // The config verbs are gone: WheelWizard reads and writes Config.toml directly now.
+ Rejects("The removed configuration read verb", "--get-config");
+ Rejects("The removed configuration write verb", "--set-config", "--install-dir", "fixed");
+ Rejects("A bare setting assignment", "--check-products", "video.show_fps=true");
+ }
+
+ ///
+ /// The marker walk-up is the one thing both languages must agree on. The depth bound keeps an
+ /// unrelated marker high up a drive from capturing an ordinary installation.
+ ///
+ private static void TestPortableRootDiscovery()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "mkwc-portable-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ var install = Path.Combine(root, "Install");
+ var deep = Path.Combine(install, "Base", "config", "a", "b", "c");
+ Directory.CreateDirectory(deep);
+
+ if (PortableRoot.TryFind(install) is not null)
+ throw new Exception("A directory without the marker was treated as portable.");
+
+ File.WriteAllText(Path.Combine(root, PortableRoot.MarkerFileName), "portable");
+ if (PortableRoot.TryFind(root) != root)
+ throw new Exception("The marker directory itself was not recognised as the root.");
+ if (PortableRoot.TryFind(install) != root)
+ throw new Exception("The install directory did not resolve to its portable root.");
+ if (PortableRoot.TryFind(Path.Combine(install, "Base", "config", "a")) != root)
+ throw new Exception("A path within the depth bound did not resolve to its portable root.");
+ if (PortableRoot.TryFind(deep) is not null)
+ throw new Exception("The portable search walked further up than its depth bound.");
+
+ if (!PortableRoot.Contains(root, Path.Combine(install, "GameAssets", "DATA")) ||
+ PortableRoot.Contains(root, Path.Combine(Path.GetTempPath(), "elsewhere")))
+ throw new Exception("Portable containment did not classify paths correctly.");
+
+ try
+ {
+ PortableRoot.EnsureUsableRoot(Path.GetPathRoot(root)!);
+ throw new Exception("A drive root was accepted as a portable root.");
+ }
+ catch (InvalidOperationException) { }
+ try
+ {
+ PortableRoot.EnsureUsableRoot(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));
+ throw new Exception("A well-known system folder was accepted as a portable root.");
+ }
+ catch (InvalidOperationException) { }
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
+ }
+
+ ///
+ /// A rejected game image must not turn the destination's parent into a portable root. Otherwise
+ /// the marker walk-up would redirect unrelated later installs into this root's UserData.
+ ///
+ private static void TestPortableValidationFailureLeavesNoMarker()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "mkwc-portable-rejected-" + Guid.NewGuid().ToString("N"));
+ var game = Path.Combine(root, "not-a-disc-image.wad");
+ try
+ {
+ Directory.CreateDirectory(root);
+ File.WriteAllBytes(game, [0]);
+ var options = new InstallOptions
+ {
+ GamePath = game,
+ InstallDirectory = Path.Combine(root, PortableRoot.InstallDirectoryName),
+ Portable = true
+ };
+
+ try
+ {
+ new InstallerEngine(new DelegatingInstallReporter(_ => { }))
+ .InstallAsync(options).GetAwaiter().GetResult();
+ throw new Exception("An unsupported game image was accepted.");
+ }
+ catch (InvalidDataException) { }
+
+ if (File.Exists(Path.Combine(root, PortableRoot.MarkerFileName)))
+ throw new Exception("A failed portable install left its marker behind.");
+ if (Directory.Exists(PortableRoot.UserDataDirectory(root)))
+ throw new Exception("A failed portable install left its UserData directory behind.");
+ if (PortableRoot.TryFind(options.InstallDirectory) is not null)
+ throw new Exception("A failed portable install captured later installs under its parent.");
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
+ }
+
+ private static void TestShortcutCleanupIsBestEffort()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "mkwc-shortcuts-" + Guid.NewGuid().ToString("N"));
+ var desktop = Path.Combine(root, "Desktop");
+ var startMenu = Path.Combine(root, "Start Menu", ProductInfo.Name);
+ try
+ {
+ Directory.CreateDirectory(desktop);
+ // File.Delete cannot delete a directory. This deterministically forces the first cleanup
+ // to fail while the remaining independently guarded targets must still be attempted.
+ Directory.CreateDirectory(Path.Combine(desktop, "WiiCompiled.lnk"));
+ var secondShortcut = Path.Combine(desktop, "Retro Rewind.lnk");
+ File.WriteAllText(secondShortcut, "shortcut");
+ Directory.CreateDirectory(startMenu);
+ File.WriteAllText(Path.Combine(startMenu, "WiiCompiled.lnk"), "shortcut");
+
+ try
+ {
+ ShellIntegration.RemoveAllShortcuts(desktop, startMenu);
+ throw new Exception("A shortcut deletion failure was not reported.");
+ }
+ catch (AggregateException) { }
+
+ if (File.Exists(secondShortcut))
+ throw new Exception("One failed shortcut deletion prevented the next file deletion.");
+ if (Directory.Exists(startMenu))
+ throw new Exception("One failed shortcut deletion prevented Start Menu cleanup.");
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
+ }
+
+ ///
+ /// The configuration location is a property of the installation, not of this process: an install
+ /// under a portable root uses that root's UserData, everything else the shared per-user file.
+ ///
+ private static void TestPortableConfigPathResolution()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "mkwc-portable-config-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ var install = Path.Combine(root, "Install");
+ Directory.CreateDirectory(install);
+ if (!RuntimeConfiguration.ResolveConfigPath(install)
+ .Equals(RuntimeConfiguration.ApplicationDataConfigPath, StringComparison.OrdinalIgnoreCase))
+ throw new Exception("A non-portable installation did not use per-user application data.");
+
+ var created = PortableRoot.Create(root);
+ if (!Directory.Exists(PortableRoot.UserDataDirectory(created)) ||
+ !File.Exists(Path.Combine(created, PortableRoot.MarkerFileName)))
+ throw new Exception("Creating a portable root did not produce its marker and user data.");
+
+ var expected = Path.Combine(root, PortableRoot.UserDataDirectoryName, "Config.toml");
+ if (!RuntimeConfiguration.ResolveConfigPath(install)
+ .Equals(expected, StringComparison.OrdinalIgnoreCase))
+ throw new Exception("A portable installation did not use its own UserData configuration.");
+ if (!new Installation(install).ConfigPath.Equals(expected, StringComparison.OrdinalIgnoreCase))
+ throw new Exception("The installation did not report its portable configuration path.");
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
+ }
+
+ ///
+ /// A portable installation stores every path inside its root relative to the configuration
+ /// directory, so the whole tree survives being moved. Ownership decisions must therefore compare
+ /// resolved paths, not stored text.
+ ///
+ private static void TestPortableRelativePathSettings()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "mkwc-portable-paths-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ PortableRoot.Create(root);
+ var install = Path.Combine(root, "Install");
+ var config = Path.Combine(root, PortableRoot.UserDataDirectoryName, "Config.toml");
+ var dvdRoot = Path.Combine(install, "GameAssets", "DATA");
+ var outsideRetro = Path.Combine(Path.GetTempPath(), "mkwc-outside-" + Guid.NewGuid().ToString("N"));
+ var insideRetro = Path.Combine(root, "RetroRewind6");
+
+ RuntimeConfiguration.SetDvdRoot(config, dvdRoot);
+ var stored = RuntimeConfiguration.GetPath(config, "dvd_root");
+ if (stored is null || Path.IsPathRooted(stored))
+ throw new Exception("A path inside the portable root was stored absolute.");
+ if (stored.Contains('\\', StringComparison.Ordinal))
+ throw new Exception("A relative path was not stored with the canonical forward slashes.");
+ if (!RuntimeConfiguration.GetResolvedPath(config, "dvd_root")!
+ .Equals(Path.GetFullPath(dvdRoot), StringComparison.OrdinalIgnoreCase))
+ throw new Exception("A relative dvd_root did not resolve against the configuration directory.");
+
+ RuntimeConfiguration.SetRetroRewindRoot(config, insideRetro);
+ if (Path.IsPathRooted(RuntimeConfiguration.GetPath(config, "retro_rewind_root")!))
+ throw new Exception("A Retro Rewind root inside the portable root was stored absolute.");
+ RuntimeConfiguration.SetRetroRewindRoot(config, outsideRetro);
+ if (!Path.IsPathRooted(RuntimeConfiguration.GetPath(config, "retro_rewind_root")!))
+ throw new Exception("A Retro Rewind root outside the portable root was stored relative.");
+
+ RuntimeConfiguration.RemoveDvdRootIfOwned(config, Path.Combine(install, "Elsewhere"));
+ if (RuntimeConfiguration.GetPath(config, "dvd_root") is null)
+ throw new Exception("A relative dvd_root owned by someone else was removed.");
+ RuntimeConfiguration.RemoveDvdRootIfOwned(config, dvdRoot);
+ if (RuntimeConfiguration.GetPath(config, "dvd_root") is not null)
+ throw new Exception("A relative dvd_root this installation owns was not removed.");
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
+ }
+
+ ///
+ /// A portable root can be moved. Healing adopts the directory the installation was actually found
+ /// in and discards the location-bound native build cache - and must never touch a non-portable
+ /// installation, where the same disagreement is a real problem to report.
+ ///
+ private static void TestPortableMoveHealing()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "mkwc-move-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ var install = Path.Combine(root, "Install");
+ var nativeBuild = Path.Combine(install, "BuildWorkspace", "native-build");
+ Directory.CreateDirectory(nativeBuild);
+ File.WriteAllText(Path.Combine(nativeBuild, "CMakeCache.txt"),
+ "CMAKE_HOME_DIRECTORY:INTERNAL=D:/Old/Install/BuildWorkspace/runtime");
+ var statePath = Path.Combine(install, "install-state.json");
+ JsonState.Write(statePath, new InstallState { InstallDir = "D:\\Old\\Install" });
+
+ var plain = new Installation(install);
+ if (PortableInstallHealing.HealMovedInstall(plain))
+ throw new Exception("A non-portable installation was healed.");
+ if (!Directory.Exists(nativeBuild))
+ throw new Exception("A non-portable installation lost its native build directory.");
+
+ PortableRoot.Create(root);
+ var portable = new Installation(install);
+ if (!PortableInstallHealing.HealMovedInstall(portable))
+ throw new Exception("A moved portable installation was not healed.");
+ var healed = portable.ReadInstallState();
+ if (healed is null || !healed.InstallDir.Equals(portable.Root, StringComparison.OrdinalIgnoreCase))
+ throw new Exception("Healing did not adopt the actual installation directory.");
+ if (Directory.Exists(nativeBuild))
+ throw new Exception("Healing kept the location-bound native build cache.");
+
+ if (PortableInstallHealing.HealMovedInstall(portable))
+ throw new Exception("An installation already at its recorded location was healed again.");
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
+ }
+
+ private static void TestCanonicalRetroRewindResolution()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "mkwc-retro-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ var package = Path.Combine(root, "wrapper", "RetroRewind6");
+ Directory.CreateDirectory(Path.Combine(package, "Binaries"));
+ File.WriteAllText(Path.Combine(package, "Binaries", "Code.pul"), "pretend-kamek");
+ File.WriteAllText(Path.Combine(package, "version.txt"), "1.2.3");
+
+ var expected = InputValidation.Sha256File(Path.Combine(package, "Binaries", "Code.pul"));
+ // The canonical folder may be named directly, or reached through the one permitted
+ // wrapper level; all three selections resolve to exactly the same installation.
+ foreach (var selection in new[] { package, Path.Combine(root, "wrapper"), root })
+ {
+ var inputs = CompileInputsFingerprint.Compute(selection);
+ if (!inputs.RetroRewindRoot.Equals(Path.GetFullPath(package),
+ StringComparison.OrdinalIgnoreCase))
+ throw new Exception($"Selecting {selection} resolved to {inputs.RetroRewindRoot}.");
+ if (inputs.CodePulSha256 != expected)
+ throw new Exception($"The Code.pul hash was wrong when selecting {selection}.");
+ }
+
+ try
+ {
+ _ = RetroRewindSource.ResolveRetroRewind6(Path.Combine(package, "Binaries"));
+ throw new Exception("A folder without Code.pul was accepted.");
+ }
+ catch (InvalidDataException) { }
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
+ }
+
+ private static void TestRetroWfcPayloadSnapshotReconciliation()
+ {
+ // The endpoint is pinned in exactly one place; this test must not restate it.
+ const string endpoint = InputValidation.CurrentRetroWfcPayloadUri;
+ const string shaB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+ var root = Path.Combine(Path.GetTempPath(), "mkwc-wfc-snapshot-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ InputValidation.ValidateRetroWfcPayloadUri(endpoint);
+ try
+ {
+ InputValidation.ValidateRetroWfcPayloadUri(endpoint + "&extra=1");
+ throw new Exception("A non-canonical Retro-WFC endpoint was accepted.");
+ }
+ catch (InvalidDataException) { }
+
+ var installation = new Installation(root);
+ Directory.CreateDirectory(installation.RetroDirectory);
+ var payloadRoot = Path.Combine(root, "download");
+ var payloadPath = Path.Combine(payloadRoot, "binary", "payload.RMCPD00.bin");
+ Directory.CreateDirectory(Path.GetDirectoryName(payloadPath)!);
+ using var signingRsa = RSA.Create(2048);
+ var signingKey = signingRsa.ExportParameters(includePrivateParameters: false);
+ var bytes = new byte[0x140];
+ "WWFC/Payload"u8.CopyTo(bytes);
+ BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(0x0C), (uint)bytes.Length);
+ bytes[^1] = 0x5A;
+ signingRsa.SignData(bytes.AsSpan(0x110), HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1).CopyTo(bytes.AsSpan(0x10));
+ File.WriteAllBytes(payloadPath, bytes);
+ _ = InputValidation.ValidateStagedRetroWfcPayloadDirectory(payloadRoot, signingKey);
+ try
+ {
+ _ = InputValidation.ValidateStagedRetroWfcPayloadDirectory(payloadRoot);
+ throw new Exception("A payload signed by a foreign key passed the pinned production key.");
+ }
+ catch (InvalidDataException) { }
+ var shaA = InputValidation.ComputeRetroWfcPayloadSha256(payloadRoot, signingKey);
+ var snapshot = new RetroWfcPayloadSnapshot(payloadRoot, shaA, bytes.Length);
+ JsonState.Write(Path.Combine(installation.RetroDirectory, ProductFingerprint.FileName),
+ new ProductFingerprint
+ {
+ Profile = "retro-rewind", ToolkitFingerprint = "toolkit",
+ RetroWfcPayloadMode = "downloaded", RetroWfcPayloadSha256 = shaA,
+ RetroWfcPayloadLength = bytes.Length
+ });
+ JsonState.Write(installation.InstallStatePath, new InstallState
+ {
+ InstallDir = root, RetroWfcPayloadMode = "downloaded",
+ RetroWfcPayloadSha256 = shaA, RetroWfcPayloadLength = bytes.Length
+ });
+ if (!installation.MatchesRetroWfcPayloadSnapshot("toolkit", snapshot))
+ throw new Exception("The same downloaded payload snapshot did not produce a no-op.");
+ if (ProductRepairService.PlanRetroWfcPayloadReconciliation(productMatchesSnapshot: true,
+ cacheMatchesSnapshot: true) != ProductRepairService.RetroWfcPayloadAction.None)
+ throw new Exception("The same downloaded snapshot did not remain a no-op.");
+ if (ProductRepairService.PlanRetroWfcPayloadReconciliation(productMatchesSnapshot: true,
+ cacheMatchesSnapshot: false) !=
+ ProductRepairService.RetroWfcPayloadAction.PublishCacheOnly)
+ throw new Exception("A damaged local payload cache requested an unnecessary recompile.");
+
+ var changedSnapshot = snapshot with { Sha256 = shaB };
+ if (installation.MatchesRetroWfcPayloadSnapshot("toolkit", changedSnapshot))
+ throw new Exception("A changed downloaded payload snapshot did not require recompilation.");
+ if (ProductRepairService.PlanRetroWfcPayloadReconciliation(productMatchesSnapshot: false,
+ cacheMatchesSnapshot: false) != ProductRepairService.RetroWfcPayloadAction.RebuildRetro)
+ throw new Exception("A changed payload did not rebuild Retro Rewind.");
+
+ bytes[0x0F]++;
+ File.WriteAllBytes(payloadPath, bytes);
+ try
+ {
+ _ = InputValidation.ValidateStagedRetroWfcPayloadDirectory(payloadRoot, signingKey);
+ throw new Exception("A payload with a malformed declared length was accepted.");
+ }
+ catch (InvalidDataException) { }
+
+ bytes[0x0F]--;
+ bytes[^1] ^= 1;
+ File.WriteAllBytes(payloadPath, bytes);
+ try
+ {
+ _ = InputValidation.ValidateStagedRetroWfcPayloadDirectory(payloadRoot, signingKey);
+ throw new Exception("A payload with tampered signed content was accepted.");
+ }
+ catch (InvalidDataException) { }
+
+ bytes[^1] ^= 1;
+ bytes[0] = 0;
+ File.WriteAllBytes(payloadPath, bytes);
+ try
+ {
+ _ = InputValidation.ValidateStagedRetroWfcPayloadDirectory(payloadRoot, signingKey);
+ throw new Exception("A payload with a malformed header was accepted.");
+ }
+ catch (InvalidDataException) { }
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
+ }
+
+ private static void TestRetroWfcTransientRetryPolicy()
+ {
+ if (!InputValidation.IsTransientRetroWfcDownloadFailure(
+ new HttpRequestException("Connection reset"), CancellationToken.None))
+ throw new Exception("A transport failure would not receive its one retry.");
+ if (!InputValidation.IsTransientRetroWfcDownloadFailure(
+ new HttpRequestException("Service unavailable", null,
+ System.Net.HttpStatusCode.ServiceUnavailable), CancellationToken.None))
+ throw new Exception("An HTTP 5xx response would not receive its one retry.");
+ if (!InputValidation.IsTransientRetroWfcDownloadFailure(
+ new TimeoutException("Timed out"), CancellationToken.None))
+ throw new Exception("A download timeout would not receive its one retry.");
+ if (InputValidation.IsTransientRetroWfcDownloadFailure(
+ new HttpRequestException("Not found", null, System.Net.HttpStatusCode.NotFound),
+ CancellationToken.None))
+ throw new Exception("A permanent HTTP 4xx response would be retried.");
+ if (InputValidation.IsTransientRetroWfcDownloadFailure(
+ new InvalidDataException("Malformed payload"), CancellationToken.None))
+ throw new Exception("A malformed payload would be retried.");
+
+ using var cancelled = new CancellationTokenSource();
+ cancelled.Cancel();
+ if (InputValidation.IsTransientRetroWfcDownloadFailure(
+ new TimeoutException("Cancelled"), cancelled.Token))
+ throw new Exception("Caller cancellation would be retried.");
+ }
+
+ private static void TestInstallTransactionRollback()
+ {
+ var parent = Path.Combine(Path.GetTempPath(),
+ "mkwc-transaction-self-test-" + Guid.NewGuid().ToString("N"));
+ var installRoot = Path.Combine(parent, "install");
+ var liveComponent = Path.Combine(installRoot, "Component");
+ var preparedComponent = Path.Combine(parent, "prepared-component");
+ var preparedFile = Path.Combine(parent, "prepared-added.json");
+ var addedFile = Path.Combine(installRoot, "added.json");
+ try
+ {
+ Directory.CreateDirectory(Path.Combine(liveComponent, "nested"));
+ File.WriteAllText(Path.Combine(liveComponent, "nested", "original.txt"), "original-bytes");
+ File.WriteAllText(Path.Combine(installRoot, "untouched.txt"), "untouched-bytes");
+ Directory.CreateDirectory(preparedComponent);
+ File.WriteAllText(Path.Combine(preparedComponent, "replacement.txt"), "replacement-bytes");
+ File.WriteAllText(preparedFile, "new-live-file");
+
+ using (var transaction = InstallTransaction.Begin(installRoot, null,
+ InstallTransactionEntry.Directory(preparedComponent, liveComponent),
+ InstallTransactionEntry.File(preparedFile, addedFile)))
+ {
+ transaction.Publish();
+ if (!File.Exists(Path.Combine(liveComponent, "replacement.txt")) ||
+ File.Exists(Path.Combine(liveComponent, "nested", "original.txt")) ||
+ File.ReadAllText(addedFile) != "new-live-file")
+ throw new Exception("The transaction did not publish its prepared replacements.");
+ // Deliberately omit Commit. Dispose must restore the exact pre-transaction tree.
+ }
+
+ var original = Path.Combine(liveComponent, "nested", "original.txt");
+ if (!File.Exists(original) || File.ReadAllText(original) != "original-bytes" ||
+ File.Exists(Path.Combine(liveComponent, "replacement.txt")) || File.Exists(addedFile) ||
+ Directory.Exists(preparedComponent) || File.Exists(preparedFile) ||
+ File.ReadAllText(Path.Combine(installRoot, "untouched.txt")) != "untouched-bytes")
+ throw new Exception("Disposing an uncommitted transaction did not restore the exact original tree.");
+ if (File.Exists(InstallOperationPaths.GetJournalPath(installRoot)))
+ throw new Exception("A rolled-back transaction left its journal behind.");
+ }
+ finally
+ {
+ if (Directory.Exists(parent)) Directory.Delete(parent, recursive: true);
+ }
+ }
+
+ private static void TestInstallScratchRecovery()
+ {
+ var parent = Path.Combine(Path.GetTempPath(),
+ "mkwc-scratch-self-test-" + Guid.NewGuid().ToString("N"));
+ var installRoot = Path.Combine(parent, "install");
+ var otherInstallRoot = Path.Combine(parent, "other-install");
+ InstallScratchSpace? scratch = null;
+ InstallScratchSpace? otherScratch = null;
+ try
+ {
+ Directory.CreateDirectory(installRoot);
+ Directory.CreateDirectory(otherInstallRoot);
+ scratch = InstallScratchSpace.CreateSibling(installRoot);
+ File.WriteAllText(Path.Combine(scratch.Root, "owned.txt"), "owned");
+
+ // Scratch is keyed by the installation's scope id, so a neighbouring installation's
+ // scratch lives beside it under a different prefix and must survive this recovery.
+ otherScratch = InstallScratchSpace.CreateSibling(otherInstallRoot);
+ var sentinel = Path.Combine(otherScratch.Root, "preserve.txt");
+ File.WriteAllText(sentinel, "preserve");
+
+ using (InstallOperationLock.Acquire(installRoot))
+ {
+ if (Directory.Exists(scratch.Root))
+ throw new Exception("Lock acquisition did not recover this installation's scratch space.");
+ if (!File.Exists(sentinel) || File.ReadAllText(sentinel) != "preserve")
+ throw new Exception("Scratch recovery removed another installation's scratch space.");
+ }
+ }
+ finally
+ {
+ scratch?.Dispose();
+ otherScratch?.Dispose();
+ if (Directory.Exists(parent)) Directory.Delete(parent, recursive: true);
+ }
+ }
+
+ private static void TestCompileInputsFingerprint()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "mkwc-compile-inputs-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ var package = Path.Combine(root, "RetroRewind6");
+ Directory.CreateDirectory(Path.Combine(package, "Binaries"));
+ Directory.CreateDirectory(Path.Combine(package, "RaceAssets"));
+ File.WriteAllText(Path.Combine(package, "Binaries", "Code.pul"), "code-A");
+ File.WriteAllText(Path.Combine(package, "RaceAssets", "tracks.txt"), "assets-A");
+
+ var first = CompileInputsFingerprint.Compute(package);
+ var throughParent = CompileInputsFingerprint.Compute(root);
+ if (first.CodePulSha256 != throughParent.CodePulSha256 ||
+ first.CompileInputsSha256 != throughParent.CompileInputsSha256)
+ throw new Exception("The compile identity changed when selected through its parent folder.");
+ if (first.CompileInputsSha256 != CompileInputsFingerprint.ComputeCompileInputsSha256(package))
+ throw new Exception("The compile-input hash is not deterministic.");
+
+ // The runtime reads assets live from the canonical installation, so an asset-only Retro
+ // Rewind update must not disturb any identity the backend keeps.
+ File.WriteAllText(Path.Combine(package, "RaceAssets", "tracks.txt"), "assets-B");
+ Directory.CreateDirectory(Path.Combine(package, "RaceAssets", "empty-runtime-directory"));
+ var assetsChanged = CompileInputsFingerprint.Compute(package);
+ if (assetsChanged.CodePulSha256 != first.CodePulSha256 ||
+ assetsChanged.CompileInputsSha256 != first.CompileInputsSha256)
+ throw new Exception("An asset-only Retro Rewind update changed a compile identity.");
+
+ // The registered mod directories affect compilation even when empty.
+ var files = Path.Combine(package, "files");
+ Directory.CreateDirectory(files);
+ var filesTopology = CompileInputsFingerprint.Compute(package);
+ if (filesTopology.CompileInputsSha256 == assetsChanged.CompileInputsSha256)
+ throw new Exception("The top-level files directory topology was not part of compile identity.");
+ File.WriteAllText(Path.Combine(files, "runtime-asset.bin"), "asset");
+ if (CompileInputsFingerprint.Compute(package).CompileInputsSha256 != filesTopology.CompileInputsSha256)
+ throw new Exception("A normal files asset incorrectly triggered a recompile.");
+
+ // Only Code.pul and the recorded file layout should trigger recompilation.
+ File.WriteAllText(Path.Combine(package, "unrelated.cpp"), "// ignored");
+ var straySource = CompileInputsFingerprint.Compute(package);
+ if (straySource.CodePulSha256 != filesTopology.CodePulSha256 ||
+ straySource.CompileInputsSha256 != filesTopology.CompileInputsSha256)
+ throw new Exception("A stray source file changed a compile identity.");
+
+ var codePulPath = Path.Combine(package, "Binaries", "Code.pul");
+ var linkTarget = Path.Combine(package, "Binaries", "Code.pul.copy");
+ File.WriteAllText(linkTarget, "linked");
+ File.Delete(codePulPath);
+ if (TryCreateFileLink(codePulPath, linkTarget))
+ {
+ try
+ {
+ _ = CompileInputsFingerprint.Compute(package);
+ throw new Exception("A linked compile input was accepted into the compile identity.");
+ }
+ catch (InvalidDataException) { }
+ finally
+ {
+ File.Delete(codePulPath);
+ }
+ }
+
+ File.WriteAllText(codePulPath, "code-B");
+ var codeChanged = CompileInputsFingerprint.Compute(package);
+ if (codeChanged.CodePulSha256 == assetsChanged.CodePulSha256 ||
+ codeChanged.CompileInputsSha256 == assetsChanged.CompileInputsSha256)
+ throw new Exception("A Code.pul update did not change both compile identities.");
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
+ }
+
+ ///
+ /// A build snapshot must copy megabytes of compile inputs, not the multi-gigabyte asset tree,
+ /// and must be provably the same inputs the canonical installation had when it was read.
+ ///
+ private static void TestCompileInputsSnapshot()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "mkwc-snapshot-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ var package = Path.Combine(root, "RetroRewind6");
+ Directory.CreateDirectory(Path.Combine(package, "Binaries"));
+ Directory.CreateDirectory(Path.Combine(package, "files", "Race", "Course"));
+ Directory.CreateDirectory(Path.Combine(package, "PulsarEngine", "MarioKartWii"));
+ File.WriteAllText(Path.Combine(package, "Binaries", "Code.pul"), "code-A");
+ File.WriteAllText(Path.Combine(package, "files", "Race", "Course", "beginner_course.szs"),
+ "a pretend multi-gigabyte asset");
+ File.WriteAllText(Path.Combine(package, "PulsarEngine", "MarioKartWii", "patch.cpp"), "int patch;");
+
+ var source = CompileInputsFingerprint.Compute(package);
+ var snapshot = CompileInputsFingerprint.Snapshot(package, Path.Combine(root, "scratch"));
+ if (snapshot.CodePulSha256 != source.CodePulSha256 ||
+ snapshot.CompileInputsSha256 != source.CompileInputsSha256)
+ throw new Exception("The compile-input snapshot is not identical to its source.");
+ // The mod root name becomes the product's relative DVD overlay root, so it is contractual.
+ if (Path.GetFileName(snapshot.RetroRewindRoot) != "RetroRewind6")
+ throw new Exception("The compile-input snapshot is not named RetroRewind6.");
+ if (!File.Exists(Path.Combine(snapshot.RetroRewindRoot, "Binaries", "Code.pul")))
+ throw new Exception("The compile-input snapshot is missing the Code.pul.");
+ if (File.Exists(Path.Combine(snapshot.RetroRewindRoot, "PulsarEngine", "MarioKartWii", "patch.cpp")))
+ throw new Exception("The compile-input snapshot copied Pulsar sources no build consumes.");
+ if (File.Exists(Path.Combine(snapshot.RetroRewindRoot, "files", "Race", "Course",
+ "beginner_course.szs")))
+ throw new Exception("The compile-input snapshot copied the runtime asset tree.");
+ if (!Directory.Exists(Path.Combine(snapshot.RetroRewindRoot, "files")))
+ throw new Exception("The compile-input snapshot lost the compile-relevant files topology.");
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
+ }
+
+ private static void TestCompileInputsFingerprintGoldenVector()
+ {
+ const string expected = GoldenCompileInputsDigest;
+ var root = Path.Combine(Path.GetTempPath(), "mkwc-vector-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ Directory.CreateDirectory(Path.Combine(root, "Binaries"));
+ Directory.CreateDirectory(Path.Combine(root, "tracks"));
+ Directory.CreateDirectory(Path.Combine(root, "empty", "nested"));
+ File.WriteAllBytes(Path.Combine(root, "Binaries", "Code.pul"), [0x00, 0x01, 0x02, 0xff]);
+ File.WriteAllBytes(Path.Combine(root, "readme.txt"),
+ System.Text.Encoding.UTF8.GetBytes("retro-rewind\n"));
+ File.WriteAllBytes(Path.Combine(root, "tracks", "course.bin"), []);
+
+ var actual = CompileInputsFingerprint.ComputeCompileInputsSha256(root);
+ if (!actual.Equals(expected, StringComparison.Ordinal))
+ throw new Exception($"Compile-inputs golden vector changed: {actual} != {expected}.");
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
+ }
+
+ ///
+ /// The compile-input digest decides whether an installed product must be recompiled, so it is
+ /// pinned here: changing the hashed set or its encoding must be a deliberate edit of this value.
+ ///
+ private const string GoldenCompileInputsDigest =
+ "19b2ead4f83f030aac110b82ac801605fa81b4ed86f7be6a1b2bb9dd20147184";
+
+ private static void TestToolkitFingerprint()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "mkwc-toolkit-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ CreateFakeToolkit(root);
+ var first = ToolkitFingerprint.Compute(root);
+ if (first != ToolkitFingerprint.Compute(root))
+ throw new Exception("The toolkit fingerprint is not stable.");
+
+ // A file that has nothing to do with generated code must not invalidate every install.
+ File.WriteAllText(Path.Combine(root, "Toolkit", "DolphinTool.exe"), "irrelevant");
+ if (ToolkitFingerprint.Compute(root) != first)
+ throw new Exception("An unrelated toolkit file changed the fingerprint.");
+
+ File.WriteAllText(Path.Combine(root, "Toolkit", "Translator", "Translator.Cli.exe"), "translator-v2");
+ var afterTranslator = ToolkitFingerprint.Compute(root);
+ if (afterTranslator == first)
+ throw new Exception("A new translator did not change the fingerprint.");
+
+ File.WriteAllText(Path.Combine(root, "BuildWorkspace", "runtime", "src", "main.cpp"), "int main2();");
+ var afterRuntimeSource = ToolkitFingerprint.Compute(root);
+ if (afterRuntimeSource == afterTranslator)
+ throw new Exception("A runtime source change did not change the fingerprint.");
+
+ var patch = Path.Combine(root, "BuildWorkspace", "aurora-main", "cmake", "patches",
+ "sdl3.patch");
+ Directory.CreateDirectory(Path.GetDirectoryName(patch)!);
+ File.WriteAllText(patch, "patch-v1");
+ var afterPatch = ToolkitFingerprint.Compute(root);
+ if (afterPatch == afterRuntimeSource)
+ throw new Exception("An Aurora patch consumed by the provider did not change compile identity.");
+
+ var installation = new Installation(root);
+ installation.WriteToolkitState(afterPatch, ToolkitFingerprint.ComputePackage(root),
+ "wiicompiled-v1", ToolkitFingerprint.ComputeRuntimeAssets(root));
+ var repair = new ProductRepairService(installation, new DelegatingInstallReporter(_ => { }));
+ repair.VerifyToolkitForCompilation(afterPatch);
+
+ void ExpectModifiedToolkitRejected(string description)
+ {
+ try
+ {
+ repair.VerifyToolkitForCompilation(afterPatch);
+ throw new Exception(description);
+ }
+ catch (InvalidDataException) { }
+ }
+
+ File.WriteAllText(patch, "patch-v2");
+ ExpectModifiedToolkitRejected("A modified mandatory Aurora patch was accepted for compilation.");
+ File.Delete(patch);
+ ExpectModifiedToolkitRejected("A deleted mandatory Aurora patch was accepted for compilation.");
+ File.WriteAllText(patch, "patch-v1");
+ repair.VerifyToolkitForCompilation(afterPatch);
+
+ var runtimeAssets = ToolkitFingerprint.ComputeRuntimeAssets(root);
+ var pipeline = Path.Combine(root, "BuildWorkspace", "runtime", "assets", "pipeline",
+ "initial_pipeline_cache.db");
+ File.WriteAllText(pipeline, "pipeline-v2");
+ if (ToolkitFingerprint.Compute(root) != afterPatch)
+ throw new Exception("A copied-only runtime asset needlessly changed compile identity.");
+ var changedRuntimeAssets = ToolkitFingerprint.ComputeRuntimeAssets(root);
+ if (changedRuntimeAssets == runtimeAssets)
+ throw new Exception("A copied runtime asset did not change its publication identity.");
+
+ var sourceEmptyDirectory = Path.Combine(root, "BuildWorkspace", "runtime", "assets", "wii",
+ "empty-runtime-directory");
+ Directory.CreateDirectory(sourceEmptyDirectory);
+ var topologyChangedRuntimeAssets = ToolkitFingerprint.ComputeRuntimeAssets(root);
+ 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.
+ var components = ToolkitFingerprint.ComputeComponents(root);
+ if (components.Compile != afterPatch)
+ throw new Exception("ComputeComponents disagrees with the compile identity.");
+ File.WriteAllText(Path.Combine(root, "Toolkit", "Translator", "Translator.Cli.exe"),
+ "translator-v3");
+ var afterNewTranslator = ToolkitFingerprint.ComputeComponents(root);
+ if (afterNewTranslator.Translation == components.Translation)
+ 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 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)
+ throw new Exception("A runtime source change needlessly changed the native toolchain identity.");
+ if (afterNewRuntime.Compile == afterNewTranslator.Compile)
+ throw new Exception("A runtime source change did not change the compile identity.");
+ File.WriteAllText(Path.Combine(root, "Toolkit", "llvm-mingw", "bin", "clang-22.exe"),
+ "clang-v2");
+ var afterNewCompiler = ToolkitFingerprint.ComputeComponents(root);
+ if (afterNewCompiler.NativeToolchain == afterNewRuntime.NativeToolchain)
+ throw new Exception("A new compiler did not change the native toolchain identity.");
+ if (afterNewCompiler.Translation != afterNewRuntime.Translation)
+ throw new Exception("A new compiler needlessly changed the translation identity.");
+ File.WriteAllText(Path.Combine(root, "BuildWorkspace", "Dependencies", "native_prebuilt",
+ "provenance.json"), "{\"BuiltUtc\":\"rewritten\"}");
+ if (ToolkitFingerprint.Compute(root) != afterNewCompiler.Compile)
+ throw new Exception(
+ "A native_prebuilt provenance rewrite needlessly changed the compile identity.");
+
+ var runtimeLink = Path.Combine(root, "BuildWorkspace", "runtime", "assets", "wii", "linked-assets");
+ if (TryCreateDirectoryLink(runtimeLink,
+ Path.Combine(root, "BuildWorkspace", "runtime", "assets", "wii", "shared2")))
+ {
+ try
+ {
+ try
+ {
+ _ = ToolkitFingerprint.ComputeRuntimeAssets(root);
+ throw new Exception("A runtime-asset reparse point was accepted into its identity.");
+ }
+ catch (InvalidDataException) { }
+ }
+ finally
+ {
+ Directory.Delete(runtimeLink);
+ }
+ }
+
+ var product = Path.Combine(root, "product");
+ Directory.CreateDirectory(product);
+ FileSystemUtilities.CopyDirectory(
+ Path.Combine(root, "BuildWorkspace", "runtime", "assets", "wii"),
+ Path.Combine(product, "wii_bootstrap"));
+ File.Copy(Path.Combine(root, "BuildWorkspace", "runtime", "assets", "dsp", "dsp_coef.bin"),
+ Path.Combine(product, "dsp_coef.bin"));
+ File.Copy(pipeline, Path.Combine(product, "initial_pipeline_cache.db"));
+ if (!ToolkitFingerprint.ProductRuntimeAssetsMatch(product, topologyChangedRuntimeAssets))
+ throw new Exception("Copied product assets did not match their normalized source identity.");
+ Directory.Delete(Path.Combine(product, "wii_bootstrap", "empty-runtime-directory"));
+ if (ToolkitFingerprint.ProductRuntimeAssetsMatch(product, topologyChangedRuntimeAssets))
+ throw new Exception("A missing copied runtime-asset directory was not detected.");
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
+ }
+
+ private static void CreateFakeToolkit(string root)
+ {
+ Directory.CreateDirectory(Path.Combine(root, "Toolkit", "Translator"));
+ Directory.CreateDirectory(Path.Combine(root, "Toolkit", "CMake", "bin"));
+ Directory.CreateDirectory(Path.Combine(root, "Toolkit", "Ninja"));
+ Directory.CreateDirectory(Path.Combine(root, "Toolkit", "llvm-mingw", "bin"));
+ Directory.CreateDirectory(Path.Combine(root, "BuildWorkspace", "projects", "mkwii"));
+ Directory.CreateDirectory(Path.Combine(root, "BuildWorkspace", "runtime", "src"));
+ Directory.CreateDirectory(Path.Combine(root, "BuildWorkspace", "runtime", "assets", "wii", "shared2",
+ "wc24"));
+ Directory.CreateDirectory(Path.Combine(root, "BuildWorkspace", "runtime", "assets", "dsp"));
+ Directory.CreateDirectory(Path.Combine(root, "BuildWorkspace", "runtime", "assets", "pipeline"));
+ var aurora = Path.Combine(root, "BuildWorkspace", "aurora-main");
+ Directory.CreateDirectory(aurora);
+ File.WriteAllText(Path.Combine(aurora, "README.md"), "aurora source");
+ foreach (var dependency in InstalledLayout.DependencyNames)
+ {
+ var directory = Path.Combine(root, "BuildWorkspace", "Dependencies", dependency);
+ Directory.CreateDirectory(directory);
+ File.WriteAllText(Path.Combine(directory, ".mkwc-layout"), dependency);
+ }
+ File.WriteAllText(Path.Combine(root, "Toolkit", "Translator", "Translator.Cli.exe"), "translator-v1");
+ File.WriteAllText(Path.Combine(root, "Toolkit", "CMake", "bin", "cmake.exe"), "cmake");
+ File.WriteAllText(Path.Combine(root, "Toolkit", "Ninja", "ninja.exe"), "ninja");
+ foreach (var executable in new[]
+ {
+ "clang-22.exe", "ld.lld.exe", "x86_64-w64-mingw32-clang.exe",
+ "x86_64-w64-mingw32-clang++.exe", "x86_64-w64-mingw32-windres.exe"
+ })
+ File.WriteAllText(Path.Combine(root, "Toolkit", "llvm-mingw", "bin", executable), executable);
+ File.WriteAllText(Path.Combine(root, "Toolkit", "DolphinTool.exe"), "tool");
+ File.WriteAllText(Path.Combine(root, "BuildWorkspace", "LocalBuild.ps1"), "# build");
+ File.WriteAllText(Path.Combine(root, "BuildWorkspace", "NativeBuildFlags.ps1"), "# flags");
+ File.WriteAllText(Path.Combine(root, "BuildWorkspace", "projects", "mkwii", "recomp.yml"), "profiles: {}");
+ File.WriteAllText(Path.Combine(root, "BuildWorkspace", "runtime", "CMakeLists.txt"), "cmake_minimum_required(VERSION 3.20)");
+ File.WriteAllText(Path.Combine(root, "BuildWorkspace", "runtime", "src", "main.cpp"), "int main();");
+ File.WriteAllText(Path.Combine(root, "BuildWorkspace", "runtime", "assets", "wii", "shared2", "wc24",
+ "bootstrap.bin"), "bootstrap");
+ File.WriteAllText(Path.Combine(root, "BuildWorkspace", "runtime", "assets", "dsp", "dsp_coef.bin"),
+ "dsp");
+ File.WriteAllText(Path.Combine(root, "BuildWorkspace", "runtime", "assets", "pipeline",
+ "initial_pipeline_cache.db"), "pipeline");
+ }
+
+ private static void CopyFakeRuntimeAssets(string root, string productDirectory)
+ {
+ var source = Path.Combine(root, "BuildWorkspace", "runtime", "assets");
+ FileSystemUtilities.CopyDirectory(Path.Combine(source, "wii"),
+ Path.Combine(productDirectory, "wii_bootstrap"));
+ File.Copy(Path.Combine(source, "dsp", "dsp_coef.bin"),
+ Path.Combine(productDirectory, "dsp_coef.bin"), overwrite: true);
+ File.Copy(Path.Combine(source, "pipeline", "initial_pipeline_cache.db"),
+ Path.Combine(productDirectory, "initial_pipeline_cache.db"), overwrite: true);
+ }
+
+ private static bool TryCreateFileLink(string linkPath, string targetPath)
+ {
+ try
+ {
+ File.CreateSymbolicLink(linkPath, targetPath);
+ return true;
+ }
+ catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or NotSupportedException)
+ {
+ return false;
+ }
+ }
+
+ private static bool TryCreateDirectoryLink(string linkPath, string targetPath)
+ {
+ try
+ {
+ Directory.CreateSymbolicLink(linkPath, targetPath);
+ return true;
+ }
+ catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or NotSupportedException)
+ {
+ // Developer Mode / symlink permission is not guaranteed on a user's Windows system.
+ // The identity check is still covered whenever the platform can create a link.
+ return false;
+ }
+ }
+
+ ///
+ /// The full inspection state machine against the canonical Retro Rewind installation. The
+ /// installation itself is never copied here, exactly as in production: only its compile inputs
+ /// are ever read, and asset-only changes to it must leave the product current.
+ ///
+ private static void TestCodePulStateMachine()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "mkwc-install-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ CreateFakeToolkit(root);
+ var installation = new Installation(root);
+ var fingerprint = ToolkitFingerprint.Compute(root);
+ installation.WriteToolkitState(fingerprint, ToolkitFingerprint.ComputePackage(root),
+ "wiicompiled-v1", ToolkitFingerprint.ComputeRuntimeAssets(root));
+
+ // Wheel Wizard's own Retro Rewind installation, deliberately outside the install root.
+ var canonical = Path.Combine(root, "canonical", "RetroRewind6");
+ Directory.CreateDirectory(Path.Combine(canonical, "Binaries"));
+ Directory.CreateDirectory(Path.Combine(canonical, "PulsarEngine"));
+ File.WriteAllText(Path.Combine(canonical, "Binaries", "Code.pul"), "code-pul-A");
+ File.WriteAllText(Path.Combine(canonical, "PulsarEngine", "patch.cpp"), "int patch;");
+ File.WriteAllText(Path.Combine(canonical, "asset.txt"), "asset-A");
+
+ Directory.CreateDirectory(installation.BaseDirectory);
+ Directory.CreateDirectory(installation.RetroDirectory);
+ File.WriteAllBytes(installation.BaseExecutable, new byte[64 * 1024]);
+ File.WriteAllBytes(installation.RetroExecutable, new byte[64 * 1024]);
+ CopyFakeRuntimeAssets(root, installation.BaseDirectory);
+ CopyFakeRuntimeAssets(root, installation.RetroDirectory);
+ var gameSys = Path.Combine(installation.GameDataDirectory, "sys");
+ var gameRel = Path.Combine(installation.GameDataDirectory, "files", "rel");
+ Directory.CreateDirectory(gameSys);
+ Directory.CreateDirectory(gameRel);
+ File.WriteAllText(Path.Combine(gameSys, "main.dol"), "dol");
+ File.WriteAllText(Path.Combine(gameSys, "fst.bin"), "fst");
+ File.WriteAllText(Path.Combine(gameRel, "StaticR.rel"), "rel");
+ var dolSha = InputValidation.Sha256File(Path.Combine(gameSys, "main.dol"));
+ var relSha = InputValidation.Sha256File(Path.Combine(gameRel, "StaticR.rel"));
+ var inputsA = CompileInputsFingerprint.Compute(canonical);
+ LocalBuildService.WriteFingerprint(installation.BaseDirectory, BuildProfile.Base, fingerprint,
+ dolSha, relSha, "", RetroWfcPayloadMode.NotApplicable);
+ JsonState.Write(Path.Combine(installation.BaseDirectory, LocalBuildProvenance.FileName),
+ new LocalBuildProvenance { SchemaVersion = 1, Profile = "base", DolSha256 = dolSha,
+ RelSha256 = relSha });
+ JsonState.Write(Path.Combine(installation.RetroDirectory, ProductFingerprint.FileName),
+ new ProductFingerprint
+ {
+ Profile = "retro-rewind", ToolkitFingerprint = fingerprint, DolSha256 = dolSha, RelSha256 = relSha,
+ ExecutableSha256 = InputValidation.Sha256File(installation.RetroExecutable),
+ CodePulSha256 = inputsA.CodePulSha256,
+ RetroRewindCompileInputsSha256 = inputsA.CompileInputsSha256,
+ RetroWfcPayloadMode = "skipped"
+ });
+ JsonState.Write(Path.Combine(installation.RetroDirectory, LocalBuildProvenance.FileName),
+ new LocalBuildProvenance { SchemaVersion = 1, Profile = "retro-rewind", DolSha256 = dolSha,
+ RelSha256 = relSha, CodePulSha256 = inputsA.CodePulSha256,
+ RetroWfcPayloadMode = "skipped" });
+ JsonState.Write(installation.InstallStatePath, new InstallState
+ {
+ InstallDir = root, RetroRewindInstalled = true,
+ DolSha256 = dolSha, RelSha256 = relSha,
+ RetroRewindCodePulSha256 = inputsA.CodePulSha256,
+ RetroRewindCompileInputsSha256 = inputsA.CompileInputsSha256,
+ RetroWfcPayloadMode = "skipped",
+ RetroRewindRoot = canonical
+ });
+
+ ProductState Inspect() =>
+ installation.CheckRetroRewind(installation.ResolveToolkitFingerprint(),
+ CompileInputsFingerprint.Compute(canonical));
+
+ void Expect(ProductStatus expected, string description)
+ {
+ var inspected = Inspect();
+ if (inspected.Status != expected)
+ throw new Exception(
+ $"{description}: expected {expected}, got {inspected.Status}: {inspected.Detail}");
+ }
+
+ Expect(ProductStatus.Current, "A freshly built Retro Rewind");
+
+ // The whole point of the canonical-source model: assets change constantly and the
+ // runtime reads them live, so they must never make the backend rebuild anything.
+ File.WriteAllText(Path.Combine(canonical, "asset.txt"), "asset-B");
+ Directory.CreateDirectory(Path.Combine(canonical, "new-course-directory"));
+ Expect(ProductStatus.Current, "An asset-only Retro Rewind update");
+
+ var unreadableCanonical = installation.CheckRetroRewind(fingerprint, null,
+ "The canonical Retro Rewind folder cannot be read: missing.");
+ if (unreadableCanonical.Status != ProductStatus.InputsMissing ||
+ !unreadableCanonical.ActionRequired)
+ throw new Exception("A missing canonical Retro Rewind folder did not fail closed.");
+
+ var unchangedInputs = ProductRepairService.FindDesiredInputDrift(installation, fingerprint,
+ dolSha, relSha);
+ if (unchangedInputs.Base || unchangedInputs.Retro)
+ throw new Exception("Current authoritative game inputs requested an unnecessary rebuild.");
+ var changedInputs = ProductRepairService.FindDesiredInputDrift(installation, fingerprint,
+ new string('a', 64), relSha);
+ if (!changedInputs.Base || !changedInputs.Retro)
+ throw new Exception("Desired DOL drift did not force both installed products to rebuild.");
+
+ File.Delete(installation.ToolkitStatePath);
+ if (!string.IsNullOrEmpty(installation.ResolveToolkitFingerprint()) ||
+ Inspect().Status != ProductStatus.Blocked)
+ throw new Exception("A missing toolkit-state.json was inferred instead of failing closed.");
+ installation.WriteToolkitState(fingerprint, ToolkitFingerprint.ComputePackage(root),
+ "wiicompiled-v1", ToolkitFingerprint.ComputeRuntimeAssets(root));
+ Expect(ProductStatus.Current, "A restored toolkit-state.json");
+
+ var cmake = Path.Combine(root, "Toolkit", "CMake", "bin", "cmake.exe");
+ File.Delete(cmake);
+ if (!string.IsNullOrEmpty(installation.ResolveToolkitFingerprint()) ||
+ Inspect().Status != ProductStatus.Blocked)
+ throw new Exception("A missing bundled build tool did not block product inspection.");
+ File.WriteAllText(cmake, "cmake");
+ Expect(ProductStatus.Current, "A restored bundled build tool");
+
+ var auroraMarker = Path.Combine(root, "BuildWorkspace", "aurora-main", "README.md");
+ File.Delete(auroraMarker);
+ if (!string.IsNullOrEmpty(installation.ResolveToolkitFingerprint()) ||
+ Inspect().Status != ProductStatus.Blocked)
+ throw new Exception("A missing Aurora source tree did not block product inspection early.");
+ File.WriteAllText(auroraMarker, "aurora source");
+
+ var dependencyMarker = Path.Combine(root, "BuildWorkspace", "Dependencies", "fmt", ".mkwc-layout");
+ File.Delete(dependencyMarker);
+ if (!string.IsNullOrEmpty(installation.ResolveToolkitFingerprint()) ||
+ Inspect().Status != ProductStatus.Blocked)
+ throw new Exception("An empty required dependency tree did not block product inspection early.");
+ File.WriteAllText(dependencyMarker, "fmt");
+ Expect(ProductStatus.Current, "A restored required toolkit layout");
+
+ var baseDsp = Path.Combine(installation.BaseDirectory, "dsp_coef.bin");
+ File.Delete(baseDsp);
+ var missingBaseRuntimeAssets = installation.CheckBase(installation.ResolveToolkitFingerprint());
+ if (missingBaseRuntimeAssets.Status != ProductStatus.Broken ||
+ installation.CheckBaseCore(installation.ResolveToolkitFingerprint()).Status !=
+ ProductStatus.Current)
+ throw new Exception("Missing copied base runtime assets were not reported as broken.");
+
+ var retroPipeline = Path.Combine(installation.RetroDirectory, "initial_pipeline_cache.db");
+ File.Delete(retroPipeline);
+ if (Inspect().Status != ProductStatus.Broken)
+ throw new Exception("Missing copied Retro Rewind runtime assets were not reported as broken.");
+
+ // Copied support files are republished from the installed workspace: repairing them must
+ // never require the translator or the compiler.
+ using (InstallOperationLock.Acquire(root))
+ {
+ var repair = new ProductRepairService(installation, new DelegatingInstallReporter(_ => { }));
+ _ = repair.RepairBaseAsync(new ProductRepairService.ReconcileOptions())
+ .GetAwaiter().GetResult();
+ }
+ if (!File.Exists(baseDsp) ||
+ installation.CheckBase(installation.ResolveToolkitFingerprint()).Status != ProductStatus.Current)
+ throw new Exception("Runtime-only repair did not restore the base support assets and health.");
+ if (!File.Exists(retroPipeline))
+ throw new Exception("Runtime-only repair did not restore the Retro Rewind support assets.");
+ Expect(ProductStatus.Current, "Runtime-only Retro Rewind repair");
+
+ // Pulsar sources are no longer compile inputs; only the Code.pul built from them is.
+ File.WriteAllText(Path.Combine(canonical, "PulsarEngine", "patch.cpp"), "int patched;");
+ Expect(ProductStatus.Current, "A changed Pulsar source without a Code.pul change");
+ File.WriteAllText(Path.Combine(canonical, "PulsarEngine", "patch.cpp"), "int patch;");
+
+ File.WriteAllText(Path.Combine(canonical, "Binaries", "Code.pul"), "code-pul-B");
+ Expect(ProductStatus.CodePulChanged, "A changed canonical Code.pul");
+ File.WriteAllText(Path.Combine(canonical, "Binaries", "Code.pul"), "code-pul-A");
+ Expect(ProductStatus.Current, "A restored canonical Code.pul");
+
+ var alteredExecutable = File.ReadAllBytes(installation.RetroExecutable);
+ alteredExecutable[0] = 1;
+ File.WriteAllBytes(installation.RetroExecutable, alteredExecutable);
+ var tamperedExecutable = Inspect();
+ if (tamperedExecutable.Status != ProductStatus.Blocked || !tamperedExecutable.ActionRequired)
+ throw new Exception("A modified Retro Rewind executable did not fail closed.");
+ File.WriteAllBytes(installation.RetroExecutable, new byte[64 * 1024]);
+ Expect(ProductStatus.Current, "A restored Retro Rewind executable");
+
+ File.Delete(installation.RetroExecutable);
+ Expect(ProductStatus.Broken, "A missing Retro Rewind executable");
+
+ File.WriteAllBytes(installation.RetroExecutable, new byte[64 * 1024]);
+ File.WriteAllText(Path.Combine(root, "Toolkit", "Translator", "Translator.Cli.exe"), "translator-v2");
+ installation.WriteToolkitState(ToolkitFingerprint.Compute(root),
+ ToolkitFingerprint.ComputePackage(root), "wiicompiled-v2",
+ ToolkitFingerprint.ComputeRuntimeAssets(root));
+ Expect(ProductStatus.ToolkitChanged, "A replaced translator");
+ if (installation.CheckBase(installation.ResolveToolkitFingerprint()).Status != ProductStatus.ToolkitChanged)
+ throw new Exception("A replaced translator did not invalidate the base product.");
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
+ }
+
+ ///
+ /// The frontend matches the products line's keys case-sensitively and branches on the typed
+ /// status alone, so the exact v1 shape is pinned here rather than left to a serializer default.
+ ///
+ private static void TestProductsReport()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "mkwc-products-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ // A path nothing was installed to must answer "absent", not fail and not ask for a rebuild.
+ var missing = ConsoleCommands.InspectProducts(new Installation(root));
+ if (missing.RebuildRequired || missing.Base.Reason != "absent" ||
+ missing.RetroRewind.Reason != "absent")
+ throw new Exception("A missing installation directory was not reported as absent.");
+ if (Directory.Exists(root))
+ throw new Exception("Inspecting a missing installation created the directory.");
+
+ Directory.CreateDirectory(root);
+ var empty = ConsoleCommands.InspectProducts(new Installation(root));
+ if (empty.RebuildRequired || empty.Base.Reason != "absent" || empty.RetroRewind.Reason != "absent")
+ throw new Exception("An empty installation directory was not reported as absent.");
+ if (File.Exists(Path.Combine(root, ToolkitState.FileName)))
+ throw new Exception("Inspecting an empty installation wrote installation state.");
+
+ var json = ConsoleCommands.SerializeProductsReport(empty);
+ foreach (var key in new[]
+ {
+ "\"type\":\"products\"", "\"setupVersion\":", "\"installDir\":",
+ "\"rebuildRequired\":false", "\"base\":{\"status\":\"absent\",\"detail\":",
+ "\"retroRewind\":{\"status\":\"absent\",\"detail\":"
+ })
+ if (!json.Contains(key, StringComparison.Ordinal))
+ throw new Exception($"The products line is missing {key}: {json}");
+ // v1 reports a status and a detail per product and nothing else; a frontend must not be
+ // able to read a second, possibly disagreeing, opinion out of the same record.
+ foreach (var removed in new[] { "\"health\"", "\"action\"", "\"compileRequired\"",
+ "\"packageSyncRequired\"", "\"actionRequired\"" })
+ if (json.Contains(removed, StringComparison.Ordinal))
+ throw new Exception($"The products line still carries {removed}: {json}");
+ foreach (var wrongCase in new[] { "\"Base\"", "\"RetroRewind\"", "\"SetupVersion\"",
+ "\"InstallDir\"", "\"RebuildRequired\"", "\"Status\"", "\"Detail\"" })
+ if (json.Contains(wrongCase, StringComparison.Ordinal))
+ throw new Exception($"The products line used PascalCase for {wrongCase}: {json}");
+
+ foreach (var status in new[]
+ {
+ ProductStatus.Absent, ProductStatus.Current, ProductStatus.ToolkitChanged,
+ ProductStatus.CodePulChanged, ProductStatus.CompileInputsChanged,
+ ProductStatus.PayloadChanged, ProductStatus.InputsMissing,
+ ProductStatus.Blocked, ProductStatus.Broken
+ })
+ {
+ var reason = new ProductState(status, "").Reason;
+ if (!new[]
+ {
+ "absent", "current", "toolkit-changed", "code-pul-changed",
+ "compile-inputs-changed", "payload-changed", "inputs-missing", "blocked",
+ "broken"
+ }.Contains(reason, StringComparer.Ordinal))
+ throw new Exception($"{status} reports the undeclared status {reason}.");
+ }
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
+ }
+
+ private static void Test(string name, Action action, List failures)
+ {
+ try { action(); }
+ catch (Exception ex) { failures.Add($"{name}: {ex.Message}"); }
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup/ShellIntegration.cs b/Launcher/WiiCompiled.Setup/ShellIntegration.cs
new file mode 100644
index 0000000..eeae1de
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/ShellIntegration.cs
@@ -0,0 +1,85 @@
+using Microsoft.Win32;
+
+namespace WiiCompiled.Setup;
+
+internal static class ShellIntegration
+{
+ public static void RegisterUninstaller(string installDirectory, bool retroInstalled)
+ {
+ using var key = Registry.CurrentUser.CreateSubKey(ProductInfo.UninstallKey, writable: true)
+ ?? throw new InvalidOperationException("Could not register the uninstaller.");
+ var cli = Path.Combine(installDirectory, ProductInfo.SetupCopyName);
+ key.SetValue("DisplayName", ProductInfo.Name);
+ key.SetValue("DisplayVersion", ProductInfo.Version);
+ key.SetValue("Publisher", "WiiCompiled");
+ key.SetValue("InstallLocation", installDirectory);
+ key.SetValue("DisplayIcon", cli);
+ key.SetValue("UninstallString", $"\"{cli}\" --uninstall --install-dir \"{installDirectory}\"");
+ key.SetValue("QuietUninstallString", $"\"{cli}\" --silent-uninstall --install-dir \"{installDirectory}\"");
+ key.SetValue("NoModify", 1, RegistryValueKind.DWord);
+ key.SetValue("NoRepair", 1, RegistryValueKind.DWord);
+ key.SetValue("EstimatedSize", EstimateSizeKb(installDirectory), RegistryValueKind.DWord);
+ key.SetValue("Comments", retroInstalled ? "Includes the Retro Rewind profile" : "WiiCompiled");
+ }
+
+ public static void UnregisterUninstaller() =>
+ Registry.CurrentUser.DeleteSubKeyTree(ProductInfo.UninstallKey, throwOnMissingSubKey: false);
+
+ /// Removes shortcuts left by GUI-capable releases from before Wheel Wizard owned the UI.
+ public static void RemoveAllShortcuts()
+ {
+ var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
+ var startMenuFolder = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), "Programs", ProductInfo.Name);
+ RemoveAllShortcuts(desktop, startMenuFolder);
+ }
+
+ internal static void RemoveAllShortcuts(string desktop, string startMenuFolder)
+ {
+ var failures = new List();
+ DeleteFileBestEffort(Path.Combine(desktop, "WiiCompiled.lnk"), failures);
+ DeleteFileBestEffort(Path.Combine(desktop, "Retro Rewind.lnk"), failures);
+ DeleteDirectoryBestEffort(startMenuFolder, failures);
+
+ if (failures.Count != 0)
+ throw new AggregateException("One or more WiiCompiled shortcuts could not be removed.", failures);
+ }
+
+ private static void DeleteFileBestEffort(string path, List failures)
+ {
+ try
+ {
+ File.Delete(path);
+ }
+ catch (Exception ex)
+ {
+ failures.Add(new IOException($"Could not delete shortcut {path}: {ex.Message}", ex));
+ }
+ }
+
+ private static void DeleteDirectoryBestEffort(string path, List failures)
+ {
+ try
+ {
+ if (Directory.Exists(path)) Directory.Delete(path, recursive: true);
+ }
+ catch (Exception ex)
+ {
+ failures.Add(new IOException($"Could not delete shortcut folder {path}: {ex.Message}", ex));
+ }
+ }
+
+ private static int EstimateSizeKb(string directory)
+ {
+ try
+ {
+ var bytes = Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)
+ .Sum(path => new FileInfo(path).Length);
+ return (int)Math.Min(int.MaxValue, (bytes + 1023) / 1024);
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup/ToolkitFingerprint.cs b/Launcher/WiiCompiled.Setup/ToolkitFingerprint.cs
new file mode 100644
index 0000000..ded4feb
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/ToolkitFingerprint.cs
@@ -0,0 +1,286 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace WiiCompiled.Setup;
+
+///
+/// Content identity of everything that decides what the locally produced executables contain.
+/// First link of toolkit content -> toolkit-state.json -> per-product build-fingerprint.json:
+/// unchanged skips retranslation, any change forces a rebuild. Content-based so re-tags are free.
+///
+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.
+ private const string Version = "mkwc-toolkit-compile-v6";
+ private const string TranslationVersion = "mkwc-toolkit-translation-v2";
+ 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";
+ private const string RuntimeAssetsDescription = "The product runtime assets";
+
+ private static readonly string[] SourceExtensions =
+ [".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".inl", ".s", ".asm",
+ ".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.
+ ///
+ private static readonly string[] TranslationPrefixes =
+ [
+ InstalledLayout.ToolkitEntryPrefix + "Translator/",
+ InstalledLayout.WorkspaceEntryPrefix + "projects/",
+ InstalledLayout.WorkspaceEntryPrefix + "runtime/src/"
+ ];
+
+ private static readonly string[] TranslationFiles =
+ [InstalledLayout.WorkspaceEntryPrefix + "LocalBuild.ps1"];
+
+ ///
+ /// 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
+ /// deliberately excluded, since CMake/Ninja already rebuild exactly the affected objects.
+ ///
+ private static readonly string[] NativeToolchainPrefixes =
+ [
+ InstalledLayout.ToolkitEntryPrefix + "llvm-mingw/",
+ InstalledLayout.ToolkitEntryPrefix + "CMake/",
+ InstalledLayout.ToolkitEntryPrefix + "Ninja/",
+ InstalledLayout.ToolkitEntryPrefix + "Redist/"
+ ];
+
+ private static readonly string[] NativeToolchainFiles =
+ [
+ InstalledLayout.WorkspaceEntryPrefix + "LocalBuild.ps1",
+ InstalledLayout.WorkspaceEntryPrefix + "NativeBuildFlags.ps1"
+ ];
+
+ ///
+ /// Fingerprint of the toolkit at : the staged payload root at
+ /// release-build time, or an install directory when a compile path re-verifies it. The install
+ /// path itself never computes this, it compares manifest identity against toolkit-state.json.
+ ///
+ public static string Compute(string root, CancellationToken cancellationToken = default) =>
+ ComputeComponents(root, cancellationToken).Compile;
+
+ ///
+ /// One walk, three identities: the compile identity plus its translation and native-toolchain
+ /// subsets. The subsets never gate correctness, compile identity alone decides if products are
+ /// current, they only decide how much completed work carries into the next build.
+ ///
+ public static ToolkitFingerprintComponents ComputeComponents(string root,
+ CancellationToken cancellationToken = default)
+ {
+ root = Path.GetFullPath(root);
+ var toolkit = InstalledLayout.Toolkit(root);
+ var workspace = InstalledLayout.Workspace(root);
+ var entries = new SortedDictionary(StringComparer.Ordinal);
+
+ // DolphinTool validates/extracts the user disc but does not influence generated products.
+ // Everything else in Toolkit can affect translation, compilation, linking, or copied
+ // runtime support and therefore belongs to the compile identity.
+ AddDirectory(entries, root, toolkit, null,
+ cancellationToken,
+ file => !Path.GetFileName(file).Equals("DolphinTool.exe", StringComparison.OrdinalIgnoreCase));
+ AddFile(entries, root, Path.Combine(workspace, "LocalBuild.ps1"), cancellationToken);
+ AddFile(entries, root, Path.Combine(workspace, "NativeBuildFlags.ps1"), cancellationToken);
+ AddDirectory(entries, root, Path.Combine(workspace, "projects"), null, cancellationToken);
+ var runtime = Path.Combine(workspace, "runtime");
+ var runtimeAssets = Path.Combine(runtime, "assets");
+ // runtime/assets is copied beside the executable; it is deliberately tracked by the
+ // independent runtime-assets identity below so changing those bytes does not recompile code.
+ AddDirectory(entries, root, runtime, SourceExtensions, cancellationToken,
+ file => !FileSystemUtilities.PathContains(runtimeAssets, file));
+ AddDirectory(entries, root, Path.Combine(workspace, "aurora-main"), SourceExtensions,
+ cancellationToken);
+ // The shipped precompiled aurora archives and the pinned Dawn runtime are linked into the
+ // product, so a change there changes the binary just as surely as a translator change does.
+ // native_prebuilt's provenance.json is excluded: its Contents list already identifies every
+ // shipped byte, while its BuiltUtc stamp would make a bit-identical re-harvest look like a
+ // toolkit change and force a global user-side rebuild for nothing.
+ AddDirectory(entries, root, Path.Combine(workspace, "Dependencies"), null, cancellationToken,
+ file => !file.EndsWith(
+ Path.Combine("native_prebuilt", "provenance.json"), StringComparison.OrdinalIgnoreCase));
+
+ if (entries.Count == 0)
+ throw new InvalidDataException($"No toolkit files were found under {root}; the installation is incomplete.");
+
+ return new ToolkitFingerprintComponents(
+ BuildIdentity(Version, entries),
+ BuildSubsetIdentity(TranslationVersion, entries, TranslationPrefixes, TranslationFiles),
+ BuildSubsetIdentity(NativeToolchainVersion, entries, NativeToolchainPrefixes,
+ NativeToolchainFiles));
+ }
+
+ private static string BuildSubsetIdentity(string version, SortedDictionary entries,
+ string[] prefixes, string[] files)
+ {
+ var subset = new SortedDictionary(StringComparer.Ordinal);
+ foreach (var (relative, hash) in entries)
+ {
+ if (prefixes.Any(prefix => relative.StartsWith(prefix, StringComparison.Ordinal)) ||
+ files.Any(file => relative.Equals(file, StringComparison.Ordinal)))
+ subset[relative] = hash;
+ }
+ return BuildIdentity(version, subset);
+ }
+
+ ///
+ /// Content identity of the source-owned files copied verbatim beside every product. This is
+ /// intentionally independent of : an asset-only release is published
+ /// transactionally without translating or compiling unchanged code.
+ ///
+ public static string ComputeRuntimeAssets(string root, CancellationToken cancellationToken = default)
+ {
+ root = Path.GetFullPath(root);
+ var assets = Path.Combine(InstalledLayout.Workspace(root), "runtime", "assets");
+ var entries = new SortedDictionary(StringComparer.Ordinal);
+ AddMappedDirectory(entries, Path.Combine(assets, ProductRuntimeAssets.SourceBootstrapDirectoryName),
+ ProductRuntimeAssets.ProductBootstrapDirectoryName, cancellationToken);
+ foreach (var (relativePath, productFileName) in ProductRuntimeAssets.Files)
+ AddRequiredMappedFile(entries, ProductRuntimeAssets.SourceFile(assets, relativePath),
+ productFileName, cancellationToken);
+ return BuildIdentity(RuntimeAssetsVersion, entries);
+ }
+
+ public static string? TryComputeRuntimeAssets(string root, CancellationToken cancellationToken = default,
+ Action? diagnostic = null)
+ {
+ try { return ComputeRuntimeAssets(root, cancellationToken); }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
+ {
+ diagnostic?.Invoke(
+ $"The workspace runtime assets under {root} could not be fingerprinted: {ex.Message}");
+ return null;
+ }
+ }
+
+ /// Checks the actual copied product files against a source runtime-assets identity.
+ public static bool ProductRuntimeAssetsMatch(string productDirectory, string expectedFingerprint,
+ CancellationToken cancellationToken = default, Action? diagnostic = null)
+ {
+ try
+ {
+ var entries = new SortedDictionary(StringComparer.Ordinal);
+ AddMappedDirectory(entries,
+ Path.Combine(productDirectory, ProductRuntimeAssets.ProductBootstrapDirectoryName),
+ ProductRuntimeAssets.ProductBootstrapDirectoryName, cancellationToken);
+ foreach (var (_, productFileName) in ProductRuntimeAssets.Files)
+ AddRequiredMappedFile(entries, Path.Combine(productDirectory, productFileName),
+ productFileName, cancellationToken);
+ return BuildIdentity(RuntimeAssetsVersion, entries).Equals(expectedFingerprint,
+ StringComparison.Ordinal);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
+ {
+ diagnostic?.Invoke(
+ $"The copied runtime assets under {productDirectory} could not be verified: {ex.Message}");
+ return false;
+ }
+ }
+
+ /// Identity of the shipped Toolkit directory, including installer-only tools.
+ public static string ComputePackage(string root, CancellationToken cancellationToken = default)
+ {
+ root = Path.GetFullPath(root);
+ var entries = new SortedDictionary(StringComparer.Ordinal);
+ AddDirectory(entries, root, InstalledLayout.Toolkit(root), null, cancellationToken);
+ if (entries.Count == 0)
+ throw new InvalidDataException($"No toolkit package files were found under {root}.");
+ return BuildIdentity(PackageVersion, entries);
+ }
+
+ ///
+ /// The toolkit is ~26,000 mostly tiny files (llvm-mingw's headers/libraries dominate), so this
+ /// walk is bound by per-file open/read overhead, not hashing throughput, minutes rather than
+ /// seconds on a cold cache under on-access antivirus. Hashing concurrently keeps a compiling
+ /// repair from taking longer to prove toolkit integrity than to translate with it.
+ ///
+ private static void AddDirectory(SortedDictionary entries, string root, string directory,
+ string[]? extensions, CancellationToken cancellationToken = default,
+ Func? include = null)
+ {
+ if (!Directory.Exists(directory)) return;
+ var selected = new List();
+ foreach (var file in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (include is not null && !include(file)) continue;
+ if (extensions is not null &&
+ !extensions.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase))
+ continue;
+ selected.Add(file);
+ }
+
+ var hashed = new System.Collections.Concurrent.ConcurrentBag<(string Relative, string Hash)>();
+ Parallel.ForEach(selected,
+ new ParallelOptions
+ {
+ CancellationToken = cancellationToken,
+ MaxDegreeOfParallelism = Math.Max(2, Environment.ProcessorCount)
+ },
+ file =>
+ {
+ if (!File.Exists(file)) return;
+ var relative = Path.GetRelativePath(root, file).Replace('\\', '/');
+ hashed.Add((relative, InputValidation.Sha256File(file)));
+ });
+ foreach (var (relative, hash) in hashed) entries[relative] = hash;
+ }
+
+ private static void AddFile(SortedDictionary entries, string root, string file,
+ CancellationToken cancellationToken = default)
+ {
+ if (!File.Exists(file)) return;
+ cancellationToken.ThrowIfCancellationRequested();
+ var relative = Path.GetRelativePath(root, file).Replace('\\', '/');
+ entries[relative] = InputValidation.Sha256File(file);
+ }
+
+ private static void AddMappedDirectory(SortedDictionary entries, string directory,
+ string identityRoot, CancellationToken cancellationToken)
+ {
+ if (!Directory.Exists(directory))
+ throw new InvalidDataException($"Required product runtime asset directory is missing: {directory}");
+ var tree = FileSystemUtilities.EnumerateRegularTree(directory, cancellationToken,
+ RuntimeAssetsDescription);
+ if (tree.Count == 0)
+ throw new InvalidDataException($"Required product runtime asset directory is empty: {directory}");
+ foreach (var entry in tree)
+ {
+ if (entry.IsEmptyDirectory)
+ entries[$"empty-directory:{identityRoot}/{entry.RelativePath}"] = "";
+ else if (!entry.IsDirectory)
+ entries[$"{identityRoot}/{entry.RelativePath}"] = InputValidation.Sha256File(entry.FullPath);
+ }
+ }
+
+ private static void AddRequiredMappedFile(SortedDictionary entries, string file,
+ string identityPath, CancellationToken cancellationToken)
+ {
+ if (!File.Exists(file))
+ throw new InvalidDataException($"Required product runtime asset is missing: {file}");
+ FileSystemUtilities.RejectReparsePoint(new FileInfo(file), RuntimeAssetsDescription);
+ cancellationToken.ThrowIfCancellationRequested();
+ entries[identityPath] = InputValidation.Sha256File(file);
+ }
+
+ private static string BuildIdentity(string version, SortedDictionary entries)
+ {
+ 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();
+ }
+}
+
+///
+/// Compile identity plus the two subsets deciding cache reuse: is the
+/// authoritative freshness identity, decides if the base translation is
+/// still current, decides if compiled objects may still be linked.
+///
+internal sealed record ToolkitFingerprintComponents(string Compile, string Translation,
+ string NativeToolchain);
diff --git a/Launcher/WiiCompiled.Setup/UninstallService.cs b/Launcher/WiiCompiled.Setup/UninstallService.cs
new file mode 100644
index 0000000..a76a9e3
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/UninstallService.cs
@@ -0,0 +1,112 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+
+namespace WiiCompiled.Setup;
+
+internal static class UninstallService
+{
+ public static void StartWorker(string installDirectory, bool quiet = false)
+ {
+ var source = Environment.ProcessPath ?? throw new InvalidOperationException("Cannot locate the uninstaller.");
+ var temporary = Path.Combine(Path.GetTempPath(), $"WiiCompiled-Uninstall-{Guid.NewGuid():N}.exe");
+ File.Copy(source, temporary, overwrite: true);
+ var info = new ProcessStartInfo
+ {
+ FileName = temporary,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ WindowStyle = ProcessWindowStyle.Hidden
+ };
+ info.ArgumentList.Add("--uninstall-worker");
+ info.ArgumentList.Add("--install-dir");
+ info.ArgumentList.Add(Path.GetFullPath(installDirectory));
+ if (quiet) info.ArgumentList.Add("--quiet");
+ _ = Process.Start(info) ?? throw new InvalidOperationException("Could not start the uninstall worker.");
+ }
+
+ public static int RunWorker(string installDirectory, bool quiet)
+ {
+ installDirectory = Path.GetFullPath(installDirectory);
+ Thread.Sleep(750);
+ TryCleanup("remove shortcuts", quiet, ShellIntegration.RemoveAllShortcuts);
+
+ // A portable installation never registered an uninstall entry, and the single machine-wide
+ // key may belong to a normal installation on the same account. Removing it here would
+ // uninstall that one from Windows' point of view.
+ bool? isPortable = null;
+ TryCleanup("determine whether the installation is portable", quiet,
+ () => isPortable = PortableRoot.TryFind(installDirectory) is not null);
+ if (isPortable == false)
+ TryCleanup("remove the Add/Remove Programs entry", quiet,
+ ShellIntegration.UnregisterUninstaller);
+
+ var installation = new Installation(installDirectory);
+ string? configPath = null;
+ TryCleanup("locate the runtime configuration", quiet,
+ () => configPath = RuntimeConfiguration.ResolveConfigPath(installDirectory));
+
+ InstallState? state = null;
+ TryCleanup("read the installation state", quiet,
+ () => state = installation.ReadInstallState());
+
+ if (configPath is not null)
+ {
+ TryCleanup("remove the installed game path from the runtime configuration", quiet,
+ () => RuntimeConfiguration.RemoveDvdRootIfOwned(configPath,
+ Path.Combine(installDirectory, "GameAssets", "DATA")));
+ }
+
+ // Remove only this install's Retro Rewind setting; leave user-owned paths untouched.
+ if (configPath is not null && !string.IsNullOrEmpty(state?.RetroRewindRoot))
+ TryCleanup("remove the Retro Rewind path from the runtime configuration", quiet,
+ () => RuntimeConfiguration.RemoveRetroRewindRootIfOwned(configPath, state.RetroRewindRoot));
+
+ Exception? lastError = null;
+ for (var attempt = 0; attempt < 20; attempt++)
+ {
+ try
+ {
+ if (Directory.Exists(installDirectory)) Directory.Delete(installDirectory, recursive: true);
+ lastError = null;
+ break;
+ }
+ catch (Exception ex)
+ {
+ lastError = ex;
+ Thread.Sleep(250);
+ }
+ }
+
+ var current = Environment.ProcessPath;
+ if (current is not null) MoveFileEx(current, null, MoveFileFlags.DelayUntilReboot);
+ if (lastError is not null)
+ {
+ if (!quiet)
+ Console.Error.WriteLine("Uninstall could not remove every file. " + lastError.Message);
+ return 1;
+ }
+
+ if (!quiet)
+ Console.Out.WriteLine("WiiCompiled was removed from this computer.");
+ return 0;
+ }
+
+ private static void TryCleanup(string description, bool quiet, Action cleanup)
+ {
+ try
+ {
+ cleanup();
+ }
+ catch (Exception ex)
+ {
+ if (!quiet)
+ Console.Error.WriteLine($"Uninstall could not {description}; continuing. {ex.Message}");
+ }
+ }
+
+ [Flags]
+ private enum MoveFileFlags : uint { DelayUntilReboot = 0x4 }
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern bool MoveFileEx(string existingFileName, string? newFileName, MoveFileFlags flags);
+}
diff --git a/Launcher/WiiCompiled.Setup/WiiCompiled.Setup.csproj b/Launcher/WiiCompiled.Setup/WiiCompiled.Setup.csproj
new file mode 100644
index 0000000..9ea644e
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/WiiCompiled.Setup.csproj
@@ -0,0 +1,17 @@
+
+
+ Exe
+ net8.0-windows
+ enable
+ enable
+ WiiCompiled.Setup
+ WiiCompiled.Setup
+ app.manifest
+ 0.2.13
+ patchzy
+ WiiCompiled
+ Command-line installer and launcher for WiiCompiled
+ embedded
+ en
+
+
diff --git a/Launcher/WiiCompiled.Setup/WorkspaceTimestamps.cs b/Launcher/WiiCompiled.Setup/WorkspaceTimestamps.cs
new file mode 100644
index 0000000..b3a0941
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/WorkspaceTimestamps.cs
@@ -0,0 +1,61 @@
+namespace WiiCompiled.Setup;
+
+
+internal static class WorkspaceTimestamps
+{
+ public static void MarkChangedFiles(string installedWorkspace, string stagedWorkspace,
+ Action diagnostic, CancellationToken cancellationToken = default)
+ {
+ if (!Directory.Exists(installedWorkspace) || !Directory.Exists(stagedWorkspace)) return;
+ installedWorkspace = Path.GetFullPath(installedWorkspace);
+ stagedWorkspace = Path.GetFullPath(stagedWorkspace);
+
+ var stampUtc = DateTime.UtcNow;
+ var changed = 0;
+ var unchanged = 0;
+ foreach (var stagedFile in Directory.EnumerateFiles(stagedWorkspace, "*",
+ SearchOption.AllDirectories))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var relative = Path.GetRelativePath(stagedWorkspace, stagedFile);
+ if (FileUnchanged(Path.Combine(installedWorkspace, relative), stagedFile))
+ {
+ unchanged++;
+ continue;
+ }
+ File.SetLastWriteTimeUtc(stagedFile, stampUtc);
+ changed++;
+ }
+ diagnostic($"Marked {changed} changed workspace file(s) for recompilation; " +
+ $"{unchanged} unchanged file(s) keep the incremental build cache valid.");
+ }
+
+ private static bool FileUnchanged(string installedFile, string stagedFile)
+ {
+ try
+ {
+ if (!File.Exists(installedFile)) return false;
+ using var installed = File.OpenRead(installedFile);
+ using var staged = File.OpenRead(stagedFile);
+ if (installed.Length != staged.Length) return false;
+ var installedBuffer = new byte[81920];
+ var stagedBuffer = new byte[81920];
+ while (true)
+ {
+ var installedRead = installed.ReadAtLeast(installedBuffer, installedBuffer.Length,
+ throwOnEndOfStream: false);
+ var stagedRead = staged.ReadAtLeast(stagedBuffer, stagedBuffer.Length,
+ throwOnEndOfStream: false);
+ if (installedRead != stagedRead) return false;
+ if (installedRead == 0) return true;
+ if (!installedBuffer.AsSpan(0, installedRead)
+ .SequenceEqual(stagedBuffer.AsSpan(0, stagedRead)))
+ return false;
+ }
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return false;
+ }
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup/app.manifest b/Launcher/WiiCompiled.Setup/app.manifest
new file mode 100644
index 0000000..abcca86
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/app.manifest
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fcbb091
--- /dev/null
+++ b/README.md
@@ -0,0 +1,198 @@
+
+# WiiCompiled
+
+A native PC port of Mario Kart Wii, made with static recompilation.
+
+There's no emulator in the loop, no interpreter, no JIT, no PowerPC
+anywhere at runtime.
+
+> [!IMPORTANT]
+> There is no Nintendo code, no assets and no game data anywhere in this project or its releases.
+> You need your own legally dumped copy of the PAL version of the game. Setup only ships the
+> toolchain, the translation runs on your machine against your disc image, and nothing ever gets
+> uploaded.
+
+[What is a github, I just want to play](https://github.com/TeamWheelWizard/WheelWizard/releases/latest)
+
+---
+
+## What it does
+
+**Unlocked framerate with interpolation.**
+The original game is hard-locked to 60 fps. The runtime can generate interpolated frames in between, so on a
+120/144 Hz monitor things genuinely look smoother.
+
+> [!WARNING]
+> Interpolation is experimental right now and will show artifacts in specific scenarios.
+
+**Any aspect ratio you want.**
+Drag the window bigger, wider, whatever, the camera adjusts
+live.
+
+**Native rendering via aurora.**
+The graphics layer is built on
+[aurora](https://github.com/encounter/aurora). Aurora is a source-level GameCube & Wii compatibility layer.
+
+**High internal resolution.**
+Play at several times the console's resolution.
+
+**Music ducking.**
+Start playing something else, Spotify, a YouTube video, and
+the game automatically mutes its own music until the other audio stops. Optional, if you'd
+rather it didn't. All audio that shows in your display media controls on your windows pc fall under this.
+
+**An in-game settings bar.**
+Press **F10** while the game window has focus:
+- Internal resolution
+- FPS counter
+- Controller assignment for all four ports
+- Full per-controller button mapping
+- Volume, instant mute, and the music ducking toggle
+
+Everything you change is saved to `Config.toml` on the spot and restored next launch.
+
+**Real controller support.**
+Controllers are fed to the game as a GameCube controller.
+The port does NOT pretend to be a Wii Remote or Classic Controller.
+Mappings are positional (`south`, `east`, `west`, `north`) rather than Xbox-labelled, so the
+same config makes sense on Xbox, PlayStation, Nintendo and generic SDL pads alike, and extra
+inputs like paddles, touchpads and share buttons show up when the hardware reports them.
+
+## Requirements
+
+- Windows 10 or 11, 64-bit
+- GPU: GTX 1650 / RX 6400 / Arc A310 or higher
+- CPU: Intel Core i5-8400 / AMD Ryzen 5 2600 (4c/6c, ~3.5GHz+) or higher
+- About 20 GB of free disk space during installation
+- A clean, unmodified **PAL `RMCP01`** disc image of Mario Kart Wii, dumped by you. ISO, GCM,
+ GCZ, CISO, WBFS, WIA and RVZ are accepted.
+
+> [!NOTE]
+> GPU/CPU minimums are set by driver support and D3D12/Vulkan feature requirements, not by the game's actual demands.
+
+Only the clean PAL revision will work. Anything else (other
+regions, patched executables) is rejected outright.
+
+> [!NOTE]
+> Nobody here will tell you where to get the game. Dumping your own disc is on you, and links to
+> game files won't be provided or tolerated.
+
+## Installing
+
+For an easy experience, use [Wheel Wizard](https://github.com/TeamWheelWizard/WheelWizard). Pick your clean PAL `RMCP01`
+image under Settings, turn on **WiiCompiled (beta)**, and hit install from the Home page.
+Wheel Wizard downloads the setup tool from this repo and walks you through install, updates and
+launching. The backend itself is deliberately command-line only, Wheel Wizard is a wrapper around it.
+
+> [!CAUTION]
+> Only take builds from this repository's
+> [Releases](https://github.com/patchzyy/Wiicompiled/releases) page. If someone's sharing an
+> installer through Discord or some random download site, don't touch it!!
+
+## Retro Rewind
+
+[Retro Rewind](https://wiki.tockdom.com/wiki/Retro_Rewind), ZPL's Mario Kart Wii mod distribution,
+can be built as its **own static profile**: instead of applying `Code.pul` as runtime patches,
+the Kamek/Pulsar code is statically translated together with the base game into a separate native
+executable.
+
+Wheel Wizard drives this too.
+
+## Building from source
+
+Owning the game is still required even if you compile everything yourself.
+
+You'll need: .NET 8 SDK, CMake, Ninja, and LLVM/Clang (the shipped build uses LLVM-MinGW targeting
+`x86-64-v3`).
+
+Build the translator:
+
+```powershell
+dotnet build translator/Translator.sln -c Release
+```
+
+The default test suite needs no binaries and no host C++ compiler, so you can hack on the
+translator without any game data around.
+
+For everything beyond that, feeding in your own `main.dol`/`StaticR.rel`, running the
+translation, generating the manifest and build graph, and compiling. see [`translator/README.md`](translator/README.md).
+
+## FAQ
+
+**Is this an emulator?**
+No. Everything is compiled to native code before you ever press play. At runtime there's nothing
+emulating a Wii CPU or GPU.
+
+**Do you provide the game?**
+No. Don't ask. Nothing in this repo or any release contains Nintendo code or assets.
+
+**Why does setup take so long?**
+Because we **don't** ship the translated binary, most other recomp projects do, but we
+don't want to risk it right now, setup has to run a static recompiler over the whole game
+and then throw a C++ compiler at the result. It's a **one-time cost** on your machine.
+
+**Which game version works?**
+Clean PAL `RMCP01`. Other regions and modified executables are **rejected**. Translating
+them against the wrong manifest would give you a subtly broken game that's miserable to debug for us.
+
+**Can I recompile other GameCube/Wii games with it?**
+The translator itself handles DOLs and RELs generically, see
+`projects/examples/generic-dol.yml`. The catch is that a *playable* port also needs a runtime:
+audio, input, GX, everything the game touches.
+
+**The game crashed / stopped with an error.**
+Errors are deliberately loud instead of quietly swallowed. Send a report along with the run log
+from `%LOCALAPPDATA%\WiiCompiled\Logs`.
+
+**Will you fix original bugs?**
+Not in the base game, behavior identical to real hardware is the goal. Only report things where this port differs
+from the original game. As for Retro Rewind, some base-game behavior **is** patched, so if it differs from the
+base game, that's normal. If Retro Rewind behavior differs between Dolphin/Wii and WiiCompiled, open an issue on GitHub.
+
+**How accurate are the physics?**
+100% - this is proven by in-game ghosts. Since ghosts are replay files based on inputs rather
+than tracked positions, matching ghosts prove the physics match across Dolphin/Wii/WiiCompiled.
+
+**Is it done?**
+Not fully. The game is in a state where everything should be playable and the physics do match
+100% with the original game, but compatibility, rendering, networking and performance are all
+actively being worked on. If you do find an issue, we strongly encourage you to open one on
+GitHub so we can take a look at it.
+
+## AI usage
+AI coding tools were used during development of this project.
+All translated output is verified against real hardware behavior and most importantly, physics accuracy is proven synced across Wii, Dolphin, and WiiCompiled (see FAQ).
+
+## Credits
+
+- **[aurora](https://github.com/encounter/aurora)** - the GX rendering/windowing backend this
+ project's whole graphics layer sits on. MIT licensed.
+- **[Dawn](https://dawn.googlesource.com/dawn)** - Google's WebGPU implementation, powering
+ aurora's Direct3D, Vulkan and OpenGL backends.
+- **[Dolphin Emulator](https://github.com/dolphin-emu/dolphin)** - an invaluable reference for Wii
+ hardware behavior during development, plus the source of the free DSP coefficient ROM and the
+ unmodified default WiiConnect24 bootstrap tree bundled with the runtime.
+- **[Retro Rewind](https://wiki.tockdom.com/wiki/Retro_Rewind)** by ZPL and team - the mod
+ distribution this project supports.
+- **[Wheel Wizard](https://github.com/TeamWheelWizard/WheelWizard)** - the mod manager this
+ project integrates with as a launch backend.
+- Everyone in the static recompilation community.
+
+Bundled third-party components and their licenses live in
+[`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
+
+
+## License
+
+WiiCompiled is free software: you can redistribute it and/or modify it under the terms of the
+[GNU General Public License, version 3](LICENSE) as published by the Free Software Foundation.
+
+WiiCompiled is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
+even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+General Public License for more details.
+
+Any mkwii distribution making use of WiiCompiled must be licensed under GPL v3.0.
+
+Not affiliated with, endorsed by, or associated with Nintendo. Mario Kart Wii is a trademark of
+Nintendo. No Nintendo intellectual property is contained in, distributed with, or obtainable
+through this project.
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
new file mode 100644
index 0000000..88fccb5
--- /dev/null
+++ b/THIRD-PARTY-NOTICES.md
@@ -0,0 +1,195 @@
+# Third-Party Notices
+
+WiiCompiled itself is licensed under the GNU General Public License v3.0
+(see [`LICENSE`](LICENSE)). It incorporates, links against, or redistributes the third-party
+components listed below. Each remains under its own license and copyright.
+
+Nothing listed here is Nintendo intellectual property. This project ships no game code, assets,
+or data of any kind - see the [README](README.md).
+
+---
+
+## Bundled in this repository
+
+### aurora - MIT
+
+Copyright (c) 2022 Luke Street.
+Source: - vendored in `aurora-main/`, license text at
+`aurora-main/LICENSE`.
+
+Aurora itself vendors:
+
+- **magic_enum** 0.7.2 - MIT, Copyright (c) 2019-2021 Daniil Goncharov.
+ `aurora-main/include/magic_enum.hpp`.
+ Source:
+- **libogc-derived SRAM structures** - zlib-style license, Copyright (c) Michael Wiedenbauer
+ (shagkur) and Dave Murphy (WinterMute). `aurora-main/lib/card/SRAM.hpp`.
+ Source:
+
+> [!NOTE]
+> Upstream aurora ships `assets/screenshot.png`, a rendered frame from a different Nintendo
+> title. It is intentionally omitted from this repository.
+
+### Dolphin Emulator data files - GPL-2.0-or-later
+
+Copyright (c) 2003+ Dolphin Emulator Project.
+Source: - license at
+
+
+Two data sets from Dolphin's `Data/Sys` tree are redistributed here under GPL-2.0-or-later:
+
+| File(s) | Upstream path | Notes |
+| --- | --- | --- |
+| `runtime/assets/dsp/dsp_coef.bin` | `Data/Sys/GC/dsp_coef.bin` | Free DSP polyphase-resampling coefficient ROM written by the Dolphin team. 4096 bytes, SHA-256 `D7741279C2E8EC5C5FB318F8FBDD6DE6BF583520D288E836A5383233A4238179`. The runtime verifies this hash at build time. |
+| `runtime/assets/wii/shared2/wc24/**` | `Data/Sys/Wii/shared2/wc24` | Dolphin's unmodified default WiiConnect24 bootstrap tree, used to seed a newly created per-user NAND on first run. It is byte-identical to the upstream directory. `nwc24dl.bin` contains Dolphin's default task list, which references Nintendo endpoints shut down in 2014; `nwc24msg.cfg` contains Dolphin's placeholder account fields, not a real user's account data. |
+
+Neither contains Nintendo executable code or game assets; both are redistributed under
+GPL-2.0-or-later from Dolphin's `Data/Sys` tree.
+
+SHA-256 hashes for the WiiConnect24 bootstrap tree:
+
+| File | SHA-256 |
+| --- | --- |
+| `runtime/assets/wii/shared2/wc24/misc.bin` | `13DD5B6B2682DEFD3B23AFD8E2983D00EDC25BD4DC28A8389380DEE0EC45A4A5` |
+| `runtime/assets/wii/shared2/wc24/nwc24dl.bin` | `057B6F840C19B41CE080318BC7E717E2B910965CE72AB781A7E319017636C38E` |
+| `runtime/assets/wii/shared2/wc24/nwc24fl.bin` | `ED94AF416C47ED3BC2C944EBCD1D734B8935D9697FEB0F7039D8FEA3EC514C18` |
+| `runtime/assets/wii/shared2/wc24/nwc24fls.bin` | `C3A4A5649D6ED2322A0DE98D2258B96A6A1D3C0179854FD21E9835D529736822` |
+| `runtime/assets/wii/shared2/wc24/nwc24msg.cbk` | `7AFEBF33EEB0035397CC74E15E892E700CD2903641D26562F5D46CFBB6171109` |
+| `runtime/assets/wii/shared2/wc24/nwc24msg.cfg` | `7AFEBF33EEB0035397CC74E15E892E700CD2903641D26562F5D46CFBB6171109` |
+| `runtime/assets/wii/shared2/wc24/mbox/Readme.txt` | `E5A888912968050C6C1D46D1C364C324684E1D15AAA62CFE36CF7FCE2C687B21` |
+| `runtime/assets/wii/shared2/wc24/mbox/wc24recv.ctl` | `EFA39268E7071941E4FE429D49C86D73BEE952DF95D91D7909C478DD1BC9050A` |
+| `runtime/assets/wii/shared2/wc24/mbox/wc24recv.mbx` | `DD2AD8C9FB38884523459963BFAEC5D5AEAA5FD20EFCDC209764D461E690E435` |
+| `runtime/assets/wii/shared2/wc24/mbox/wc24send.ctl` | `430C3795F1A0AEB198BF626A4A2FF6D123321D453807DD7B904DC3B74DB35D13` |
+| `runtime/assets/wii/shared2/wc24/mbox/wc24send.mbx` | `C248DC031CE09F7BE1E55956B6F173E79D6A47D913C22A16593C4687325692B7` |
+
+Dolphin was also used extensively as a behavioural reference during development of this project's
+hardware and IOS high-level implementations.
+
+### Dolphin Emulator Riivolution code - GPL-2.0-or-later
+
+Copyright (c) 2021 Dolphin Emulator Project.
+The runtime's Riivolution patch handling is a port of Dolphin's
+`Source/Core/DiscIO/RiivolutionParser.{h,cpp}` and the external-path resolution rules of
+`Source/Core/DiscIO/RiivolutionPatcher.cpp`, adapted in
+`runtime/include/hle/riivolution_contract.h` and `runtime/src/hle/storage/riivolution.cpp`
+(both marked `SPDX-License-Identifier: GPL-2.0-or-later`).
+Source:
+
+### pugixml - MIT
+
+Copyright (c) 2006-2025 Arseny Kapoulkine.
+The Riivolution XML reader uses pugixml 1.15, vendored in
+`runtime/third_party/pugixml` from commit `ee86beb30e4973f5feffe3ce63bfa4fbadf72f38`.
+Source and license:
+
+### Crypto++ 8.9.0 - Boost Software License 1.0 / public domain
+
+Copyright (c) 1995-2019 Wei Dai and contributors.
+The runtime uses Crypto++ for SHA-1 and sect233r1 ECDSA key derivation and signing. Its portable
+sources are vendored in `runtime/third_party/cryptopp`; assembly implementations are disabled.
+Source: . Full license text:
+`runtime/third_party/cryptopp/License.txt`.
+
+### toml11 4.4.0 - MIT
+
+Copyright (c) 2017 Toru Niina.
+The runtime configuration reader and scalar string writer use the single-header distribution,
+vendored in `runtime/third_party/toml11`.
+Source: . Full license text:
+`runtime/third_party/toml11/LICENSE`.
+
+### YamlDotNet - MIT
+
+Copyright (c) Antoine Aubry and contributors.
+Referenced by `translator/src/Translator.Core`. Source:
+
+---
+
+## Fetched at build time and redistributed in release builds
+
+These are pinned in `aurora-main/extern/CMakeLists.txt` and
+`aurora-main/cmake/AuroraDawnProvider.cmake`. They are not stored in this repository; the build
+downloads them, and release installers carry the resulting binaries. Their license texts are
+included in the installer's `licenses/` folder.
+
+| Component | Version | License | Upstream |
+| --- | --- | --- | --- |
+| Dawn (WebGPU) | `v20260603.191052` prebuilt | BSD-3-Clause | |
+| Tint (part of Dawn) | with Dawn | BSD-3-Clause | |
+| DirectXShaderCompiler (`dxcompiler.dll`) | with Dawn | NCSA / University of Illinois Open Source | |
+| SDL | 3.4.4 | zlib | |
+| Abseil | LTS 20240722.0 | Apache-2.0 | |
+| Dear ImGui | 1.91.9b-docking | MIT | |
+| {fmt} | 11.1.4 | MIT | |
+| xxHash | 0.8.3 | BSD-2-Clause | |
+| zlib | 1.3.2 | zlib | |
+| libpng | 1.6.58 | PNG Reference Library License v2 | |
+| FreeType | 2.14.3 | **FreeType License (FTL)** - see below | |
+| Zstandard | 1.5.7 | **BSD-3-Clause** - see below | |
+| SQLite | 3.51.3 amalgamation | Public domain | |
+| Tracy Profiler | pinned commit | BSD-3-Clause | |
+| C++/WinRT | - | MIT (Microsoft) | |
+
+### Dual-licensed components - elections made by this project
+
+- **FreeType** is offered under the FreeType License (FTL) or GPL-2.0. **This project elects the
+ FreeType License.** The FTL requires the following credit, which is given here and reproduced in
+ distributed builds:
+
+ > Portions of this software are copyright © 2026 The FreeType Project (www.freetype.org).
+ > All rights reserved.
+
+- **Zstandard** is offered under BSD-3-Clause or GPL-2.0. **This project elects BSD-3-Clause.**
+ Copyright (c) Meta Platforms, Inc. and affiliates.
+
+## Bundled in the setup executable's toolkit payload
+
+The distributed `WiiCompiled-Setup.exe` carries a build toolkit so that translation and
+compilation can run on a machine with nothing preinstalled. These tools are redistributed
+unmodified, with their license texts, in the installer's `licenses/` folder.
+
+| Component | License | Upstream |
+| --- | --- | --- |
+| llvm-mingw (Clang, LLD, libc++, libunwind, MinGW-w64 runtime) | Apache-2.0 with LLVM Exception; MinGW-w64 runtime under its own permissive terms; bundled GNU utilities under GPL-2.0-or-later or GPL-3.0-or-later | |
+| CMake | BSD-3-Clause | |
+| Ninja | Apache-2.0 | |
+| DolphinTool (disc image extraction) | GPL-2.0-or-later | |
+| Microsoft Visual C++ Runtime (`vcruntime140.dll`, `vcruntime140_1.dll`, `msvcp140.dll`) | Microsoft redistributable terms | Microsoft Visual Studio |
+| `dxil.dll` | Microsoft redistributable (proprietary signing library) | Microsoft |
+
+> [!IMPORTANT]
+> Several toolkit components are GPL-licensed (DolphinTool, and the GNU utilities inside
+> llvm-mingw). Their complete corresponding source is available from the upstream projects linked
+> above at their pinned versions, and this project will supply it on request for the exact versions
+> shipped in any given release. Pins live in `Launcher/Prepare-PortableTools.ps1` and
+> `Launcher/NativeBuildFlags.ps1`.
+
+---
+
+## Development-only dependencies
+
+Not redistributed in any release artifact.
+
+| Component | License |
+| --- | --- |
+| xUnit.net 2.4.2, xunit.runner.visualstudio 2.4.5 | Apache-2.0 |
+| Microsoft.NET.Test.Sdk 17.6.0 | MIT |
+| coverlet.collector 6.0.0 | MIT |
+| .NET 8 SDK | MIT |
+
+---
+
+## Reference material
+
+Not code, but the documentation this project depends on:
+
+- [WiiBrew](https://wiibrew.org/wiki/) - Wii hardware and IOS documentation.
+- [Custom Mario Kart Wiiki (Tockdom)](https://wiki.tockdom.com/) - Mario Kart Wii file formats and
+ modding documentation.
+- [Retro Rewind](https://wiki.tockdom.com/wiki/Retro_Rewind) by ZPL - the mod distribution this
+ project can build as a static profile. No Retro Rewind content is redistributed here; users
+ supply their own copy.
+
+---
+
+If you believe a component is missing or misattributed here, please open an issue.
diff --git a/aurora-main/.clang-format b/aurora-main/.clang-format
new file mode 100644
index 0000000..c144259
--- /dev/null
+++ b/aurora-main/.clang-format
@@ -0,0 +1,29 @@
+---
+BasedOnStyle: LLVM
+ColumnLimit: 120
+UseTab: Never
+TabWidth: 2
+---
+Language: Cpp
+DerivePointerAlignment: false
+PointerAlignment: Left
+AlignAfterOpenBracket: Align
+AlignConsecutiveAssignments: false
+IndentCaseLabels: false
+AllowShortBlocksOnASingleLine: Always
+AlignOperands: true
+AlignTrailingComments: true
+AlwaysBreakBeforeMultilineStrings: true
+AlwaysBreakTemplateDeclarations: Yes
+BreakConstructorInitializersBeforeComma: true
+AlwaysBreakAfterReturnType: None
+AlwaysBreakAfterDefinitionReturnType: None
+AllowShortFunctionsOnASingleLine: All
+Cpp11BracedListStyle: true
+NamespaceIndentation: None
+BinPackArguments: true
+BinPackParameters: true
+SortIncludes: false
+AccessModifierOffset: -2
+ConstructorInitializerIndentWidth: 0
+ConstructorInitializerAllOnOneLineOrOnePerLine: true
diff --git a/aurora-main/.gitignore b/aurora-main/.gitignore
new file mode 100644
index 0000000..5418932
--- /dev/null
+++ b/aurora-main/.gitignore
@@ -0,0 +1,10 @@
+.buildcache/
+.DS_Store
+.idea/
+.cache/
+.vscode/
+.vs/
+build/
+cmake-build-*/
+CMakeUserPresets.json
+out/
diff --git a/aurora-main/CMakeLists.txt b/aurora-main/CMakeLists.txt
new file mode 100644
index 0000000..8ff456f
--- /dev/null
+++ b/aurora-main/CMakeLists.txt
@@ -0,0 +1,83 @@
+cmake_minimum_required(VERSION 3.25)
+project(aurora LANGUAGES C CXX)
+if (APPLE)
+ enable_language(OBJC)
+endif()
+set(CMAKE_C_STANDARD 11)
+set(CMAKE_CXX_STANDARD 20)
+set(CMAKE_POSITION_INDEPENDENT_CODE ON)
+
+option(AURORA_ENABLE_GX "Enable GX implementation and WebGPU renderer" ON)
+option(AURORA_ENABLE_DVD "Enable DVD implementation backed by nod" OFF) # TODO: need to fix tests
+option(AURORA_ENABLE_CARD "Enable CARD implementation based on kabufuda" ON)
+option(AURORA_CACHE_USE_ZSTD "Compress WebGPU cache entries with zstd" ON)
+
+# Dependency versions
+set(AURORA_DAWN_VERSION "v20260603.191052" CACHE STRING "Dawn version tag (https://github.com/encounter/dawn-build/releases)")
+set(AURORA_SDL3_VERSION "3.4.4" CACHE STRING "SDL3 version tag (https://github.com/libsdl-org/SDL/releases)")
+set(AURORA_NOD_VERSION "v2.0.0-alpha.8" CACHE STRING "nod version tag (https://github.com/encounter/nod/releases)")
+
+# Platform-specific defaults
+if (CMAKE_CROSSCOMPILING)
+ # Cross-compiling: vendor everything, don't use system packages
+ set(_default_provider "vendor")
+ set(_default_linkage "static")
+else ()
+ set(_default_provider "auto")
+ if (WIN32)
+ set(_default_linkage "shared")
+ else ()
+ set(_default_linkage "static")
+ endif ()
+endif ()
+
+# Dawn/WebGPU (if AURORA_ENABLE_GX)
+set(AURORA_DAWN_PROVIDER "${_default_provider}" CACHE STRING
+ "How to provide Dawn: auto, vendor (build from source), system (find_package/imported), package (download prebuilt)")
+set_property(CACHE AURORA_DAWN_PROVIDER PROPERTY STRINGS auto vendor system package)
+set(AURORA_DAWN_LINKAGE "${_default_linkage}" CACHE STRING "Dawn linkage type preference")
+set_property(CACHE AURORA_DAWN_LINKAGE PROPERTY STRINGS shared static)
+
+# SDL3
+set(AURORA_SDL3_PROVIDER "${_default_provider}" CACHE STRING
+ "How to provide SDL3: auto, vendor (build from source), system (find_package/imported), or package (download prebuilt)")
+set_property(CACHE AURORA_SDL3_PROVIDER PROPERTY STRINGS auto vendor system package)
+set(AURORA_SDL3_LINKAGE "${_default_linkage}" CACHE STRING "SDL3 linkage type preference")
+set_property(CACHE AURORA_SDL3_LINKAGE PROPERTY STRINGS shared static)
+
+# nod (if AURORA_ENABLE_DVD)
+set(AURORA_NOD_PROVIDER "${_default_provider}" CACHE STRING
+ "How to provide nod: auto, vendor (build from source), system (find_package/imported), package (download prebuilt)")
+set_property(CACHE AURORA_NOD_PROVIDER PROPERTY STRINGS auto vendor system package)
+set(AURORA_NOD_LINKAGE "${_default_linkage}" CACHE STRING "nod linkage type preference")
+set_property(CACHE AURORA_NOD_LINKAGE PROPERTY STRINGS shared static)
+
+add_subdirectory(extern)
+
+include(cmake/aurora_core.cmake)
+include(cmake/aurora_os.cmake)
+if (AURORA_ENABLE_GX)
+ include(cmake/aurora_gx.cmake)
+ include(cmake/aurora_gd.cmake)
+endif ()
+include(cmake/aurora_pad.cmake)
+include(cmake/aurora_ms.cmake)
+include(cmake/aurora_si.cmake)
+include(cmake/aurora_main.cmake)
+include(cmake/aurora_mtx.cmake)
+include(cmake/aurora_vi.cmake)
+if(AURORA_ENABLE_CARD)
+ include(cmake/aurora_card.cmake)
+endif()
+if (AURORA_ENABLE_DVD)
+ include(cmake/aurora_dvd.cmake)
+endif ()
+
+if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
+ add_subdirectory(examples)
+endif ()
+
+if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR AND NOT CMAKE_CROSSCOMPILING)
+ enable_testing()
+ add_subdirectory(tests)
+endif ()
diff --git a/aurora-main/LICENSE b/aurora-main/LICENSE
new file mode 100644
index 0000000..baf2790
--- /dev/null
+++ b/aurora-main/LICENSE
@@ -0,0 +1,21 @@
+The MIT License
+
+Copyright (c) 2022 Luke Street
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/aurora-main/README.md b/aurora-main/README.md
new file mode 100644
index 0000000..9d16c77
--- /dev/null
+++ b/aurora-main/README.md
@@ -0,0 +1,46 @@
+
+

+
+
+
+Aurora is a source-level GameCube & Wii compatibility layer intended for use with game decompilation projects.
+
+Originally developed for use in [Metaforce](https://github.com/AxioDL/metaforce), a Metroid Prime reverse engineering project.
+It now powers several completed source ports, including [Dusklight](https://github.com/TwilitRealm/dusklight).
+
+### Features
+
+- Application layer using SDL3
+ - Runs on Windows, Linux, macOS, iOS, tvOS, Android
+- GX compatibility layer
+ - Graphics API support: D3D12, Vulkan, Metal
+ - Highly accurate and performant GX implementation
+ - Robust pipeline cache system with "transferable" cache support for releases
+ - Dolphin-compatible texture pack support
+ - Widescreen & resolution scaling support
+ - Custom APIs for offscreen rendering
+- PAD compatibility layer
+ - Utilizes `SDL_Gamepad` for wide controller support, including GameCube controller adapters
+ - Automatically saves and loads controller bindings and port mappings
+ - Gyro & mouse support
+- DVD compatibility layer
+ - Utilizes [nod](https://github.com/encounter/nod) to support all GameCube/Wii disc image types, including RVZ
+- CARD compatibility layer
+ - Full compatibility with Dolphin `.gci` and `.raw` for game saves
+- [Dear ImGui](https://github.com/ocornut/imgui) built-in for simple debug UIs
+
+### Graphics
+
+The GX compatibility layer is built on top of [WebGPU](https://www.w3.org/TR/webgpu/), a cross-platform graphics API
+abstraction layer. WebGPU allows targeting all major platforms simultaneously with minimal overhead. The WebGPU
+implementation used is Chromium's [Dawn](https://dawn.googlesource.com/dawn/).
+
+
+
+### Building
+
+See [docs/building.md](docs/building.md) for build instructions, CMake integration, and configuration options.
+
+### License
+
+Aurora is licensed under the [MIT License](LICENSE).
diff --git a/aurora-main/assets/aurora.png b/aurora-main/assets/aurora.png
new file mode 100644
index 0000000..ac897bb
Binary files /dev/null and b/aurora-main/assets/aurora.png differ
diff --git a/aurora-main/cmake/AuroraCopyRuntimeDLLs.cmake b/aurora-main/cmake/AuroraCopyRuntimeDLLs.cmake
new file mode 100644
index 0000000..b4fa166
--- /dev/null
+++ b/aurora-main/cmake/AuroraCopyRuntimeDLLs.cmake
@@ -0,0 +1,55 @@
+include_guard(GLOBAL)
+
+# aurora_copy_runtime_dlls( [...])
+#
+# POST_BUILD command that copies all TARGET_RUNTIME_DLLS from the given targets
+# next to the executable, plus any extra DLLs that Dawn loads dynamically
+# (dxcompiler.dll, dxil.dll).
+function(aurora_copy_runtime_dlls target)
+ if (NOT WIN32)
+ return()
+ endif ()
+
+ # Copy TARGET_RUNTIME_DLLS for each target
+ foreach (_t IN ITEMS ${target} ${ARGN})
+ add_custom_command(TARGET ${target} POST_BUILD
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different $ $
+ COMMAND_EXPAND_LISTS
+ )
+ endforeach ()
+
+ # Copy DXC DLLs that Dawn loads via LoadLibrary at runtime
+ if (TARGET dawn::webgpu_dawn)
+ get_target_property(_dawn_loc dawn::webgpu_dawn IMPORTED_LOCATION)
+ if (NOT _dawn_loc)
+ get_target_property(_dawn_loc dawn::webgpu_dawn IMPORTED_LOCATION_RELEASE)
+ endif ()
+ if (_dawn_loc)
+ cmake_path(GET _dawn_loc PARENT_PATH _dawn_bin_dir)
+ foreach (_dll IN ITEMS dxcompiler.dll dxil.dll)
+ if (EXISTS "${_dawn_bin_dir}/${_dll}")
+ add_custom_command(TARGET ${target} POST_BUILD
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_dawn_bin_dir}/${_dll}" $
+ )
+ endif ()
+ endforeach ()
+ endif ()
+ endif ()
+endfunction()
+
+# aurora_install_runtime_dlls( )
+#
+# INSTALL rule that copies all DLLs already placed next to the built target
+# into the install destination.
+function(aurora_install_runtime_dlls target install_destination)
+ if (NOT WIN32)
+ return()
+ endif ()
+
+ install(CODE
+ "file(GLOB _aurora_runtime_dlls \"$/*.dll\")\n\
+if(_aurora_runtime_dlls)\n\
+ file(INSTALL DESTINATION \"${install_destination}\" TYPE FILE FILES \${_aurora_runtime_dlls})\n\
+endif()"
+ )
+endfunction()
diff --git a/aurora-main/cmake/AuroraDawnProvider.cmake b/aurora-main/cmake/AuroraDawnProvider.cmake
new file mode 100644
index 0000000..002344b
--- /dev/null
+++ b/aurora-main/cmake/AuroraDawnProvider.cmake
@@ -0,0 +1,255 @@
+include_guard(GLOBAL)
+
+# Resolve Dawn/WebGPU dependency based on AURORA_DAWN_PROVIDER and AURORA_DAWN_LINKAGE.
+#
+# After this module runs:
+# - dawn::webgpu_dawn target is available
+# - dawn::dawncpp_headers target is available (for tests)
+# - DAWN_ENABLE_* backend variables are set for the target platform
+# - AURORA_DAWN_IS_SHARED is set to TRUE/FALSE
+
+# When using a non-vendored Dawn, we don't get DAWN_ENABLE_* from its build.
+# Infer from the target platform instead.
+function(_aurora_dawn_set_platform_backends)
+ if (WIN32)
+ set(DAWN_ENABLE_D3D12 ON CACHE INTERNAL "")
+ # Disable D3D11 because this Aurora fork does not support it.
+ set(DAWN_ENABLE_D3D11 OFF CACHE INTERNAL "")
+ set(DAWN_ENABLE_VULKAN ON CACHE INTERNAL "")
+ set(DAWN_ENABLE_METAL OFF CACHE INTERNAL "")
+ set(DAWN_ENABLE_DESKTOP_GL OFF CACHE INTERNAL "")
+ set(DAWN_ENABLE_OPENGLES OFF CACHE INTERNAL "")
+ set(DAWN_ENABLE_NULL ON CACHE INTERNAL "")
+ elseif (APPLE)
+ set(DAWN_ENABLE_D3D12 OFF CACHE INTERNAL "")
+ set(DAWN_ENABLE_D3D11 OFF CACHE INTERNAL "")
+ set(DAWN_ENABLE_VULKAN OFF CACHE INTERNAL "")
+ set(DAWN_ENABLE_METAL ON CACHE INTERNAL "")
+ set(DAWN_ENABLE_DESKTOP_GL OFF CACHE INTERNAL "")
+ set(DAWN_ENABLE_OPENGLES OFF CACHE INTERNAL "")
+ set(DAWN_ENABLE_NULL ON CACHE INTERNAL "")
+ else () # Linux / other
+ set(DAWN_ENABLE_D3D12 OFF CACHE INTERNAL "")
+ set(DAWN_ENABLE_D3D11 OFF CACHE INTERNAL "")
+ set(DAWN_ENABLE_VULKAN ON CACHE INTERNAL "")
+ set(DAWN_ENABLE_METAL OFF CACHE INTERNAL "")
+ set(DAWN_ENABLE_DESKTOP_GL OFF CACHE INTERNAL "")
+ set(DAWN_ENABLE_OPENGLES OFF CACHE INTERNAL "")
+ set(DAWN_ENABLE_NULL ON CACHE INTERNAL "")
+ endif ()
+endfunction()
+
+# ── Auto: resolve provider based on platform availability ──
+set(_aurora_dawn_provider "${AURORA_DAWN_PROVIDER}")
+if (_aurora_dawn_provider STREQUAL "auto")
+ # Prebuilt Dawn packages available for: windows-{amd64,arm64}, linux-{x86_64,aarch64}, darwin-{arm64,x86_64}
+ set(_has_package FALSE)
+ if (WIN32 AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|x86_64|ARM64|aarch64)$")
+ set(_has_package TRUE)
+ elseif (CMAKE_SYSTEM_NAME STREQUAL "Linux" AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|aarch64)$")
+ set(_has_package TRUE)
+ elseif (APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|x86_64)$")
+ set(_has_package TRUE)
+ endif ()
+
+ if (_has_package)
+ set(_aurora_dawn_provider "package")
+ else ()
+ set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ON)
+ find_package(Dawn QUIET)
+ set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL OFF)
+ if (Dawn_FOUND)
+ set(_aurora_dawn_provider "system")
+ else ()
+ set(_aurora_dawn_provider "vendor")
+ endif ()
+ endif ()
+ set(AURORA_DAWN_PROVIDER "${_aurora_dawn_provider}" CACHE STRING "" FORCE)
+ message(STATUS "aurora: Dawn auto-resolved provider: ${_aurora_dawn_provider}")
+endif ()
+
+if (_aurora_dawn_provider STREQUAL "vendor")
+ # ── Vendor: FetchContent build from source ──
+ if (NOT TARGET webgpu_dawn)
+ message(STATUS "aurora: Fetching Dawn (provider=vendor, linkage=${AURORA_DAWN_LINKAGE})")
+
+ if (AURORA_DAWN_LINKAGE STREQUAL "shared")
+ set(DAWN_BUILD_MONOLITHIC_LIBRARY SHARED CACHE INTERNAL
+ "Bundle all dawn components into a single shared library.")
+ else ()
+ set(DAWN_BUILD_MONOLITHIC_LIBRARY STATIC CACHE INTERNAL
+ "Bundle all dawn components into a single static library.")
+ endif ()
+ set(DAWN_BUILD_SAMPLES OFF CACHE INTERNAL "Disable Dawn sample applications")
+ set(DAWN_BUILD_BENCHMARKS OFF CACHE INTERNAL "Disable Dawn benchmarks")
+ set(DAWN_SUPPORTS_GLFW_FOR_WINDOWING OFF CACHE INTERNAL "Disable Dawn GLFW windowing")
+ set(DAWN_ENABLE_DESKTOP_GL OFF CACHE INTERNAL "Enable compilation of the OpenGL backend")
+ set(DAWN_ENABLE_OPENGLES OFF CACHE INTERNAL "Enable compilation of the OpenGL ES backend")
+ set(DAWN_FETCH_DEPENDENCIES ON CACHE INTERNAL
+ "Use fetch_dawn_dependencies.py as an alternative to using depot_tools")
+ if (CMAKE_SYSTEM_NAME STREQUAL Linux)
+ set(DAWN_USE_WAYLAND ON CACHE INTERNAL "Enable support for Wayland surface")
+ endif ()
+ set(TINT_BUILD_TESTS OFF CACHE INTERNAL "Build tests")
+ set(TINT_BUILD_CMD_TOOLS OFF CACHE INTERNAL "Build the Tint command line tools")
+ if (NOT DEFINED CMAKE_MSVC_RUNTIME_LIBRARY OR CMAKE_MSVC_RUNTIME_LIBRARY MATCHES "DLL$")
+ set(ABSL_MSVC_STATIC_RUNTIME OFF CACHE INTERNAL "Link static runtime libraries")
+ else ()
+ set(ABSL_MSVC_STATIC_RUNTIME ON CACHE INTERNAL "Link static runtime libraries")
+ endif ()
+
+ include(FetchContent)
+ FetchContent_Declare(dawn
+ URL "https://github.com/google/dawn/archive/refs/tags/${AURORA_DAWN_VERSION}.tar.gz"
+ DOWNLOAD_EXTRACT_TIMESTAMP TRUE
+ EXCLUDE_FROM_ALL
+ )
+ FetchContent_MakeAvailable(dawn)
+ if (NOT TARGET webgpu_dawn)
+ message(FATAL_ERROR "Failed to make dawn available")
+ endif ()
+ else ()
+ message(STATUS "aurora: Using existing Dawn")
+ endif ()
+
+ if (AURORA_DAWN_LINKAGE STREQUAL "shared")
+ set(AURORA_DAWN_IS_SHARED TRUE PARENT_SCOPE)
+ else ()
+ set(AURORA_DAWN_IS_SHARED FALSE PARENT_SCOPE)
+ endif ()
+
+elseif (_aurora_dawn_provider STREQUAL "system")
+ # ── System: find_package(Dawn) ──
+ message(STATUS "aurora: Using system Dawn (provider=system)")
+ if (NOT Dawn_FOUND)
+ set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ON)
+ find_package(Dawn REQUIRED)
+ set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL OFF)
+ endif ()
+ if (NOT TARGET dawn::webgpu_dawn)
+ message(FATAL_ERROR "find_package(Dawn) succeeded but dawn::webgpu_dawn target not found")
+ endif ()
+ _aurora_dawn_set_platform_backends()
+
+ get_target_property(_dawn_type dawn::webgpu_dawn TYPE)
+ if (_dawn_type STREQUAL "SHARED_LIBRARY")
+ set(AURORA_DAWN_IS_SHARED TRUE PARENT_SCOPE)
+ else ()
+ set(AURORA_DAWN_IS_SHARED FALSE PARENT_SCOPE)
+ endif ()
+
+elseif (_aurora_dawn_provider STREQUAL "package")
+ # ── Package: download prebuilt Dawn install tree ──
+ if (NOT AURORA_DAWN_PACKAGE_URL)
+ string(TOLOWER "${CMAKE_SYSTEM_NAME}" _dawn_system)
+ string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" _dawn_arch)
+ if (_dawn_system STREQUAL "windows")
+ if (_dawn_arch STREQUAL "x86_64")
+ set(_dawn_arch "amd64")
+ elseif (_dawn_arch STREQUAL "aarch64")
+ set(_dawn_arch "arm64")
+ endif ()
+ endif ()
+ set(AURORA_DAWN_PACKAGE_URL
+ "https://github.com/encounter/dawn-build/releases/download/${AURORA_DAWN_VERSION}/dawn-${_dawn_system}-${_dawn_arch}.tar.gz")
+
+ # A release asset is mutable: the same tag has already served two different windows-amd64 archives,
+ # and a cached extraction is never re-verified. Pin the digest for the combinations we ship.
+ if (NOT AURORA_DAWN_PACKAGE_URL_HASH
+ AND AURORA_DAWN_VERSION STREQUAL "v20260603.191052"
+ AND _dawn_system STREQUAL "windows" AND _dawn_arch STREQUAL "amd64")
+ set(AURORA_DAWN_PACKAGE_URL_HASH
+ "SHA256=7785373d569b3b0237918ec9c523239f7d0667857c5ea8242e3cdfde95e6aeab")
+ endif ()
+ endif ()
+ message(STATUS "aurora: Fetching prebuilt Dawn package from ${AURORA_DAWN_PACKAGE_URL}")
+
+ set(_dawn_prebuilt_hash_argument "")
+ if (AURORA_DAWN_PACKAGE_URL_HASH)
+ set(_dawn_prebuilt_hash_argument URL_HASH "${AURORA_DAWN_PACKAGE_URL_HASH}")
+ endif ()
+
+ include(FetchContent)
+ FetchContent_Declare(dawn_prebuilt
+ URL "${AURORA_DAWN_PACKAGE_URL}"
+ ${_dawn_prebuilt_hash_argument}
+ DOWNLOAD_EXTRACT_TIMESTAMP TRUE
+ )
+ FetchContent_MakeAvailable(dawn_prebuilt)
+
+ # Detect package layout: may be nested in a subdirectory
+ set(_dawn_pkg_dir "${dawn_prebuilt_SOURCE_DIR}")
+ file(GLOB _dawn_subdirs
+ "${dawn_prebuilt_SOURCE_DIR}/dawn-*"
+ "${dawn_prebuilt_SOURCE_DIR}/Dawn-*"
+ )
+ foreach (_sd IN LISTS _dawn_subdirs)
+ if (IS_DIRECTORY "${_sd}")
+ set(_dawn_pkg_dir "${_sd}")
+ break()
+ endif ()
+ endforeach ()
+ if (IS_DIRECTORY "${dawn_prebuilt_SOURCE_DIR}/dawn-install")
+ set(_dawn_pkg_dir "${dawn_prebuilt_SOURCE_DIR}/dawn-install")
+ endif ()
+
+ # Static Dawn packages may link Threads::Threads
+ find_package(Threads QUIET)
+
+ # Find DawnConfig.cmake in the package
+ set(_dawn_cmake_found FALSE)
+ foreach (_cmake_path
+ "${_dawn_pkg_dir}/lib/cmake/Dawn"
+ "${_dawn_pkg_dir}/lib64/cmake/Dawn"
+ "${_dawn_pkg_dir}/cmake/Dawn"
+ "${_dawn_pkg_dir}/cmake"
+ )
+ if (EXISTS "${_cmake_path}/DawnConfig.cmake")
+ set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ON)
+ find_package(Dawn REQUIRED CONFIG PATHS "${_cmake_path}" NO_DEFAULT_PATH)
+ set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL OFF)
+ set(_dawn_cmake_found TRUE)
+ break()
+ endif ()
+ endforeach ()
+
+ if (NOT _dawn_cmake_found OR NOT TARGET dawn::webgpu_dawn)
+ message(FATAL_ERROR
+ "Dawn package does not contain DawnConfig.cmake.\n"
+ "Searched: ${_dawn_pkg_dir}/{lib,lib64,cmake}/cmake/Dawn/\n"
+ "The package must be a Dawn install tree built with DAWN_ENABLE_INSTALL=ON.")
+ endif ()
+
+ _aurora_dawn_set_platform_backends()
+
+ get_target_property(_dawn_pkg_type dawn::webgpu_dawn TYPE)
+ if (_dawn_pkg_type STREQUAL "SHARED_LIBRARY")
+ set(AURORA_DAWN_IS_SHARED TRUE PARENT_SCOPE)
+
+ else ()
+ set(AURORA_DAWN_IS_SHARED FALSE PARENT_SCOPE)
+ endif ()
+
+else ()
+ message(FATAL_ERROR "Invalid AURORA_DAWN_PROVIDER: ${AURORA_DAWN_PROVIDER} "
+ "(must be auto, vendor, system, or package)")
+endif ()
+
+# Ensure dawn::dawncpp_headers target exists (needed by tests).
+# When Dawn is vendored, this target is created by Dawn's CMake.
+# For package/system providers, create it from the same include dirs.
+if (NOT TARGET dawn::dawncpp_headers)
+ add_library(dawn_dawncpp_headers INTERFACE IMPORTED GLOBAL)
+ get_target_property(_dawn_inc dawn::webgpu_dawn INTERFACE_INCLUDE_DIRECTORIES)
+ if (NOT _dawn_inc)
+ if (TARGET dawn::dawn_public_config)
+ get_target_property(_dawn_inc dawn::dawn_public_config INTERFACE_INCLUDE_DIRECTORIES)
+ endif ()
+ endif ()
+ if (_dawn_inc)
+ target_include_directories(dawn_dawncpp_headers INTERFACE ${_dawn_inc})
+ else ()
+ target_link_libraries(dawn_dawncpp_headers INTERFACE dawn::webgpu_dawn)
+ endif ()
+ add_library(dawn::dawncpp_headers ALIAS dawn_dawncpp_headers)
+endif ()
diff --git a/aurora-main/cmake/AuroraNodProvider.cmake b/aurora-main/cmake/AuroraNodProvider.cmake
new file mode 100644
index 0000000..dbb9a71
--- /dev/null
+++ b/aurora-main/cmake/AuroraNodProvider.cmake
@@ -0,0 +1,150 @@
+include_guard(GLOBAL)
+
+# Resolve nod dependency based on AURORA_NOD_PROVIDER.
+#
+# After this module runs:
+# - nod::nod target is available
+#
+# AURORA_NOD_LINKAGE controls shared vs static linkage:
+# - "shared" (default): prefer nod::nod_shared
+# - "static": prefer nod::nod_static
+
+# ── Auto: resolve provider based on platform availability ──
+set(_aurora_nod_provider "${AURORA_NOD_PROVIDER}")
+if (_aurora_nod_provider STREQUAL "auto")
+ # Prebuilt nod packages available for: windows-x86_64, linux-x86_64, macos-arm64
+ set(_has_package FALSE)
+ if (WIN32 AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64)$")
+ set(_has_package TRUE)
+ elseif (CMAKE_SYSTEM_NAME STREQUAL "Linux" AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64)$")
+ set(_has_package TRUE)
+ elseif (APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64)$")
+ set(_has_package TRUE)
+ endif ()
+
+ if (_has_package)
+ set(_aurora_nod_provider "package")
+ else ()
+ find_package(nod QUIET CONFIG)
+ if (nod_FOUND)
+ set(_aurora_nod_provider "system")
+ else ()
+ set(_aurora_nod_provider "vendor")
+ endif ()
+ endif ()
+ set(AURORA_NOD_PROVIDER "${_aurora_nod_provider}" CACHE STRING "" FORCE)
+ message(STATUS "aurora: nod auto-resolved provider: ${_aurora_nod_provider}")
+endif ()
+
+if (_aurora_nod_provider STREQUAL "vendor")
+ # ── Vendor: FetchContent + Corrosion build from source ──
+ if (NOT TARGET nod::nod)
+ message(STATUS "aurora: Building nod (provider=vendor, linkage=${AURORA_NOD_LINKAGE})")
+
+ # Corrosion may default to the host Rust target triple (commonly x86_64 on
+ # Windows). Force Rust to i686 for true Win32 builds so nod and its vendored
+ # compression libraries are built for the same architecture as the C/C++ build.
+ if (WIN32 AND CMAKE_SIZEOF_VOID_P EQUAL 4)
+ set(Rust_CARGO_TARGET "i686-pc-windows-msvc" CACHE STRING "Rust target triple" FORCE)
+ set(Rust_RUSTUP_INSTALL_MISSING_TARGET ON CACHE BOOL "Allow Corrosion to install missing Rust targets" FORCE)
+ message(STATUS "aurora: forcing Rust_CARGO_TARGET=${Rust_CARGO_TARGET} for Win32 nod build")
+ endif ()
+
+ # Control whether nod builds shared, static, or both.
+ # Save/restore BUILD_SHARED_LIBS to avoid leaking into other subdirectories.
+ set(_aurora_nod_saved_bsl "${BUILD_SHARED_LIBS}")
+ if (AURORA_NOD_LINKAGE STREQUAL "static")
+ set(BUILD_SHARED_LIBS OFF)
+ else ()
+ set(BUILD_SHARED_LIBS ON)
+ endif ()
+
+ include(FetchContent)
+ FetchContent_Declare(aurora_nod
+ GIT_REPOSITORY "https://github.com/encounter/nod.git"
+ GIT_TAG "${AURORA_NOD_VERSION}"
+ GIT_SHALLOW TRUE
+ EXCLUDE_FROM_ALL
+ )
+
+ FetchContent_MakeAvailable(aurora_nod)
+ set(BUILD_SHARED_LIBS "${_aurora_nod_saved_bsl}")
+ unset(_aurora_nod_saved_bsl)
+ if (NOT TARGET nod::nod)
+ message(FATAL_ERROR "Failed to make nod available")
+ endif ()
+ else ()
+ message(STATUS "aurora: Using existing nod")
+ endif ()
+
+elseif (_aurora_nod_provider STREQUAL "system")
+ # ── System: find preinstalled nod ──
+ message(STATUS "aurora: Using system nod (provider=system, linkage=${AURORA_NOD_LINKAGE})")
+
+ if (NOT nod_FOUND)
+ set(_aurora_nod_saved_bsl "${BUILD_SHARED_LIBS}")
+ if (AURORA_NOD_LINKAGE STREQUAL "static")
+ set(BUILD_SHARED_LIBS OFF)
+ else ()
+ set(BUILD_SHARED_LIBS ON)
+ endif ()
+
+ find_package(nod REQUIRED CONFIG)
+ set(BUILD_SHARED_LIBS "${_aurora_nod_saved_bsl}")
+ unset(_aurora_nod_saved_bsl)
+ endif ()
+ if (NOT TARGET nod::nod)
+ message(FATAL_ERROR "find_package(nod) succeeded but nod::nod target not found")
+ endif ()
+ message(STATUS "aurora: Found nod via find_package(nod)")
+
+elseif (_aurora_nod_provider STREQUAL "package")
+ # ── Package: download prebuilt nod library ──
+ if (NOT AURORA_NOD_PACKAGE_URL)
+ if (WIN32 AND CMAKE_SIZEOF_VOID_P EQUAL 8)
+ set(_nod_platform "windows-x86_64")
+ elseif (CMAKE_SYSTEM_NAME STREQUAL "Linux" AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64|amd64)$")
+ set(_nod_platform "linux-x86_64")
+ elseif (APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64|ARM64)$")
+ set(_nod_platform "macos-arm64")
+ else ()
+ message(FATAL_ERROR
+ "AURORA_NOD_PROVIDER=package requires AURORA_NOD_PACKAGE_URL on this platform.\n"
+ "Prebuilt packages: https://github.com/encounter/nod/releases")
+ endif ()
+ set(AURORA_NOD_PACKAGE_URL
+ "https://github.com/encounter/nod/releases/download/${AURORA_NOD_VERSION}/libnod-${_nod_platform}.tar.gz")
+ endif ()
+ message(STATUS "aurora: Fetching prebuilt nod package (provider=package, linkage=${AURORA_NOD_LINKAGE})")
+
+ include(FetchContent)
+ FetchContent_Declare(nod_prebuilt
+ URL "${AURORA_NOD_PACKAGE_URL}"
+ DOWNLOAD_EXTRACT_TIMESTAMP TRUE
+ EXCLUDE_FROM_ALL
+ )
+ FetchContent_MakeAvailable(nod_prebuilt)
+
+ # The prebuilt package ships a CMake config that creates nod::nod_shared,
+ # nod::nod_static, and a default nod::nod (based on BUILD_SHARED_LIBS).
+ # Set BUILD_SHARED_LIBS so the config selects the right default target.
+ set(_aurora_nod_saved_bsl "${BUILD_SHARED_LIBS}")
+ if (AURORA_NOD_LINKAGE STREQUAL "static")
+ set(BUILD_SHARED_LIBS OFF)
+ else ()
+ set(BUILD_SHARED_LIBS ON)
+ endif ()
+ find_package(nod REQUIRED CONFIG
+ PATHS "${nod_prebuilt_SOURCE_DIR}"
+ NO_DEFAULT_PATH
+ )
+ set(BUILD_SHARED_LIBS "${_aurora_nod_saved_bsl}")
+ unset(_aurora_nod_saved_bsl)
+ if (NOT TARGET nod::nod)
+ message(FATAL_ERROR "Prebuilt nod package did not provide nod::nod target")
+ endif ()
+
+else ()
+ message(FATAL_ERROR "Invalid AURORA_NOD_PROVIDER: ${AURORA_NOD_PROVIDER} "
+ "(must be auto, vendor, system, or package)")
+endif ()
diff --git a/aurora-main/cmake/AuroraSDL3Provider.cmake b/aurora-main/cmake/AuroraSDL3Provider.cmake
new file mode 100644
index 0000000..458d424
--- /dev/null
+++ b/aurora-main/cmake/AuroraSDL3Provider.cmake
@@ -0,0 +1,149 @@
+include_guard(GLOBAL)
+
+# Resolve SDL3 dependency based on AURORA_SDL3_PROVIDER and AURORA_SDL3_LINKAGE.
+#
+# After this module runs, the variable AURORA_SDL3_TARGET is set to the
+# appropriate target name for use in target_link_libraries() calls.
+
+# Select the SDL3 target matching the requested linkage.
+function(_aurora_sdl3_select_target)
+ if (AURORA_SDL3_LINKAGE STREQUAL "static")
+ if (TARGET SDL3::SDL3-static)
+ set(AURORA_SDL3_TARGET SDL3::SDL3-static CACHE INTERNAL "")
+ message(STATUS "aurora: Using static SDL3")
+ return()
+ elseif (TARGET SDL3::SDL3)
+ message(WARNING "aurora: Static SDL3 requested but only shared is available")
+ set(AURORA_SDL3_TARGET SDL3::SDL3 CACHE INTERNAL "")
+ message(STATUS "aurora: Using shared SDL3")
+ return()
+ endif ()
+ else ()
+ if (TARGET SDL3::SDL3)
+ set(AURORA_SDL3_TARGET SDL3::SDL3 CACHE INTERNAL "")
+ message(STATUS "aurora: Using shared SDL3")
+ return()
+ elseif (TARGET SDL3::SDL3-static)
+ message(WARNING "aurora: Shared SDL3 requested but only static is available")
+ set(AURORA_SDL3_TARGET SDL3::SDL3-static CACHE INTERNAL "")
+ message(STATUS "aurora: Using static SDL3")
+ return()
+ endif ()
+ endif ()
+ message(FATAL_ERROR "No SDL3 target found (looked for SDL3::SDL3 and SDL3::SDL3-static)")
+endfunction()
+
+# ── Auto: resolve provider based on platform availability ──
+set(_aurora_sdl3_provider "${AURORA_SDL3_PROVIDER}")
+if (_aurora_sdl3_provider STREQUAL "auto")
+ # Prebuilt SDL3 packages are available for Windows (MSVC and MinGW)
+ if (WIN32)
+ set(_aurora_sdl3_provider "package")
+ else ()
+ set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ON)
+ find_package(SDL3 QUIET)
+ set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL OFF)
+ if (SDL3_FOUND)
+ set(_aurora_sdl3_provider "system")
+ else ()
+ set(_aurora_sdl3_provider "vendor")
+ endif ()
+ endif ()
+ set(AURORA_SDL3_PROVIDER "${_aurora_sdl3_provider}" CACHE STRING "" FORCE)
+ message(STATUS "aurora: SDL3 auto-resolved provider: ${_aurora_sdl3_provider}")
+endif ()
+
+if (_aurora_sdl3_provider STREQUAL "system")
+ # ── System: find_package(SDL3) ──
+ message(STATUS "aurora: Using system SDL3 (provider=system)")
+ if (NOT SDL3_FOUND)
+ set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ON)
+ find_package(SDL3 REQUIRED)
+ set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL OFF)
+ endif ()
+ _aurora_sdl3_select_target()
+
+elseif (_aurora_sdl3_provider STREQUAL "package")
+ # ── Package: download custom build or official SDL3 development package ──
+ if (NOT AURORA_SDL3_PACKAGE_URL)
+ if (MINGW)
+ # WIN32 is also true for MinGW. Select the matching GNU-ABI package
+ # before considering Visual C++ packages.
+ set(AURORA_SDL3_PACKAGE_URL
+ "https://github.com/libsdl-org/SDL/releases/download/release-${AURORA_SDL3_VERSION}/SDL3-devel-${AURORA_SDL3_VERSION}-mingw.tar.gz")
+ elseif (WIN32)
+ # We have custom builds of SDL3 for Win32 x86/AMD64 with libusb support included
+ if (CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|x86)$")
+ string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" _sdl3_arch)
+ set(AURORA_SDL3_PACKAGE_URL
+ "https://github.com/encounter/sdl3-build/releases/download/v${AURORA_SDL3_VERSION}/SDL3-windows-${_sdl3_arch}.tar.gz")
+ else ()
+ set(AURORA_SDL3_PACKAGE_URL
+ "https://github.com/libsdl-org/SDL/releases/download/release-${AURORA_SDL3_VERSION}/SDL3-devel-${AURORA_SDL3_VERSION}-VC.zip")
+ endif ()
+ else ()
+ message(FATAL_ERROR
+ "AURORA_SDL3_PROVIDER=package requires AURORA_SDL3_PACKAGE_URL on non-Windows platforms.\n"
+ "Official SDL3 packages: https://github.com/libsdl-org/SDL/releases")
+ endif ()
+ endif ()
+ message(STATUS "aurora: Fetching prebuilt SDL3 package from ${AURORA_SDL3_PACKAGE_URL}")
+
+ include(FetchContent)
+ FetchContent_Declare(sdl3_prebuilt
+ URL "${AURORA_SDL3_PACKAGE_URL}"
+ DOWNLOAD_EXTRACT_TIMESTAMP TRUE
+ )
+ FetchContent_MakeAvailable(sdl3_prebuilt)
+
+ set(_sdl3_search_paths
+ "${sdl3_prebuilt_SOURCE_DIR}/cmake"
+ "${sdl3_prebuilt_SOURCE_DIR}/lib/cmake/SDL3"
+ "${sdl3_prebuilt_SOURCE_DIR}"
+ )
+ file(GLOB _sdl3_subdirs "${sdl3_prebuilt_SOURCE_DIR}/SDL3-*")
+ foreach (_sd IN LISTS _sdl3_subdirs)
+ list(APPEND _sdl3_search_paths
+ "${_sd}/cmake"
+ "${_sd}/lib/cmake/SDL3"
+ "${_sd}"
+ )
+ endforeach ()
+
+ set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ON)
+ find_package(SDL3 REQUIRED CONFIG PATHS ${_sdl3_search_paths} NO_DEFAULT_PATH)
+ set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL OFF)
+ _aurora_sdl3_select_target()
+
+elseif (_aurora_sdl3_provider STREQUAL "vendor")
+ # ── Vendor: FetchContent build from source ──
+ if (NOT TARGET SDL3-static AND NOT TARGET SDL3-shared)
+ message(STATUS "aurora: Fetching SDL3 (provider=vendor)")
+
+ if (AURORA_SDL3_LINKAGE STREQUAL "static")
+ set(SDL_STATIC ON CACHE BOOL "Build SDL3 static library" FORCE)
+ set(SDL_SHARED OFF CACHE BOOL "Build SDL3 shared library" FORCE)
+ else ()
+ set(SDL_STATIC OFF CACHE BOOL "Build SDL3 static library" FORCE)
+ set(SDL_SHARED ON CACHE BOOL "Build SDL3 shared library" FORCE)
+ endif ()
+ if (WIN32)
+ set(SDL_LIBC ON CACHE BOOL "Use the system C library" FORCE)
+ endif ()
+
+ include(FetchContent)
+ FetchContent_Declare(SDL
+ URL "https://github.com/libsdl-org/SDL/releases/download/release-${AURORA_SDL3_VERSION}/SDL3-${AURORA_SDL3_VERSION}.tar.gz"
+ DOWNLOAD_EXTRACT_TIMESTAMP TRUE
+ EXCLUDE_FROM_ALL
+ )
+ FetchContent_MakeAvailable(SDL)
+ else ()
+ message(STATUS "aurora: Using existing SDL3")
+ endif ()
+ _aurora_sdl3_select_target()
+
+else ()
+ message(FATAL_ERROR "Invalid AURORA_SDL3_PROVIDER: ${AURORA_SDL3_PROVIDER} "
+ "(must be auto, vendor, system, or package)")
+endif ()
diff --git a/aurora-main/cmake/aurora_card.cmake b/aurora-main/cmake/aurora_card.cmake
new file mode 100644
index 0000000..5eace0f
--- /dev/null
+++ b/aurora-main/cmake/aurora_card.cmake
@@ -0,0 +1,17 @@
+add_library(aurora_card STATIC
+ lib/card/BlockAllocationTable.cpp
+ lib/card/CardRawFile.cpp
+ lib/card/CardGciFolder.cpp
+ lib/card/Directory.cpp
+ lib/card/DolphinCardPath.cpp
+ lib/card/File.cpp
+ lib/card/FileIO.cpp
+ lib/card/SRAM.cpp
+ lib/card/Util.cpp
+ lib/dolphin/card.cpp
+)
+add_library(aurora::card ALIAS aurora_card)
+set_target_properties(aurora_card PROPERTIES FOLDER "aurora")
+
+target_link_libraries(aurora_card PUBLIC aurora::core)
+target_include_directories(aurora_card PRIVATE include)
diff --git a/aurora-main/cmake/aurora_core.cmake b/aurora-main/cmake/aurora_core.cmake
new file mode 100644
index 0000000..3e53a62
--- /dev/null
+++ b/aurora-main/cmake/aurora_core.cmake
@@ -0,0 +1,64 @@
+add_library(aurora_core STATIC
+ lib/aurora.cpp
+ lib/input.cpp
+ lib/window.cpp
+ lib/logging.cpp
+ lib/system_info.cpp
+ lib/system_info.hpp
+)
+add_library(aurora::core ALIAS aurora_core)
+set_target_properties(aurora_core PROPERTIES FOLDER "aurora")
+
+target_compile_definitions(aurora_core PUBLIC AURORA TARGET_PC)
+target_include_directories(aurora_core PUBLIC include)
+target_link_libraries(aurora_core PUBLIC fmt::fmt ${AURORA_SDL3_TARGET} xxhash)
+target_link_libraries(aurora_core PRIVATE absl::btree absl::flat_hash_map sqlite3 TracyClient)
+if (AURORA_ENABLE_GX AND AURORA_CACHE_USE_ZSTD)
+ target_compile_definitions(aurora_core PRIVATE AURORA_CACHE_USE_ZSTD)
+ target_link_libraries(aurora_core PRIVATE libzstd_static)
+endif ()
+
+if (CMAKE_SYSTEM_NAME STREQUAL Windows)
+ # stuff for fetching system info.
+ target_link_libraries(aurora_core PRIVATE ntdll dxgi advapi32 user32)
+elseif (APPLE)
+ target_sources(aurora_core PRIVATE lib/system_info_mac.mm)
+endif ()
+
+if (AURORA_ENABLE_GX)
+ target_sources(aurora_core PRIVATE lib/imgui.cpp)
+ target_link_libraries(aurora_core PUBLIC imgui)
+endif ()
+
+if (AURORA_ENABLE_GX)
+ target_compile_definitions(aurora_core PUBLIC AURORA_ENABLE_GX WEBGPU_DAWN)
+ target_sources(aurora_core PRIVATE lib/webgpu/gpu.cpp lib/webgpu/gpu_cache.cpp lib/dawn/BackendBinding.cpp)
+ target_link_libraries(aurora_core PRIVATE dawn::webgpu_dawn)
+ if (DAWN_ENABLE_VULKAN)
+ target_compile_definitions(aurora_core PRIVATE DAWN_ENABLE_BACKEND_VULKAN)
+ endif ()
+ if (DAWN_ENABLE_METAL)
+ target_compile_definitions(aurora_core PRIVATE DAWN_ENABLE_BACKEND_METAL)
+ target_sources(aurora_core PRIVATE lib/dawn/MetalBinding.mm)
+ set_source_files_properties(lib/dawn/MetalBinding.mm PROPERTIES COMPILE_FLAGS -fobjc-arc)
+ target_link_options(aurora_core PUBLIC "LINKER:-weak_framework,Metal")
+ endif ()
+ if (DAWN_ENABLE_D3D11)
+ target_compile_definitions(aurora_core PRIVATE DAWN_ENABLE_BACKEND_D3D11)
+ endif ()
+ if (DAWN_ENABLE_D3D12)
+ target_compile_definitions(aurora_core PRIVATE DAWN_ENABLE_BACKEND_D3D12)
+ endif ()
+ if (DAWN_ENABLE_DESKTOP_GL OR DAWN_ENABLE_OPENGLES)
+ target_compile_definitions(aurora_core PRIVATE DAWN_ENABLE_BACKEND_OPENGL)
+ if (DAWN_ENABLE_DESKTOP_GL)
+ target_compile_definitions(aurora_core PRIVATE DAWN_ENABLE_BACKEND_DESKTOP_GL)
+ endif ()
+ if (DAWN_ENABLE_OPENGLES)
+ target_compile_definitions(aurora_core PRIVATE DAWN_ENABLE_BACKEND_OPENGLES)
+ endif ()
+ endif ()
+ if (DAWN_ENABLE_NULL)
+ target_compile_definitions(aurora_core PRIVATE DAWN_ENABLE_BACKEND_NULL)
+ endif ()
+endif ()
diff --git a/aurora-main/cmake/aurora_dvd.cmake b/aurora-main/cmake/aurora_dvd.cmake
new file mode 100644
index 0000000..c95f72e
--- /dev/null
+++ b/aurora-main/cmake/aurora_dvd.cmake
@@ -0,0 +1,10 @@
+include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/AuroraNodProvider.cmake)
+
+add_library(aurora_dvd STATIC lib/dolphin/dvd/dvd.cpp)
+add_library(aurora::dvd ALIAS aurora_dvd)
+set_target_properties(aurora_dvd PROPERTIES FOLDER "aurora")
+
+target_compile_definitions(aurora_dvd PUBLIC AURORA TARGET_PC)
+target_include_directories(aurora_dvd PUBLIC include)
+target_link_libraries(aurora_dvd PUBLIC nod::nod ${AURORA_SDL3_TARGET})
+target_link_libraries(aurora_dvd PRIVATE fmt::fmt)
diff --git a/aurora-main/cmake/aurora_gd.cmake b/aurora-main/cmake/aurora_gd.cmake
new file mode 100644
index 0000000..80bd667
--- /dev/null
+++ b/aurora-main/cmake/aurora_gd.cmake
@@ -0,0 +1,16 @@
+add_library(aurora_gd STATIC
+ lib/dolphin/gd/GDBase.cpp
+ lib/dolphin/gd/GDGeometry.cpp
+ lib/dolphin/gd/GDIndirect.cpp
+ lib/dolphin/gd/GDLight.cpp
+ lib/dolphin/gd/GDPixel.cpp
+ lib/dolphin/gd/GDTev.cpp
+ lib/dolphin/gd/GDTexture.cpp
+ lib/dolphin/gd/GDTransform.cpp
+ lib/dolphin/gd/gd.hpp
+ lib/dolphin/gd/GDAurora.cpp
+)
+add_library(aurora::gd ALIAS aurora_gd)
+set_target_properties(aurora_gd PROPERTIES FOLDER "aurora")
+
+target_link_libraries(aurora_gd PUBLIC aurora::gx)
diff --git a/aurora-main/cmake/aurora_gx.cmake b/aurora-main/cmake/aurora_gx.cmake
new file mode 100644
index 0000000..ff740a7
--- /dev/null
+++ b/aurora-main/cmake/aurora_gx.cmake
@@ -0,0 +1,47 @@
+add_library(aurora_gx STATIC
+ lib/gfx/clear.cpp
+ lib/gfx/common.cpp
+ lib/gfx/depth_peek.cpp
+ lib/gfx/efb_ram_copy.cpp
+ lib/gfx/efb_ram_encoder.cpp
+ lib/gfx/pipeline_cache.cpp
+ lib/gfx/dds_io.cpp
+ lib/gfx/tex_copy_conv.cpp
+ lib/gfx/tex_palette_conv.cpp
+ lib/gfx/texture.cpp
+ lib/gfx/texture_convert.cpp
+ lib/gfx/texture_replacement.cpp
+ lib/gx/command_processor.cpp
+ lib/gx/fifo.cpp
+ lib/gx/frame_interpolation.cpp
+ lib/gx/gx.cpp
+ lib/gx/pipeline.cpp
+ lib/gx/shader.cpp
+ lib/gx/shader_info.cpp
+ lib/dolphin/gx/GXBump.cpp
+ lib/dolphin/gx/GXCull.cpp
+ lib/dolphin/gx/GXCpu2Efb.cpp
+ lib/dolphin/gx/GXDispList.cpp
+ lib/dolphin/gx/GXDraw.cpp
+ lib/dolphin/gx/GXExtra.cpp
+ lib/dolphin/gx/GXFifo.cpp
+ lib/dolphin/gx/GXFrameBuffer.cpp
+ lib/dolphin/gx/GXGeometry.cpp
+ lib/dolphin/gx/GXGet.cpp
+ lib/dolphin/gx/GXLighting.cpp
+ lib/dolphin/gx/GXManage.cpp
+ lib/dolphin/gx/GXPixel.cpp
+ lib/dolphin/gx/GXTev.cpp
+ lib/dolphin/gx/GXTexture.cpp
+ lib/dolphin/gx/GXTransform.cpp
+ lib/dolphin/gx/GXVert.cpp
+ lib/dolphin/gx/GXAurora.cpp
+ lib/gfx/png_io.cpp
+ lib/gfx/png_io.hpp
+)
+add_library(aurora::gx ALIAS aurora_gx)
+set_target_properties(aurora_gx PROPERTIES FOLDER "aurora")
+
+target_link_libraries(aurora_gx PUBLIC aurora::core xxhash)
+target_link_libraries(aurora_gx PRIVATE absl::btree absl::flat_hash_map dawn::webgpu_dawn sqlite3 TracyClient PNG::PNG)
+target_compile_definitions(aurora_gx PRIVATE WEBGPU_DAWN)
diff --git a/aurora-main/cmake/aurora_main.cmake b/aurora-main/cmake/aurora_main.cmake
new file mode 100644
index 0000000..b712164
--- /dev/null
+++ b/aurora-main/cmake/aurora_main.cmake
@@ -0,0 +1,6 @@
+add_library(aurora_main STATIC lib/main.cpp)
+add_library(aurora::main ALIAS aurora_main)
+set_target_properties(aurora_main PROPERTIES FOLDER "aurora")
+
+target_include_directories(aurora_main PUBLIC include)
+target_link_libraries(aurora_main PUBLIC ${AURORA_SDL3_TARGET})
diff --git a/aurora-main/cmake/aurora_ms.cmake b/aurora-main/cmake/aurora_ms.cmake
new file mode 100644
index 0000000..9df3020
--- /dev/null
+++ b/aurora-main/cmake/aurora_ms.cmake
@@ -0,0 +1,7 @@
+add_library(aurora_ms STATIC lib/dolphin/ms/mouse.cpp)
+add_library(aurora::ms ALIAS aurora_ms)
+set_target_properties(aurora_ms PROPERTIES FOLDER "aurora")
+
+target_include_directories(aurora_ms PUBLIC include)
+target_link_libraries(aurora_ms PUBLIC aurora::core)
+target_link_libraries(aurora_ms PRIVATE absl::flat_hash_map)
diff --git a/aurora-main/cmake/aurora_mtx.cmake b/aurora-main/cmake/aurora_mtx.cmake
new file mode 100644
index 0000000..580a942
--- /dev/null
+++ b/aurora-main/cmake/aurora_mtx.cmake
@@ -0,0 +1,12 @@
+add_library(aurora_mtx STATIC
+ lib/dolphin/mtx/mtx.c
+ lib/dolphin/mtx/mtxstack.c
+ lib/dolphin/mtx/mtxvec.c
+ lib/dolphin/mtx/mtx44.c
+ lib/dolphin/mtx/vec.c
+ lib/dolphin/mtx/quat.c
+)
+add_library(aurora::mtx ALIAS aurora_mtx)
+set_target_properties(aurora_mtx PROPERTIES FOLDER "aurora")
+
+target_include_directories(aurora_mtx PUBLIC include)
diff --git a/aurora-main/cmake/aurora_os.cmake b/aurora-main/cmake/aurora_os.cmake
new file mode 100644
index 0000000..77156f1
--- /dev/null
+++ b/aurora-main/cmake/aurora_os.cmake
@@ -0,0 +1,14 @@
+add_library(aurora_os STATIC lib/dolphin/os/OSInit.cpp
+ lib/dolphin/os/OSMemory.cpp
+ lib/dolphin/os/internal.hpp
+ lib/dolphin/os/OSBootInfo.cpp
+ lib/dolphin/os/OSTime.cpp
+ lib/dolphin/os/OSArena.cpp
+ lib/dolphin/os/OSAlloc.cpp
+ lib/dolphin/os/OSAddress.cpp
+ lib/dolphin/AR.cpp)
+add_library(aurora::os ALIAS aurora_os)
+set_target_properties(aurora_os PROPERTIES FOLDER "aurora")
+
+target_include_directories(aurora_os PUBLIC include)
+target_link_libraries(aurora_os PRIVATE aurora::core)
diff --git a/aurora-main/cmake/aurora_pad.cmake b/aurora-main/cmake/aurora_pad.cmake
new file mode 100644
index 0000000..5790fd0
--- /dev/null
+++ b/aurora-main/cmake/aurora_pad.cmake
@@ -0,0 +1,7 @@
+add_library(aurora_pad STATIC lib/dolphin/pad/pad.cpp)
+add_library(aurora::pad ALIAS aurora_pad)
+set_target_properties(aurora_pad PROPERTIES FOLDER "aurora")
+
+target_include_directories(aurora_pad PUBLIC include)
+target_link_libraries(aurora_pad PUBLIC aurora::core aurora::si)
+target_link_libraries(aurora_pad PRIVATE absl::flat_hash_map)
diff --git a/aurora-main/cmake/aurora_si.cmake b/aurora-main/cmake/aurora_si.cmake
new file mode 100644
index 0000000..0682c62
--- /dev/null
+++ b/aurora-main/cmake/aurora_si.cmake
@@ -0,0 +1,7 @@
+add_library(aurora_si STATIC lib/dolphin/si/si.cpp)
+add_library(aurora::si ALIAS aurora_si)
+set_target_properties(aurora_si PROPERTIES FOLDER "aurora")
+
+target_include_directories(aurora_si PUBLIC include)
+target_link_libraries(aurora_si PUBLIC aurora::core)
+target_link_libraries(aurora_si PRIVATE absl::flat_hash_map)
diff --git a/aurora-main/cmake/aurora_vi.cmake b/aurora-main/cmake/aurora_vi.cmake
new file mode 100644
index 0000000..38544af
--- /dev/null
+++ b/aurora-main/cmake/aurora_vi.cmake
@@ -0,0 +1,7 @@
+add_library(aurora_vi STATIC
+ lib/dolphin/vi/vi.cpp
+)
+add_library(aurora::vi ALIAS aurora_vi)
+set_target_properties(aurora_vi PROPERTIES FOLDER "aurora")
+
+target_link_libraries(aurora_vi PUBLIC aurora::core)
diff --git a/aurora-main/docs/building.md b/aurora-main/docs/building.md
new file mode 100644
index 0000000..ca1cabd
--- /dev/null
+++ b/aurora-main/docs/building.md
@@ -0,0 +1,55 @@
+# Building
+
+## Prerequisites
+
+- CMake 3.25 or later
+- A C++20 compatible compiler (Clang, GCC, or MSVC)
+- Git (for cloning)
+
+## Building the Examples
+
+To build Aurora's included examples:
+
+```bash
+git clone https://github.com/encounter/aurora.git
+cd aurora
+mkdir build && cd build
+cmake ..
+cmake --build . --target simple
+```
+
+The `simple` example demonstrates a minimal Aurora application with a blue screen. The built executable will be in `build/examples/simple`.
+
+## Using Aurora in Your Project
+
+Aurora is designed to be integrated as a library in GameCube/Wii decompilation projects.
+
+**CMakeLists.txt example:**
+
+```cmake
+cmake_minimum_required(VERSION 3.25)
+project(your_game)
+
+# Add Aurora as a subdirectory
+add_subdirectory(extern/aurora EXCLUDE_FROM_ALL)
+
+# Create your executable
+add_executable(your_game src/main.c)
+
+# Link against Aurora components
+target_link_libraries(your_game PRIVATE
+ aurora::core
+ aurora::gx
+ aurora::main
+ aurora::vi
+)
+```
+
+See [examples/simple.c](../examples/simple.c) for a minimal application template.
+
+## CMake Options
+
+- `AURORA_ENABLE_GX` (default: ON) - Enable GX implementation and WebGPU renderer
+- `AURORA_ENABLE_DVD` (default: OFF) - Enable DVD implementation backed by nod
+- `AURORA_ENABLE_CARD` (default: ON) - Enable CARD implementation based on kabufuda
+- `AURORA_CACHE_USE_ZSTD` (default: ON) - Compress WebGPU cache entries with zstd
diff --git a/aurora-main/examples/CMakeLists.txt b/aurora-main/examples/CMakeLists.txt
new file mode 100644
index 0000000..c5977f6
--- /dev/null
+++ b/aurora-main/examples/CMakeLists.txt
@@ -0,0 +1,4 @@
+if (AURORA_ENABLE_GX)
+ add_executable(simple simple.c)
+ target_link_libraries(simple PRIVATE aurora::core aurora::gx aurora::main aurora::vi)
+endif ()
diff --git a/aurora-main/examples/simple.c b/aurora-main/examples/simple.c
new file mode 100644
index 0000000..cb8a6c4
--- /dev/null
+++ b/aurora-main/examples/simple.c
@@ -0,0 +1,90 @@
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+static void log_callback(AuroraLogLevel level, const char* module, const char* message, unsigned int len) {
+ const char* levelStr;
+ FILE* out = stdout;
+ switch (level) {
+ case LOG_DEBUG:
+ levelStr = "DEBUG";
+ break;
+ case LOG_INFO:
+ levelStr = "INFO";
+ break;
+ case LOG_WARNING:
+ levelStr = "WARNING";
+ break;
+ case LOG_ERROR:
+ levelStr = "ERROR";
+ out = stderr;
+ break;
+ case LOG_FATAL:
+ levelStr = "FATAL";
+ out = stderr;
+ break;
+ }
+ fprintf(out, "[%s: %s;%s]\n", levelStr, module, message);
+ if (level == LOG_FATAL) {
+ fflush(out);
+ abort();
+ }
+}
+
+static void draw() {
+ GXSetCopyClear(
+ (GXColor){
+ .r = 0,
+ .g = 0,
+ .b = 100,
+ .a = 255,
+ },
+ GX_MAX_Z24);
+}
+
+int main(int argc, char* argv[]) {
+ const AuroraConfig config = {
+ .appName = "Demo",
+ .logCallback = &log_callback,
+ };
+ AuroraInfo initInfo = aurora_initialize(argc, argv, &config);
+
+ bool exiting = false;
+ bool paused = false;
+ while (!exiting) {
+ const AuroraEvent* event = aurora_update();
+ while (event != NULL && event->type != AURORA_NONE) {
+ switch (event->type) {
+ case AURORA_EXIT:
+ exiting = true;
+ break;
+ case AURORA_PAUSED:
+ paused = true;
+ break;
+ case AURORA_UNPAUSED:
+ paused = false;
+ break;
+ case AURORA_WINDOW_RESIZED:
+ initInfo.windowSize = event->windowSize;
+ break;
+ default:
+ break;
+ }
+ ++event;
+ }
+ if (exiting || paused || !aurora_begin_frame()) {
+ continue;
+ }
+ draw();
+ aurora_end_frame();
+ }
+
+ aurora_shutdown();
+ return 0;
+}
diff --git a/aurora-main/extern/CMakeLists.txt b/aurora-main/extern/CMakeLists.txt
new file mode 100644
index 0000000..9d77bbb
--- /dev/null
+++ b/aurora-main/extern/CMakeLists.txt
@@ -0,0 +1,310 @@
+include(FetchContent)
+
+# Don't build tests for third party dependencies
+set(BUILD_TESTING OFF CACHE INTERNAL "Build tests")
+set(SKIP_INSTALL_ALL ON)
+if (NOT DEFINED BUILD_SHARED_LIBS)
+ set(_USE_SHARED ON)
+else ()
+ set(_USE_SHARED OFF)
+endif ()
+
+function(aurora_find_package_global)
+ set(_PREV_FIND_PACKAGE_TARGETS_GLOBAL ${CMAKE_FIND_PACKAGE_TARGETS_GLOBAL})
+ set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ON)
+ find_package(${ARGV})
+ set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ${_PREV_FIND_PACKAGE_TARGETS_GLOBAL})
+endfunction()
+
+if (AURORA_ENABLE_GX)
+ include(${CMAKE_CURRENT_SOURCE_DIR}/../cmake/AuroraDawnProvider.cmake)
+endif ()
+
+# Abseil is needed for core libraries. It normally comes via Dawn's vendor build.
+# Otherwise prefer a system package and only fetch it as a last resort.
+if (NOT TARGET absl::flat_hash_map OR NOT TARGET absl::btree)
+ aurora_find_package_global(absl CONFIG QUIET GLOBAL)
+endif ()
+
+if (TARGET absl::flat_hash_map AND TARGET absl::btree)
+ message(STATUS "aurora: Using system/existing abseil")
+else ()
+ message(STATUS "aurora: Fetching abseil-cpp (standalone)")
+ if(NOT DEFINED CMAKE_MSVC_RUNTIME_LIBRARY OR CMAKE_MSVC_RUNTIME_LIBRARY MATCHES "DLL$")
+ set(ABSL_MSVC_STATIC_RUNTIME OFF CACHE INTERNAL "Link static runtime libraries")
+ else()
+ set(ABSL_MSVC_STATIC_RUNTIME ON CACHE INTERNAL "Link static runtime libraries")
+ endif()
+ set(ABSL_PROPAGATE_CXX_STD ON)
+ FetchContent_Declare(abseil-cpp
+ URL https://github.com/abseil/abseil-cpp/archive/refs/tags/20240722.0.tar.gz
+ DOWNLOAD_EXTRACT_TIMESTAMP TRUE
+ EXCLUDE_FROM_ALL
+ )
+ FetchContent_MakeAvailable(abseil-cpp)
+ if (NOT TARGET absl::flat_hash_map OR NOT TARGET absl::btree)
+ message(FATAL_ERROR "Failed to make abseil-cpp available")
+ endif ()
+endif ()
+
+include(${CMAKE_CURRENT_SOURCE_DIR}/../cmake/AuroraSDL3Provider.cmake)
+
+if (NOT TARGET xxhash)
+ message(STATUS "aurora: Fetching xxhash")
+ FetchContent_Declare(xxhash
+ URL https://github.com/Cyan4973/xxHash/archive/refs/tags/v0.8.3.tar.gz
+ URL_HASH SHA256=aae608dfe8213dfd05d909a57718ef82f30722c392344583d3f39050c7f29a80
+ SOURCE_SUBDIR cmake_unofficial
+ DOWNLOAD_EXTRACT_TIMESTAMP TRUE
+ EXCLUDE_FROM_ALL
+ )
+ set(XXHASH_BUILD_XXHSUM OFF CACHE INTERNAL "Build the xxhsum binary")
+ # xxhash uses CMAKE_DEPENDENT_OPTION(BUILD_SHARED_LIBS ...) which writes
+ # a sticky CACHE INTERNAL entry on reconfigure. Undo it afterward.
+ set(_aurora_xxhash_saved_bsl "${BUILD_SHARED_LIBS}")
+ FetchContent_MakeAvailable(xxhash)
+ unset(BUILD_SHARED_LIBS CACHE)
+ set(BUILD_SHARED_LIBS "${_aurora_xxhash_saved_bsl}")
+ unset(_aurora_xxhash_saved_bsl)
+ if (NOT TARGET xxhash)
+ message(FATAL_ERROR "Failed to make xxhash available")
+ endif ()
+else ()
+ message(STATUS "aurora: Using existing xxhash")
+endif ()
+
+if (NOT TARGET fmt)
+ message(STATUS "aurora: Fetching fmt")
+ FetchContent_Declare(fmt
+ URL https://github.com/fmtlib/fmt/archive/refs/tags/11.1.4.tar.gz
+ URL_HASH SHA256=ac366b7b4c2e9f0dde63a59b3feb5ee59b67974b14ee5dc9ea8ad78aa2c1ee1e
+ DOWNLOAD_EXTRACT_TIMESTAMP TRUE
+ EXCLUDE_FROM_ALL
+ )
+ FetchContent_MakeAvailable(fmt)
+ if (NOT TARGET fmt)
+ message(FATAL_ERROR "Failed to make fmt available")
+ endif ()
+else ()
+ message(STATUS "aurora: Using existing fmt")
+endif ()
+
+# fmt 11.1 drops its libc++ transitive includes but still calls malloc/free without .
+# Force the missing header for fmt and its consumers until the pinned release is updated.
+if (MINGW AND TARGET fmt)
+ target_compile_options(fmt PUBLIC
+ $<$:-include>
+ $<$:cstdlib>)
+endif ()
+
+if (AURORA_ENABLE_GX)
+ if (NOT TARGET ZLIB::ZLIB)
+ aurora_find_package_global(ZLIB QUIET)
+ endif ()
+ if (NOT TARGET ZLIB::ZLIB)
+ message(STATUS "aurora: Fetching zlib")
+ set(_AURORA_ZLIB_SHARED ${_USE_SHARED})
+ # ClangCL injects Clang's C headers into the MSVC resource compiler path, and zlib's DLL version
+ # resource then hits syntax rc.exe cannot parse. Use the resource-free static target here.
+ if (MSVC AND CMAKE_CXX_COMPILER_ID MATCHES "Clang")
+ set(_AURORA_ZLIB_SHARED OFF)
+ endif ()
+ set(ZLIB_BUILD_SHARED ${_AURORA_ZLIB_SHARED} CACHE BOOL "Enable building zlib shared library" FORCE)
+ set(ZLIB_BUILD_TESTING OFF CACHE BOOL "Enable Zlib Examples as tests" FORCE)
+ set(ZLIB_INSTALL OFF CACHE BOOL "Enable installation of zlib" FORCE)
+ FetchContent_Declare(
+ ZLIB
+ URL https://github.com/madler/zlib/releases/download/v1.3.2/zlib-1.3.2.tar.gz
+ URL_HASH SHA256=bb329a0a2cd0274d05519d61c667c062e06990d72e125ee2dfa8de64f0119d16
+ DOWNLOAD_EXTRACT_TIMESTAMP TRUE
+ EXCLUDE_FROM_ALL
+ )
+ FetchContent_MakeAvailable(ZLIB)
+ if (NOT TARGET ZLIB::ZLIB AND TARGET ZLIB::ZLIBSTATIC)
+ add_library(ZLIB::ZLIB ALIAS zlibstatic)
+ endif ()
+ if (NOT TARGET ZLIB::ZLIB)
+ message(FATAL_ERROR "Failed to make zlib available")
+ endif ()
+ # libpng calls find_package(ZLIB) even when zlib was already provided by FetchContent.
+ file(WRITE "${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/ZLIBConfig.cmake"
+ "set(ZLIB_FOUND TRUE)\n"
+ "set(ZLIB_INCLUDE_DIR \"${zlib_SOURCE_DIR}\")\n"
+ "set(ZLIB_INCLUDE_DIRS \"${zlib_BINARY_DIR};${zlib_SOURCE_DIR}\")\n"
+ )
+ endif ()
+
+ if (NOT TARGET PNG::PNG)
+ if (TARGET png_shared AND _USE_SHARED)
+ add_library(PNG::PNG ALIAS png_shared)
+ elseif (TARGET png_static)
+ add_library(PNG::PNG ALIAS png_static)
+ else ()
+ aurora_find_package_global(PNG QUIET)
+ endif ()
+ endif ()
+ if (NOT TARGET PNG::PNG)
+ message(STATUS "aurora: Fetching libpng")
+ set(PNG_SHARED ${_USE_SHARED} CACHE BOOL "Build libpng as a shared library" FORCE)
+ set(PNG_TESTS OFF CACHE BOOL "Build the libpng tests" FORCE)
+ set(PNG_TOOLS OFF CACHE BOOL "Build the libpng tools" FORCE)
+ FetchContent_Declare(
+ PNG
+ URL https://github.com/pnggroup/libpng/archive/refs/tags/v1.6.58.tar.gz
+ URL_HASH SHA256=A9D4DF463D36A6E5F9C29BD6F4967312D17E996C1854F3511F833924EB1993CF
+ DOWNLOAD_EXTRACT_TIMESTAMP TRUE
+ EXCLUDE_FROM_ALL
+ )
+ FetchContent_MakeAvailable(PNG)
+ if (TARGET png_shared AND _USE_SHARED)
+ add_library(PNG::PNG ALIAS png_shared)
+ elseif (TARGET png_static)
+ add_library(PNG::PNG ALIAS png_static)
+ else ()
+ message(FATAL_ERROR "Failed to make libpng available")
+ endif ()
+ else ()
+ message(STATUS "aurora: Using existing libpng")
+ endif ()
+
+ aurora_find_package_global(Freetype QUIET)
+ if (NOT TARGET Freetype::Freetype)
+ message(STATUS "aurora: Fetching Freetype")
+ set(FT_DISABLE_BROTLI ON CACHE BOOL "" FORCE)
+ set(FT_DISABLE_HARFBUZZ ON CACHE BOOL "" FORCE)
+ set(FT_DISABLE_PNG ON CACHE BOOL "" FORCE)
+ FetchContent_Declare(Freetype
+ URL https://files.twilitrealm.dev/freetype-2.14.3.tar.gz
+ URL_HASH SHA256=e61b31ab26358b946e767ed7eb7f4bb2e507da1cfefeb7a8861ace7fd5c899a1
+ DOWNLOAD_EXTRACT_TIMESTAMP TRUE
+ EXCLUDE_FROM_ALL
+ )
+ FetchContent_MakeAvailable(Freetype)
+ add_library(Freetype::Freetype ALIAS freetype)
+ else ()
+ message(STATUS "aurora: Using existing Freetype")
+ endif ()
+
+ if (NOT TARGET imgui)
+ message(STATUS "aurora: Fetching imgui")
+ FetchContent_Declare(imgui
+ URL https://github.com/ocornut/imgui/archive/refs/tags/v1.91.9b-docking.tar.gz
+ URL_HASH SHA256=466fdef9b18de15f0bb6e288e3d00ffa3d82200ec458ce5e4f724a161d9528a5
+ DOWNLOAD_EXTRACT_TIMESTAMP TRUE
+ EXCLUDE_FROM_ALL
+ )
+ FetchContent_MakeAvailable(imgui)
+
+ add_library(imgui STATIC
+ ${imgui_SOURCE_DIR}/imgui.cpp
+ ${imgui_SOURCE_DIR}/imgui_demo.cpp
+ ${imgui_SOURCE_DIR}/imgui_draw.cpp
+ ${imgui_SOURCE_DIR}/imgui_tables.cpp
+ ${imgui_SOURCE_DIR}/imgui_widgets.cpp
+ ${imgui_SOURCE_DIR}/misc/cpp/imgui_stdlib.cpp
+ )
+ target_include_directories(imgui PUBLIC ${imgui_SOURCE_DIR})
+
+ add_library(imgui_backends STATIC
+ ${imgui_SOURCE_DIR}/backends/imgui_impl_sdl3.cpp
+ ${imgui_SOURCE_DIR}/backends/imgui_impl_sdlrenderer3.cpp
+ ${imgui_SOURCE_DIR}/backends/imgui_impl_wgpu.cpp
+ )
+ target_compile_definitions(imgui_backends PRIVATE IMGUI_IMPL_WEBGPU_BACKEND_DAWN)
+ target_link_libraries(imgui_backends PRIVATE imgui ${AURORA_SDL3_TARGET} dawn::webgpu_dawn)
+ target_link_libraries(imgui PUBLIC imgui_backends)
+
+ if (TARGET Freetype::Freetype)
+ target_sources(imgui PRIVATE ${imgui_SOURCE_DIR}/misc/freetype/imgui_freetype.cpp)
+ target_compile_definitions(imgui PUBLIC IMGUI_ENABLE_FREETYPE)
+ target_link_libraries(imgui PRIVATE Freetype::Freetype)
+ endif ()
+ else ()
+ message(STATUS "aurora: Using existing imgui")
+ endif ()
+
+ if (TARGET imgui)
+ target_include_directories(imgui PUBLIC ${PROJECT_SOURCE_DIR}/include)
+ target_compile_definitions(imgui PUBLIC IMGUI_USER_CONFIG="aurora/imgui_config.h")
+ target_sources(imgui PRIVATE ${PROJECT_SOURCE_DIR}/lib/imgui_config.cpp)
+ target_link_libraries(imgui PRIVATE ${AURORA_SDL3_TARGET})
+ endif ()
+
+ if (NOT TARGET sqlite3)
+ message(STATUS "aurora: Fetching sqlite3")
+ FetchContent_Declare(sqlite3
+ URL https://sqlite.org/2026/sqlite-amalgamation-3510300.zip
+ URL_HASH SHA256=acb1e6f5d832484bf6d32b681e858c38add8b2acdfd42ac5df24b8afb46552b4
+ DOWNLOAD_EXTRACT_TIMESTAMP TRUE
+ EXCLUDE_FROM_ALL
+ )
+ FetchContent_MakeAvailable(sqlite3)
+
+ add_library(sqlite3 STATIC ${sqlite3_SOURCE_DIR}/sqlite3.c)
+ target_include_directories(sqlite3 PUBLIC ${sqlite3_SOURCE_DIR})
+ else ()
+ message(STATUS "aurora: Using existing sqlite3")
+ endif ()
+
+ if (AURORA_CACHE_USE_ZSTD)
+ if (NOT TARGET libzstd_static)
+ message(STATUS "aurora: Fetching zstd")
+ set(ZSTD_BUILD_STATIC ON)
+ set(ZSTD_BUILD_SHARED OFF)
+ set(ZSTD_BUILD_PROGRAMS OFF CACHE BOOL "BUILD PROGRAMS" FORCE)
+ set(ZSTD_BUILD_TESTS OFF CACHE BOOL "BUILD TESTS" FORCE)
+
+ if (WIN32 AND CMAKE_C_COMPILER_ID MATCHES "Clang" AND CMAKE_CXX_COMPILER_ID MATCHES "Clang")
+ set(C_FLAG_WALL TRUE CACHE INTERNAL "")
+ set(CXX_FLAG_WALL TRUE CACHE INTERNAL "")
+ set(C_FLAG_WEXTRA TRUE CACHE INTERNAL "")
+ set(CXX_FLAG_WEXTRA TRUE CACHE INTERNAL "")
+ set(C_FLAG_WUNDEF TRUE CACHE INTERNAL "")
+ set(CXX_FLAG_WUNDEF TRUE CACHE INTERNAL "")
+ set(C_FLAG_WSHADOW TRUE CACHE INTERNAL "")
+ set(CXX_FLAG_WSHADOW TRUE CACHE INTERNAL "")
+ set(C_FLAG_WCAST_ALIGN TRUE CACHE INTERNAL "")
+ set(CXX_FLAG_WCAST_ALIGN TRUE CACHE INTERNAL "")
+ set(C_FLAG_WCAST_QUAL TRUE CACHE INTERNAL "")
+ set(CXX_FLAG_WCAST_QUAL TRUE CACHE INTERNAL "")
+ set(C_FLAG_WSTRICT_PROTOTYPES TRUE CACHE INTERNAL "")
+ set(LD_FLAG_WL_Z_NOEXECSTACK FALSE CACHE INTERNAL "")
+ set(C_FLAG_QUNUSED_ARGUMENTS TRUE CACHE INTERNAL "")
+ set(CXX_FLAG_QUNUSED_ARGUMENTS TRUE CACHE INTERNAL "")
+ set(C_FLAG_WA_NOEXECSTACK TRUE CACHE INTERNAL "")
+ set(CXX_FLAG_WA_NOEXECSTACK TRUE CACHE INTERNAL "")
+ endif()
+
+ FetchContent_Declare(zstd
+ URL https://github.com/facebook/zstd/releases/download/v1.5.7/zstd-1.5.7.tar.gz
+ URL_HASH SHA256=eb33e51f49a15e023950cd7825ca74a4a2b43db8354825ac24fc1b7ee09e6fa3
+ DOWNLOAD_EXTRACT_TIMESTAMP TRUE
+ SOURCE_SUBDIR build/cmake
+ EXCLUDE_FROM_ALL
+ )
+ FetchContent_MakeAvailable(zstd)
+ else ()
+ message(STATUS "aurora: Using existing zstd")
+ endif ()
+ endif ()
+endif ()
+
+if (NOT TARGET TracyClient)
+ message(STATUS "aurora: Fetching Tracy")
+
+ # Default to off, users can enable the cache variable if they want to use it.
+ set(TRACY_ENABLE OFF CACHE BOOL "Enable Tracy profiling")
+
+ FetchContent_Declare(
+ tracy
+ URL https://github.com/wolfpld/tracy/archive/a64b9a20294d59421a2f57aeca3c6383d8c48169.tar.gz
+ URL_HASH SHA256=24d342b5127d7f659dc3cf94f24347b348cc736f25b17a691fd7f69541937658
+ DOWNLOAD_EXTRACT_TIMESTAMP TRUE
+ EXCLUDE_FROM_ALL
+ )
+ FetchContent_MakeAvailable(tracy)
+
+ if (NOT TARGET TracyClient)
+ message(FATAL_ERROR "Failed to make TracyClient available")
+ endif ()
+endif ()
diff --git a/aurora-main/include/aurora/aurora.h b/aurora-main/include/aurora/aurora.h
new file mode 100644
index 0000000..c3f70d2
--- /dev/null
+++ b/aurora-main/include/aurora/aurora.h
@@ -0,0 +1,172 @@
+#ifndef AURORA_AURORA_H
+#define AURORA_AURORA_H
+
+#ifdef __cplusplus
+#include
+#include
+
+extern "C" {
+#else
+#include "stdbool.h"
+#include "stddef.h"
+#include "stdint.h"
+#endif
+
+typedef enum {
+ BACKEND_AUTO,
+ BACKEND_D3D11,
+ BACKEND_D3D12,
+ BACKEND_METAL,
+ BACKEND_VULKAN,
+ BACKEND_OPENGL,
+ BACKEND_OPENGLES,
+ BACKEND_WEBGPU,
+ BACKEND_NULL,
+} AuroraBackend;
+
+typedef enum {
+ LOG_DEBUG,
+ LOG_INFO,
+ LOG_WARNING,
+ LOG_ERROR,
+ LOG_FATAL,
+} AuroraLogLevel;
+
+typedef enum {
+ AURORA_DISPLAY_MODE_WINDOWED,
+ AURORA_DISPLAY_MODE_BORDERLESS,
+ AURORA_DISPLAY_MODE_EXCLUSIVE,
+} AuroraDisplayMode;
+
+typedef struct {
+ int32_t x;
+ int32_t y;
+} AuroraWindowPos;
+
+typedef struct {
+ uint32_t width;
+ uint32_t height;
+
+ /**
+ * Width of the main GX framebuffer.
+ */
+ uint32_t fb_width;
+
+ /**
+ * Height of the main GX framebuffer.
+ */
+ uint32_t fb_height;
+
+ /**
+ * The size of the framebuffer used to present to the operating system.
+ * May differ from fb_width if Aurora is instructed to force an aspect ratio or resolution configuration.
+ */
+ uint32_t native_fb_width;
+
+ /**
+ * The size of the framebuffer used to present to the operating system.
+ * May differ from fb_height if Aurora is instructed to force an aspect ratio or resolution configuration.
+ */
+ uint32_t native_fb_height;
+ float scale;
+} AuroraWindowSize;
+
+typedef struct SDL_Window SDL_Window;
+typedef struct AuroraEvent AuroraEvent;
+
+typedef void (*AuroraLogCallback)(AuroraLogLevel level, const char* module, const char* message, unsigned int len);
+typedef void (*AuroraImGuiInitCallback)(const AuroraWindowSize* size);
+
+typedef struct {
+ const char* appName;
+ const char* userPath;
+ const char* cachePath;
+ // Read-only application resources. Defaults to SDL_GetBasePath(), which is
+ // where release builds place initial_pipeline_cache.db.
+ const char* resourcesPath;
+ AuroraBackend desiredBackend;
+ uint32_t msaa;
+ uint16_t maxTextureAnisotropy;
+ // No vsync knob exists: the swapchain is always configured for a
+ // non-blocking present mode (Immediate, else Mailbox). See best_present_mode.
+ bool startFullscreen;
+ bool allowJoystickBackgroundEvents;
+ bool pauseOnFocusLost;
+ bool allowTextureReplacements;
+ bool allowTextureDumps;
+ bool disableCopyFilter;
+ // When false, Aurora centers the first window. When true, windowPosX/Y are restored verbatim,
+ // including negative coordinates on monitors left of or above the primary display.
+ bool hasWindowPosition;
+ int32_t windowPosX;
+ int32_t windowPosY;
+ uint32_t windowWidth;
+ uint32_t windowHeight;
+ void* iconRGBA8;
+ uint32_t iconWidth;
+ uint32_t iconHeight;
+ AuroraLogCallback logCallback;
+ AuroraLogLevel logLevel;
+ AuroraImGuiInitCallback imGuiInitCallback;
+
+ /*
+ * The size of the GameCube's main memory, or MEM1 on the Wii.
+ * Note that it will not be allocated at the exact 0x80000000 address, as that cannot be guaranteed.
+ * This can be set to 0 to disable allocating this region.
+ */
+ uint32_t mem1Size;
+
+ /*
+ * The size of the GameCube's ARAM, or MEM2 on the Wii.
+ * This can be set to 0 to disable allocating this region.
+ */
+ uint32_t mem2Size;
+
+ // Optional directory for the portable GX pipeline database. When null, the
+ // database is stored in cachePath with Dawn's machine-specific cache.
+ const char* pipelineCachePath;
+} AuroraConfig;
+
+typedef struct {
+ AuroraBackend backend;
+ const char* userPath;
+ const char* cachePath;
+ SDL_Window* window;
+ AuroraWindowSize windowSize;
+} AuroraInfo;
+
+AuroraInfo aurora_initialize(int argc, char* argv[], const AuroraConfig* config);
+void aurora_shutdown();
+const AuroraEvent* aurora_update();
+bool aurora_begin_frame();
+void aurora_end_frame();
+typedef void (*AuroraFrameWorkerWaitCallback)();
+// Called from the producer thread at bounded intervals while Aurora waits for
+// the asynchronous frame worker. The callback must not enter Aurora.
+void aurora_set_frame_worker_wait_callback(AuroraFrameWorkerWaitCallback callback);
+void aurora_wait_for_frame_worker();
+bool aurora_wait_for_frame_worker_for(uint32_t timeoutMicros);
+// Absolute schedule for the next sealed frame, on steady_clock: baseNanos anchors the group and
+// intervalNanos is the period, so slot k of N+1 fires at base + k*interval/(N+1). Zeros clear it.
+void aurora_set_present_schedule(uint64_t baseNanos, uint64_t intervalNanos);
+// Reports whether the frame about to be sealed met its display boundary. Interpolation sizes its
+// slot group from this, backing off after misses. Only paced presents may report.
+void aurora_report_producer_paced(bool paced);
+void aurora_request_frame_capture(uint32_t frame, const char* outputPath);
+bool aurora_flush_efb_copies_to_ram();
+bool aurora_flush_efb_copy_to_ram(void* dest);
+
+void aurora_set_log_level(AuroraLogLevel level);
+void aurora_set_pause_on_focus_lost(bool value);
+void aurora_set_background_input(bool value);
+void aurora_set_display_mode(AuroraDisplayMode mode);
+AuroraDisplayMode aurora_get_display_mode();
+
+AuroraBackend aurora_get_backend();
+const AuroraBackend* aurora_get_available_backends(size_t* count);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/aurora-main/include/aurora/dvd.h b/aurora-main/include/aurora/dvd.h
new file mode 100644
index 0000000..4a7f53b
--- /dev/null
+++ b/aurora-main/include/aurora/dvd.h
@@ -0,0 +1,27 @@
+#ifndef AURORA_DVD_H
+#define AURORA_DVD_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#include
+#include
+
+/**
+ * Open a GC/Wii disc image for use by the DVD API.
+ * Must be called before DVDInit().
+ * Returns true on success, false on failure.
+ */
+bool aurora_dvd_open(const char* disc_path);
+
+/**
+ * Close the disc image and free all resources.
+ */
+void aurora_dvd_close(void);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/aurora-main/include/aurora/event.h b/aurora-main/include/aurora/event.h
new file mode 100644
index 0000000..46ebf40
--- /dev/null
+++ b/aurora-main/include/aurora/event.h
@@ -0,0 +1,44 @@
+#ifndef AURORA_EVENT_H
+#define AURORA_EVENT_H
+
+#include "aurora.h"
+
+#include
+#include
+
+#ifdef __cplusplus
+#include
+
+extern "C" {
+#else
+#include "stdint.h"
+#endif
+
+typedef enum {
+ AURORA_NONE,
+ AURORA_EXIT,
+ AURORA_SDL_EVENT,
+ AURORA_WINDOW_MOVED,
+ AURORA_WINDOW_RESIZED,
+ AURORA_CONTROLLER_ADDED,
+ AURORA_CONTROLLER_REMOVED,
+ AURORA_PAUSED,
+ AURORA_UNPAUSED,
+ AURORA_DISPLAY_SCALE_CHANGED,
+} AuroraEventType;
+
+struct AuroraEvent {
+ AuroraEventType type;
+ union {
+ SDL_Event sdl;
+ AuroraWindowPos windowPos;
+ AuroraWindowSize windowSize;
+ SDL_JoystickID controller;
+ };
+};
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/aurora-main/include/aurora/gfx.h b/aurora-main/include/aurora/gfx.h
new file mode 100644
index 0000000..f58252f
--- /dev/null
+++ b/aurora-main/include/aurora/gfx.h
@@ -0,0 +1,97 @@
+#ifndef AURORA_GFX_H
+#define AURORA_GFX_H
+
+#ifdef __cplusplus
+#include
+#include
+
+extern "C" {
+#else
+#include "stddef.h"
+#include "stdint.h"
+#endif
+
+#ifndef NDEBUG
+#define AURORA_GFX_DEBUG_GROUPS
+#endif
+
+void aurora_push_debug_group(const char* label);
+void aurora_pop_debug_group();
+
+typedef struct {
+ uint32_t queuedPipelines;
+ uint32_t createdPipelines;
+ uint32_t drawCallCount;
+ uint32_t mergedDrawCallCount;
+ uint32_t lastVertSize;
+ uint32_t lastUniformSize;
+ uint32_t lastIndexSize;
+ uint32_t lastStorageSize;
+ uint32_t lastTextureUploadSize;
+ uint32_t presentedFrameCount;
+ uint32_t interpolatedFrameCount;
+} AuroraStats;
+
+typedef struct {
+ uint64_t totalPresentCount;
+ uint32_t sampleCount;
+ double framesPerSecond;
+ double averageFrameTimeMs;
+ double p95FrameTimeMs;
+ double jitterMs;
+ // framesPerSecond with duplicated presentation slots scaled out, so this is the rate of frames
+ // that carried new motion. Equal to framesPerSecond when every slot replayed real interpolation.
+ double effectiveFramesPerSecond;
+} AuroraPresentTiming;
+
+const AuroraStats* aurora_get_stats();
+void aurora_get_present_timing(AuroraPresentTiming* timing);
+
+// Interpolation health: the per-frame fields describe the last sealed frame, the counters
+// accumulate since it was configured. This answers "output FPS dropped but the game held 60".
+typedef struct {
+ uint32_t targetFps; // configured target, 0 when interpolation is off
+ uint32_t targetSamples; // slots the pacing controller currently aims for
+ uint32_t activeSamples; // slots latched for the latest sealed frame
+ uint32_t candidates; // perspective draws in the latest sealed frame
+ uint32_t matchable; // candidates whose identity also existed last frame
+ uint32_t matches; // draws matched to the previous frame
+ uint32_t eligible; // latest frame inserted interpolated slots
+ uint32_t replaySafe; // latest frame could replay its command stream
+ uint64_t framesSealed;
+ uint64_t framesLowMatch;
+ uint64_t framesReplayUnsafe;
+ uint64_t slotReductions;
+ uint64_t lateSealDrops;
+} AuroraFrameInterpolationDiagnostics;
+
+void aurora_get_frame_interpolation_diagnostics(AuroraFrameInterpolationDiagnostics* diagnostics);
+
+// Generates transform-interpolated perspective frames between consecutive 60 Hz logical frames.
+// Supported targets are 0 (off), 120, 180 and 240. Guest simulation and VI timing are unchanged.
+void aurora_set_frame_interpolation_fps(uint32_t targetFps);
+uint32_t aurora_get_frame_interpolation_fps();
+
+// Newly encountered GX pipelines compile on the bounded worker queue. Draws whose pipeline is not
+// ready are skipped rather than stalling submission, and pick it up once compilation finishes.
+void aurora_set_skip_unready_pipelines(bool enabled);
+bool aurora_get_skip_unready_pipelines();
+uint32_t aurora_get_queued_pipeline_count();
+
+// Controls whether display copies bypass the Wii's vertical copy filter.
+void aurora_set_disable_copy_filter(bool disabled);
+bool aurora_get_disable_copy_filter();
+
+// Guest-RAM write tracking. `generation` changes whenever guest RAM covering a host range was
+// written (or returns AURORA_GUEST_WRITE_UNTRACKED); `notify` reports writes aurora made itself.
+#define AURORA_GUEST_WRITE_UNTRACKED UINT64_MAX
+typedef uint64_t (*AuroraGuestWriteGenerationCallback)(const void* hostPtr, size_t size);
+typedef void (*AuroraGuestWriteNotifyCallback)(const void* hostPtr, size_t size);
+void aurora_set_guest_write_hooks(AuroraGuestWriteGenerationCallback generation,
+ AuroraGuestWriteNotifyCallback notify);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/aurora-main/include/aurora/imgui.h b/aurora-main/include/aurora/imgui.h
new file mode 100644
index 0000000..77eba9a
--- /dev/null
+++ b/aurora-main/include/aurora/imgui.h
@@ -0,0 +1,20 @@
+#ifndef AURORA_IMGUI_H
+#define AURORA_IMGUI_H
+
+#include
+
+#ifdef __cplusplus
+#include
+
+extern "C" {
+#else
+#include "stdint.h"
+#endif
+
+ImTextureID aurora_imgui_add_texture(uint32_t width, uint32_t height, const void* rgba8);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/aurora-main/include/aurora/imgui_config.h b/aurora-main/include/aurora/imgui_config.h
new file mode 100644
index 0000000..3e62e59
--- /dev/null
+++ b/aurora-main/include/aurora/imgui_config.h
@@ -0,0 +1,13 @@
+#pragma once
+
+#define IMGUI_DISABLE_DEFAULT_FILE_FUNCTIONS 1
+
+typedef struct SDL_IOStream SDL_IOStream;
+typedef SDL_IOStream* ImFileHandle;
+typedef unsigned long long ImU64;
+
+ImFileHandle ImFileOpen(const char* filename, const char* mode);
+bool ImFileClose(ImFileHandle file);
+ImU64 ImFileGetSize(ImFileHandle file);
+ImU64 ImFileRead(void* data, ImU64 size, ImU64 count, ImFileHandle file);
+ImU64 ImFileWrite(const void* data, ImU64 size, ImU64 count, ImFileHandle file);
diff --git a/aurora-main/include/aurora/main.h b/aurora-main/include/aurora/main.h
new file mode 100644
index 0000000..68ee67c
--- /dev/null
+++ b/aurora-main/include/aurora/main.h
@@ -0,0 +1,16 @@
+#ifndef AURORA_MAIN_H
+#define AURORA_MAIN_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+int aurora_main(int argc, char* argv[]);
+
+#ifdef __cplusplus
+}
+#endif
+
+#define main aurora_main
+
+#endif
diff --git a/aurora-main/include/aurora/math.hpp b/aurora-main/include/aurora/math.hpp
new file mode 100644
index 0000000..cb15cf1
--- /dev/null
+++ b/aurora-main/include/aurora/math.hpp
@@ -0,0 +1,254 @@
+#pragma once
+
+#include
+#include
+#include
+
+#ifndef AURORA_VEC2_EXTRA
+#define AURORA_VEC2_EXTRA
+#endif
+#ifndef AURORA_VEC3_EXTRA
+#define AURORA_VEC3_EXTRA
+#endif
+#ifndef AURORA_VEC4_EXTRA
+#define AURORA_VEC4_EXTRA
+#endif
+#ifndef AURORA_MAT4X4_EXTRA
+#define AURORA_MAT4X4_EXTRA
+#endif
+
+#ifndef __has_attribute
+#define __has_attribute(x) 0
+#endif
+#ifndef __has_builtin
+#define __has_builtin(x) 0
+#endif
+#if __has_attribute(vector_size) && !defined(__clang__)
+#define USE_GCC_VECTOR_EXTENSIONS
+#endif
+
+namespace aurora {
+template
+struct Vec2 {
+ T x{};
+ T y{};
+
+ constexpr Vec2() = default;
+ constexpr Vec2(T x, T y) : x(x), y(y) {}
+ AURORA_VEC2_EXTRA
+
+ bool operator==(const Vec2& rhs) const { return x == rhs.x && y == rhs.y; }
+ bool operator!=(const Vec2& rhs) const { return !(*this == rhs); }
+};
+template
+struct Vec3 {
+ T x{};
+ T y{};
+ T z{};
+
+ constexpr Vec3() = default;
+ constexpr Vec3(T x, T y, T z) : x(x), y(y), z(z) {}
+ AURORA_VEC3_EXTRA
+
+ bool operator==(const Vec3& rhs) const { return x == rhs.x && y == rhs.y && z == rhs.z; }
+ bool operator!=(const Vec3& rhs) const { return !(*this == rhs); }
+};
+template
+struct Vec4 {
+#ifdef USE_GCC_VECTOR_EXTENSIONS
+ typedef T Vt __attribute__((vector_size(sizeof(T) * 4)));
+ Vt m;
+#else
+ using Vt = T[4];
+ Vt m;
+#endif
+
+ constexpr Vec4() = default;
+ constexpr Vec4(Vt m) : m(m) {}
+ constexpr Vec4(T x, T y, T z, T w) : m{x, y, z, w} {}
+ // For Vec3 with padding
+ constexpr Vec4(T x, T y, T z) : m{x, y, z, {}} {}
+ // For Vec3 -> Vec4
+ constexpr Vec4(Vec3 v, T w) : m{v.x, v.y, v.z, w} {}
+ AURORA_VEC4_EXTRA
+
+ inline Vec4& operator=(const Vec4& other) {
+ memcpy(&m, &other.m, sizeof(Vt));
+ return *this;
+ }
+
+ [[nodiscard]] inline T& x() { return m[0]; }
+ [[nodiscard]] inline T x() const { return m[0]; }
+ [[nodiscard]] inline T& y() { return m[1]; }
+ [[nodiscard]] inline T y() const { return m[1]; }
+ [[nodiscard]] inline T& z() { return m[2]; }
+ [[nodiscard]] inline T z() const { return m[2]; }
+ [[nodiscard]] inline T& w() { return m[3]; }
+ [[nodiscard]] inline T w() const { return m[3]; }
+ [[nodiscard]] inline T& operator[](size_t i) { return m[i]; }
+ [[nodiscard]] inline T operator[](size_t i) const { return m[i]; }
+
+ template
+ [[nodiscard]] constexpr Vec4 shuffle() const {
+ static_assert(x < 4 && y < 4 && z < 4 && w < 4);
+#if defined(USE_GCC_VECTOR_EXTENSIONS) && __has_builtin(__builtin_shuffle)
+ typedef int Vi __attribute__((vector_size(16)));
+ return __builtin_shuffle(m, Vi{x, y, z, w});
+#else
+ return {m[x], m[y], m[z], m[w]};
+#endif
+ }
+
+ bool operator==(const Vec4& rhs) const {
+#if defined(USE_GCC_VECTOR_EXTENSIONS) && __has_builtin(__builtin_reduce_and)
+ return __builtin_reduce_and(m == rhs.m) != 0;
+#else
+ return m[0] == rhs.m[0] && m[1] == rhs.m[1] && m[2] == rhs.m[2] && m[3] == rhs.m[3];
+#endif
+ }
+ bool operator!=(const Vec4& rhs) const { return !(*this == rhs); }
+};
+template
+[[nodiscard]] Vec4 operator+(const Vec4& a, const Vec4& b) {
+#ifdef USE_GCC_VECTOR_EXTENSIONS
+ return a.m + b.m;
+#else
+ return {a.m[0] + b.m[0], a.m[1] + b.m[1], a.m[2] + b.m[2], a.m[3] + b.m[3]};
+#endif
+}
+template
+[[nodiscard]] Vec4 operator*(const Vec4& a, const Vec4& b) {
+#ifdef USE_GCC_VECTOR_EXTENSIONS
+ return a.m * b.m;
+#else
+ return {a.m[0] * b.m[0], a.m[1] * b.m[1], a.m[2] * b.m[2], a.m[3] * b.m[3]};
+#endif
+}
+template
+struct Mat3x2 {
+ Vec2 m0{};
+ Vec2 m1{};
+ Vec2 m2{};
+
+ constexpr Mat3x2() = default;
+ constexpr Mat3x2(const Vec2& m0, const Vec2& m1, const Vec2& m2) : m0(m0), m1(m1), m2(m2) {}
+
+ bool operator==(const Mat3x2& rhs) const { return m0 == rhs.m0 && m1 == rhs.m1 && m2 == rhs.m2; }
+ bool operator!=(const Mat3x2& rhs) const { return !(*this == rhs); }
+};
+template
+struct Mat4x2 {
+ Vec2 m0{};
+ Vec2 m1{};
+ Vec2 m2{};
+ Vec2 m3{};
+
+ constexpr Mat4x2() = default;
+ constexpr Mat4x2(const Vec2& m0, const Vec2& m1, const Vec2& m2, const Vec2& m3)
+ : m0(m0), m1(m1), m2(m2), m3(m3) {}
+
+ inline Mat4x2 transpose() const {
+ return {
+ {m0.x, m2.x},
+ {m0.y, m2.y},
+ {m1.x, m3.x},
+ {m1.y, m3.y},
+ };
+ }
+
+ bool operator==(const Mat4x2& rhs) const { return m0 == rhs.m0 && m1 == rhs.m1 && m2 == rhs.m2 && m3 == rhs.m3; }
+ bool operator!=(const Mat4x2& rhs) const { return !(*this == rhs); }
+};
+template
+struct Mat2x4 {
+ Vec4 m0{};
+ Vec4 m1{};
+
+ constexpr Mat2x4() = default;
+ constexpr Mat2x4(const Vec4& m0, const Vec4& m1, const Vec4& m2) : m0(m0), m1(m1) {}
+
+ bool operator==(const Mat2x4& rhs) const { return m0 == rhs.m0 && m1 == rhs.m1; }
+ bool operator!=(const Mat2x4& rhs) const { return !(*this == rhs); }
+};
+static_assert(sizeof(Mat2x4) == 32);
+template
+struct Mat4x4;
+template
+struct Mat3x4 {
+ Vec4 m0{};
+ Vec4 m1{};
+ Vec4 m2{};
+
+ constexpr Mat3x4() = default;
+ constexpr Mat3x4(const Vec4& m0, const Vec4& m1, const Vec4& m2) : m0(m0), m1(m1), m2(m2) {}
+
+ [[nodiscard]] Mat4x4 to4x4() const;
+ [[nodiscard]] Mat4x4 toTransposed4x4() const;
+
+ bool operator==(const Mat3x4& rhs) const { return m0 == rhs.m0 && m1 == rhs.m1 && m2 == rhs.m2; }
+ bool operator!=(const Mat3x4& rhs) const { return !(*this == rhs); }
+};
+static_assert(sizeof(Mat3x4) == 48);
+template
+struct Mat4x4 {
+ Vec4 m0{};
+ Vec4 m1{};
+ Vec4 m2{};
+ Vec4 m3{};
+
+ constexpr Mat4x4() = default;
+ constexpr Mat4x4(const Vec4& m0, const Vec4& m1, const Vec4& m2, const Vec4& m3)
+ : m0(m0), m1(m1), m2(m2), m3(m3) {}
+ AURORA_MAT4X4_EXTRA
+
+ [[nodiscard]] Mat4x4 transpose() const {
+ return {
+ {m0[0], m1[0], m2[0], m3[0]},
+ {m0[1], m1[1], m2[1], m3[1]},
+ {m0[2], m1[2], m2[2], m3[2]},
+ {m0[3], m1[3], m2[3], m3[3]},
+ };
+ }
+ Mat4x4& operator=(const Mat4x4& other) = default;
+
+ Vec4& operator[](size_t i) { return *(&m0 + i); }
+ const Vec4& operator[](size_t i) const { return *(&m0 + i); }
+
+ bool operator==(const Mat4x4& rhs) const { return m0 == rhs.m0 && m1 == rhs.m1 && m2 == rhs.m2 && m3 == rhs.m3; }
+ bool operator!=(const Mat4x4& rhs) const { return !(*this == rhs); }
+};
+static_assert(sizeof(Mat4x4) == 64);
+template
+[[nodiscard]] Mat4x4 operator*(const Mat4x4& a, const Mat4x4& b) {
+ Mat4x4 out;
+ for (size_t i = 0; i < 4; ++i) {
+ *(&out.m0 + i) = a.m0 * b[i].template shuffle<0, 0, 0, 0>() + a.m1 * b[i].template shuffle<1, 1, 1, 1>() +
+ a.m2 * b[i].template shuffle<2, 2, 2, 2>() + a.m3 * b[i].template shuffle<3, 3, 3, 3>();
+ }
+ return out;
+}
+template
+[[nodiscard]] Mat4x4 Mat3x4::to4x4() const {
+ return {
+ {m0[0], m0[1], m0[2], 0.f},
+ {m1[0], m1[1], m1[2], 0.f},
+ {m2[0], m2[1], m2[2], 0.f},
+ {m0[3], m1[3], m2[3], 1.f},
+ };
+}
+template
+[[nodiscard]] Mat4x4 Mat3x4::toTransposed4x4() const {
+ return Mat4x4{
+ {m0[0], m1[0], m2[0], 0.f},
+ {m0[1], m1[1], m2[1], 0.f},
+ {m0[2], m1[2], m2[2], 0.f},
+ {m0[3], m1[3], m2[3], 1.f},
+ };
+}
+constexpr Mat4x4 Mat4x4_Identity{
+ Vec4{1.f, 0.f, 0.f, 0.f},
+ Vec4{0.f, 1.f, 0.f, 0.f},
+ Vec4{0.f, 0.f, 1.f, 0.f},
+ Vec4{0.f, 0.f, 0.f, 1.f},
+};
+} // namespace aurora
diff --git a/aurora-main/include/aurora/render_size_limits.hpp b/aurora-main/include/aurora/render_size_limits.hpp
new file mode 100644
index 0000000..20d92f3
--- /dev/null
+++ b/aurora-main/include/aurora/render_size_limits.hpp
@@ -0,0 +1,81 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+
+namespace aurora::render_size_limits {
+
+struct Size {
+ uint32_t width;
+ uint32_t height;
+};
+
+// The canvas follows the window aspect however wide it gets, since the presenter stretches it and
+// a cap would distort the UI. Only the portrait side clamps, where the guest cannot fill taller.
+inline constexpr float kMaxDynamicPortraitExpansion = 6.f;
+inline constexpr float kMinDynamicAspect = (16.f / 9.f) / kMaxDynamicPortraitExpansion;
+
+// The adapter limit is an API ceiling, not an allocation budget: a 16384-wide RGBA attachment is
+// near 100 MiB. 8192 plus an 8K-UHD pixel budget keeps 8x 16:9 while avoiding device removal.
+inline constexpr uint32_t kMaxFramebufferDimension = 8192;
+inline constexpr uint64_t kMaxFramebufferPixels = 7680ull * 4320ull;
+
+inline float clamp_dynamic_aspect(float aspect) noexcept {
+ if (!std::isfinite(aspect) || aspect <= 0.f) {
+ return 1.f;
+ }
+ return std::max(aspect, kMinDynamicAspect);
+}
+
+inline Size scale_framebuffer_to_aspect(uint32_t width, uint32_t height, float scale, float requestedAspect) noexcept {
+ if (width == 0 || height == 0 || !std::isfinite(scale) || scale <= 0.f) {
+ return {std::max(width, 1u), std::max(height, 1u)};
+ }
+
+ const auto scaledWidth = std::max(1u, static_cast(std::lround(static_cast(width) * scale)));
+ const auto scaledHeight = std::max(1u, static_cast(std::lround(static_cast(height) * scale)));
+ const float aspect = clamp_dynamic_aspect(requestedAspect);
+ if (aspect >= static_cast(width) / static_cast(height)) {
+ return {
+ std::max(1u, static_cast(std::lround(static_cast(scaledHeight) * aspect))),
+ scaledHeight,
+ };
+ }
+ return {
+ scaledWidth,
+ std::max(1u, static_cast(std::lround(static_cast(scaledWidth) / aspect))),
+ };
+}
+
+inline Size fit_framebuffer_to_budget(uint32_t width, uint32_t height, uint32_t adapterMaxDimension) noexcept {
+ if (width == 0 || height == 0) {
+ return {width, height};
+ }
+
+ uint32_t dimensionLimit = kMaxFramebufferDimension;
+ if (adapterMaxDimension != 0 && adapterMaxDimension != std::numeric_limits::max()) {
+ dimensionLimit = std::min(dimensionLimit, adapterMaxDimension);
+ }
+ dimensionLimit = std::max(dimensionLimit, 1u);
+
+ double fitScale = 1.0;
+ fitScale = std::min(fitScale, static_cast(dimensionLimit) / static_cast(width));
+ fitScale = std::min(fitScale, static_cast(dimensionLimit) / static_cast(height));
+
+ const double requestedPixels = static_cast(width) * static_cast(height);
+ if (requestedPixels > static_cast(kMaxFramebufferPixels)) {
+ fitScale = std::min(fitScale, std::sqrt(static_cast