Files
llamamod/notes/llamamod-app.md
T

35 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). save returns true when the merge pulled in values written elsewhere; Config.refresh(file) does the same merge without writing (unsaved changes of this instance are kept). The launcher refreshes when its window is activated and after a save that pulled something in, then rebuilds the Mods and Settings tabs (status "Took over changes made in another LlamaMod window.", transient: after 6 s the previous status comes back, ui/StatusLine.flash), so a stale window's next click cannot undo another window's selection. The Mods tab rebuild keeps keyboard focus, caret and selection of the option the user is typing in (ui/FocusKeeper: inputs are tagged modId/optionKey; FlatLaf's select-all on focus gain is suppressed for the restored field, it would eat the next key). Short windows: header 150 px from 860 px window height, 104 px below, 92 px below 700 px. The Saves tab shows the table legend ("Coins: …") in the left column, so at the 820×640 minimum the chapter table still shows 4 rows (7 at 720 px; img/app/saves-640.jpg). Screenshots: img/app/two-windows-before.jpg / -after.jpg (right window ticked Test World, left window after a click into it), img/app/interrupted-apply-notice.jpg, img/app/saves-min-width.jpg, img/app/settings-min-width.jpg (hints are ui/WrapLabels: they wrap at the panel width, the Settings tab tracks the viewport width, the header subtitle is shortened in the middle before the buttons).

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. Wizard progress: the bar follows the copied bytes exactly (Installer.COPY_SHARE of the install progress is the copy), so "Copying 553 of 568 MB" stands next to 97 %; the short steps after it (check, game folder, re-applying mods) show a busy bar without a percentage (img/app/wizard-copy-mb-fixed.jpg).

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; MD5 with only this part 6199a42eb7582b2a70b1ac6e29f00f43; the base patch also relocates the world-9 level records, see "groove.exe patches" below). The patcher searches the pattern (push of the subkey string VA + push HKLM) instead of fixed offsets; the English build has the changed bytes at the same file offsets. Supported builds: see "groove.exe versions" below.

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). It returns a Recovery: NONE, FINISHED (new folder moved in), RESTORED (old folder back), BUSY (another window/CLI holds .apply.lock: BusyException), FAILED. The launcher shows FINISHED/RESTORED once (status line + small dialog: "The last apply was interrupted; LlamaMod finished it." / "…restored the previous mod selection."); after BUSY it checks again every 3 s and on window activation while idle, and Play reports the lock instead of starting (ui/RecoveryNotice). CLI --launch prints the same texts. 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 ExePatcher.patch (installer output); "Restore original" goes back to it. The base patch (REVISION 2) = HKCU + relocateLevelRecords (world-9 coin/flag arrays moved into .data's page slack – English build: .rdata's page slack, and .rdata's section flags get the write bit, because its .data slack is only 43 bytes; 4-byte address operands only, see testlevel-chapters.md); ExePatcher.changes lists both as byte runs. 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).

groove.exe versions (exe/GameExe, exe/Site)

Two builds are supported; their code is the same but shifted, and the .data globals are laid out differently, so no patch uses a fixed address:

Build PE date Size SHA-256
German (reference, all RE notes) 2001-02-12 1,642,496 7832eda4ee47a717e94441f21b75a9f30d11445c9f2408c6135a8fc01812e95b
English first release ("Start Game", "Chapter") 2000-10-17 1,634,304 bf0eb8829ed3e371c4e951ece0a7753acf2c28d07001b842e079b098576403b0

exe.game() (a GameExe, cached per ExeImage) identifies the build by SHA-256 (build(), describe() – logged in apply.log as groove.exe: English version of 2000-10-17) and resolves each named Site on the file as parsed (before any patch): va(site), original(site, n) = the original bytes there (expected bytes for patch/hookCall, and the stolen bytes a detour re-emits, e.g. the frame handlers' mov eax,[global]). Rules in Site: code(sig) = unique match in .text, ?? masking absolute addresses and rel32 operands; operand(sig, off) / operandAt(site, off) = the absolute address an instruction uses (must lie in .data – globals are always read from code, never guessed); callTarget(site) = target of a call; .calling(s) also checks that the call at the site goes to s. Each site lists its German and English VA: exe/GameExeTest checks that both builds resolve to them and that every site disassembles (objdump) to the expected instruction; mods/ExeVersionsTest checks that the German output of every exe mod and option set is byte-identical to the output before the version layer (SHA-256 golden values) and that every mod makes the same patches in the English exe; ExeModCombinationsTest runs all combinations on both. The English exe is read from -Dllamamod.exeEn (default re/groove_en.exe, gitignored – never commit it or add it to test resources); tests skip without it.

An unknown build fails on the first site that does not match exactly once, e.g. groove.exe (unknown version (PE date …, … bytes)): MENU_FRAME (state 2 (main menu) frame handler …) not found – unknown program version. LlamaMod supports: German version of 2001-02-12, English version of 2000-10-17. The base patch stays lenient (HKCU by pattern, relocation skipped). The mod cache key includes the exe hash for every build except the German one (whose keys did not change).

In game (apptest :25, 2026-11-07, English exe in original/hd with the German CD data – the English exe runs with it; menus are English because the strings are in the exe): main menu without logos after 17 s and no attract demo after 100 s (intro skip); boot to level (Village 2, TestLevel 9); practice F5/F8 restores the position; free camera F7 flies away from the llama; cheats: winedbg showed cheat flags 0x583DA0 = the chosen bits, energy 0x584500 stayed 0x5000 after falls into water with health, dropped to 0x4000 without it; TestLevel chapter 9 → "Quit to Map Select" gives a sane map (Village / Chapter 1 / 55) instead of the broken progress. English exe: menu, practice restore, free camera, map after TestLevel 9

Adding a build: copy it to re/ (gitignored), add a GameExe.Build (language, PE date, size, SHA-256), run GameExeTest: every failing site needs a longer/other signature (keep it unique in all builds; mask only addresses and rel32). For the new column, read each address off the resolved sites (or with a script that pairs relocation entries of matched instructions, as done for the English build: an operand is accepted only if most references to the German global agree), then add the column to Site and a case to Build.va. Check the base patch's free space (ExePatcher.SLACK_SECTIONS) and run ExeVersionsTest / ExeModCombinationsTest; then test in game.

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. Its coin counter/flag byte for chapters 2–31 land on other globals (chapter 9 = furthest progress): mods use only chapters 1 and 32–49 (save/TestLevelChapters, testlevel-chapters.md). 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, else $CLAUDE_JOB_DIR/tmp (AI agent sessions), else ~/.cache/llamamod-apptest. The original game data is installed once from the ISO (--iso, $APPTEST_ISO, default ~/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 slots 2 and 3 at world/chapter (cursor) + start + tolevel (slot 2) + 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 [--slot 3]  # menu "Spiel starten" -> slot (default 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.
  • Flakiness under load: with several agents' games running (load average 30 on 6 cores) the game draws only a few frames per second and the slot screen ignores presses shorter than a frame (seen 2026-11-07 at load ~30: the save slot carousel ignored Left/Right – 0.3 to 4 s holds – for minutes while Return/Escape worked; root cause not found). run therefore loads slot 2, where the carousel opens (no turning; slot 3 gets the same unlock for mods whose save-slot option is 3); 3 + 3 runs passed after that change. tolevel checks every menu step and repeats it with longer holds (Return on menus 0.6 s first – 0.2 s presses were often missed under load, reported by the test-world agent too): Return on the main menu until the slot screen shows (0.8/1.0/1.2 s), Right one step at a time until OCR reads the wanted slot (the carousel wraps 3 → 1; a step that did not move is repeated with 0.6/1.0/1.5 s; gives up after 240 s), Return on the slot until the slot screen is gone, Return on the map again if the picture did not change after 8 s. Before opening the slot it checks the slot text: an empty slot (would start a new game in the village) or another world's name (SLOT_WORLD_NAMES, verified: 1 "dorf", 9 "testlevel") is an error instead of a wrong level. run retries the start once when the game crashed before the main menu (Wine page fault at start-up) or the slot screen never came: report.json "retries": [{attempt, phase, error}], retry1_failure.jpg/retry1_game.log in the out dir, result line "OK (after a retry: …)"; --no-retry turns it off. report.json also records load_avg/cpus. Your own key sequences (--seq) are not retried: hold keys ≥0.5 s for menus when the machine is busy. Offline tests of the retry/slot logic: python3 -m unittest tools/test_apptest.py.
  • 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.