484 lines
35 KiB
Markdown
484 lines
35 KiB
Markdown
# 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/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=<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
|
||
|
||
```java
|
||
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" → `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.
|
||
|
||

|
||
|
||
**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 `<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.
|