- Shell 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Real-world run: the dotNetFx40 SFX exited 0 even though its chained netfx_Core_x64.msi failed with 1603 (faithful to the real SFX behavior), so the fallback (gated on rc!=0) never ran and the run died at the clr.dll gate. install_dotnet40 now attempts the direct-MSI fallback (netfx_Core_x64.msi + netfx_Extended_x64.msi, msiexec /i with /l*v verbose logging) when the CLR-artifact verification fails, and dies only if the fallback also fails. Fixture: the dotNetFx40 installer faithfully exits 0 without installing when MD_TEST_FAIL contains dotnet40; native_pe moved to fixture-lib.sh (the fixture proton's msiexec /i emulation used it without defining it - set -u without set -e made the failure silent). New test T30. Suite: 136 assertions pass, shellcheck clean. |
||
| tests | ||
| MonoDisabler | ||
| README.md | ||
MonoDisabler
A production-grade Steam/Proton Bash wrapper that suppresses Wine-Mono and
installs native Microsoft .NET runtimes into an isolated per-game Proton
prefix, then launches the original %command% array unchanged.
Steam launch option
In Steam → game → Properties → General → Launch Options, use the exact form:
/path/to/MonoDisabler %command%
Use an absolute path. The optional repair switch must precede %command%:
/path/to/MonoDisabler --repair %command%
Everything after the wrapper path is expanded by Steam into an argument array and is never modified, stringified, or re-parsed by the wrapper.
Install / update
Steam runs a concrete script file, not your git checkout. Copy (or symlink)
MonoDisabler to a stable location and point the launch option at that file:
install -Dm755 MonoDisabler ~/.local/share/MonoDisabler/MonoDisabler
After pulling a newer revision, re-run that command — the launch option does not track the checkout automatically. Alternatively, symlink the stable location to a permanent clone so updates apply as soon as you pull:
ln -sf /path/to/permanent/clone/MonoDisabler ~/.local/share/MonoDisabler/MonoDisabler
(Keep the clone on permanent storage, not /tmp, or the symlink will break.)
Then the launch option is:
/home/<you>/.local/share/MonoDisabler/MonoDisabler %command%
What it does, in order
- Detects the Steam App ID from the
%command%array (see below). - Detects the Proton runner from the same array.
- Acquires an exclusive
flockon the per-prefix lock file. - Lets Proton create the isolated prefix (first run only) with Wine-Mono suppressed from the very first Wine process.
- Sets Windows 7 (
winecfg -v win7) and verifies it by reading the authoritativeHKLM\Software\Microsoft\Windows NT\CurrentVersionvalues withreg.exe— Wine's ownRtlGetVersionreads those, whereas winecfg's NT setter clearsHKCU\Software\Wine\Version, andwinecfg /voutput goes to Wine's Unix stderr (Proton-managed) and cannot be captured through a cmd redirect. The original version is recorded in<prefix>/.winver.originalbefore any change so an interrupted run can still restore it. Then it uninstalls Wine-Mono MSI products, deletes the fake NDP registry keys Wine-Mono pre-seeds, and purges Wine-Mono directory trees (positively identified only). - Installs, in this exact order, with verification after each step:
- Microsoft .NET Framework 4.0 (prerequisite — winxp emulation,
replicating the bundled winetricks
dotnet48chain, including its registry fixups) - Microsoft .NET Framework 4.8 (official offline installer)
- Microsoft .NET Desktop Runtime 6.0.36 (x86 and x64 on win64 prefixes)
- Microsoft .NET Desktop Runtime 8.0.31 (x86 and x64 on win64 prefixes)
- Microsoft .NET Desktop Runtime 9.0.20 (x86 and x64 on win64 prefixes)
- Microsoft .NET Framework 4.0 (prerequisite — winxp emulation,
replicating the bundled winetricks
- Re-verifies Mono suppression (installers may have changed the prefix).
- Restores the prefix's original Windows version setting.
- Launches the original
%command%array withWINEDLLOVERRIDES="mscoree=n,b"andSTEAM_COMPAT_DATA_PATHpointing at the isolated prefix, waits for it, and returns its exit status.
Prefix location
~/.local/share/MonoDisabler/prefixes/app_<SteamAppId>/
STEAM_COMPAT_DATA_PATH points at that directory (the compat-data root).
Proton derives the actual Wine prefix as <root>/pfx internally — verified
in the Proton source (CompatData.__init__:
self.prefix_dir = self.path("pfx/")). The wrapper never sets WINEPREFIX
itself: Proton sets it from the compat-data path. Your real Steam
compatdata directories are never touched.
Support directories:
| Path | Purpose |
|---|---|
~/.local/share/MonoDisabler/logs/ |
per-run log files (last 30 kept) |
~/.cache/MonoDisabler/downloads/ |
pinned installer cache |
<prefix>/MonoDisabler.state |
informational state record (not authoritative) |
<prefix>/.winver.original |
original Windows version, for restore after interruption |
<prefix>/.prefix.lock |
per-prefix flock target |
App ID detection
Detection order (first match wins, always validated as 1–10 decimal digits, empty and non-numeric rejected):
- A token
AppId=<digits>immediately following a literalSteamLaunchtoken (this is the shape of real Steam reaper/SteamLaunch arrays). - Any other
AppId=<digits>token in the array. - A
--steam3id=<digits>token (Steam Linux Runtime argument). - Inherited
SteamAppId(numeric only). - Inherited
SteamGameId(numeric only).
The exact detection method is logged on every run. No App ID is hard-coded.
Proton detection
The wrapper scans the actual argument array for tokens whose basename is
exactly proton, requires the token to be an executable regular file
(rejecting bare strings and directories), and scores each candidate:
- followed by
waitforexitandrun(+4) orrun(+2) - followed by a target token after the verb (+1)
The highest-scoring candidate wins; equal scores go to the earliest token, which matches reality (in a Steam array the real Proton token precedes game arguments, and decoy strings can only appear as game arguments). All candidates are logged. If no valid Proton runner exists, the wrapper fails with a sanitized dump of the argument array.
Helper commands (winecfg, reg.exe, uninstaller.exe, cmd.exe, installers) are
built by slicing the original array up through and including
[proton] waitforexitandrun and appending exactly one helper executable —
preserving the reaper / SteamLaunch / Steam Linux Runtime / pressure-vessel
structure instead of reconstructing any command string. If the detected verb
is not waitforexitandrun, helpers explicitly append that verb (Proton
implements waitforexitandrun as wineserver -w followed by the run).
Why WINEDLLOVERRIDES differs between phases
- Preparation (
mscoree=): an empty override disablesmscoreeloading entirely (the same semantics winetricks uses for itsdisabledmode), so Wine's builtin mscoree — the entry point that would load Wine-Mono — never runs during prefix creation, registry work, or installs. It is scoped per-invocation viaenv, never exported. - .NET Framework 4.8 installer (
fusion=b): the exact value the bundled winetricks applies to .NET Framework installers (verified in this Proton'sprotonfixes/files/bin/winetricks, verbdotnet48). The mandated prep valuemscoree=is used for every other preparation operation, but must not be combined with this installer (see "How Wine-Mono is suppressed" and the verified evidence in the README's installer section). - Execution (
mscoree=n,b): the exact mandated value. Native Microsoft mscoree (installed by .NET) is preferred, with the builtin as fallback — this is what makes games use the real Microsoft CLR.
How Wine-Mono is suppressed
Layered, with every layer verified:
WINEDLLOVERRIDESscoping (above).- Registry value
HKCU\Software\Wine\MonoDisabled= DWORD 1, created and re-verified every run. Honesty note: no code path in upstream Wine master, Proton's wine, or winetricks was found to read this value; it is maintained because the specification requires it, but the suppression does not depend on it. - Wine-Mono MSI products whose description contains
Wine Monoare enumerated viareg.exe query ... /sover both Uninstall registry views (the 64-bitHKLM\Software\Microsoft\...\Uninstalland the 32-bitHKLM\Software\Wow6432Node\...\Uninstall— the Wine-Mono "Windows Support" product registers only under Wow6432Node, as observed in a real proton-rtsp prefix) and removed withmsiexec.exe /x {ProductCode} /qn. We deliberately do not useuninstaller.exe --list: Wine's uninstaller writes through its debug/MESSAGE channel (the process's Unix stderr), which Proton redirects into its own log, so a cmd-level redirect cannot capture it;reg.exewrites through the Windows stdout handle and is captured reliably. This step mirrors bundled winetricksremove_mono, which the .NET Framework install recipe requires (leftover Wine-Mono support files/registry interfere with the real .NET install). - The fake
NDP\v4\Full/NDP\v3.5registry keys that Wine-Mono's MSI pre-seeds (verified:Release=0x00082348in real prefixes) are deleted before the real .NET Framework 4.8 installation. - Targeted directory purge of
drive_c/windows/monoanddrive_c/windows/syswow64/mono— deleted only when positively identified as Wine-Mono (contains amono-2.0subdirectory orlibmono*files, matching Wine'sget_mono_path/find_mono_dllsearch). Anything else (including Proton-tracked builtins likemonodebg.vxdand Microsoft's own .NET files) is never touched, and amono-named directory without the signature is left alone with a warning. - A second purge/verification pass runs after all installations.
How Microsoft .NET is verified
Installer exit codes are never treated as proof.
.NET Framework 4.8 — installer invocation:
- The bundled winetricks
dotnet48verb chains .NET Framework 4.0 first (remove_mono→dotnet40under winxp emulation →win7→ ndp48); the wrapper replicates that chain, including winetricks' registry fixups (NDP\v4\Full Install=1/Version=4.0.30319,OnlyUseLatestCLR). The .NET 4.8 MSI fails with 1603 in a prefix without the prerequisite (observed on real runs twice; its custom actions are native, so the DLL override was not the cause). WINEDLLOVERRIDES=fusion=b— the exact value the bundled winetricks applies to .NET Framework installers (verified in GE-Proton11-7'sprotonfixes/files/bin/winetricks, verbdotnet48). This is a deliberate exception to themscoree=prep value, kept for consistency with the verified recipe./log <file>(a Microsoft-documented installer option) writes the verbose setup log into the prefix; on any installer failure or verification failure the wrapper copies the installer's own logs from the prefix into the run log directory and prints filtered tails.- Direct-MSI fallback: if the setup engine does not produce a verified
installation, the wrapper extracts the failing netfx MSI (and its payload
.mzz) from the cached installer on the Linux side (needs7z) and installs it directly withmsiexec /i ... /l*v <log> /qn, collecting the full verbose MSI log into the run log directory. Wine's msi honors logging only viaMsiEnableLogW(msiexec/l*v), which the setup engine does not use — this fallback is both the diagnostic and, when the engine was the problem, the cure. The run aborts only if both paths fail verification.
.NET Framework 4.8 — three verification signals, all required:
HKLM\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\FullReleaseREG_DWORD ≥ 528040 (Microsoft-documented minimum for 4.8).system32\mscoree.dll(andsyswow64\mscoree.dllon win64 prefixes) is a real native PE, not a Wine builtin — detected by the same PE-offset-0x40 tag check Proton itself uses. This matters because Wine-Mono's MSI pre-seeds a fakeReleasevalue.- Real CLR files
clr.dllpresent underMicrosoft.NET/Framework[64]/v4.0.30319/(absent from Wine-Mono's fake skeleton).
.NET Desktop Runtime 6/8/9:
dotnet.exe --list-runtimesexecuted inside the target prefix (output captured via a cmd.exe batch redirect into the prefix) must listMicrosoft.WindowsDesktop.App <major>.*for each of 6, 8, 9. AMicrosoft.NETCore.Appentry alone is NOT accepted.- On win64 prefixes the x86 Desktop Runtime is additionally verified by the
presence of
Program Files (x86)\dotnet\shared\Microsoft.WindowsDesktop.App\<pinned version>. dotnet.exeis located at the documented default (C:\Program Files\dotnet\dotnet.exe) with prefix-filesystem discovery as a fallback; failure to find it is fatal with diagnostics.
Any verification failure aborts the run with the installer path, exit code, verification command output, expected value, and detected value.
Installer pinning and checksums
All installers are pinned in the script (versions, URLs, hashes) and were downloaded and hash-verified at implementation time:
| Runtime | Version | Hash verified against |
|---|---|---|
| .NET Framework 4.0 (prerequisite) | dotNetFx40_Full_x86_x64.exe | SHA256 from the bundled winetricks; re-verified by download |
| .NET Framework 4.8 | ndp48-x86-x64-allos-enu.exe | SHA256 from the winetricks bundled with this Proton; re-verified by download |
| .NET Desktop Runtime 6 | 6.0.36 | SHA512 from Microsoft's official release metadata; SHA256 also matches the bundled winetricks |
| .NET Desktop Runtime 8 | 8.0.31 | SHA512 from Microsoft's official release metadata |
| .NET Desktop Runtime 9 | 9.0.20 | SHA512 from Microsoft's official release metadata |
Downloads use curl --fail with HTTPS-only protocol restriction, retries,
non-empty-file checks, and hash verification; files are renamed atomically
into the cache only after validation. Corrupt cached files are re-downloaded
and a failed download is fatal.
Downloads happen on first use (the wrapper runs when you launch the game); ~560 MB are fetched once and cached.
Concurrency
An exclusive flock on <prefix>/.prefix.lock (dynamic fd:
exec {LOCK_FD}>"$LOCK_FILE"; flock -x "$LOCK_FD") is held across prefix
creation, all mutations, and the final game launch. A second launch of the
same game blocks until the first one fully finishes, then re-verifies and
launches. The lock file's content (pid/timestamp) is informational only.
Spawned children close the lock fd so nothing can keep the lock alive after
the wrapper exits. No PID files, no pkill/killall of Wine processes.
Signals and cleanup
ERRlogs the failing command with context.INT/TERMterminate only the child tree this wrapper started (each child runs undersetsid, i.e. its own process group), escalating toSIGKILLif the child ignores TERM, release the lock, clean temporary files, and exit128+signal.EXITcloses the lock fd, removes the run's temp dir, and preserves the original exit status.
Logs
Per-run logs live in ~/.local/share/MonoDisabler/logs/ and mirror to
stderr (visible in Steam's console). They contain: timestamp, PID, App ID
and detection method, Proton path and identity, prefix path, every helper
invocation, installer paths and exit codes, checksum results, registry
operations, Windows-version operations, verification results, lock timing,
Mono purge operations, and the final launch status. Proton's own logs are
redirected into the same directory (PROTON_LOG_DIR).
Environment knobs
| Variable | Default | Meaning |
|---|---|---|
MD_REPAIR=1 |
off | same as --repair: force reinstall of all runtimes |
MD_TIMEOUT_HELPER |
900 | per-helper timeout in seconds |
MD_TIMEOUT_DOTNET48 |
2700 | .NET 4.8 installer timeout |
MD_TIMEOUT_DCR |
1800 | per-Desktop-Runtime installer timeout |
MD_KEEP_LOGS |
30 | number of log files retained |
MD_TEST_* variables are reserved for the test suite (see tests/) and
must not be used in production launch options.
Running the tests
tests/run_tests.sh # full suite (28 scenarios, ~100 assertions)
tests/run_tests.sh T18 # single test by name prefix
The suite runs entirely against fixtures: a fake Proton launcher (path with spaces), fake reaper/runtime passthroughs, and fake installers in a temp cache. It never touches a real Steam installation or prefix.
Known limitations
- The real Microsoft installers have not been executed against a real Proton prefix during this implementation (tests use fixtures); the pinned URLs, hashes, installer flags, winver dance, and Proton semantics are all source-verified, but a first real-world run is still the final proof. Verification failures abort loudly rather than guessing.
- The .NET Framework 4.8 self-extractor picks the "largest fixed drive" for
its temporary extraction. Under Proton, Proton maps the Steam library as a
fixed drive (
S:), so the extractor briefly writes its setup files into the Steam library directory and cleans them up afterwards. This is installer behavior, not something the wrapper controls. .NET Framework 4.8verification still aborts the whole flow if the layered checks fail; a--repairrun retries the full chain.HKCU\Software\Wine\Mono Disabled=1has no verified reader in current Wine/Proton source (see above); it is maintained for specification compliance.- Helpers run through the same reaper/SteamLaunch pipeline the game uses; Steam may briefly register the helper runs in its console. This preserves the runtime/pressure-vessel environment the Proton binaries require.
- Exit codes from Windows installers pass through Wine modulo 256 (e.g. a Windows 1603 error surfaces as 67). Any nonzero installer code aborts.
- The wrapper targets Bash ≥ 4.4 and standard GNU userland (
flock,curl,sha256sum/sha512sum, GNUfind,stat,tail -c, etc.).