35 KiB
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 withWINEARCH=win32(falls back to the default arch if this Wine cannot create win32 prefixes; system.reg only appears afterwineserver -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; belowGameLauncher.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.

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" →antivirusNoticeDismissedin config.json. - Detection: every apply stores SHA-256 of the written exe (
gameExeSha256).ModManager.checkGameExe()→OK | MISSING | LOCKED | CHANGEDruns after apply/restore and before every launch (GUI and--launch/--applyCLI). On Windows,CreateProcesserrors 225 (ERROR_VIRUS_INFECTED), 226, 5 (access denied) and 2 raiseGameLauncher.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.
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) – setJDK_JAVA_OPTIONSfor testing; launch4j's ownjavaw -versioncheck then hangs under Wine 6, so the app was started withruntime\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, …).
startrefuses 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 forbuild/classes/java/mainof 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=trueit faults on purpose andrunexits 3 with "Unhandled page fault … at address 006EF012". - Crash detection (
crashed()): groove.exe process of the prefix gone, "Unhandled exception/page fault" indata/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:
statuslistsapplication.nameof the sink inputs on the private server; the game shows up asgroove.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).
runtherefore 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.tolevelchecks 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.runretries 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.login the out dir, result line "OK (after a retry: …)";--no-retryturns it off. report.json also recordsload_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; logsxvfb.log,pulse.logthere, game output indata/logs/game.log. Delete<root>/<name>to start from scratch.
