diff --git a/tools/linux/README.md b/tools/linux/README.md new file mode 100644 index 00000000..32c073aa --- /dev/null +++ b/tools/linux/README.md @@ -0,0 +1,55 @@ +# Linux / Wine setup + +Cosmonarchy runs under Wine, but a stock prefix gets four things wrong. Each one +fails in a way that does not name its own cause, so they are collected here. + +```sh +tools/linux/setup-bottle.sh --check # report, change nothing +tools/linux/setup-bottle.sh # apply +``` + +Defaults to the Bottles bottle named `Blizzard`. Use `--bottle NAME` for a +different bottle, `--prefix PATH` for a plain `WINEPREFIX`, and `--game-dir PATH` +if the StarCraft directory cannot be read from the prefix's registry. + +Every change is backed up next to the file it modifies. `--check` exits non-zero +when something is missing, so it works in a pre-flight. + +## What it fixes + +| Symptom | Cause | Fix | +| --- | --- | --- | +| Fraud Launcher 2 exits instantly | .NET 8 loads `icu.dll`, which Wine forwards to a nonexistent `icuuc68.dll` | `DOTNET_SYSTEM_GLOBALIZATION_USENLS=1` | +| `Fonts could not be loaded` | `aize_debug.qdp` opens `lucon.ttf` / `arialuni.ttf` with FreeType; Wine ships neither | drop DejaVu in under those filenames | +| Entirely black screen | Wine loads its own `ddraw.dll`, ignoring cnc-ddraw, so StarCraft does a real 640×480 fullscreen mode switch | `ddraw=native,builtin` | +| Black window, correct size | cnc-ddraw's `renderer=auto` draws black here | `renderer=gdi` | + +### Notes + +- The launcher failure shows up in Wine's output as + `err:module:find_forwarded_export module not found for forward + 'icuuc68.u_charsToUChars_68'`. `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1` also + works but discards culture support, so NLS is preferred. +- FreeType opens fonts by path, not by name, so any monospace / wide-coverage + TTF under those two filenames satisfies it. Needs `fonts-dejavu` (or + Liberation / Noto, which the script also accepts). +- `renderer=gdi` and clearing `shader=` were changed together, so which one + mattered is not isolated. The bundled GLSL shader only applies to the OpenGL + renderer, so it is inert under GDI either way — if you want it back, try + `renderer=opengl` first. +- Only the global `[ddraw]` section of `ddraw.ini` is touched; the per-game + preset sections below it are left alone, and CRLF endings are preserved. +- The `ddraw` override is written to `user.reg` directly, so close the game + first — Wine rewrites that file from memory when the last process exits. + +## Verified on + +Bottles `Blizzard` bottle, runner **GE-Proton10-25**, X11, dual 2560×1440. +Plain system wine 10.0 could not boot even vanilla StarCraft here (null call +through `storm.dll` ordinal 313 during 640×480 init), so GE-Proton is the +supported runner. + +Fraud Launcher 2 installs to `C:\Cosmonarchy\`: `Starcraft\` is a stock 1.16.1 +install, `Release\` is a git clone of this repo. The game runs from +`Release\Cosmonarchy BW.exe`, and `_qdp-hotloader.qdp` packs `Release\mpq\` into +`Release\temp.mpq` and loads it alongside the plugins. diff --git a/tools/linux/add-dll-override.py b/tools/linux/add-dll-override.py new file mode 100755 index 00000000..c2b8f57c --- /dev/null +++ b/tools/linux/add-dll-override.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Add a DllOverrides entry to a Wine prefix's user.reg. + +Usage: add-dll-override.py USER_REG DLL VALUE +e.g. add-dll-override.py ~/.../Blizzard/user.reg ddraw native,builtin + +Only safe while no wineserver is live for that prefix - Wine rewrites user.reg +from memory when the last process exits, which would drop the edit. +""" +import sys + +SECTION = r'[Software\\Wine\\DllOverrides]' + + +def main(path, dll, value): + with open(path, encoding='utf-8', errors='surrogateescape') as fh: + text = fh.read() + + entry = '"%s"="%s"\n' % (dll, value) + if entry in text: + print('already set: %s = %s' % (dll, value)) + return 0 + + i = text.find(SECTION) + if i == -1: + text = text.rstrip('\n') + '\n\n' + SECTION + ' 0\n' + entry + else: + j = text.index('\n', i) + 1 # past the section header + while j < len(text) and text[j] == '#': # past #time= and friends + j = text.index('\n', j) + 1 + text = text[:j] + entry + text[j:] + + with open(path, 'w', encoding='utf-8', errors='surrogateescape') as fh: + fh.write(text) + print('set: %s = %s' % (dll, value)) + return 0 + + +if __name__ == '__main__': + if len(sys.argv) != 4: + sys.exit(__doc__) + sys.exit(main(*sys.argv[1:])) diff --git a/tools/linux/patch-ddraw-ini.py b/tools/linux/patch-ddraw-ini.py new file mode 100755 index 00000000..af82e6df --- /dev/null +++ b/tools/linux/patch-ddraw-ini.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Set keys in the [ddraw] section of a cnc-ddraw ddraw.ini. + +Usage: patch-ddraw-ini.py [--check] DDRAW_INI KEY=VALUE [KEY=VALUE ...] + +Only the global [ddraw] section is touched; the hundreds of per-game preset +sections further down the file are left alone. CRLF line endings are preserved, +because cnc-ddraw writes the file back out as a Windows INI. + +Exit status: 0 if the file already matches (or was updated), 1 under --check if +it does not match. +""" +import re +import sys + + +def main(argv): + check = False + if argv and argv[0] == '--check': + check, argv = True, argv[1:] + if len(argv) < 2: + sys.exit(__doc__) + + path, assignments = argv[0], argv[1:] + wanted = {} + for a in assignments: + if '=' not in a: + sys.exit('not a KEY=VALUE pair: %s' % a) + k, v = a.split('=', 1) + wanted[k.strip().lower()] = v + + with open(path, 'rb') as fh: + raw = fh.read().decode('utf-8', 'surrogateescape') + + newline = '\r\n' if '\r\n' in raw else '\n' + lines = raw.split(newline) + + out, in_ddraw, pending, changes = [], False, dict(wanted), [] + for line in lines: + stripped = line.strip() + if stripped.startswith('['): + # leaving [ddraw]: append any keys the section never had + if in_ddraw and pending: + for k, v in pending.items(): + out.append('%s=%s' % (k, v)) + changes.append('added %s=%s' % (k, v)) + pending = {} + in_ddraw = stripped.lower() == '[ddraw]' + elif in_ddraw: + m = re.match(r'^(\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*)=(.*)$', line) + if m and m.group(2).lower() in pending: + key = m.group(2).lower() + want = pending.pop(key) + if m.group(4) != want: + changes.append('%s=%s -> %s=%s' % (m.group(2), m.group(4), m.group(2), want)) + line = '%s=%s' % (m.group(2), want) + out.append(line) + + if in_ddraw and pending: + for k, v in pending.items(): + out.append('%s=%s' % (k, v)) + changes.append('added %s=%s' % (k, v)) + + if not changes: + print('already set: %s' % ', '.join('%s=%s' % kv for kv in wanted.items())) + return 0 + + if check: + for c in changes: + print('would change: %s' % c) + return 1 + + with open(path, 'wb') as fh: + fh.write(newline.join(out).encode('utf-8', 'surrogateescape')) + for c in changes: + print(c) + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/tools/linux/setup-bottle.sh b/tools/linux/setup-bottle.sh new file mode 100755 index 00000000..bd34c073 --- /dev/null +++ b/tools/linux/setup-bottle.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env bash +# Prepare a Bottles/Wine prefix so Fraud Launcher 2 and Cosmonarchy BW will run. +# +# Fixes two things that a stock Wine prefix gets wrong: +# +# 1. Fraud Launcher 2 (.NET 8 WPF) aborts at startup because Wine's icu.dll +# forwards to icuuc68.dll, which does not exist: +# err:module:find_forwarded_export module not found for forward +# 'icuuc68.u_charsToUChars_68' used by L"C:\\windows\\system32\\icu.dll" +# Setting DOTNET_SYSTEM_GLOBALIZATION_USENLS=1 makes .NET use Windows NLS +# instead of ICU. (DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 also works but +# throws away culture support, so prefer NLS.) +# +# 2. aize_debug.qdp initialises its console with FreeType against two Windows +# system fonts that Wine does not ship, and dies with "Fonts could not be +# loaded": +# %systemroot%\fonts\lucon.ttf (Lucida Console) +# %systemroot%\fonts\arialuni.ttf (Arial Unicode MS) +# FreeType opens these by path, not by name, so any monospace / wide-coverage +# TTF dropped in under those filenames satisfies it. +# +# 3. Cosmonarchy ships cnc-ddraw (ddraw.dll beside StarCraft.exe), but Wine +# loads its own builtin ddraw.dll unless told otherwise, so cnc-ddraw is +# silently ignored. StarCraft then performs a real exclusive-fullscreen +# 640x480 DirectDraw mode switch, which on a multi-monitor X11 desktop +# shows up as an entirely black screen. Overriding ddraw to native,builtin +# makes Wine load cnc-ddraw so the game renders in a normal window. +# +# 4. With cnc-ddraw actually loaded, its default renderer=auto still draws a +# black window here; renderer=gdi renders correctly. The bundled GLSL +# shader only applies to the OpenGL renderer, so it is cleared alongside. +# (Both were changed together, so which one mattered is not isolated - if +# you want the shader back, try renderer=opengl first.) +# +# Usage: +# tools/linux/setup-bottle.sh [--check] [--bottle NAME | --prefix PATH] +# +# --check report what is missing, change nothing +# --bottle NAME Bottles bottle name (default: Blizzard) +# --prefix PATH a plain WINEPREFIX, instead of a Bottles bottle +# --game-dir PATH the StarCraft directory holding ddraw.ini +# (default: read from the prefix's registry InstallPath) +# +set -euo pipefail + +BOTTLE=Blizzard +PREFIX= +GAMEDIR= +CHECK=0 + +while [ $# -gt 0 ]; do + case "$1" in + --check) CHECK=1; shift ;; + --bottle) BOTTLE="${2:?--bottle needs a name}"; shift 2 ;; + --prefix) PREFIX="${2:?--prefix needs a path}"; shift 2 ;; + --game-dir) GAMEDIR="${2:?--game-dir needs a path}"; shift 2 ;; + -h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +if [ -z "$PREFIX" ]; then + for base in \ + "$HOME/.var/app/com.usebottles.bottles/data/bottles/bottles" \ + "$HOME/.local/share/bottles/bottles" + do + if [ -d "$base/$BOTTLE" ]; then PREFIX="$base/$BOTTLE"; break; fi + done +fi + +if [ -z "$PREFIX" ] || [ ! -d "$PREFIX/drive_c" ]; then + echo "error: no Wine prefix found (bottle '$BOTTLE')." >&2 + echo " pass --bottle NAME or --prefix /path/to/prefix" >&2 + exit 1 +fi + +echo "prefix: $PREFIX" +rc=0 + +note() { if [ "$CHECK" = 1 ]; then echo " MISSING $1"; rc=1; else echo " fixed $1"; fi; } +ok() { echo " ok $1"; } + +# --- 1. fonts ----------------------------------------------------------------- +# Each entry is "target-filenamecandidate source paths, first match wins". +install_font() { + local target="$1"; shift + local fontdir dest src + fontdir="$PREFIX/drive_c/windows/Fonts" + [ -d "$fontdir" ] || fontdir="$PREFIX/drive_c/windows/fonts" + mkdir -p "$fontdir" + dest="$fontdir/$target" + + if [ -s "$dest" ]; then ok "$target"; return 0; fi + + if [ "$CHECK" = 1 ]; then note "$target"; return 0; fi + + for src in "$@"; do + if [ -s "$src" ]; then + cp -- "$src" "$dest" + note "$target <- $(basename "$src")" + return 0 + fi + done + + # last resort: ask fontconfig for anything matching + if command -v fc-match >/dev/null 2>&1; then + src=$(fc-match -f '%{file}' "${FC_QUERY:-sans}" 2>/dev/null || true) + if [ -n "$src" ] && [ -s "$src" ]; then + cp -- "$src" "$dest" + note "$target <- $(basename "$src") (fontconfig)" + return 0 + fi + fi + + echo " FAILED $target - no source font found; install fonts-dejavu" >&2 + rc=1 +} + +echo "fonts (for aize_debug.qdp's FreeType console):" +FC_QUERY=monospace install_font lucon.ttf \ + /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf \ + /usr/share/fonts/dejavu/DejaVuSansMono.ttf \ + /usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf \ + /usr/share/fonts/liberation/LiberationMono-Regular.ttf +FC_QUERY=sans install_font arialuni.ttf \ + /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf \ + /usr/share/fonts/dejavu/DejaVuSans.ttf \ + /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf \ + /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf + +# --- 2. .NET globalization (Fraud Launcher 2) --------------------------------- +echo ".NET globalization (for Fraud Launcher 2):" +VAR=DOTNET_SYSTEM_GLOBALIZATION_USENLS +YML="$PREFIX/bottle.yml" + +if [ -f "$YML" ]; then + if grep -q "^ *$VAR:" "$YML"; then + ok "$VAR set in bottle.yml" + elif [ "$CHECK" = 1 ]; then + note "$VAR in bottle.yml" + else + cp -- "$YML" "$YML.bak-$(date +%Y%m%d%H%M%S)" + python3 - "$YML" "$VAR" <<'PY' +import re, sys +path, var = sys.argv[1], sys.argv[2] +s = open(path, encoding='utf-8').read() +line = " %s: '1'\n" % var +if re.search(r'^Environment_Variables:\s*\{\}\s*$', s, re.M): + s = re.sub(r'^Environment_Variables:\s*\{\}\s*$', + "Environment_Variables:\n" + line.rstrip('\n'), s, count=1, flags=re.M) +elif re.search(r'^Environment_Variables:\s*$', s, re.M): + s = re.sub(r'^(Environment_Variables:\s*\n)', r'\1' + line, s, count=1, flags=re.M) +else: + raise SystemExit("could not find Environment_Variables: in %s" % path) +open(path, 'w', encoding='utf-8').write(s) +PY + note "$VAR in bottle.yml (backup written alongside)" + fi +else + # plain WINEPREFIX: no bottle.yml to edit, so tell the caller how to export it + if [ "$CHECK" = 1 ]; then + note "$VAR (no bottle.yml; export it yourself)" + else + echo " note no bottle.yml here - run the launcher with:" + echo " $VAR=1 wine 'Fraud Launcher 2.exe'" + fi +fi + +# --- 3. ddraw override, so Wine loads cnc-ddraw rather than its own builtin ---- +echo "ddraw override (cnc-ddraw):" +REG="$PREFIX/user.reg" +HELPER="$(dirname "$0")/add-dll-override.py" +if [ ! -f "$REG" ]; then + echo " FAILED no user.reg in $PREFIX" >&2 + rc=1 +elif grep -q '^"ddraw"=' "$REG"; then + ok "ddraw = $(grep -m1 '^\"ddraw\"=' "$REG" | cut -d= -f2- | tr -d '\"')" +elif [ "$CHECK" = 1 ]; then + note "ddraw=native,builtin in user.reg" +elif pgrep -f "wineserver" >/dev/null 2>&1 && pgrep -af wineserver | grep -qF "$PREFIX"; then + echo " SKIPPED a wineserver is live for this prefix - close the game and re-run" >&2 + rc=1 +elif [ ! -f "$HELPER" ]; then + echo " FAILED helper not found: $HELPER" >&2 + rc=1 +else + cp -- "$REG" "$REG.bak-$(date +%Y%m%d%H%M%S)" + python3 "$HELPER" "$REG" ddraw native,builtin >/dev/null + note "ddraw=native,builtin in user.reg (backup written alongside)" +fi + + +# --- 4. cnc-ddraw renderer ---------------------------------------------------- +echo "cnc-ddraw renderer:" +if [ -z "$GAMEDIR" ]; then + # HKLM\Software\Wow6432Node\Blizzard Entertainment\Starcraft -> InstallPath + winpath=$(grep -a -m1 '^"InstallPath"=' "$PREFIX/system.reg" 2>/dev/null \ + | sed 's/^"InstallPath"="//; s/"$//') + if [ -n "$winpath" ]; then + rel=$(printf '%s' "$winpath" | sed 's/\\\\/\//g; s|^[A-Za-z]:||; s|/*$||') + GAMEDIR="$PREFIX/drive_c$rel" + fi +fi + +INI="$GAMEDIR/ddraw.ini" +PATCHER="$(dirname "$0")/patch-ddraw-ini.py" +if [ -z "$GAMEDIR" ] || [ ! -f "$INI" ]; then + echo " SKIPPED no ddraw.ini found${GAMEDIR:+ at $GAMEDIR}; pass --game-dir" >&2 +elif [ ! -f "$PATCHER" ]; then + echo " FAILED helper not found: $PATCHER" >&2 + rc=1 +elif python3 "$PATCHER" --check "$INI" renderer=gdi shader= >/dev/null 2>&1; then + ok "renderer=gdi in $(basename "$GAMEDIR")/ddraw.ini" +elif [ "$CHECK" = 1 ]; then + note "renderer=gdi in $(basename "$GAMEDIR")/ddraw.ini" +else + cp -- "$INI" "$INI.bak-$(date +%Y%m%d%H%M%S)" + python3 "$PATCHER" "$INI" renderer=gdi shader= >/dev/null + note "renderer=gdi, shader= in $(basename "$GAMEDIR")/ddraw.ini (backup written alongside)" +fi + + +echo +if [ "$CHECK" = 1 ] && [ "$rc" != 0 ]; then + echo "check failed: run without --check to apply." +elif [ "$rc" = 0 ]; then + echo "bottle ready." +fi +exit $rc