# 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--all.jar (fatJarLauncher: …-launcher.jar) ./gradlew packageLinux # build/dist/LlamaMod--full-linux-x64.tar.gz (jlinked runtime) ./gradlew packageWindows # build/dist/LlamaMod--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=`): 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 --mods --launch [--wait] llamamod --apply [id,id] [--opt id.key=value ...] llamamod --registry --saves --unlock [--cursor] llamamod --import-level --remove-level ``` `--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=` or `LLAMAMOD_DATA` (not persisted). Test-only system properties: `llamamod.extraMods=` (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//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 outputs + `manifest.json` | | `backups/saves//` | save game backups (taken before every write) | | `levels/` | level projects = level mods (`.playtest/`: temporary editor play-test copies) | | `backups/levels//` | 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/WrapLabel`s: 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=`), 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 `/.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 ```java public interface Mod { String id(); String name(); String description(); default String category() { return OTHER; } // LEVELS, GAMEPLAY, VISUAL, CHEATS, OTHER default List 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..`, 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 : 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().jmp(site + n)); exe.hookJmp(site, "", 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](img/mods/english_exe.jpg) **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](img/app/av-warning.png) **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](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 `.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 (`//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 `/_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="` (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): `//harness/state.json`; logs `xvfb.log`, `pulse.log` there, game output in `data/logs/game.log`. Delete `/` to start from scratch.