Files
llamamod/notes/llamamod-app.md
T

26 KiB
Raw Blame History

LlamaMod – the mod manager app

Single-click mod manager for The Emperor's New Groove (PC, 2000; German: Ein Königreich für ein Lama). Java 25 + Swing + FlatLaf, runs on Linux (game through Wine) and Windows. UI language: English; all strings are in src/main/resources/com/eliasmueller/llamamod/ui/messages.properties (a translation is another file such as messages_de.properties, selected with -Dllamamod.lang=de).

Build

./gradlew spotlessApply build       # compile + tests
./gradlew fatJar                    # build/libs/llamamod-<ver>-all.jar (fatJarLauncher: …-launcher.jar)
./gradlew packageLinux              # build/dist/LlamaMod-<ver>-full-linux-x64.tar.gz (jlinked runtime)
./gradlew packageWindows            # build/dist/LlamaMod-<ver>-full-windows-x64.zip (LlamaMod.exe + runtime)
./gradlew packageAll                # both editions: …-full-… and …-launcher-… for Linux and Windows

Version: single source version in build.gradle → manifest Implementation-Version, launch4j version info, package names and the generated build-info.properties (task buildInfo: version and build = first 10 hex digits of SHA-256 over the compiled classes and src/main/resources except edition.properties; deterministic, so an unchanged rebuild keeps the id). Read by BuildInfo (UI header, editor About, --version, --help, mod cache key). Editions: full (editor) and launcher (edition.properties editor=false, generated by launcherEdition; same classes, only the editor UI/commands are hidden). Launcher tasks have the suffix Launcher (fatJarLauncher, packageLinuxLauncher, packageWindowsLauncher).

Both packages are built on Linux. fetchJdk downloads (once, into ~/opt, override with -Pllamamod.opt=<dir>): the Temurin JDK 25.0.5+8 for Linux (its jlink), and the Temurin jmods archives for Linux and Windows of exactly the same version (JDK 25 itself ships without jmods, JEP 493; cross-linking works when versions match). exeWindows downloads launch4j 3.50 (Linux build) and wraps the fat jar into LlamaMod.exe, which starts runtime\bin\javaw.exe. Runtime modules: java.base, java.desktop, java.prefs, java.logging, jdk.charsets, jdk.zipfs (+ jdk.crypto.mscapi on Windows).

Command line (scripting/tests)

llamamod --version   --install <iso|folder>   --mods   --launch [--wait]
llamamod --apply [id,id] [--opt id.key=value ...]
llamamod --registry   --saves   --unlock <slot 1-3> <world> <chapter> [--cursor]
llamamod --import-level <file>   --remove-level <id|file>

--mods lists every mod with category and its options (current value, type, range, default). --opt values are validated and saved in config.json like the GUI does. Errors (e.g. "The game is running") are printed as Error: … with exit code 1. Data folder override: -Dllamamod.data=<dir> or LLAMAMOD_DATA (not persisted). Test-only system properties: llamamod.extraMods=<class,...> (extra Mod classes), llamamod.gameDisplay=:5 (X display for the game, Linux).

Data folder

Default ~/.local/share/LlamaMod ($XDG_DATA_HOME), Windows %LOCALAPPDATA%\LlamaMod; a different folder chosen in the wizard is remembered via java.util.prefs.

Path Content
config.json settings, enabled/active mods, mod option values (modOptions), CD fingerprint
.config.lock, .apply.lock file locks: config merge-on-save, apply/restore
original/hd, original/cd pristine copy of Setup/<lang>/Hd and …/Cd from the ISO
game/ copy of the HD part the game runs from: patched groove.exe, DLLs, music, save games
cd-active/ what the game sees as its CD: hard links to originals + mod outputs
cache/<mod>/<key>/ mod outputs + manifest.json
backups/saves/<timestamp>/ save game backups (taken before every write)
levels/ level projects = level mods (.playtest/: temporary editor play-test copies)
backups/levels/<timestamp>/ level mods removed with "Remove…" / --remove-level
wine/ own Wine prefix (Linux)
logs/ game.log, wineboot.log, registry.log

Config merge-on-save: Config.save takes a file lock on .config.lock (plus a JVM-wide monitor), re-reads config.json and three-way merges field by field against the values this instance last loaded/saved: fields it did not change keep what another window/CLI/editor wrote meanwhile (mod options merge per mod and key; unknown keys of newer versions are kept); a field changed on both sides = last writer wins. The object then holds the merged values. A new Config() that never touched a file overwrites everything (installer, tests).

First start

The wizard takes an ISO file, an extracted folder or a mounted CD. ISO 9660 is read in pure Java (platform/IsoImage, Joliet names when present, else Rock Ridge names, multi-extent files; all-upper-case HD names of plain ISO 9660 copies are installed lower-case). The game folder is found by looking for …/Hd/groove.exe next to …/Cd/wads/. After copying, platform/Integrity runs the same checks as deb-build/build.sh (FESOUND.WAD length field, "OFNI" at offset 4 of every level WAD, music/english.cvs must not end in 4 MiB of zeros); damaged copies are rejected. The ISO also contains three (null)king*.ksv leftovers; they are not copied.

Registry, CD path and the exe patch

All registry access of groove.exe goes through five small wrappers, always with HKEY_LOCAL_MACHINE\Software\Disney Interactive\Emperors New Groove\1.0:

Function Purpose
FUN_00405790 read string (CDPath, HDPath, DisplayDevice, D3DDevice)
FUN_004057c0 read number (ScreenW/H/BPP, SoundVolume, MusicVolume, …)
FUN_00405800 / FUN_00405830 write string / float
FUN_00405870 write DWORD

Each contains 68 90 E0 56 00 (push offset subkey string, VA 0x56E090) followed by 68 02 00 00 80 (push HKEY_LOCAL_MACHINE). FUN_00405960 opens the key and creates it if missing.

Patch (platform/ExePatcher, applied only to game/groove.exe, never the original): the byte 02 of each push 80000002h becomes 01 (HKEY_CURRENT_USER), so no admin rights are needed on Windows. German CD (groove.exe MD5 697d67a31c20079838c662d8187dbd86, 1,642,496 bytes), file offsets of the changed bytes: 0x579B, 0x57D0, 0x5810, 0x5859, 0x5880 (original 02, new 01; patched MD5 6199a42eb7582b2a70b1ac6e29f00f43). The patcher searches the pattern (push of the subkey string VA + push HKLM) instead of fixed offsets, so other language builds work if their code is the same.

File lookup (FUN_00415690): tries HDPath\dir\file, then CDPath\dir\file (checked with _access), and only then loops over drive letters with GetDriveTypeA == DRIVE_CDROM. A plain folder in CDPath is therefore enough – no CD drive, no drive-type check.

Values LlamaMod writes before every start (Windows: reg import, Linux: wine regedit /S, both from a UTF-16 .reg file):

Value Content
CDPath absolute path of cd-active\ (Linux: L:\cd-active\, drive L: = data folder in our prefix)
HDPath .\ – save games are named sprintf("%sking%d.ksv", HDPath); without HDPath MSVCRT prints (null)king0.ksv. Existing (null)kingN.ksv files are migrated into empty slots.
ScreenW / ScreenH resolution (automatic = main screen; with a Wine virtual desktop, minus panels and title bar)

The game must run with the game folder as working directory (it saves relative to it). Path limits: the game builds file names in a 260-byte buffer (0x583748) and uses ANSI file APIs, so CDPath and the game folder must be ≤ 200 characters and encodable in the ANSI code page. On Linux the data folder is mapped as drive L: (wine/dosdevices/l:), which keeps both short and ASCII; on Windows the launcher refuses other paths with a message (choose a folder like C:\Games\LlamaMod).

Launching

  • Windows: game\groove.exe, cwd = game folder. The game has no windowed mode.
  • Linux: own prefix wine/, created with WINEARCH=win32 (falls back to the default arch if this Wine cannot create win32 prefixes; system.reg only appears after wineserver -w). WINEDLLOVERRIDES=winemenubuilder.exe=d;mscoree=d;mshtml=d (no menu entries, no Mono/Gecko prompts). Optional virtual desktop: wine explorer /desktop=LlamaMod,WxH Z:\…\groove.exe. Wine missing → clear error message. wine --version (cached per command) is shown in Settings; below GameLauncher.MIN_WINE = 5.0 the line turns red and Play warns once per session ("Play anyway"); unparsable output never warns or blocks. Only Wine 6.0.3 is actually tested; 5.0 is a guess at the oldest usable version. Running instances are detected via /proc/*/environ (WINEPREFIX=<our prefix>), so two games never share the prefix.

Mods

mods/ModRegistry loads built-in mods by class name (missing classes are skipped): mods.debug.DebugWireframeMod, mods.testworld.TestWorldMod, plus ServiceLoader mods. mods/ModManager applies the enabled mods in order() (then id) against an overlay context: reads see original + earlier mods, writes go to cache/. Paths are case-insensitive and mapped to the CD's spelling; list(dir) returns CD-root-relative paths. Cache key of a mod = SHA-256(key of the input state | id | version [| options as sorted JSON]), the chain starts at SHA-256(CD fingerprint (names and sizes) | app version+build id | ExePatcher.REVISION), so a hit skips the mod, and an app update re-runs every mod once (a changed mod without a version() bump never serves stale output). Manifests store "app"; after each apply, cache entries of other app builds (including all of 0.1.0, which have no "app") are deleted. Then cd-active is rebuilt in cd-active.new and swapped in (hard link → symlink (not Windows) → copy). Once cd-active.new is complete, cd-active.new.complete (content: the activeKey of that build) is written; it is removed after the swap. If the app is killed between the two renames, ModManager.recoverInterruptedApply() (GUI start, Play, CLI --launch) moves cd-active.new in when its marker matches config.json's activeKey (exe and config are only written after the swap, so they still describe the previous selection), otherwise moves cd-active.old back; without either nothing is touched (Play then restores the original as before). Applying (and restoring) is refused while the game runs (GameLauncher.isRunning()) and holds an exclusive lock on <data>/.apply.lock (a second window/CLI/editor gets "Another LlamaMod window or command is applying mods"); every run is logged to logs/apply.log.

Writing a mod

public interface Mod {
    String id(); String name(); String description();
    default String category() { return OTHER; }          // LEVELS, GAMEPLAY, VISUAL, CHEATS, OTHER
    default List<ModOption> options() { return List.of(); }
    default int order() { return 100; }
    default String version() { return "1"; }            // bump to invalidate cached outputs
    void apply(ModContext ctx) throws IOException;
}

Register built-in mods in ModRegistry.BUILTIN. The UI groups mods by category (in the order above, collapsible headers with "n of m on") and sorts them by name inside a group.

Options (mods/ModOption, record (key, label, description, type, defaultValue, min, max, choices)): factories ModOption.bool(key, label, desc, def), integer(key, label, desc, def, min, max), string(…, def), choice(key, label, desc, def, ModOption.of(value, label)…), seed(key, label, desc, def) (empty default = a random seed is chosen on first use and saved), multi(key, label, desc, def, ModOption.of(value, label)…) (any subset; value all, none or the chosen values comma-separated in choice order, e.g. 1,3; empty reads as all, so a former STRING option holding "all"/"1,3" keeps its value and cache key; o.selected(value) → list of chosen values; UI: a grid of check boxes; first user: mirror worlds). In apply: ctx.option(key) (normalized string), ctx.optionBool, ctx.optionInt, ctx.optionSeed (long: numeric text as is, other text hashed). Values live in config.json (modOptions.<id>.<key>, only user-set values and generated seeds); invalid stored values fall back to the default. The UI shows the editors under the mod card while the mod is enabled (checkbox / spinner / combo box / text field / seed field with "Random"); any change marks the selection as pending ("Apply" needed, compared via ModManager.selectionKey() against activeKey).

Unit tests of a mod: src/test/…/mods/TestModContext(cdDir, mod, optionOverrides, exeImageOrNull) reads a CD folder, collects writes in ctx.written, resolves options (defaults + overrides).

groove.exe patches and code caves (exe/ExeImage, exe/X86)

game/groove.exe is rebuilt on every apply: original/hd/groove.exe → HKCU base patch (platform/ExePatcher, owner "base", always first) → exe changes of the enabled mods in apply order. Without exe mods the result is byte-identical to the old ExePatcher output; "Restore original" goes back to it. A mod gets the shared image with ctx.exe(); all changes are attributed to the mod id, journaled into the mod's cache manifest ("exe": [...]) and replayed (with full verification) on a cache hit. Every patch/allocation is logged in apply.log (exe <mod>: patch 0x… n bytes – label).

API Purpose
sections(), section(".text"), imageBase(), entryPoint() PE layout
fileOffset(va) / va(fileOffset) conversion (-1 if not file-backed, e.g. .bss)
read(va, n), readU8, readU32, findString(s) read current bytes
find("8B 0D ?? ?? ?? ?? 85 C9"), find(".text", pattern), findUnique(pattern) byte patterns, ?? = any byte
findImport(dll, fn) IAT slot of an imported function
importAddress(dll, fn) IAT slot, or for functions groove.exe does not import (GetAsyncKeyState, GetKeyState: user32 imports only window functions) a cave slot filled at startup by a loader stub (LoadLibraryA + GetProcAddress, runs before the original entry point 0x563EE0) – call with call dword [slot] either way
patch(va, "expected hex (?? ok)", "replacement hex", label) / byte[] variant verifies the current bytes, refuses overlap with another mod's patch or cave block (ExePatchException with both owners and addresses)
allocate(size, label) → VA zeroed, 16-byte aligned code block in section .llamac (VA 0x6EF000+, read + execute, not writable, max CODE_RESERVE = 128 KiB)
allocateData(size, label) → VA zeroed, 16-byte aligned data block in section .llamad (VA 0x70F000+ = caveStart + CODE_RESERVE, read + write, not executable)
write(va, bytes, label), place(bytes, label), placeData(bytes, label) fill own cave blocks (build time); place = read-only data in the code section, placeData = initialized variables
caveStart(), dataStart(), nextAllocation(), nextDataAllocation(), inDataSection(va) cave addresses
emit(label, a -> …) → VA assemble with X86 at the next cave address and place it
hookJmp(site, expectedHex, target, label) / hookCall(…) E9/E8 rel32 over ≥5 bytes, rest NOPs
ExeImage.callRel32(from, to) raw encoding

Runtime-writable memory must come from allocateData/placeData. Cave code that writes to an allocate/place/emit block at runtime (counters, flags, saved registers, toggles) now faults with an access violation, because .llamac is not writable. Strings and constant tables may stay in the code section. Why: a section that is writable and executable (the old RWX .llama) is a classic antivirus heuristic. Layout: both sections are appended at the file end (SizeOfImage, NumberOfSections, SizeOfCode, SizeOfInitializedData updated, checksum 0); with a data section the code section's VirtualSize spans the whole reserve so the sections stay contiguous in memory (Windows rejects gaps); a code-only result has just .llamac. The loader's import slots (importAddress for non-imported functions) live in .llamad. Cache manifests mark data blocks with "data": true; old manifests whose import slot was in the code cave fail the replay and the mod simply runs again.

X86 (origin VA): hex("60 9C"), u8/u16/u32, asciiz, push(imm), call(va), jmp(va), jcc(X86.JNE, va), callPtr(slot), pushad/popad/pushfd/popfd/ret/ret(n), align(n), labels (label(), bind(l), jmp(l), jcc(cc, l), call(l)). A typical detour: long fn = exe.emit("x", a -> a.pushad()…popad().hex(<stolen bytes>).jmp(site + n)); exe.hookJmp(site, "<stolen bytes>", fn, "x") – stolen bytes must not contain relative jumps/calls. The exe has a .reloc section but no ASLR flag, so it always loads at 0x400000 (absolute addresses in cave code are fine). Tests: exe/ExeImageTest (real exe, re-parse + objdump -h), mods/ModOptionsAndExeTest; in-game check: mods/ExeSmokeMod (test classes, see below; its call counter is an allocateData block).

Antivirus false positives

A changed groove.exe with extra code may be flagged by antivirus programs (Windows Defender). Mitigations: no RWX section (see above), Mod.modifiesExecutable() (default false; override it to true in mods that call ctx.exe() so the user is warned before the first apply), and ModManager.modifiesExecutable(mod) = declared or recorded (config.json exeMods: mods whose journal was non-empty at their last apply). The UI shows a badge "modifies groove.exe" on such cards, --mods appends [modifies groove.exe].

  • Notice (ui/AntivirusDialogs.notice): when applying a selection with such mods (and once more after an apply that turned out to patch the exe), once per session; explains the false positive, that the original files are untouched, shows the data folder with a Copy button and the Defender exclusion path; "Don't show again" → antivirusNoticeDismissed in config.json.
  • Detection: every apply stores SHA-256 of the written exe (gameExeSha256). ModManager.checkGameExe() → OK | MISSING | LOCKED | CHANGED runs after apply/restore and before every launch (GUI and --launch/--apply CLI). On Windows, CreateProcess errors 225 (ERROR_VIRUS_INFECTED), 226, 5 (access denied) and 2 raise GameLauncher.BlockedException. Both show "groove.exe was removed or changed – probably by antivirus software" with Rebuild (re-applies the active mods), Open data folder and the exclusion hint.

antivirus notice

World 9 ("TestLevel"): if the result contains wads/T1L9M001.wad, chapters 2–49 (T1L9M002..049.wad) are linked to it and T192..T1949.COR to T191.COR; a missing T191.COR is taken from t111.cor (see levelbau.md: the exit unlocks chapter n+1 and the map crashes if its WAD is missing).

Save games (KSV, 256 bytes)

Decoded from FUN_0041c640 (load) / FUN_0041c820 (save); details in save/Ksv.java.

Offset Meaning
0x00 used flag (0 = empty, shown as "LEER")
0x01 lives (0x584e6c >> 12, new game 5)
0x02 energy (0x584e70 >> 12, reset to 5 on level start)
0x03–0x06 map cursor: tribe (1), world, chapter, map (0)
0x07 gold coin count (= chapters with flag bit 1, recomputed by FUN_0041cd70)
0x08–0x3D u8[9][6] chapter flags, index world·6+chapter: bit 0 collectible (strat var 0xFA), bit 1 gold coin
0x3E–0x41 last played: tribe, world, chapter, map
0x42 / 0x43 highest unlocked world / chapter
0x44–0xAF u16[9][6] coins per chapter (stored as coins<<12, truncated to 16 bits → coins mod 16)
0xB0–0xCF unknown 32-byte buffer (0x584e80)
0xD0–0xD9 options: effects volume, music volume, 0x80, speaker mode, 6 more option bytes

Chapters per world (table 0x572420): 4, 3, 4, 3, 3, 3, 5, 5, 49. World 9 has no row in the per-chapter arrays. The KSV files shipped on the CD are empty (0x00 then 0xCC). Selecting an empty slot and "new game" immediately writes a zeroed file.

Tests done (2026-11-06)

  • Linux (Wine 6.0.3, Xvfb): ISO → wizard → install (all 175 CD files identical to the reference copy) → mods (test mod + real DebugWireframeMod, 33 WADs in ~4 s) → save editor (slot set to TestLevel) → Play → game reaches menu, slot shows "TestLevel Kapitel 1", the modded world-9 WAD loads; Village shows the debug wireframe. Saves are written as king1.ksv (HDPath fix works); registry values land in HKCU only.
  • Windows build under Wine 6.0.3 (win64 prefix): UI, wizard and install work, cd-active uses hard links, game starts from the Windows code path (reg import, HKCU only, CDPath with spaces) and reaches a level. Wine 6 quirks: JDK 25 needs -XX:ActiveProcessorCount=n (GetProcessGroupAffinity missing) – set JDK_JAVA_OPTIONS for testing; launch4j's own javaw -version check then hangs under Wine 6, so the app was started with runtime\bin\javaw.exe -jar LlamaMod.exe (the exe is a valid jar). On Windows the app makes SunMSCAPI the first provider (OS RNG; the default DRBG seeding enumerates network interfaces, which crashes old Wine).
  • Audio in automated runs: a private PulseAudio daemon with only a null sink (PULSE_SERVER=unix:…), because PulseEffects on the desktop moves new streams away from a null sink on the main server. Disabling the Wine audio driver is not an option: the game crashes at 0x0054696D (FUN_00546960 writes to the NULL voice channel) when a voiced dialog starts.

Troubleshooting: Windows in a virtual machine

Tested in a VirtualBox 7 Windows 10 guest (VBoxSVGA, 3D acceleration on). LlamaMod itself works (install, exe patch, HKCU registry, cd-active, mods – the debug wireframe was visible in the demo), but the guest's legacy DirectX 6/7 support does not:

Symptom Cause Workaround
"Direct Draw kann nicht gestartet werden." device enumeration finds no usable D3D HAL device (-display_diagnostics writes <PC>.dispdiag next to the exe) use a DirectDraw wrapper
"…muss DirectX7 installiert sein…" with dgVoodoo2 (WARP or D3D11) DirectDrawCreateEx(IID_IDirectDraw7) fails inside dgVoodoo's DDraw.dll not solved; use WineD3D instead
WineD3D for Windows: game runs, picture upside down, no keyboard input VirtualBox OpenGL flips FBO blits; OffscreenRenderingMode=backbuffer is ignored by newer WineD3D untested: older WineD3D (Wine 3.x based) or dxwrapper with Dd7to9 = 1 (D3D9 path)

On real hardware no wrapper should be needed (unverified).

Testing mods in game (tools/apptest.py)

Runs the game through the LlamaMod app (fat jar CLI), invisible and muted, one isolated session per name: own data folder (<root>/<name>/data, = LLAMAMOD_DATA, own Wine prefix and save games), own Xvfb display, own PulseAudio daemon with only a null sink (PulseEffects on the desktop steals streams from null sinks on the main server). Root: $APPTEST_ROOT, default /home/elias/.claude/jobs/90c6eed7/tmp. The original game data is installed once from ~/Downloads/GROOVE_DK.iso into <root>/_llamamod-template (file lock, so parallel agents wait) and hard-linked into each session (~30 MB per session + ~200 MB Wine prefix on first start). ~/.wine-lama and the user's own game are never touched.

./gradlew fatJar                      # the harness uses the newest build/libs/*-all.jar (--jar to override)
T=tools/apptest.py
$T run NAME --display 5 --mods fog,testworld --opt fog.strength=2 --level 1 1 \
   --seq "Up:1.5 shot:walk a+x:0.5 sleep=1 shot:after" --out DIR [--keep]
      # setup + apply + unlock slot 3 at world/chapter (cursor) + start + tolevel + keys + stop
      # exit 0 ok, 3 crash, 4 timeout; DIR: report.json, 00_start.png, seq_NN_*.jpg, 99_end.jpg,
      # zz_failure.jpg, game.log, apply.log
$T setup NAME                         # install session (fast after the template exists)
$T apply NAME fog,mirror --opt fog.strength=0 --opt mirror.on=true   # no ids = original
$T unlock NAME 3 WORLD CHAPTER        # slot 3, map cursor on WORLD/CHAPTER (do this before start)
$T start NAME --display 5             # Xvfb + private pulse + `--launch`; waits for window and audio
$T tolevel NAME --world 1             # menu "Spiel starten" -> slot 3 -> map -> Enter -> skip fly-by
$T keys NAME Up:1.5 x a+x:0.5 sleep=2 shot:name dialog --out DIR   # KEY[:hold s], default hold 0.2 s
$T shot NAME out.png [--small]        # 1920x1080 PNG, --small = 960x540 JPEG
$T ocr NAME [WxH+X+Y]                 # German tesseract OCR
$T status NAME                        # JSON: game pids, window, audio clients, crash (exit 3)
$T stop NAME                          # wineserver -k (own prefix), kill own Xvfb/pulse by PID

Python: sys.path.insert(0, "tools"); from apptest import Session; s = Session("x", display=7); s.setup(); s.apply(["fog"], ["fog.strength=2"]); s.unlock(3, 1, 1); s.start(); s.tolevel(world=1); s.key("Up", 1.5); s.shot("a.png"); print(s.crashed()); s.stop().

  • Display: the user watches :5 with a viewer; parallel agents use their own numbers (7, 8, …). start refuses a display that is in use and never uses :0; xdotool only talks to the session's display.
  • Unregistered mods: APPTEST_CP=build/classes/java/test APPTEST_JAVA_OPTS="-Dllamamod.extraMods=<class>" (classpath = jar + APPTEST_CP, also works for build/classes/java/main of a work in progress). Example (exe pipeline smoke test, passes): APPTEST_CP=build/classes/java/test APPTEST_JAVA_OPTS="-Dllamamod.extraMods=com.eliasmueller.llamamod.mods.ExeSmokeMod" $T run fw --display 5 --mods exe-smoke,testworld --level 9 1 --out DIR; with --opt exe-smoke.crash=true it faults on purpose and run exits 3 with "Unhandled page fault … at address 006EF012".
  • Crash detection (crashed()): groove.exe process of the prefix gone, "Unhandled exception/page fault" in data/logs/game.log, winedbg running, or the window gone. With the Wine virtual desktop (always on in sessions, 1920x1080) Wine error dialogs are drawn inside the desktop window, so they are found through game.log/winedbg rather than X window titles.
  • Audio check: status lists application.name of the sink inputs on the private server; the game shows up as groove.exe (binary "wine").
  • Keys: hold ≥0.12 s (DirectInput misses shorter presses); default 0.2 s, menu navigation 0.3 s. Controls are camera-relative (see testharness.md for movement measurements).
  • Navigation timing (Wine 6, llvmpipe): menu ~6 s, slot ~14 s, gameplay ~25 s after start. tolevel(world=9) waits for the beige world-9 map, other worlds for a stable picture.
  • Session state (PIDs, display): <root>/<name>/harness/state.json; logs xvfb.log, pulse.log there, game output in data/logs/game.log. Delete <root>/<name> to start from scratch.