# Player installer

**Legacy reference:** this format belongs to the old MelonLoader-only runtime,
which is not retained in the public history. It cannot install the current BepInEx DLL. Use the
[player guide](/docs/player-guide/) and [Thunderstore package](/docs/thunderstore/) for
Megabonk Level Editor; the old platform package tasks are internal in current
source and reject direct CLI invocation.

The player-facing download: a folder a Linux player unzips and runs `./megabonk-installer` in, which installs MelonLoader, the MegabonkLevels mod, the .NET 6 runtime it needs, and any levels or packs the package carries. This doc is the contract between the two halves — the **package** the build produces and the **installer** that consumes it — plus the Linux-specific behaviour that contract cannot express.

The installer is one static Go binary per platform, built from `go/` by `task installer:build` and shipped inside the package, so a player needs nothing else: no Python, no uv, no repo checkout, no .NET SDK, no Wine installation. The package builder and verifier (`scripts/build_player_package.py`, `task package:check`) are Python and share a small contract module with the installer, `scripts/package_contract/`: the manifest schema in `manifest.py` and the pack checks in `packs.py`. The installer parses the same manifest in `go/internal/manifest`.

## Linux player setup

Use the **Windows game under Proton/Wine**, not the native Linux build. In Steam,
open Megabonk's Properties → Compatibility and select Proton, then wait for the
Windows download to finish. Selecting Proton in the GUI triggered the depot
switch on Fedora; editing Steam's configuration alone did not. Launch vanilla
once to create the prefix, choose **DX11 - Recommended**, then quit the game
before installing. `-force-d3d11` in Steam's Launch Options pins that renderer.

Unzip the player package and run `./megabonk-installer`.
Packages are currently built with `task package:linux`; a published player
download URL is not yet available. No separate Wine install is needed with
Steam Proton. For a game whose prefix is in a different Steam library, pass
`--prefix` explicitly on both install and check: automatic prefix discovery
currently assumes the game's library.

The following prefix-mode sequence loaded the mod on the CachyOS Proton host.
Replace both paths with your actual game and active prefix paths; while vanilla
is running, its `STEAM_COMPAT_DATA_PATH` environment variable names the compatdata
directory (append `/pfx`). See [the gamepadui guide](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/gamepadui.md#install-launch-and-verify)
for measurement, package transfer, and remote launch commands.

```sh
cd /path/to/unzipped-player-package
G=/media/sdcard/SteamLibrary/steamapps/common/Megabonk
P="$HOME/.local/share/Steam/steamapps/compatdata/3405340/pfx"
./megabonk-installer --game-dir "$G" --prefix "$P" --dotnet-in prefix --wine-override registry
./megabonk-installer --game-dir "$G" --prefix "$P" --dotnet-in prefix --wine-override registry --check
```

`--check` must exit 0 and report that the install matches the package. Start
Megabonk from Steam and check the **new launch's** `MelonLoader/Latest.log` for
the pinned version banner, `1 Mod loaded.` and
`[MegabonkLevels] MegabonkLevels mod initialized.`. First-launch assembly
generation took about 40 seconds on the tested host; a cached launch need not
repeat `Assembly Generation Successful!`. Prefix-mode .NET needed no
`DOTNET_ROOT` launch option. Inspect the install record for the actual DLL
override method: the observed run reported `via: wine` despite requesting
registry mode. Do not infer success from the Steam launch command or an old log.

Re-run the same install command to repair drift. With the game stopped,
`./megabonk-installer --game-dir "$G" --prefix "$P" --uninstall` removes recorded
installer-owned files, not unrelated mods or saves. Packs must contain Windows
bundles even though the host OS is Linux.

## Package layout

```text
megabonk-levels-<mod>-ml<melonloader>-linux/
├── megabonk-installer            the Go installer for this platform (install.exe on Windows)
├── manifest.json                 the contract, described below
├── vendor/                       vendor archives, named exactly as the manifest names them
├── content/levels/               bundled .mblevel.json files and their sidecar thumbnails
└── content/packs/<pack-id>/      bundle + manifest.json (+ attachments.json) per pack
```

`megabonk-installer` is the only entry point a player needs: a static binary that reads `manifest.json` from the folder it runs in (or `--package <dir>`), so there is no interpreter to choose and nothing to put on a path. The builder copies the platform's binary from `.tools/installer/` and marks it executable; a package build without `--installer-binary` fails, and a missing file is a missing input that points at `task installer:build`.

## The manifest

`manifest.json` is a JSON object with `schema` `megabonk-player-package/1`. `load_manifest` in `scripts/package_contract/manifest.py` (the builder's side) and `LoadManifest` in `go/internal/manifest` (the installer's) refuse anything they cannot use, naming the missing or bad key rather than failing later during the install.

| Key | Meaning |
| --- | --- |
| `schema` | `megabonk-player-package/1`. An unknown schema is an error, not a warning: an old installer must not half-install a package from a newer build. |
| `platform` | The OS this package targets: `linux` or `windows`. Both installers accept either value; the manifest differs only in this key. |
| `package_id` | Name in the install record and in `--check` output. |
| `mod_version`, `melonloader_version`, `game_buildid` | Recorded at install time and echoed back by `--check` and `--uninstall`. `game_buildid` is the depot build id the shipped levels were authored against (`GAME_BUILD_ID` in `scripts/build_thps_level.py`), so a package and a level file agree about which game they expect. |
| `generated` | UTC timestamp of the build. Set `SOURCE_DATE_EPOCH` for a byte-identical rebuild. |
| `verified_on` | Distro/version pairs this package was actually tested on. Empty means untested, and it is not a claim of support. |
| `vendors` | The archives under `vendor/`. |
| `files` | Every file to place, and where. |

### vendors

Each entry needs `name`, `url`, `sha256`, `kind`, `version`.

`name` is the **filename under `vendor/`**, not a display name — the installer resolves an archive as `vendor/<name>`. `kind` is the lookup key: the installer asks for `manifest.vendor("melonloader")` and `manifest.vendor("dotnet")`, so a package that omits either kind is rejected before anything is copied. Only those two kinds exist today: MelonLoader's Windows release archive, and a base Windows x64 .NET 6 runtime.

Both archives are the **Windows** ones on purpose, even though the target is Linux: MelonLoader's native Linux build cannot load the native Linux game build, so the whole stack is the Windows stack running under Proton.

`url` is provenance, not a download instruction: the installer never goes to the network. It verifies the copy under `vendor/` against `sha256` and stops if it does not match.

### files

Each entry needs `path` (relative to the package root), `sha256`, and `dest` (relative to the game directory). Destinations follow what the game and the mod actually read:

| Content | `dest` | Why there |
| --- | --- | --- |
| Mod DLL | `Mods/MegabonkLevels.Mod.dll` | Where MelonLoader loads mods from. |
| Managed dependencies (NLayer) | `UserLibs/NLayer.dll` | MelonLoader's probe path for third-party assemblies. |
| Levels | `UserData/Levels/<name>.mblevel.json` | `LiveEditPatch.LevelsDirectoryPath()` lists titled levels from here, and Map Selection shows them beside the native maps. A level anywhere else is invisible to the player. |
| Packs | `UserData/LevelPacks/<id>/{<bundle>,manifest.json,attachments.json}` | `PackPrefabs` loads packs from this layout; see [asset-bundles.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/asset-bundles.md). |

Bundled content is ordinary `files[]` entries, so nothing in the schema is special about it: the same digest check covers it, and `--uninstall` removes it by the same record that removes the mod.

`verify_package` checks every `vendor/<name>` and `files[].path` against its digest and returns a message per problem — missing file or checksum mismatch — which is what `task package:check` runs.

## Install record

After a successful install the installer writes `MegabonkLevels.installer.json` into the game directory, schema `megabonk-levels-install/1`: package id and versions, `installed_at`, the relative paths of every mod file, level and pack it placed, where the .NET runtime went and with what launch option, and how the `version.dll` override was applied. `game_buildid` is the build the package was verified against; `installed_game_buildid` is the build Steam's appmanifest reported when the installer ran, or `null` when it reported none. The second key is optional, so records written before it existed still load under the same schema.

That one file drives both `--check` and `--uninstall`. Uninstall is deliberately record-driven rather than a glob: it removes what it recorded placing and leaves anything it did not put there alone, so a player's own `Mods/` entries, saves, and `UserData` survive.

## Exit codes

| Code | Meaning |
| --- | --- |
| 0 | Done, nothing needed. |
| 1 | Error (bad package, checksum mismatch, I/O). |
| 2 | Usage — e.g. no `--package`. |
| 3 | Nothing found to work on: no Steam install, no game directory, no package. |
| 4 | The game directory is a build this installer will not touch, i.e. the native Linux build. |
| 10 | `--check` found drift between the record and the package. A script can treat 10 as "reinstall" and 0 as "already installed". |

## Installer contract

Every platform's installer — the same Go binary, built as `megabonk-installer` for Linux and macOS and `install.exe` for Windows — implements the steps below in this order, and a player's experience differs between platforms only in how the installer is launched. The sections after this one describe the platform specifics; this one is the single list they must agree with, and the Go tests under `go/internal` hold the installer to it.

1. **Check the package.** Load `manifest.json` and verify every `vendor/` archive and `files[]` entry against its digest before touching the game. A bad or tampered package exits 1 with the list of mismatches and changes nothing.
2. **Find the game directory.** Use `--game-dir` when given. Otherwise read Steam's recorded libraries and accept a directory only when it holds `Megabonk.exe`, `Megabonk.x86_64` or `Megabonk_Data/`. A game that cannot be found exits 3 and names every Steam client and library considered, each with why it was rejected, plus the flag to retry with.
3. **Classify the build.** Only the Windows build is supported, on every platform. The native Linux build is refused with exit 4 and the steps for switching to the Windows build.
4. **Compare the game build.** Read the installed build id from the library's `appmanifest_3405340.acf` and print whether it matches the build the package was verified against. A difference prints a warning and the run continues, because a game update must not lock a player out; an absent appmanifest prints that the installed build is unknown. The installed id goes into the install record.
5. **Install MelonLoader at the pinned version** from `vendor/`, verified against the manifest checksum. The installer never goes to the network.
6. **Provide the .NET 6 runtime** from `vendor/` at user scope, inside the game directory or the Wine prefix, never machine-wide. Where it goes depends on the platform: [Windows package](#windows-package), [macOS package](#macos-package) and [.NET placement](#net-placement-and-the-launch-option) on Linux.
7. **Apply the `version.dll` override** where a Wine or Proton prefix needs it. Native Windows has none.
8. **Place the mod files** from `files[]`: the mod DLL and its managed dependencies.
9. **Create `UserData/Levels/`**, then **import levels** and **install packs** the package carries or that `--level` and `--pack` name. Inputs are validated first, so a bad level or pack is listed and nothing is half-installed.
10. **Write the install record**, which drives `--check` and `--uninstall`.
11. **Report** what was done and what was not, as numbered `N/M label` lines or a progress window. A failure exits nonzero with a message that says what to do.

### Re-running

A second run with nothing changed does nothing and says so. A run after something has gone missing or stale repairs only that: `--check` compares the install against the package by file digest and loader version marker, exits 10 when anything differs, and the next install run fixes just those entries. A wiped runtime is reinstalled together with its `version.dll` override, because losing one without the other leaves a game that starts and never loads a mod.

After a game update the answer is the same run again. The game build comparison in step 4 tells the player whether the package still matches the game and the repair covers any loader file that went missing; if the build differs and the mod stops loading, the fix is a newer package, which the warning says. Whether Steam's own update or file verification removes the loader files it did not install is not established here, and the game has not updated since February 2026, so no update has been observed; see [what is not verified](#what-is-verified-and-what-is-not).

### Uninstall

`--uninstall` is record-driven: it removes what the record says the installer placed, plus what the loader generates on first launch where the platform notes say so, and leaves a player's own plugins, preferences, levels and saves alone. With no record it removes nothing.

## Building a package

```sh
task package:linux
task package:linux MOD_VERSION=0.4.2
```

`scripts/build_player_package.py` builds the mod, assembles everything into `.tools/player-package/`, and writes a distributable zip beside it.

Two choices are intentional:

- **The default is Bramblecrown Hill only.** The package tasks name `bramblecrown-hill.mblevel.json`, bringing its original selection illustration and biome icon, with no packs or music. See [Starter level](/docs/starter-level/) for the content inventory, first-edit tutorial and packaged smoke test. `levels/` also holds development fixtures, so there is no directory-wide bundling. Explicit `LEVELS` and `PACKS` overrides still support local packages; the lower-level builder bundles only files named with `--level` and `--pack`. A `.local.mblevel.json` is skipped even when named. Matching thumbnail and `.biome` sidecars are ordinary hashed manifest files, installed beside the level, repaired by a rerun and removed with it on uninstall. Images that match no bundled level stay out.
- **Packs come from two places, the way `task pack:install` assembles them**: the built AssetBundle from `.tools/bundles/<platform>/<pack>` and the manifest (plus optional attachments) from `unity/pack-authoring/packs/<pack>/`. `--pack-platform` picks the bundle; `windows` is right on Linux because the game runs the Windows build. Staged packs are validated with `validate_pack_source` (`scripts/package_contract/packs.py`), which reproduces the game's own rules — required `id`/`name`/`author`/`version`/`assets` fields, `id` matching the folder name, a bundle file present, and a `pack-attachments/1` sidecar when there is one — so a broken pack fails the build instead of landing in the Warehouse as a placeholder.

Vendor archives are taken from `.tools/vendor-cache/` and only verified there, never downloaded unless you pass `--allow-download` (`ALLOW_DOWNLOAD=true` to the task, which forwards it). The build writes into a staging directory and swaps it in at the end, so a failure leaves the previous package untouched.

When a download does happen it goes through `scripts/asset_download.py`, the only code in `scripts/` that touches the network (a test pins that). The rules: https only, TLS 1.2 floor with certificate verification; the status is checked before the body is read, so a 404, a login or captive-portal page, or an empty body reads as a missing asset rather than a checksum mismatch, and nothing poisoned ever lands in the cache; the body is capped, and an archive whose digest does not match the manifest pin is refused before it is cached. Redirects stay on the original host except for an explicit CDN allowance — GitHub release assets hand off to `release-assets.githubusercontent.com` and dotnetcli to azureedge/microsoft hosts — so an off-list redirect is refused as a missing asset. `install_melonloader.py` uses the same fetcher. The `url` in a manifest entry stays provenance for the player side: the installer itself never downloads.

The zip is deterministic — sorted entries, fixed timestamps, modes 0755 for the installer binary and 0644 elsewhere — so two builds of the same inputs are byte-identical once `SOURCE_DATE_EPOCH` is pinned.

## Windows package

```sh
task package:windows
task package:windows MOD_VERSION=0.4.2
```

`task package:windows` builds the mod and the `windows/amd64` Go installer, then runs `scripts/build_player_package.py --platform windows --installer-binary <exe>`. The package has the same `vendor/`, `content/` and `manifest.json` layout as the Linux one with `"platform": "windows"`, but the binary is `install.exe`. It lands in `.tools/player-package-windows/` (override with `OUTPUT_WINDOWS`), with `megabonk-levels-<mod>-ml<loader>-windows.zip` beside it. Windows players unzip it and double-click `install.exe`: with no `--package`, the Go installer takes the folder it runs from.

On native Windows the installer finds Steam through `HKCU\Software\Valve\Steam` (then `HKLM\...\WOW6432Node\Valve\Steam` and the Program Files default) and reads `libraryfolders.vdf` for libraries on other drives. It has no prefix, no `version.dll` override and no launch option; every write is inside the game directory. MelonLoader's bootstrap otherwise installs .NET 6 machine-wide under Program Files on first launch, so the installer pre-seeds the vendored runtime into `MelonLoader/Dependencies/dotnet`, the path the loader itself uses; it then logs `Using HostFXR Path: <game>\MelonLoader\Dependencies\dotnet\host\fxr\6.0.36\hostfxr.dll` and runs no runtime installer. `--uninstall` also removes the files the loader creates at first launch (`UserData/Loader.cfg`, and `Plugins/`, `UserData/Levels`, `UserData/LevelPacks`, `UserData` when empty), so the game returns to vanilla while a player's own plugins, preferences and levels stay.

The first launch downloads Cpp2IL from GitHub and takes about 35 seconds, so it needs network access. The Windows flow has been verified in a software-rendered Windows 11 guest, not on real hardware.

## macOS package

```sh
task package:macos
task package:macos MOD_VERSION=0.4.2
APPLE_SIGNING_IDENTITY="<Developer ID Application identity>" NOTARY_PROFILE=<notarytool keychain profile> task package:macos
```

`task package:macos` builds the mod in Release and the `darwin/arm64` Go installer, then runs `scripts/build_player_package.py --platform macos --installer-binary <binary>`. The package has the same `vendor/`, `content/` and `manifest.json` layout as the others with `"platform": "macos"`, and carries a bare `megabonk-installer` binary with no extension and no wrapper script, mode 0755 in the zip. It lands in `.tools/player-package-macos/` (override with `OUTPUT_MACOS`), with `megabonk-levels-<mod>-ml<loader>-macos.zip` beside it. Only Apple silicon is built: no Intel Mac was available to test an `amd64` binary.

Megabonk has no macOS build, so the player runs the **Windows build under Wine** and the installer works inside that prefix. It does not install Wine, create a prefix or log into Steam: Steam and Megabonk must already be installed in a Wine prefix, and the game started once. The player runs `./megabonk-installer` from the unzipped folder, which is the default `--package`.

### Finding the game and the prefix

Prefixes are searched in this order, and the first Steam library that holds Megabonk wins:

1. `--prefix <folder holding drive_c>`, which limits the search to that prefix.
2. Plain Wine: `~/.local/share/megabonk/wine`, then `~/.wine`.
3. CrossOver bottles under `~/Library/Application Support/CrossOver/Bottles/`, in name order. CrossOver is commercial and is never required, so a bottle is only used when no plain-Wine prefix holds the game.

In each prefix the installer reads Steam's `libraryfolders.vdf` from `drive_c/Program Files (x86)/Steam` and maps the Windows paths in it back to the Mac filesystem: `C:` is the prefix's `drive_c`, `Z:` is the filesystem root, and any other letter follows the prefix's `dosdevices/<letter>:` link. `--game-dir` skips discovery, and the prefix is then the folder above the `drive_c` in that path; a game directory outside any prefix needs `--prefix` as well, and the installer says so rather than guessing.

### What it writes

- MelonLoader and the mod files inside the game directory, as on every platform.
- The vendored .NET 6 runtime in `MelonLoader/Dependencies/dotnet`, the path the loader itself uses, the same placement as native Windows. The launch then logs `Using HostFXR Path: C:\Program Files (x86)\Steam\steamapps\common\Megabonk\MelonLoader\Dependencies\dotnet\host\fxr\6.0.36\hostfxr.dll` and runs no runtime installer. No launch option and no `DOTNET_ROOT` are needed.
- The `version.dll` = `native,builtin` override in the prefix. It goes in through a `wine` binary when one is found (`MEGABONK_WINE`, then `wine64` or `wine` on `PATH`, then a `Wine*.app` under `~/Applications` or `/Applications`), and otherwise by merging into the prefix's `user.reg`, which is also how CrossOver bottles are handled. The method used is recorded as `via` in the install record, and `--wine-override off` skips it.

Re-running is a no-op at the expected version, a wiped runtime is repaired together with its override, and `--uninstall` removes the loader, the mod, the runtime, the override and what the loader generated at first launch, leaving a player's own plugins, preferences and levels.

### First launch

The first launch downloads Cpp2IL and the Unity dependencies and generates the Il2Cpp assemblies, so it needs network access and takes noticeably longer under Wine than elsewhere: about half a minute after the loader starts, during which the window stays black. The mod logs `MegabonkLevels mod initialized.` when it is done. The repo's own `scripts/launch_game.py` passes `--melonloader.agfoffline`, which forbids those downloads, so a fresh install launched that way fails with `UnityDependencies_<version>.zip does not Exist!` and a black screen for ever; start the first launch from Steam, or without the flag.

### Signing

`APPLE_SIGNING_IDENTITY` names a Developer ID Application identity (`security find-identity -v -p codesigning`); the task then signs the installer with the hardened runtime before it is packed, and `codesign --verify --strict` checks it. The finished zip is submitted through `scripts/notarize_macos.py`, which waits for the verdict, when `NOTARY_PROFILE` names a `notarytool` keychain profile or when `APPLE_API_KEY` (key id), `APPLE_API_ISSUER` and `APPLE_API_KEY_B64` (the base64 `.p8`) hold an App Store Connect API key; a profile wins over a key. The key is decoded to a mode 0600 temporary file that is deleted when the submission ends, so the same three variables serve a local run and a CI job. All of them come from the environment, never from the repo. A download through curl carries no quarantine attribute, so Gatekeeper does not assess it; a browser download does, and an unnotarized binary then needs a right-click Open. A notarization ticket cannot be stapled to a bare binary or a zip, so Gatekeeper checks Apple's service when it assesses one.

### What is verified

On an Apple silicon Mac with plain Wine 11 (Wine Devel), against a copy of a working prefix with MelonLoader, the mod, the override and every loader-generated folder removed: the signed package installed 7 steps (exit 0) with `--prefix`, a re-run and `--check` reported nothing to do, the game launched without `--melonloader.agfoffline` and logged `Using HostFXR Path:` under `MelonLoader/Dependencies/dotnet`, `Assembly Generation Successful!`, `1 Mod loaded.` and `MegabonkLevels mod initialized.`, and `--uninstall` returned the game directory to its vanilla contents and removed the override. Default discovery of the plain-Wine prefix was checked with `--dry-run`. CrossOver is covered by unit tests and a `--dry-run` against a real bottle only, since the licence on the test machines has expired. The same install started from Terminal in the desktop session, with no `--no-gui`, opened the progress window, waited for its Install confirmation, and completed with exit 0.

## Linux behaviour

### The Windows build only

The installer classifies the game directory before touching it (`gamebuild.Classify` in `go/internal/gamebuild`). `Megabonk.exe` plus `Megabonk_Data/` is the Windows build and the only supported one. A directory with `Megabonk.x86_64` is the native Linux build and the installer stops with exit 4 and instructions for switching to the Windows build through Steam Play, because MelonLoader 0.7.3 segfaults the native binary during Il2Cpp assembly generation and the upstream fix is not in a release. That is a hard refusal, not a warning: installing onto it would leave a player with a crashed game and no log.

### Finding Steam and the game

`go/internal/steam` looks for a Steam install under `$XDG_DATA_HOME/Steam`, `~/.local/share/Steam`, `~/.steam/root`, `~/.steam/steam`, the two Flatpak locations (`~/.var/app/com.valvesoftware.Steam/...`), and any Flatpak-granted path. A directory counts as a client when it has `steamapps/`. Library folders are read out of `config/libraryfolders.vdf` (`go/internal/vdf`, a read-only parser — the installer never writes VDF).

Game identity comes from `GameCandidates`, which offers every directory three searches thought might be Megabonk, in the order it should be trusted: the `installdir` in the library's own `appmanifest_3405340.acf`, then `steamapps/common/Megabonk` by name, then any other Il2Cpp install in `common`. The third tier exists because a copy moved out of its library keeps `GameAssembly.dll` and its data folder but loses the name. Acceptance never rests on that shape: a candidate is Megabonk only when it holds `Megabonk.exe`, `Megabonk.x86_64` or `Megabonk_Data/`, all three named by this game alone. `FindGameDir` takes the first accepted candidate; `--check` and the not-found report name what was rejected and why (`an Il2Cpp install (GameAssembly.dll) but no Megabonk.exe, Megabonk.x86_64 or Megabonk_Data/`), so a machine with another Unity/Il2Cpp title installed says so instead of installing into it.

Two more things make this sturdier than it looks:

- **A library on a detached drive** shows up as a configured root whose `steamapps` is gone. Those roots are skipped rather than reported as a missing game, and only when even the parent directory is missing.
- **Flatpak Steam can only read paths granted to it**, so granted paths from `flatpak override --show` (or the persisted `local-overrides` file) are read on every run and added as extra search roots. They also count as the Flatpak client itself when one of them holds `steamapps/`: a maintainer machine exists where the entire Steam root — `config/`, `userdata/` and `steamapps/` — was moved to a data disk, so no client is left at any default location, and the grants are the only way back to it. Without this, a game on a second disk is simply invisible to a Flatpak install.

### Which Proton prefix

Steam keeps the prefix at `<library>/steamapps/compatdata/<appid>/pfx` **in the library the game was installed to**, which is not always the one holding the game files after a move — the SD-card layout has the files on removable media and the live prefix in the home library. `FindPrefix` prefers the game's own library, and when that prefix has no `drive_c/` it takes the only other usable prefix among the libraries searched and prints `Proton prefix: <path> (Steam keeps it in that library, not beside the game)`. Two usable prefixes elsewhere is a guess, so nothing is chosen: both are printed with the `--prefix` hint, and the plan falls back to `gamedir` mode. A prefix passed with `--prefix` silences all of that reporting. `--check` names the prefixes it considered and why each was rejected (`no steamapps/compatdata prefix in this library`, `the prefix has no drive_c/: the game has never been launched under Proton in this library`).

### .NET placement and the launch option

MelonLoader's Il2Cpp support needs a .NET 6 runtime, and the vendored one is the Windows build. It goes in one of two places:

- `--dotnet-in prefix` (the default) extracts it to `<prefix>/drive_c/Program Files/dotnet`, inside the Proton prefix, where Windows .NET finds it on its own and the player changes nothing.
- `--dotnet-in gamedir` extracts it to a `dotnet6-win` directory beside the game executable, for when the prefix is not writable or not usable. The installer then prints the Steam launch option to paste:

```sh
DOTNET_ROOT=Z:\path\to\game\dotnet6-win %command%
```

`Z:` is Proton's drive letter for the filesystem root, so the path after it is the ordinary Unix path. The installer **never edits `localconfig.vdf`**: Steam rewrites that file while it runs and a concurrent write loses. It prints the exact line and leaves the paste to the player.

`UsablePrefix` checks for `drive_c`, which Steam creates on the first Proton launch — so the fallback to `gamedir` mode fires when the player has not launched the game yet under Proton, and the warning tells them the launch option is coming.

### version.dll override

IL2Cpp hooking under Proton needs the `version.dll` = `native,builtin` DLL override in the prefix, and the game directory ships a `version.dll` stub for it to pick up. `SetDllOverride` in `go/internal/proton` writes it through the Proton tool's own `wine` with `reg add` when that binary is findable (`via: wine`), and otherwise merges `Software\Wine\DllOverrides` into the prefix's `user.reg` (`via: registry`). `--wine-override off` skips it; `--wine-override wine` fails loudly when no wine binary exists instead of falling back.

The override is written when the entry is **missing or** when the runtime is being (re)installed. The second condition matters: a wiped prefix loses the override along with the runtime, and reinstalling only one of the two leaves a game that starts and never loads a mod.

The method actually used is recorded and echoed back, so a player reporting a problem can say whether it went in through wine or the registry.

### Levels and packs from the command line

`--level <file>` and `--pack <dir>` install extra content the package does not carry, through the same record so `--uninstall` removes it too. Both are validated before anything is copied — a bad pack is reported as a list of problems and nothing is half-installed.

### Progress reporting

The installer shows a native progress window when it has a display and falls back to numbered `N/M label` lines otherwise; the window is described under "Progress window" below. `--non-interactive`, `--no-gui` or `MEGABONK_NON_INTERACTIVE=1` force plain text for install and uninstall, even on a desktop. The environment setting also accepts `true`, `yes` and `on` (case-insensitive); empty, `0`, `false`, `no` and `off` leave automatic selection enabled. `--json` suppresses progress output and dialogs. No installer prompt reads stdin.

## The installer

`go/` holds the installer: `go/cmd/megabonk-installer` builds one cgo-free binary per platform. The one external module is MyGo (`github.com/egoist/mygo`), used only for the progress window below, and `task installer:verify-static` fails if any other direct dependency appears or if a Linux binary needs anything at load time beyond glibc. `task package:linux`, `package:windows` and `package:macos` each copy the matching binary into their package; the binaries themselves land in gitignored `.tools/installer/`.

```sh
task installer:build            # cross-compile into .tools/installer
task installer:test             # gofmt check, go vet, go test (part of task check)
task installer:verify-static    # CGO off, MyGo the only module, glibc-only load deps
```

Targets are `darwin/arm64`, `linux/amd64` and `windows/amd64`. `linux/arm64` is left out on purpose: the game and its Proton prefixes are x86_64, so an arm64 Linux player would run the installer and never the mod. The Windows binary is exercised in a Windows 11 Docker guest (see below).

Windows path handling has one rule: `resolve` answers in slash-separated form on every OS.
`filepath.Abs` and `filepath.EvalSymlinks` return backslash paths there, and any comparison
that mixes them with the slash-separated install paths misclassifies safe zip members as
escaping the game directory. The `plan` and `steam` resolve seams take injectable
abs/toSlash/eval so tests pin this without a Windows host; a guest run first caught the leak.

### Test seams

Go tests are hermetic by construction. `MEGABONK_MOUNT_ROOTS` replaces the removable-media scan list (empty scans nothing) so a maintainer's mounted library cannot turn a "not found" scenario into an install, and a `local-overrides` file with no grants is the source `flatpak_granted_paths` prefers, so the host's `flatpak` CLI is never asked. Go unit tests cover what is awkward to reach through a subprocess — path arithmetic, the VDF and registry parsers, the flag seam above.

### Progress window

The Go binary reports progress in a MyGo native-UI window — a checklist of the plan's steps (pending circle, spinner on the running step, green check when done, red cross on the step that failed), a collapsible Details log and Cancel and Install buttons — drawn with GTK 3 on Linux, Metal on macOS and Direct3D 11 on Windows. It never uses MyGo's webview mode, so WebKitGTK is never loaded. The selection order is the window, then plain numbered text; kdialog, zenity and yad are not used.

On Linux the window needs a display (`DISPLAY` or `WAYLAND_DISPLAY`) and GTK 3, which the desktops players run already carry; GTK is `dlopen`ed at run time, so the binary itself loads only glibc and nothing depends on GTK being installed. `--no-gui`, `--non-interactive`, `MEGABONK_NON_INTERACTIVE=1`, `--json`, no display, or an app that cannot start (missing GTK) all give the text reporter, with `N/M label` lines, a closing message that starts with a capital letter, and the same exit codes.

The window opens on a prompt — "Install Megabonk Levels" or "Uninstall Megabonk Levels" — listing every step the plan will take, with Cancel and Install buttons, and nothing is touched until Install is pressed. Cancel or closing the window at the prompt exits 1 with "nothing was changed". There is no terminal check: a double-click launch gets the window, and scripted runs opt out with the variable or flags above.

After a run the window stays open with the result and a Done button, so a double-click launch does not lose the outcome. A failure marks the failing step with a red cross, shows the error and opens Details. The Details log holds the step lines and everything the installer would print on stdout. After a successful install that needs a Steam launch option, the window shows it with a Copy button. Cancel closes the window once the executor has stopped.

Cancel, or closing the window, once the run has started is only a request: the executor checks it between actions, never mid-copy. A cancelled install writes a partial install record for the actions already done and runs the normal record-driven uninstall, so the game directory returns to its prior state. A cancelled uninstall stops between steps and does not roll back. `MEGABONK_UI_STEP_DELAY` (a Go duration such as `2s`) pauses after each step so a person or a script can press Cancel mid-run; it is unset in normal use. On Windows MyGo logs `drawing without the GPU` lines to stderr when Direct3D 11 cannot create a swap chain (a VM or a service session), so stderr is not empty there even though stdout matches the text reporter. Packaging through `mygo build -platform` and its `.deb`/desktop-entry output was evaluated and not adopted; the binaries still come from plain `go build`.

## What is verified, and what is not

Automated, in `task check`:

- Go: `task installer:test` runs gofmt, `go vet` and the unit tests under `go/internal`: plan, prefix selection, VDF and Steam discovery, manifest rules, record-driven uninstall, cancellation and the text reporter.
- Python: `tests/test_build_player_package.py`, `tests/test_package_manifest.py` and `tests/test_package_packs.py` cover assembly for all three platforms, the manifest round-trip and `verify_package` on built packages, deterministic zips, vendor download handling and pack validation.

Run against the real binaries when the installer was ported, using the Python installer it replaced as the oracle (it is no longer in the repository):

- `linux/amd64` runs the full lifecycle — install, `--check` no-drift, drift repair with exit 10, uninstall — on `ubuntu:24.04`, `fedora:42` and the SteamOS-based `thevakhovske/holoiso` image, and in a `FROM scratch` image holding nothing but the binary and the fixture tree (no shell, no interpreter), so the no-interpreter claim is measured rather than inferred.
- The four library layouts discovery has to survive ran as a non-root user in `fedora:42` and `ubuntu:24.04` with no network: a single library holding Megabonk beside an unrelated Il2Cpp title that sorts first, the same library with no Megabonk at all, the game on removable media with the live prefix in the home library, and two other libraries each with a usable prefix. The decoy keeps its own digest through every install, the report names the Il2Cpp install it refused with its reason, the SD-card install writes the .NET runtime into the home library's prefix and says so, and the two-prefix case prints both candidates with the `--prefix` hint and falls back to `gamedir` mode until `--prefix` picks one.
- `windows/amd64` runs the same lifecycle in the dockurr/windows Windows 11 guest. `windows_path` keeps an existing drive letter and only prefixes `Z:` for drive-less paths, so the `DOTNET_ROOT` launch option legitimately differs between a Linux host run and a native Windows run.
- On the AlmaLinux 10.2 maintainer host's real layout (Flatpak Steam with the root relocated to `/media/steam`, the stale unmounted library still in `libraryfolders.vdf`, and the native install present) the installer reports `Flatpak Steam may only read: /media/steam`, finds the relocated root through its Flatpak grant, skips the unmounted library, and refuses the native build with exit 4 and the Windows-build steps.
- The download path runs against the real vendors: both pinned archives, the GitHub MelonLoader release and the dotnetcli .NET 6 runtime, download through their cross-host redirects with digests matching the pins, a real GitHub 404 reads as a missing asset, plain http and an off-list redirect host are refused, and a 404 leaves nothing in the cache; a cold-cache `--allow-download` rebuilds the whole package from scratch.

Live-game evidence below was gathered with the Python installer that this one replaced, and has not been repeated with the Go binary:

- On the CachyOS Proton host, the player-package install and `--check`, Windows .NET 6 resolving inside the active home-library prefix while the game lives on SD-card storage, the DLL override recorded as `via: wine`, and a fresh launch logging the mod-initialized marker. See [gamepadui.md](https://github.com/pythoninthegrass/megabonk_mods/blob/main/docs/gamepadui.md) for the measured workflow and the three launch traps that cost time there.
- On the Fedora player box, auto-discovery with no flags against a real single library holding an unrelated Il2Cpp title that sorts before Megabonk: discovery picked Megabonk through the appmanifest and the library's own prefix, `--check` matched, and with Steam stopped the game launched under Proton through the offline Steamworks stub to the same three markers (`Assembly Generation Successful!`, `1 Mod loaded.`, `[MegabonkLevels] MegabonkLevels mod initialized.`), stable over three minutes of mod telemetry.
- On an Apple silicon Mac with plain Wine, see "macOS package" above.

Still pending:

- A live Steam launch from a Go-installed Linux package, now that the Go binary is the only installer.
- What a Steam update or "verify integrity of game files" does to the loader files in the game directory, which decides whether re-running after an update is ever needed beyond the build warning.
- The printed game-directory `DOTNET_ROOT` launch option working when Steam actually applies it (both live proofs use prefix mode).
- The persisted `local-overrides` file as the source of the Flatpak grants; the real host went through `flatpak override --show`, which is the other of the two paths.
- Steam's own behaviour around the download, including whether the Windows build needs the "Only download on my OS" untick on a given Steam version.