Compare commits

5 Commits
35 changed files with 1428 additions and 264 deletions

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
# game data (copyrighted) - never commit
re/groove.exe
re/groove_en.exe
re/groove_decomp.c
re/proj/
re/ghidra.log
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## 0.2.2 – 2026-11-07
- **English game version supported.** All exe mods (boot to level, cheats, intro skip, practice
tools, free camera) and the base patch now also work with the English groove.exe of
2000-10-17. With any other version LlamaMod warns at start and before the first Apply (nothing
is blocked); mods that change groove.exe then stop with a clear message.
- **Display diagnostics option renamed and explained.** The cheats option is now called
"Troubleshooting: write display diagnostics" and warns that the game switches through every
screen mode of every monitor at start (screens go black for a while). It stays off by default.
## 0.2.1 – changes since 0.1.0
The first test release was 0.1.0 (launcher edition). Everything below is new for testers.
+8 -2
View File
@@ -47,8 +47,14 @@ More pictures:
## Get it
You need **your own copy of the game** (the CD or an ISO image of it). Nothing else – LlamaMod
brings everything it needs (also Java). Development used the German CD; other language versions
should work but are untested.
brings everything it needs (also Java).
**Supported game versions:** the **German CD** (*Ein Königreich für ein Lama*, `groove.exe` of
2001-02-12 – the version everything was developed and tested with) and the **English first release**
(*The Emperor's New Groove*, `groove.exe` of 2000-10-17). With any other version the level mods
(Test World, Arena, Coaster, your own levels, Mirror, Randomizer, Draw distance, Debug wireframe)
should still work; the mods marked *modifies groove.exe* stop with a clear message instead – then
please send us that `groove.exe` so we can add it.
### Install in 5 steps
+3 -1
View File
@@ -5,7 +5,7 @@ plugins {
group = 'com.eliasmueller.llamamod'
// Release builds pass -PreleaseVersion=<tag without v> (see .gitea/workflows/release.yml).
version = findProperty('releaseVersion') ?: '0.2.1'
version = findProperty('releaseVersion') ?: '0.2.2'
repositories {
mavenCentral()
@@ -99,6 +99,8 @@ test {
systemProperty 'llamamod.ref', System.getProperty('llamamod.ref', '')
// level builder references: python3 tools/levelbuild.py levels/testwelt.py <lbref>/testwelt.wad etc.
systemProperty 'llamamod.lbref', System.getProperty('llamamod.lbref', '')
// English groove.exe (2000-10-17) for the version tests; never committed, skipped when absent
systemProperty 'llamamod.exeEn', System.getProperty('llamamod.exeEn', "${projectDir}/re/groove_en.exe")
maxHeapSize = '2g'
}
+2 -2
View File
@@ -72,6 +72,6 @@ test harnesses
## Status and honesty
Everything here was found by reverse engineering and tested against the game; where something is
a guess, the page says so (*likely*, *unknown*). Development used the German CD; other language
versions are expected to work but were not tested. The project is not affiliated with Disney or
a guess, the page says so (*likely*, *unknown*). Development used the German CD; the English
first release is supported too (see [game versions](reference/game-files.md#grooveexe-versions)). The project is not affiliated with Disney or
Argonaut and contains no game data.
+1 -1
View File
@@ -98,7 +98,7 @@ and press **Play** to play the level – or press **F5** in the editor. See
## What you need
* The PC game (CD or ISO image; developed with the German version).
* The PC game (CD or ISO image): the German or the English version (see [FAQ](20-troubleshooting.md#faq)).
* LlamaMod – see [Installing LlamaMod](02-installing.md).
* A graphics card with **OpenGL 3.3** for the editor's 3D view.
* On Linux, Wine to run the game (LlamaMod sets it up).
+1 -1
View File
@@ -1,7 +1,7 @@
# Installing LlamaMod and the first start
LlamaMod runs on **Windows** and **Linux**. It needs your own copy of the game – the CD or an ISO
image of it. The game itself does not have to be installed: LlamaMod copies what it needs into
image of it, German or English version. The game itself does not have to be installed: LlamaMod copies what it needs into
its own data folder and never changes the original.
## Download and start
+1 -1
View File
@@ -241,7 +241,7 @@ Switches on the game's secret cheat codes for you, so you don't have to type the
| Complete chapter rewards | on / off | off | Finishing a chapter also gives you its collectible and gold coin (code → → ↓ ↓ ↓ ↑ ↓ ↓). |
| Start with 500 fruit | on / off | off | The purple fruit counter starts at 500 in every chapter (code ↑ ↓ → ← ← ← ← ↓). |
| Full charge meter | on / off | off | The charge bar starts full in every chapter (code ↑ ← ↓ → ↓ ↓ ↑ ↑). |
| Write display diagnostics | on / off | off | For graphics problems: the game writes a report of your graphics devices (<computer name>.dispdiag) into the game folder. Same as its hidden -display_diagnostics switch. |
| Troubleshooting: write display diagnostics | on / off | off | Only for troubleshooting when asked to. At start the game switches through every screen mode of every monitor – all screens go black for a while (30 s or more) – and writes a report (<computer name>.dispdiag) into the game folder, then starts normally. Same as its hidden -display_diagnostics switch. |
#### Debug wireframe
+8 -3
View File
@@ -11,6 +11,7 @@
| "Not enough free disk space" | The game copy needs about 900 MB. Free up space or choose another **Data folder**. |
| The game hangs or its window is gone | Click **■ Quit game** in LlamaMod (the Play button while the game runs). |
| "The game crashed" | Switch off the mods you turned on last, or click **Restore original**, and play again. If the original game crashes too, report it with `game.log` (**Settings → Logs**). |
| All monitors flash black for half a minute or more when the game starts | The **Built-in cheats** option *Troubleshooting: write display diagnostics* is on. It makes the game try every screen mode of every monitor before it starts. Switch it off; use it only when you are asked for a display report. |
| "groove.exe was removed or changed" | Click **Rebuild**; LlamaMod writes it again and then starts the game. On Windows, see [Antivirus warnings](#antivirus-warnings). |
## The editor
@@ -101,9 +102,13 @@ after an update, **Settings → Clear cache** throws it away; active mods are re
## FAQ
**Do I need the German version of the game?**
The tools were developed with the German CD. Other language versions use the same engine and
should work; if one does not, please report it.
**Which versions of the game work?**
The German CD (`groove.exe` of 2001-02-12) and the English first release (2000-10-17) are
supported, including every mod. With another version LlamaMod shows a warning at start and before
the first Apply (nothing is blocked); the level mods still work, but the mods
marked *modifies groove.exe* stop with "unknown program version" – the first lines of
`logs/apply.log` name the version LlamaMod found. Please report it together with that
`groove.exe`, then it can be added.
**Can I share levels with people who do not have the game?**
No – levels are built from the receiver's own game copy. That is also what makes sharing legal:
+13 -2
View File
@@ -20,8 +20,19 @@ Registry key (all values): `HKEY_LOCAL_MACHINE\Software\Disney Interactive\Emper
with `CDPath`, `HDPath`, screen size, volumes and devices. LlamaMod's base patch of `groove.exe`
redirects it to `HKEY_CURRENT_USER`, so no admin rights are needed.
Development used the **German CD**. Other language versions are expected to use the same formats
but were not tested.
Development used the **German CD**. The English first release is supported as well: all exe
patches find their places in both builds (table below). Its own CD data has not been tested yet –
the formats are expected to be the same.
### groove.exe versions
| Build | PE date | Size | Notes |
|---|---|---|---|
| German | 2001-02-12 | 1,642,496 bytes | reference for all addresses in these pages |
| English (first release, menu "Start Game") | 2000-10-17 | 1,634,304 bytes | same code, shifted (e.g. main menu frame handler 0x549990 instead of 0x549C50); .data globals laid out differently (game state 0x583D74 instead of 0x5846E4, player strat 0x6D94A4 instead of 0x6D9E1C); .data 0xD000 bytes in the file instead of 0xE000 |
LlamaMod supports both (`exe/Site` lists every address it uses in both builds). Addresses in the
reference pages are those of the German build.
## Level naming scheme
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

+57 -4
View File
@@ -123,8 +123,8 @@ on Windows. German CD (`groove.exe` MD5 697d67a31c20079838c662d8187dbd86, 1,642,
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, so other language builds work
if their code is the same.
(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
@@ -225,8 +225,9 @@ reads a CD folder, collects writes in `ctx.written`, resolves options (defaults
(`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, 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
(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`).
@@ -270,6 +271,58 @@ in cave code are fine). Tests: `exe/ExeImageTest` (real exe, re-parse + `objdump
`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).
+1 -1
View File
@@ -57,7 +57,7 @@ diagnostics) → `jmp 0x41C050`. The cave writes only game globals, so it needs
| Feature | State | In the mod |
|---|---|---|
| `-display_diagnostics` (DAT_005834D0) | works: writes `<COMPUTER>.dispdiag` (DDraw/D3D enumeration) into the game folder | option `diagnostics` (tested: ELIASPC.dispdiag, 60 KB) |
| `-display_diagnostics` (DAT_005834D0) | works: writes `<COMPUTER>.dispdiag` (DDraw/D3D enumeration) into the game folder; FUN_0040f4f0 opens **every display mode of every DirectDraw device × D3D device** (FUN_00410550/FUN_00410700 = mode switch, FUN_00411000), logs caps and texture formats, closes it again, then returns and the game starts normally. On Windows with two monitors both screens flashed black for 30+ s (tester, 2026-11); invisible under Wine | option `diagnostics`, label "Troubleshooting: write display diagnostics", off by default, warning in its description (tested: ELIASPC.dispdiag, 60 KB) |
| `c:\kingdom_dbg.txt` | dead: FUN_0040d2c0 (called from FUN_0043c220) only creates the file with "Logfile %s started now/today (write this code!)"; the 43 debug prints (FUN_00414600) go to OutputDebugStringA (0x6DC3E8), never to the file | no |
| FrameAdvanceDelay (FUN_0043cb40) | slide show `In\001.bmp…` + `In\In.wav` at start, `Out\…` at exit, delay from registry `FrameAdvanceDelay` (default 1.0 s); folders are empty | no (data, not a cheat) |
| F4 in game | requests state 0x0C (world map) – the normal "back to map" | no |
@@ -46,6 +46,10 @@ public final class ExeImage {
return (characteristics & 0x20000000) != 0;
}
public boolean writable() {
return (characteristics & 0x80000000) != 0;
}
/** Bytes of the section that come from the file. */
public long fileBackedSize() {
return virtualSize == 0 ? rawSize : Math.min(virtualSize, rawSize);
@@ -70,6 +74,9 @@ public final class ExeImage {
public record DynamicImport(String owner, String dll, String function, long slot) implements Op {}
private final byte[] data;
/** The file as parsed, before any patch: {@link GameExe} resolves its sites here. */
private final byte[] original;
private final int peOff;
private final int optOff;
private final long imageBase;
@@ -81,9 +88,11 @@ public final class ExeImage {
private final Region dataRegion;
private final List<Op> journal = new ArrayList<>();
private String owner = "base";
private GameExe game;
private ExeImage(byte[] exe) throws ExePatchException {
data = exe.clone();
original = exe.clone();
if (data.length < 0x40 || data[0] != 'M' || data[1] != 'Z') {
throw new ExePatchException("Not a Windows executable (no MZ header)");
}
@@ -157,12 +166,18 @@ public final class ExeImage {
return va >= dataRegion.va;
}
/** File offset of a VA in the original file, or -1 (not file-backed, e.g. .bss or the cave). */
/**
* File offset of a VA in the original file, or -1 (not file-backed, e.g. .bss or the cave). The
* PE headers are mapped at the image base, so they have VAs too (section flags can be patched).
*/
public int fileOffset(long va) {
return fileOffset(va, 1);
}
private int fileOffset(long va, int len) {
if (va >= imageBase && va + len <= imageBase + headerSize()) {
return (int) (va - imageBase);
}
for (Section s : sections) {
if (s.containsFileBacked(va, len)) {
return (int) (s.rawOffset() + (va - s.va()));
@@ -171,8 +186,11 @@ public final class ExeImage {
return -1;
}
/** VA of a file offset inside a section, or -1. */
/** VA of a file offset inside the headers or a section, or -1. */
public long va(int fileOffset) {
if (fileOffset >= 0 && fileOffset < headerSize()) {
return imageBase + fileOffset;
}
for (Section s : sections) {
if (fileOffset >= s.rawOffset() && fileOffset < s.rawOffset() + s.fileBackedSize()) {
return s.va() + (fileOffset - s.rawOffset());
@@ -181,6 +199,21 @@ public final class ExeImage {
return -1;
}
private int headerSize() {
return i32(optOff + 60); // SizeOfHeaders
}
/** File offset of the Characteristics field of section {@code name}'s header. */
public int sectionFlagsOffset(String name) {
int table = optOff + u16(peOff + 20);
for (int k = 0; k < sections.size(); k++) {
if (sections.get(k).name().equals(name)) {
return table + 40 * k + 36;
}
}
throw new IllegalArgumentException("No section " + name);
}
// ---------------------------------------------------------------- reading
public byte[] read(long va, int len) {
@@ -195,6 +228,26 @@ public final class ExeImage {
return out;
}
/** Bytes of the file as parsed (before any patch); file-backed section data only. */
public byte[] readOriginal(long va, int len) {
int off = fileOffsetChecked(va, len);
return Arrays.copyOfRange(original, off, off + len);
}
/** Build identification and version-independent sites of this executable. */
public GameExe game() {
if (game == null) {
game = new GameExe(this, original);
}
return game;
}
/** Like {@link #find(String, String)}, but on the file as parsed and stopping after {@code limit} hits. */
public List<Long> findOriginal(String sectionName, String pattern, int limit) {
Pattern p = Pattern.of(pattern);
return find(original, List.of(section(sectionName)), p.bytes, p.mask, limit);
}
public int readU8(long va) {
return read(va, 1)[0] & 0xff;
}
@@ -237,8 +290,12 @@ public final class ExeImage {
}
private List<Long> find(byte[] pat, boolean[] mask, int limit) {
return find(data, sections, pat, mask, limit);
}
private static List<Long> find(byte[] data, List<Section> in, byte[] pat, boolean[] mask, int limit) {
List<Long> out = new ArrayList<>();
for (Section s : sections) {
for (Section s : in) {
int start = s.rawOffset();
int end = s.rawOffset() + (int) s.fileBackedSize();
outer:
@@ -0,0 +1,202 @@
package com.eliasmueller.llamamod.exe;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.EnumMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* Which groove.exe this is, and where the {@link Site}s are in it. Sites are resolved on the file
* as parsed (before any patch), so the order in which mods patch does not matter. A site that is
* not found exactly once means an unknown program version: {@link #va} throws with a message that
* names the build and the supported ones.
*/
public final class GameExe {
/** The known builds. */
public enum Build {
GERMAN("German", "2001-02-12", 1_642_496, "7832eda4ee47a717e94441f21b75a9f30d11445c9f2408c6135a8fc01812e95b"),
ENGLISH("English", "2000-10-17", 1_634_304, "bf0eb8829ed3e371c4e951ece0a7753acf2c28d07001b842e079b098576403b0");
public final String language;
/** PE timestamp (UTC date). */
public final String date;
public final int size;
public final String sha256;
Build(String language, String date, int size, String sha256) {
this.language = language;
this.date = date;
this.size = size;
this.sha256 = sha256;
}
public long va(Site s) {
return this == GERMAN ? s.german : s.english;
}
@Override
public String toString() {
return language + " version of " + date;
}
}
private final ExeImage img;
private final byte[] original;
private final Build build;
private final String sha256;
private final Map<Site, Long> sites = new EnumMap<>(Site.class);
private final Map<Site, Long> matches = new EnumMap<>(Site.class);
GameExe(ExeImage img, byte[] original) {
this.img = img;
this.original = original;
sha256 = sha256(original);
Build b = null;
for (Build k : Build.values()) {
if (k.sha256.equals(sha256)) {
b = k;
}
}
build = b;
}
/** SHA-256 of the file as parsed (hex). */
public String sha256() {
return sha256;
}
/** The known build, or null for any other groove.exe. */
public Build build() {
return build;
}
/** "German version of 2001-02-12" or "unknown version (PE date …, … bytes)". */
public String describe() {
if (build != null) {
return build.toString();
}
int pe = ByteBuffer.wrap(original).order(ByteOrder.LITTLE_ENDIAN).getInt(0x3c);
long ts = ByteBuffer.wrap(original).order(ByteOrder.LITTLE_ENDIAN).getInt(pe + 8) & 0xffffffffL;
return String.format(
Locale.ROOT,
"unknown version (PE date %s, %d bytes)",
Instant.ofEpochSecond(ts).atZone(ZoneOffset.UTC).toLocalDate(),
original.length);
}
/** Address of {@code s} in this executable. */
public long va(Site s) throws ExePatchException {
Long v = sites.get(s);
if (v == null) {
v = resolve(s);
sites.put(s, v);
}
return v;
}
/** Where the rule of {@code s} matched: its signature match, or the address in its base site. */
public long anchor(Site s) throws ExePatchException {
va(s);
return matches.get(s);
}
/** The original bytes at {@code s} as hex (expected bytes of a patch there). */
public String original(Site s, int len) throws ExePatchException {
return HexFormat.ofDelimiter(" ")
.formatHex(img.readOriginal(va(s), len))
.toUpperCase(Locale.ROOT);
}
private long resolve(Site s) throws ExePatchException {
Site.Rule r = s.rule;
long at;
if (r.pattern() != null) {
List<Long> hits = img.findOriginal(".text", r.pattern(), 2);
if (hits.size() != 1) {
throw unknown(s, hits.isEmpty() ? "not found" : "found more than once");
}
at = hits.getFirst() + r.offset();
} else {
at = va(r.base()) + r.offset();
}
matches.put(s, at);
long v =
switch (r.kind()) {
case CODE -> at;
case OPERAND -> {
long a = u32(at);
if (!inSection(a, ".data")) {
throw unknown(s, String.format(Locale.ROOT, "address 0x%X is not in .data", a));
}
yield a;
}
case CALL_TARGET -> {
long t = relTarget(at);
if (t < 0) {
throw unknown(s, "no call at " + ExeImage.hexVa(at));
}
yield t;
}
};
if (r.calls() != null && relTarget(v) != va(r.calls())) {
throw unknown(s, "does not call " + r.calls().name());
}
return v;
}
/** Target of the call/jmp rel32 at {@code va}, or -1. */
private long relTarget(long va) {
int op = img.readOriginal(va, 1)[0] & 0xff;
if (op != 0xE8 && op != 0xE9) {
return -1;
}
return (va + 5 + (int) u32(va + 1)) & 0xffffffffL;
}
private long u32(long va) {
return ByteBuffer.wrap(img.readOriginal(va, 4))
.order(ByteOrder.LITTLE_ENDIAN)
.getInt()
& 0xffffffffL;
}
private boolean inSection(long va, String name) {
try {
ExeImage.Section sec = img.section(name);
return va >= sec.va() && va < sec.va() + Math.max(sec.virtualSize(), sec.rawSize());
} catch (IllegalArgumentException e) {
return false;
}
}
private ExePatchException unknown(Site s, String why) {
StringBuilder known = new StringBuilder();
for (Build b : Build.values()) {
known.append(known.isEmpty() ? "" : ", ").append(b);
}
return new ExePatchException(String.format(
Locale.ROOT,
"groove.exe (%s): %s (%s) %s – unknown program version. LlamaMod supports: %s.",
describe(),
s.name(),
s.description,
why,
known));
}
private static String sha256(byte[] b) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(b));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,266 @@
package com.eliasmueller.llamamod.exe;
/**
* Named code sites and globals of groove.exe that LlamaMod patches or uses, with the rule that
* finds each one in any build ({@link GameExe#va}) and its address in the two known builds
* (German 2001-02-12 / English 2000-10-17; GameExeTest checks both).
*
* <p>Rules: {@code code} = unique match of a byte signature in .text ({@code ??} masks absolute
* addresses and rel32 operands, which differ between builds); {@code operand} = the absolute
* address stored at an offset of a signature match (or of another site); {@code callTarget} = the
* target of the rel32 call/jmp at a site. {@code calling(s)} additionally requires the instruction
* at the site to call {@code s}. Globals are never guessed: each is read from an instruction that
* uses it. To support another build see notes/llamamod-app.md ("groove.exe versions").
*/
public enum Site {
// boot to level
MENU_FRAME(
code("A1 ?? ?? ?? ?? 57 33 FF 3B C7 0F 85 ?? ?? ?? ??"),
0x549C50,
0x549990,
"state 2 (main menu) frame handler, starts with mov eax,[dword]"),
GAME_FRAME(
code("A1 ?? ?? ?? ?? 85 C0 0F 85 ?? ?? ?? ?? 83 3D ?? ?? ?? ?? 01"),
0x549F40,
0x549C80,
"state 3 (in game) frame handler, starts with mov eax,[dword]"),
DEMO_SWITCH(
code(
"E8 ?? ?? ?? ?? 8B 15 ?? ?? ?? ?? 89 3D ?? ?? ?? ?? 52 E8 ?? ?? ?? ?? 83 C4 04 E8 ?? ?? ?? ?? A1 ?? ?? ?? ?? 83 F8 04 75 58 8B 0D ?? ?? ?? ?? A3 ?? ?? ?? ?? 8B C1 C1 E0 04 41 8B 90 ?? ?? ?? ?? 83 F9 03 89 15 ?? ?? ?? ?? 8B 90 ?? ?? ?? ?? 89 15 ?? ?? ?? ?? 8B 90 ?? ?? ?? ?? 8B 80 ?? ?? ?? ?? 89 15 ?? ?? ?? ?? A3 ?? ?? ?? ?? 89 0D ?? ?? ?? ??"),
0x549E47,
0x549B87,
"main menu: music shutdown and switch to the attract demo (state 2 -> 4)"),
MUSIC_STOP(callTarget(DEMO_SWITCH), 0x546CC0, 0x546AF0, "music shutdown, part 1 (call at DEMO_SWITCH)"),
MUSIC_TRACK(operandAt(DEMO_SWITCH, 7), 0x6D94B4, 0x6D8B44, "current music track (pushed for MUSIC_PLAY)"),
MUSIC_ID(operandAt(DEMO_SWITCH, 13), 0x586430, 0x585AC0, "music id, zeroed before the switch"),
MUSIC_PLAY(callTarget(DEMO_SWITCH, 18), 0x546990, 0x5467C0, "music shutdown, part 2 (cdecl, 1 argument)"),
MUSIC_UPDATE(callTarget(DEMO_SWITCH, 26), 0x5464D0, 0x546350, "music shutdown, part 3"),
NEXT_STATE(operandAt(DEMO_SWITCH, 32), 0x584700, 0x583D90, "requested next game state"),
STATE(
operandAt(DEMO_SWITCH, 48),
0x5846E4,
0x583D74,
"game state (2 main menu, 3 in game, 6 save slots, 12 world map)"),
TRIBE(operandAt(DEMO_SWITCH, 69), 0x584EB4, 0x584544, "current tribe"),
WORLD(operandAt(DEMO_SWITCH, 81), 0x584EB8, 0x584548, "current world"),
CHAPTER(operandAt(DEMO_SWITCH, 99), 0x584EBC, 0x58454C, "current chapter"),
MAP(operandAt(DEMO_SWITCH, 104), 0x584EC0, 0x584550, "current map"),
READ_SLOTS(
code("81 EC 00 01 00 00 53 56 57 33 DB BF ?? ?? ?? ??"),
0x41C0E0,
0x41C050,
"reads the three save slot files"),
LOAD_SLOT(
code("A1 ?? ?? ?? ?? C7 05 ?? ?? ?? ?? 00 00 00 00 C1 E0 08"),
0x41CC70,
0x41CB80,
"loads save slot SLOT (or starts a new game in it)"),
SUB_STATE(operand("C7 05 ?? ?? ?? ?? 04 00 00 00 EB 10", 2), 0x584638, 0x583CC8, "menu sub-state"),
MAP_TRIBE(operand("A0 ?? ?? ?? ?? 8D BA ?? ?? ?? ?? C1 E9 0C", 1), 0x585588, 0x584C18, "world map: tribe"),
MAP_WORLD(operand("89 15 ?? ?? ?? ?? A3 ?? ?? ?? ?? 5E", 2), 0x584C24, 0x5842B4, "world map: world"),
MAP_CHAPTER(
operand("8B 15 ?? ?? ?? ?? A3 ?? ?? ?? ?? A0 ?? ?? ?? ??", 2), 0x584C20, 0x5842B0, "world map: chapter"),
MAP_MAP(operand("A3 ?? ?? ?? ?? 5E 83 C4 60 C3 90 90", 1), 0x58557C, 0x584C0C, "world map: map"),
FROM_MAIN_MENU(
operand("39 1D ?? ?? ?? ?? 74 6E A1 ?? ?? ?? ??", 2),
0x5855B0,
0x584C40,
"set when the save slots are opened from the main menu"),
SLOT(operand("A1 ?? ?? ?? ?? 83 F8 FF 75 31 33 C9", 1), 0x584C28, 0x5842B8, "selected save slot (0-2)"),
PRESSED(
operand("A1 ?? ?? ?? ?? 25 00 40 00 00 74 36", 1),
0x5FCF08,
0x5FC598,
"control bits pressed this frame (0x8 = pause)"),
// cheats
STARTUP_CALL(
code("E8 ?? ?? ?? ?? E8 ?? ?? ?? ?? 84 C0 75 15"),
0x41D3EA,
0x41D2BA,
"WinMain: call right after the command line was parsed"),
STARTUP_TARGET(callTarget(STARTUP_CALL), 0x41C050, 0x41BFC0, "function called at STARTUP_CALL"),
CHEAT_FLAGS(
operand("8B 0D ?? ?? ?? ?? 85 C8 75 3A 6A 08", 2),
0x584710,
0x583DA0,
"cheat bits toggled by the main-menu codes"),
DISPLAY_DIAGNOSTICS(
operand("C6 05 ?? ?? ?? ?? 01 68 ?? ?? ?? ??", 2),
0x5834D0,
0x582B60,
"-display_diagnostics switch (byte)"),
// intro skip
LOGOS(
code("68 ?? ?? ?? ?? E8 ?? ?? ?? ?? A1 ?? ?? ?? ?? 83 C4 08"),
0x41D56E,
0x41D43E,
"WinMain: push offset disneylogompeg.dat, first instruction of the logo block"),
AFTER_LOGOS(
code("E8 ?? ?? ?? ?? E8 ?? ?? ?? ?? 6A 2C"),
0x41D5D3,
0x41D4A3,
"WinMain: first instruction after the logo block"),
DEMO_IDLE_JBE(
code("76 41 A1 ?? ?? ?? ?? 6A 01 A3 ?? ?? ?? ??"),
0x549DC0,
0x549B00,
"main menu: jbe of the idle check (1800 frames -> attract demo)"),
STORY_INTRO(
code("E8 ?? ?? ?? ?? A1 ?? ?? ?? ?? 83 C4 08 83 F8 04"),
0x54AEC9,
0x54ABD9,
"call of the movie player for %d%d%dINTRO.DAT"),
MOVIE_PLAYER(callTarget(STORY_INTRO), 0x41F630, 0x41F4B0, "movie player (called at STORY_INTRO)"),
STORY_EULOGY(
code("E8 ?? ?? ?? ?? A1 ?? ?? ?? ?? 68 ?? ?? ?? ?? A3 ?? ?? ?? ??").calling(MOVIE_PLAYER),
0x54B138,
0x54AE48,
"call of the movie player for EULOGY.DAT"),
STORY_OUTRO(
code("E8 ?? ?? ?? ?? 68 ?? ?? ?? ?? E8 ?? ?? ?? ?? 6A 03").calling(MOVIE_PLAYER),
0x54B26F,
0x54AF7F,
"call of the movie player for %d%d%dOUTRO.DAT"),
// practice
TICK_CALL(
code("E8 ?? ?? ?? ?? E8 ?? ?? ?? ?? 8B 15 ?? ?? ?? ?? A1 ?? ?? ?? ?? 42"),
0x54B77E,
0x54B48E,
"game loop: per-tick call of STRAT_UPDATE"),
STRAT_UPDATE(callTarget(TICK_CALL), 0x54C4A0, 0x54C1B0, "strat update (one logic tick of all objects)"),
PLAYER(operand("A1 ?? ?? ?? ?? 83 C4 0C 46 83 C7 10", 1), 0x6D9E1C, 0x6D94A4, "player (llama) strat pointer"),
// free camera
CAM_CALL_RUNNING(
code("E8 ?? ?? ?? ?? E8 ?? ?? ?? ?? 5F 5E 5D C3 89 2D ?? ?? ?? ??"),
0x54B79F,
0x54B4AF,
"frame: call of CAM_UPDATE while running"),
CAM_UPDATE(callTarget(CAM_CALL_RUNNING), 0x54BAD0, 0x54B7E0, "copies the camera strat into the render camera"),
CAM_CALL_PAUSED(
code("E8 ?? ?? ?? ?? E8 ?? ?? ?? ?? 5F 5E 5D C3 90").calling(CAM_UPDATE),
0x54B7E0,
0x54B4F0,
"frame: call of CAM_UPDATE while paused"),
TICK_AFTER_STRATS(
code("E8 ?? ?? ?? ?? 8B 15 ?? ?? ?? ?? A1 ?? ?? ?? ?? 42"),
0x54B783,
0x54B493,
"tick loop: call of PARTICLES right after TICK_CALL"),
PARTICLES(callTarget(TICK_AFTER_STRATS), 0x4287C0, 0x428630, "particle update"),
AFTER_INPUT(
code("E8 ?? ?? ?? ?? A0 ?? ?? ?? ?? 84 C0 74 09"),
0x54B5FD,
0x54B30D,
"frame: call right after the frame's input read"),
AFTER_INPUT_TARGET(callTarget(AFTER_INPUT), 0x427C40, 0x427AB0, "function called at AFTER_INPUT"),
PAD_BITS(
code("8B 44 24 04 83 EC 08 53 33 DB 3B C3"),
0x4256C0,
0x425540,
"pad structs -> control bits (int argument)"),
REGIONS_CALL_FRAME(
code("E8 ?? ?? ?? ?? A1 ?? ?? ?? ?? BF 04 00 00 00"),
0x54B614,
0x54B324,
"frame: call of REGIONS before the ticks"),
REGIONS(callTarget(REGIONS_CALL_FRAME), 0x41E020, 0x41DEA0, "region mask (rooms to draw) from the camera cell"),
REGIONS_CALL_RUNNING(
code("E8 ?? ?? ?? ?? 5F 5E 5D C3 89 2D ?? ?? ?? ??").calling(REGIONS),
0x54B7A4,
0x54B4B4,
"frame: call of REGIONS after CAM_CALL_RUNNING"),
REGIONS_CALL_PAUSED(
code("E8 ?? ?? ?? ?? 5F 5E 5D C3 90 90 E9 ?? ?? ?? ??").calling(REGIONS),
0x54B7E5,
0x54B4F5,
"frame: call of REGIONS after CAM_CALL_PAUSED"),
STRAT_RESET(
code(
"89 3D ?? ?? ?? ?? 89 3D ?? ?? ?? ?? 89 3D ?? ?? ?? ?? 89 3D ?? ?? ?? ?? 89 3D ?? ?? ?? ?? 89 3D ?? ?? ?? ?? 89 3D ?? ?? ?? ?? 89 3D ?? ?? ?? ?? 89 3D ?? ?? ?? ?? 89 3D ?? ?? ?? ?? 89 3D ?? ?? ?? ??"),
0x54D0D5,
0x54CDE5,
"level start (strat system reset): mov [dword],edi"),
TICKS(
operand("89 35 ?? ?? ?? ?? 8B 0D ?? ?? ?? ??", 2),
0x5724D4,
0x5724D8,
"logic ticks this frame (30 per second)"),
PAUSED(operand("A1 ?? ?? ?? ?? 83 C4 10 85 C0 74 53", 1), 0x584640, 0x583CD0, "pause flag"),
CONTROLS(operand("A3 ?? ?? ?? ?? 7E 0A B8 10 00 00 00", 1), 0x5FCF20, 0x5FC5B0, "held control bits"),
STICK(operand("8B 3D ?? ?? ?? ?? 83 FF 2A 0F 83 ?? ?? ?? ??", 2), 0x5FCEFC, 0x5FC58C, "stick magnitude"),
DEMO(operand("8B 0D ?? ?? ?? ?? 3B CB 89 1D ?? ?? ?? ??", 2), 0x5FCF3C, 0x5FC5CC, "demo playback active"),
PAD_COUNT(operand("3B 2D ?? ?? ?? ?? 0F 83 ?? ?? ?? ??", 2), 0x5FCF6C, 0x5FC5FC, "number of pad structs"),
PADS(operand("89 3D ?? ?? ?? ?? 75 04 32 C0 5F C3", 2), 0x5FCF70, 0x5FC600, "pad structs (12 bytes each)"),
PITCH(
operand("A3 ?? ?? ?? ?? A1 ?? ?? ?? ?? 89 0D ?? ?? ?? ??", 1),
0x5790A0,
0x5790A0,
"render camera pitch (4096 = 360 degrees)"),
YAW(operand("66 8B 0D ?? ?? ?? ?? 89 1D ?? ?? ?? ??", 3), 0x5790A4, 0x5790A4, "render camera yaw"),
CAM_X(operand("8B 15 ?? ?? ?? ?? 8B 01 8B 49 08 2B C2", 2), 0x5790B0, 0x5790B0, "render camera position x, y, z"),
TARGET_X(
operand("68 ?? ?? ?? ?? E8 ?? ?? ?? ?? 8B 54 24 1C", 1),
0x6D9D80,
0x6D9408,
"render camera target x, y, z"),
SINE(operand("8B 1C 8D ?? ?? ?? ?? 8B C8 0F AF CA", 3), 0x574318, 0x574318, "s32[4096] sine table, 4096 = 1.0"),
// base patch: level record relocation
COINS(
operand("8B 34 B5 ?? ?? ?? ?? C1 EE 0C 3B F2", 3),
0x584F1C,
0x5845AC,
"u32 coin counters [world*6 + chapter], 54 rows"),
FLAGS(
operand("BE ?? ?? ?? ?? C6 82 ?? ?? ?? ?? 01", 1),
0x584EC8,
0x584558,
"u8 chapter flags [world*6 + chapter], 54 rows");
final Rule rule;
/** Address in the German build (2001-02-12). */
public final long german;
/** Address in the English build (2000-10-17). */
public final long english;
public final String description;
Site(Rule rule, long german, long english, String description) {
this.rule = rule;
this.german = german;
this.english = english;
this.description = description;
}
enum Kind {
CODE,
OPERAND,
CALL_TARGET
}
/** {@code pattern} null: relative to {@code base}. */
record Rule(Kind kind, String pattern, Site base, int offset, Site calls) {
Rule calling(Site s) {
return new Rule(kind, pattern, base, offset, s);
}
}
private static Rule code(String pattern) {
return new Rule(Kind.CODE, pattern, null, 0, null);
}
private static Rule operand(String pattern, int offset) {
return new Rule(Kind.OPERAND, pattern, null, offset, null);
}
private static Rule operandAt(Site base, int offset) {
return new Rule(Kind.OPERAND, null, base, offset, null);
}
private static Rule callTarget(Site base) {
return callTarget(base, 0);
}
private static Rule callTarget(Site base, int offset) {
return new Rule(Kind.CALL_TARGET, null, base, offset, null);
}
}
@@ -2,6 +2,7 @@ package com.eliasmueller.llamamod.mods;
import com.eliasmueller.llamamod.BuildInfo;
import com.eliasmueller.llamamod.exe.ExeImage;
import com.eliasmueller.llamamod.exe.GameExe;
import com.eliasmueller.llamamod.platform.AppPaths;
import com.eliasmueller.llamamod.platform.Config;
import com.eliasmueller.llamamod.platform.ExePatcher;
@@ -313,9 +314,15 @@ public final class ModManager {
return l;
}
/** Start of the cache key chain: a new CD, app build or base exe patch invalidates every cached output. */
/**
* Start of the cache key chain: a new CD, app build, base exe patch or groove.exe build (other
* than the German one, whose keys stay as they were) invalidates every cached output.
*/
private String rootKey() {
return FileOps.sha256(cfg.cdFingerprint + "|app " + appBuild + "|base " + ExePatcher.REVISION)
String exe = cfg.exeSha256.isEmpty() || cfg.exeSha256.equals(GameExe.Build.GERMAN.sha256)
? ""
: "|exe " + cfg.exeSha256;
return FileOps.sha256(cfg.cdFingerprint + "|app " + appBuild + "|base " + ExePatcher.REVISION + exe)
.substring(0, 24);
}
@@ -562,6 +569,8 @@ public final class ModManager {
}
byte[] b = Files.readAllBytes(orig);
ExeImage img = ExeImage.parse(b);
cfg.exeSha256 = img.game().sha256();
log.line("groove.exe: " + img.game().describe());
img.setOwner(BASE_EXE_OWNER);
for (ExePatcher.Change c : ExePatcher.changes(b)) {
img.patch(img.va(c.offset()), c.original(), c.replacement(), c.label());
@@ -1,6 +1,8 @@
package com.eliasmueller.llamamod.mods.boottolevel;
import com.eliasmueller.llamamod.exe.ExeImage;
import com.eliasmueller.llamamod.exe.GameExe;
import com.eliasmueller.llamamod.exe.Site;
import com.eliasmueller.llamamod.exe.X86;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModContext;
@@ -17,30 +19,19 @@ import java.util.Locale;
* switches to game state 3 with the chosen world/chapter, like the map's Enter would. Optionally
* moves the player start of that level's WAD (play-test "play from here").
*
* <p>Engine facts (groove.exe): game state 0x5846E4 (2 = main menu, 3 = in game, 6 = save slots,
* 12 = world map), frame handlers table 0x578F28 (return 1 = state changed, the main loop then
* runs the enter handler of table 0x578F7C; state 3 = FUN_0054ae20, which loads tribe/world/
* chapter/map 0x584EB4/B8/BC/C0). FUN_0041c0e0 reads the three slot files, FUN_0041cc70 loads slot
* 0x584C28 (or starts a new game in it when empty). The demo switch at 0x549E5D (state 2 → 4) does
* the same music shutdown before it changes state.
* <p>Engine facts (German groove.exe addresses; {@link Site} has them for every build): game state
* 0x5846E4 (2 = main menu, 3 = in game, 6 = save slots, 12 = world map), frame handlers table
* 0x578F28 (return 1 = state changed, the main loop then runs the enter handler of table 0x578F7C;
* state 3 = FUN_0054ae20, which loads tribe/world/chapter/map 0x584EB4/B8/BC/C0). FUN_0041c0e0
* reads the three slot files, FUN_0041cc70 loads slot 0x584C28 (or starts a new game in it when
* empty). The demo switch at 0x549E47 (state 2 → 4) does the same music shutdown before it changes
* state.
*/
public final class BootToLevelMod implements Mod {
public static final String ID = "boot-to-level";
static final long MENU_FRAME = 0x549C50; // state 2 frame handler: mov eax,[0x58372c]
static final long GAME_FRAME = 0x549F40; // state 3 frame handler: mov eax,[0x58372c]
static final long PRESSED = 0x5FCF08; // control bits pressed this frame; 0x8 = pause (Escape)
static final int STATE_QUIT = 0x11; // main menu "Beenden": the main loop exits through its cleanup
static final String FRAME_PROLOGUE = "A1 2C 37 58 00"; // both frame handlers start with it
static final long STATE = 0x5846E4, NEXT_STATE = 0x584700, SUB_STATE = 0x584638;
static final long TRIBE = 0x584EB4, WORLD = 0x584EB8, CHAPTER = 0x584EBC, MAP = 0x584EC0;
static final long MAP_TRIBE = 0x585588, MAP_WORLD = 0x584C24, MAP_CHAPTER = 0x584C20, MAP_MAP = 0x58557C;
static final long FROM_MAIN_MENU = 0x5855B0;
static final long SLOT = 0x584C28;
static final long READ_SLOTS = 0x41C0E0, LOAD_SLOT = 0x41CC70;
static final long MUSIC_A = 0x546CC0, MUSIC_B = 0x546990, MUSIC_C = 0x5464D0, MUSIC_ID = 0x586430;
static final long MUSIC_TRACK = 0x6D94B4;
static final int PAUSE_BIT = 0x08; // Escape in the pressed control bits
@Override
public String id() {
@@ -136,6 +127,17 @@ public final class BootToLevelMod implements Mod {
if (ctx.optionBool("logos")) {
IntroSkipMod.skipLogos(exe, "skip logo movies");
}
GameExe g = exe.game();
long menuFrame = g.va(Site.MENU_FRAME), gameFrame = g.va(Site.GAME_FRAME);
String menuPrologue = g.original(Site.MENU_FRAME, 5), gamePrologue = g.original(Site.GAME_FRAME, 5);
long state = g.va(Site.STATE), nextState = g.va(Site.NEXT_STATE), subState = g.va(Site.SUB_STATE);
long slotVar = g.va(Site.SLOT), readSlots = g.va(Site.READ_SLOTS), loadSlot = g.va(Site.LOAD_SLOT);
long[] level = {g.va(Site.TRIBE), g.va(Site.WORLD), g.va(Site.CHAPTER), g.va(Site.MAP)};
long[] mapLevel = {g.va(Site.MAP_TRIBE), g.va(Site.MAP_WORLD), g.va(Site.MAP_CHAPTER), g.va(Site.MAP_MAP)};
long fromMainMenu = g.va(Site.FROM_MAIN_MENU), musicId = g.va(Site.MUSIC_ID), track = g.va(Site.MUSIC_TRACK);
long musicStop = g.va(Site.MUSIC_STOP),
musicPlay = g.va(Site.MUSIC_PLAY),
musicUpdate = g.va(Site.MUSIC_UPDATE);
long done = exe.allocateData(4, "boot-to-level: done flag");
long cave = exe.emit("boot-to-level: first menu frame", a -> {
Asm m = new Asm(a);
@@ -144,48 +146,49 @@ public final class BootToLevelMod implements Mod {
a.jcc(X86.JNE, menu);
m.storeImm(done, 1);
a.pushad();
a.call(READ_SLOTS);
m.storeImm(SLOT, slot - 1);
a.call(LOAD_SLOT); // loads the slot into the game variables (or a new game)
m.storeImm(TRIBE, 1)
.storeImm(WORLD, world)
.storeImm(CHAPTER, chapter)
.storeImm(MAP, 0);
m.storeImm(MAP_TRIBE, 1)
.storeImm(MAP_WORLD, world)
.storeImm(MAP_CHAPTER, chapter)
.storeImm(MAP_MAP, 0);
m.storeImm(FROM_MAIN_MENU, 0).storeImm(SUB_STATE, 7).storeImm(NEXT_STATE, 3);
a.call(MUSIC_A);
m.storeImm(MUSIC_ID, 0);
a.hex("FF 35").u32(MUSIC_TRACK); // push dword [track]
a.call(MUSIC_B);
a.call(readSlots);
m.storeImm(slotVar, slot - 1);
a.call(loadSlot); // loads the slot into the game variables (or a new game)
m.storeImm(level[0], 1)
.storeImm(level[1], world)
.storeImm(level[2], chapter)
.storeImm(level[3], 0);
m.storeImm(mapLevel[0], 1)
.storeImm(mapLevel[1], world)
.storeImm(mapLevel[2], chapter)
.storeImm(mapLevel[3], 0);
m.storeImm(fromMainMenu, 0).storeImm(subState, 7).storeImm(nextState, 3);
a.call(musicStop);
m.storeImm(musicId, 0);
a.hex("FF 35").u32(track); // push dword [track]
a.call(musicPlay);
a.hex("83 C4 04");
a.call(MUSIC_C);
m.storeImm(STATE, 3);
a.call(musicUpdate);
m.storeImm(state, 3);
a.popad();
m.movImm(Asm.EAX, 1);
a.ret();
a.bind(menu);
a.hex(FRAME_PROLOGUE);
a.jmp(MENU_FRAME + 5);
a.hex(menuPrologue);
a.jmp(menuFrame + 5);
});
exe.hookJmp(MENU_FRAME, FRAME_PROLOGUE, cave, "boot-to-level: main menu → level");
exe.hookJmp(menuFrame, menuPrologue, cave, "boot-to-level: main menu → level");
if (ctx.optionBool("quitOnEscape")) {
// same exit as the main menu's "Beenden": state 0x11, frame handler returns 1
long pressed = g.va(Site.PRESSED);
long quit = exe.emit("boot-to-level: Escape quits", a -> {
Asm m = new Asm(a);
X86.Label run = a.label();
a.hex("F6 05").u32(PRESSED).u8(0x08); // test byte [pressed], 8
a.hex("F6 05").u32(pressed).u8(PAUSE_BIT); // test byte [pressed], 8
a.jcc(X86.JE, run);
m.storeImm(STATE, STATE_QUIT);
m.storeImm(state, STATE_QUIT);
m.movImm(Asm.EAX, 1);
a.ret();
a.bind(run);
a.hex(FRAME_PROLOGUE);
a.jmp(GAME_FRAME + 5);
a.hex(gamePrologue);
a.jmp(gameFrame + 5);
});
exe.hookJmp(GAME_FRAME, FRAME_PROLOGUE, quit, "boot-to-level: Escape quits the game");
exe.hookJmp(gameFrame, gamePrologue, quit, "boot-to-level: Escape quits the game");
}
}
@@ -1,6 +1,8 @@
package com.eliasmueller.llamamod.mods.cheats;
import com.eliasmueller.llamamod.exe.ExeImage;
import com.eliasmueller.llamamod.exe.GameExe;
import com.eliasmueller.llamamod.exe.Site;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModContext;
import com.eliasmueller.llamamod.mods.ModOption;
@@ -10,18 +12,12 @@ import java.util.List;
/**
* Turns on the game's built-in cheats at startup. The main menu matches arrow-key sequences
* (FUN_0041a160, table 0x571FBC, 6 × 9 dwords) and toggles bit i of the cheat word 0x584710;
* this mod sets the chosen bits before the main menu runs, so no sequence has to be typed (typing
* one still toggles the cheat off and on).
* (German exe: FUN_0041a160, table 0x571FBC, 6 × 9 dwords) and toggles bit i of the cheat word
* ({@link Site#CHEAT_FLAGS}); this mod sets the chosen bits before the main menu runs (wrapping
* {@link Site#STARTUP_CALL} in WinMain), so no sequence has to be typed (typing one still toggles the
* cheat off and on).
*/
public final class CheatsMod implements Mod {
static final long CHEAT_FLAGS = 0x584710;
static final long DISPLAY_DIAGNOSTICS = 0x5834D0;
/** {@code call FUN_0041c050} in WinMain, right after the command line has been parsed. */
static final long STARTUP_CALL = 0x41D3EA;
static final long STARTUP_TARGET = 0x41C050;
record Cheat(String key, int bit, String label, String description) {}
static final List<Cheat> TABLE = List.of(
@@ -83,11 +79,14 @@ public final class CheatsMod implements Mod {
for (Cheat c : TABLE) {
o.add(ModOption.bool(c.key(), c.label(), c.description(), c.bit() == 0));
}
// not a cheat: listed last under its own label prefix; the key stays "diagnostics" (stored configs)
o.add(ModOption.bool(
"diagnostics",
"Write display diagnostics",
"For graphics problems: the game writes a report of your graphics devices (<computer"
+ " name>.dispdiag) into the game folder. Same as its hidden -display_diagnostics switch.",
"Troubleshooting: write display diagnostics",
"Only for troubleshooting when asked to. At start the game switches through every screen mode"
+ " of every monitor – all screens go black for a while (30 s or more) – and writes a"
+ " report (<computer name>.dispdiag) into the game folder, then starts normally. Same"
+ " as its hidden -display_diagnostics switch.",
false));
return o;
}
@@ -106,15 +105,18 @@ public final class CheatsMod implements Mod {
}
int flags = mask;
ExeImage exe = ctx.exe();
GameExe g = exe.game();
long cheatFlags = g.va(Site.CHEAT_FLAGS), diagnostics = g.va(Site.DISPLAY_DIAGNOSTICS);
long target = g.va(Site.STARTUP_TARGET);
long fn = exe.emit("cheats: set flags at startup", a -> {
if (flags != 0) {
a.hex("81 0D").u32(CHEAT_FLAGS).u32(flags); // or dword [0x584710], flags
a.hex("81 0D").u32(cheatFlags).u32(flags); // or dword [cheat flags], flags
}
if (diag) {
a.hex("C6 05").u32(DISPLAY_DIAGNOSTICS).u8(1); // mov byte [0x5834D0], 1
a.hex("C6 05").u32(diagnostics).u8(1); // mov byte [display diagnostics], 1
}
a.jmp(STARTUP_TARGET);
a.jmp(target);
});
exe.hookCall(STARTUP_CALL, "E8 61 EC FF FF", fn, "WinMain: set cheat flags");
exe.hookCall(g.va(Site.STARTUP_CALL), g.original(Site.STARTUP_CALL, 5), fn, "WinMain: set cheat flags");
}
}
@@ -9,11 +9,15 @@ import static com.eliasmueller.llamamod.mods.practice.Asm.ESI;
import com.eliasmueller.llamamod.exe.ExeImage;
import com.eliasmueller.llamamod.exe.ExePatchException;
import com.eliasmueller.llamamod.exe.GameExe;
import com.eliasmueller.llamamod.exe.Site;
import com.eliasmueller.llamamod.exe.X86;
import com.eliasmueller.llamamod.mods.practice.Asm;
import java.util.List;
/**
* Free camera and noclip caves.
* Free camera and noclip caves. Addresses below are those of the German exe; the code uses the
* {@link Site}s, which exist in every supported build.
*
* <ul>
* <li>Controls are the game's own control bits (0x5fcf20, built by FUN_004256c0 from the bound
@@ -33,29 +37,9 @@ import com.eliasmueller.llamamod.mods.practice.Asm;
* </ul>
*/
public final class FreeCamPatch {
static final long CAM_UPDATE = 0x54bad0;
static final long CAM_CALL_RUNNING = 0x54b79f;
static final long CAM_CALL_PAUSED = 0x54b7e0;
static final long TICK_AFTER_STRATS = 0x54b783; // call FUN_004287c0 (particles)
static final long PARTICLES = 0x4287c0;
static final long AFTER_INPUT = 0x54b5fd; // call FUN_00427c40, after the frame's input read
static final long AFTER_INPUT_TARGET = 0x427c40;
static final long PAD_BITS = 0x4256c0; // FUN_004256c0(int): pad structs -> control bits
static final long REGIONS = 0x41e020; // FUN_0041e020: region mask 0x58642c from the camera cell
static final long[] REGIONS_CALLS = {0x54b614, 0x54b7a4, 0x54b7e5};
static final String[] REGIONS_BYTES = {"E8 07 2A ED FF", "E8 77 28 ED FF", "E8 36 28 ED FF"};
static final long STRAT_UPDATE = 0x54c4a0;
static final long STRAT_RESET = 0x54d0d5; // mov [0x6d9e40], edi
static final long PLAYER = 0x6d9e1c;
static final long TICKS = 0x5724d4; // logic ticks this frame (30 per second)
static final long PAUSED = 0x584640;
static final long CONTROLS = 0x5fcf20; // held control bits
static final long STICK = 0x5fcefc; // stick magnitude (0xb5 for keyboard arrows)
static final long DEMO = 0x5fcf3c; // demo playback active
static final long PAD_COUNT = 0x5fcf6c, PADS = 0x5fcf70; // pad structs, 12 bytes each
static final long PITCH = 0x5790a0, YAW = 0x5790a4, CAM_X = 0x5790b0;
static final long TARGET_X = 0x6d9d80;
static final long SINE = 0x574318; // s32[4096], 4096 = 1.0; cos(a) = sine[a + 0x400]
/** Frame calls of REGIONS (before the ticks, after the running / paused camera update). */
static final List<Site> REGIONS_CALLS =
List.of(Site.REGIONS_CALL_FRAME, Site.REGIONS_CALL_RUNNING, Site.REGIONS_CALL_PAUSED);
/** Control bits (table 0x572100 for the 14 bindings). */
static final int C_UP = 0x10,
@@ -82,6 +66,19 @@ public final class FreeCamPatch {
public static void apply(ExeImage exe, int vkCam, int vkNoclip, boolean freeze, int camSpeed, int noclipSpeed)
throws ExePatchException {
GameExe g = exe.game();
long camUpdate = g.va(Site.CAM_UPDATE),
stratUpdate = g.va(Site.STRAT_UPDATE),
stratReset = g.va(Site.STRAT_RESET);
long afterInput = g.va(Site.AFTER_INPUT), afterInputTarget = g.va(Site.AFTER_INPUT_TARGET);
long particles = g.va(Site.PARTICLES), padBits = g.va(Site.PAD_BITS), regions = g.va(Site.REGIONS);
long player = g.va(Site.PLAYER), gameTicks = g.va(Site.TICKS), paused = g.va(Site.PAUSED);
long controls = g.va(Site.CONTROLS), stick = g.va(Site.STICK), demo = g.va(Site.DEMO);
long padCount = g.va(Site.PAD_COUNT), pads = g.va(Site.PADS), sine = g.va(Site.SINE);
long camPitch = g.va(Site.PITCH),
camYaw = g.va(Site.YAW),
camX = g.va(Site.CAM_X),
targetX = g.va(Site.TARGET_X);
long gaks = exe.importAddress("user32.dll", "GetAsyncKeyState");
long d = exe.allocateData(192, "freecam: state");
long fcOn = d, ncOn = d + 4, prevCam = d + 8, prevNc = d + 12;
@@ -106,14 +103,14 @@ public final class FreeCamPatch {
m.storeImm(pad, 0);
m.cmpMemImm(fcOn, 0);
a.jcc(X86.JE, out);
m.cmpMemImm(PAUSED, 0);
m.cmpMemImm(paused, 0);
a.jcc(X86.JNE, out);
m.cmpMemImm(DEMO, 0);
m.cmpMemImm(demo, 0);
a.jcc(X86.JNE, out);
// PgUp/PgDn (0x300) only exist in the keyboard pad word, not in the control bits
m.load(EAX, PADS).loadField(EAX, EAX, 0).andImm(EAX, C_PGUP | C_PGDN);
m.load(ECX, CONTROLS).add(EAX, ECX).store(pad, EAX);
m.load(ECX, PAD_COUNT).load(EAX, PADS);
m.load(EAX, pads).loadField(EAX, EAX, 0).andImm(EAX, C_PGUP | C_PGDN);
m.load(ECX, controls).add(EAX, ECX).store(pad, EAX);
m.load(ECX, padCount).load(EAX, pads);
a.bind(loop);
m.test(ECX, ECX);
a.jcc(X86.JE, done);
@@ -122,28 +119,28 @@ public final class FreeCamPatch {
m.addImm(EAX, 12).addImm(ECX, -1);
a.jmp(loop);
a.bind(done);
a.push(0).call(PAD_BITS);
a.push(0).call(padBits);
m.addImm(Asm.ESP, 4);
m.storeImm(STICK, 0);
m.storeImm(stick, 0);
a.bind(out);
a.popad();
a.jmp(AFTER_INPUT_TARGET);
a.jmp(afterInputTarget);
});
exe.hookCall(AFTER_INPUT, "E8 3E C6 ED FF", inFn, "freecam: input");
exe.hookCall(afterInput, g.original(Site.AFTER_INPUT, 5), inFn, "freecam: input");
long camFn = exe.emit("freecam: camera", a -> {
Asm m = new Asm(a);
X86.Label t1 = a.label(), t2 = a.label(), ncOff = a.label(), out = a.label(), chase = a.label();
a.call(CAM_UPDATE);
a.call(camUpdate);
a.pushad();
m.unlessPressed(gaks, vkCam, prevCam, t1);
m.xorMemImm(fcOn, 1).cmpMemImm(fcOn, 0);
a.jcc(X86.JE, t1);
for (int i = 0; i < 3; i++) {
m.load(EAX, CAM_X + 4L * i).store(cam + 4L * i, EAX);
m.load(EAX, camX + 4L * i).store(cam + 4L * i, EAX);
}
m.load(EAX, YAW).store(yaw, EAX);
m.load(EAX, PITCH);
m.load(EAX, camYaw).store(yaw, EAX);
m.load(EAX, camPitch);
signedAngle(a, m);
m.store(pitch, EAX);
a.bind(t1);
@@ -151,18 +148,18 @@ public final class FreeCamPatch {
X86.Label ncOffNow = a.label();
m.unlessPressed(gaks, vkNoclip, prevNc, t2);
m.xorMemImm(ncOn, 1);
m.load(ESI, PLAYER).test(ESI, ESI);
m.load(ESI, player).test(ESI, ESI);
a.jcc(X86.JE, ncOff);
m.cmpMemImm(ncOn, 0);
a.jcc(X86.JE, ncOffNow);
for (int i = 0; i < 3; i++) {
m.loadField(EAX, ESI, 0x30 + 4 * i).store(nc + 4L * i, EAX);
m.load(ECX, TARGET_X + 4L * i).sub(ECX, EAX).store(offT + 4L * i, ECX);
m.load(EAX, CAM_X + 4L * i).load(ECX, TARGET_X + 4L * i);
m.load(ECX, targetX + 4L * i).sub(ECX, EAX).store(offT + 4L * i, ECX);
m.load(EAX, camX + 4L * i).load(ECX, targetX + 4L * i);
m.sub(EAX, ECX).store(offC + 4L * i, EAX);
}
m.load(EAX, YAW).store(chYaw, EAX).storeImm(chTurn, 0);
m.load(EAX, PITCH).store(chPitch, EAX);
m.load(EAX, camYaw).store(chYaw, EAX).storeImm(chTurn, 0);
m.load(EAX, camPitch).store(chPitch, EAX);
m.loadField(EAX, ESI, 0xe8).andImm(EAX, COLLISION_FLAGS).store(ncFlags, EAX);
m.store(ncPlayer, ESI);
a.jmp(t2);
@@ -176,7 +173,7 @@ public final class FreeCamPatch {
m.storeImm(ncOn, 0);
a.bind(t2);
clampTicks(a, m, ticks);
clampTicks(a, m, gameTicks, ticks);
m.cmpMemImm(fcOn, 0);
a.jcc(X86.JE, chase);
@@ -203,8 +200,8 @@ public final class FreeCamPatch {
a.bind(p2);
m.load(EAX, yaw).andImm(EAX, 0xfff).store(yaw, EAX);
trig(m, yaw, sinY, cosY);
trig(m, pitch, sinP, cosP);
trig(m, sine, yaw, sinY, cosY);
trig(m, sine, pitch, sinP, cosP);
m.load(EAX, sinY).load(ECX, cosP).imul(EAX, ECX).sar(EAX, 12).store(fwd, EAX);
m.load(EAX, sinP).store(fwd + 4, EAX);
m.load(EAX, cosY).load(ECX, cosP).imul(EAX, ECX).sar(EAX, 12).store(fwd + 8, EAX);
@@ -220,54 +217,56 @@ public final class FreeCamPatch {
ifBits(a, m, pad, C_ACTION, () -> m.mov(EAX, EBX).neg(EAX).addMem(cam + 4, EAX));
for (int i = 0; i < 3; i++) {
m.load(EAX, cam + 4L * i).store(CAM_X + 4L * i, EAX);
m.load(ECX, fwd + 4L * i).shl(ECX, 2).add(EAX, ECX).store(TARGET_X + 4L * i, EAX);
m.load(EAX, cam + 4L * i).store(camX + 4L * i, EAX);
m.load(ECX, fwd + 4L * i).shl(ECX, 2).add(EAX, ECX).store(targetX + 4L * i, EAX);
}
m.load(EAX, yaw).store(YAW, EAX);
m.load(EAX, pitch).andImm(EAX, 0xfff).store(PITCH, EAX);
m.load(EAX, yaw).store(camYaw, EAX);
m.load(EAX, pitch).andImm(EAX, 0xfff).store(camPitch, EAX);
a.jmp(out);
// noclip chase camera: keep the offset to the llama, A/S orbit around it
a.bind(chase);
m.cmpMemImm(ncOn, 0);
a.jcc(X86.JE, out);
m.load(ESI, PLAYER).test(ESI, ESI);
m.load(ESI, player).test(ESI, ESI);
a.jcc(X86.JE, out);
m.cmpMem(ESI, ncPlayer);
a.jcc(X86.JNE, out);
X86.Label noTurn = a.label();
m.cmpMemImm(PAUSED, 0);
m.cmpMemImm(paused, 0);
a.jcc(X86.JNE, noTurn);
m.load(EDI, ticks).movImm(EAX, CHASE_TURN_PER_TICK).imul(EDI, EAX);
ifBits(a, m, CONTROLS, C_CAM_LEFT, () -> m.addMem(chTurn, EDI));
ifBits(a, m, CONTROLS, C_CAM_RIGHT, () -> m.mov(EAX, EDI).neg(EAX).addMem(chTurn, EAX));
ifBits(a, m, controls, C_CAM_LEFT, () -> m.addMem(chTurn, EDI));
ifBits(a, m, controls, C_CAM_RIGHT, () -> m.mov(EAX, EDI).neg(EAX).addMem(chTurn, EAX));
a.bind(noTurn);
trig(m, chTurn, sinY, cosY);
trig(m, sine, chTurn, sinY, cosY);
for (int i = 0; i < 3; i++) {
m.loadField(EAX, ESI, 0x30 + 4 * i).load(ECX, offT + 4L * i).add(EAX, ECX);
m.store(TARGET_X + 4L * i, EAX);
m.store(targetX + 4L * i, EAX);
}
// x' = x cos + z sin, z' = z cos - x sin (yaw of the offset grows by the turn)
m.load(EAX, offC).load(ECX, cosY).imul(EAX, ECX);
m.load(EDX, offC + 8).load(ECX, sinY).imul(EDX, ECX).add(EAX, EDX).sar(EAX, 12);
m.load(ECX, TARGET_X).add(EAX, ECX).store(CAM_X, EAX);
m.load(EAX, offC + 4).load(ECX, TARGET_X + 4).add(EAX, ECX).store(CAM_X + 4, EAX);
m.load(ECX, targetX).add(EAX, ECX).store(camX, EAX);
m.load(EAX, offC + 4).load(ECX, targetX + 4).add(EAX, ECX).store(camX + 4, EAX);
m.load(EAX, offC + 8).load(ECX, cosY).imul(EAX, ECX);
m.load(EDX, offC).load(ECX, sinY).imul(EDX, ECX).sub(EAX, EDX).sar(EAX, 12);
m.load(ECX, TARGET_X + 8).add(EAX, ECX).store(CAM_X + 8, EAX);
m.load(ECX, targetX + 8).add(EAX, ECX).store(camX + 8, EAX);
m.load(EAX, chYaw)
.load(ECX, chTurn)
.add(EAX, ECX)
.andImm(EAX, 0xfff)
.store(YAW, EAX);
m.load(EAX, chPitch).store(PITCH, EAX);
.store(camYaw, EAX);
m.load(EAX, chPitch).store(camPitch, EAX);
a.bind(out);
a.popad();
a.ret();
});
exe.hookCall(CAM_CALL_RUNNING, "E8 2C 03 00 00", camFn, "freecam: camera (running)");
exe.hookCall(CAM_CALL_PAUSED, "E8 EB 02 00 00", camFn, "freecam: camera (paused)");
exe.hookCall(
g.va(Site.CAM_CALL_RUNNING), g.original(Site.CAM_CALL_RUNNING, 5), camFn, "freecam: camera (running)");
exe.hookCall(
g.va(Site.CAM_CALL_PAUSED), g.original(Site.CAM_CALL_PAUSED, 5), camFn, "freecam: camera (paused)");
// region mask (which rooms are drawn) from the llama's cell instead of the free/chase camera
long rgFn = exe.emit("freecam: regions", a -> {
@@ -275,23 +274,23 @@ public final class FreeCamPatch {
X86.Label orig = a.label();
m.load(EAX, fcOn).load(ECX, ncOn).add(EAX, ECX);
a.jcc(X86.JE, orig);
m.load(EAX, PLAYER).test(EAX, EAX);
m.load(EAX, player).test(EAX, EAX);
a.jcc(X86.JE, orig);
a.pushad();
m.load(ECX, CAM_X).push(ECX).load(ECX, CAM_X + 8).push(ECX);
m.load(ECX, camX).push(ECX).load(ECX, camX + 8).push(ECX);
m.loadField(ECX, EAX, 0x30)
.store(CAM_X, ECX)
.store(camX, ECX)
.loadField(ECX, EAX, 0x38)
.store(CAM_X + 8, ECX);
a.call(REGIONS);
m.pop(ECX).store(CAM_X + 8, ECX).pop(ECX).store(CAM_X, ECX);
.store(camX + 8, ECX);
a.call(regions);
m.pop(ECX).store(camX + 8, ECX).pop(ECX).store(camX, ECX);
a.popad();
a.ret();
a.bind(orig);
a.jmp(REGIONS);
a.jmp(regions);
});
for (int i = 0; i < REGIONS_CALLS.length; i++) {
exe.hookCall(REGIONS_CALLS[i], REGIONS_BYTES[i], rgFn, "freecam: regions");
for (Site s : REGIONS_CALLS) {
exe.hookCall(g.va(s), g.original(s, 5), rgFn, "freecam: regions");
}
long ncFn = exe.emit("freecam: noclip", a -> {
@@ -300,21 +299,21 @@ public final class FreeCamPatch {
a.pushad();
m.cmpMemImm(ncOn, 0);
a.jcc(X86.JE, out);
m.load(ESI, PLAYER).test(ESI, ESI);
m.load(ESI, player).test(ESI, ESI);
a.jcc(X86.JE, out);
m.movImm(EBX, noclipSpeed * 128L);
boost(a, m, gaks, CONTROLS);
boost(a, m, gaks, controls);
m.store(step, EBX);
trig(m, YAW, sinY, cosY);
trig(m, sine, camYaw, sinY, cosY);
m.load(EAX, sinY).imul(EAX, EBX).sar(EAX, 12).store(mv, EAX);
m.load(EAX, cosY).imul(EAX, EBX).sar(EAX, 12).store(mv + 8, EAX);
rightVector(m, rv, sinY, cosY);
ifBits(a, m, CONTROLS, C_UP, () -> addXz(m, nc, mv, 1));
ifBits(a, m, CONTROLS, C_DOWN, () -> addXz(m, nc, mv, -1));
ifBits(a, m, CONTROLS, C_RIGHT, () -> addXz(m, nc, rv, 1));
ifBits(a, m, CONTROLS, C_LEFT, () -> addXz(m, nc, rv, -1));
ifBits(a, m, CONTROLS, C_JUMP, () -> m.addMem(nc + 4, EBX));
ifBits(a, m, CONTROLS, C_ACTION, () -> m.mov(EAX, EBX).neg(EAX).addMem(nc + 4, EAX));
ifBits(a, m, controls, C_UP, () -> addXz(m, nc, mv, 1));
ifBits(a, m, controls, C_DOWN, () -> addXz(m, nc, mv, -1));
ifBits(a, m, controls, C_RIGHT, () -> addXz(m, nc, rv, 1));
ifBits(a, m, controls, C_LEFT, () -> addXz(m, nc, rv, -1));
ifBits(a, m, controls, C_JUMP, () -> m.addMem(nc + 4, EBX));
ifBits(a, m, controls, C_ACTION, () -> m.mov(EAX, EBX).neg(EAX).addMem(nc + 4, EAX));
m.andFieldImm(ESI, 0xe8, ~COLLISION_FLAGS & 0xffffffffL);
for (int i = 0; i < 3; i++) {
m.load(EAX, nc + 4L * i);
@@ -323,9 +322,9 @@ public final class FreeCamPatch {
}
a.bind(out);
a.popad();
a.jmp(PARTICLES);
a.jmp(particles);
});
exe.hookCall(TICK_AFTER_STRATS, "E8 38 D0 ED FF", ncFn, "freecam: noclip tick");
exe.hookCall(g.va(Site.TICK_AFTER_STRATS), g.original(Site.TICK_AFTER_STRATS, 5), ncFn, "freecam: noclip tick");
if (freeze) {
long fz = exe.emit("freecam: freeze", a -> {
@@ -336,18 +335,19 @@ public final class FreeCamPatch {
a.ret();
a.bind(run);
a.hex("55 8B EC 83 EC 4C");
a.jmp(STRAT_UPDATE + 6);
a.jmp(stratUpdate + 6);
});
exe.hookJmp(STRAT_UPDATE, "55 8B EC 83 EC 4C", fz, "freecam: freeze strats");
exe.hookJmp(stratUpdate, "55 8B EC 83 EC 4C", fz, "freecam: freeze strats");
}
String reset = g.original(Site.STRAT_RESET, 6); // mov [strat list], edi
long rs = exe.emit("freecam: level reset", a -> {
Asm m = new Asm(a);
a.hex("89 3D 40 9E 6D 00");
a.hex(reset);
m.storeImm(fcOn, 0).storeImm(ncOn, 0);
a.jmp(STRAT_RESET + 6);
a.jmp(stratReset + 6);
});
exe.hookJmp(STRAT_RESET, "89 3D 40 9E 6D 00", rs, "freecam: level reset");
exe.hookJmp(stratReset, reset, rs, "freecam: level reset");
}
/** eax: angle 0..4095 → -2048..2047. */
@@ -359,10 +359,10 @@ public final class FreeCamPatch {
a.bind(pos);
}
/** [ticks] = clamp([TICKS], 1, 4). */
private static void clampTicks(X86 a, Asm m, long ticks) {
/** [ticks] = clamp([gameTicks], 1, 4). */
private static void clampTicks(X86 a, Asm m, long gameTicks, long ticks) {
X86.Label lo = a.label(), hi = a.label();
m.load(EBX, TICKS).cmpImm(EBX, 1);
m.load(EBX, gameTicks).cmpImm(EBX, 1);
a.jcc(X86.JGE, lo);
m.movImm(EBX, 1);
a.bind(lo);
@@ -374,9 +374,9 @@ public final class FreeCamPatch {
}
/** sin/cos of the angle at {@code angle} (eax, ecx). */
private static void trig(Asm m, long angle, long sinDst, long cosDst) {
m.load(EAX, angle).andImm(EAX, 0xfff).loadTable(ECX, SINE, EAX).store(sinDst, ECX);
m.addImm(EAX, 0x400).andImm(EAX, 0xfff).loadTable(ECX, SINE, EAX).store(cosDst, ECX);
private static void trig(Asm m, long sine, long angle, long sinDst, long cosDst) {
m.load(EAX, angle).andImm(EAX, 0xfff).loadTable(ECX, sine, EAX).store(sinDst, ECX);
m.addImm(EAX, 0x400).andImm(EAX, 0xfff).loadTable(ECX, sine, EAX).store(cosDst, ECX);
}
/** Screen-right on the ground for yaw (sin, cos): (-cos, sin) · ebx. */
@@ -1,6 +1,8 @@
package com.eliasmueller.llamamod.mods.introskip;
import com.eliasmueller.llamamod.exe.ExeImage;
import com.eliasmueller.llamamod.exe.GameExe;
import com.eliasmueller.llamamod.exe.Site;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModContext;
import com.eliasmueller.llamamod.mods.ModOption;
@@ -13,18 +15,8 @@ import java.util.List;
* played before/after chapters and the attract demo that starts after idling in the main menu.
*/
public final class IntroSkipMod implements Mod {
/** {@code push offset "disneylogompeg.dat"} in WinMain, first byte of the logo block. */
public static final long LOGOS = 0x41D56E;
/** First instruction after the logo block ({@code call FUN_00545030}). */
static final long AFTER_LOGOS = 0x41D5D3;
/** {@code call FUN_0041f630} (movie player) for %d%d%dINTRO.DAT, EULOGY.DAT, %d%d%dOUTRO.DAT. */
static final long[] STORY_MOVIES = {0x54AEC9, 0x54B138, 0x54B26F};
static final String[] STORY_MOVIE_CALLS = {"E8 62 47 ED FF", "E8 F3 44 ED FF", "E8 BC 43 ED FF"};
/** {@code jbe} of the main-menu idle check (1800 frames since the last input → demo). */
static final long DEMO_IDLE_JBE = 0x549DC0;
/** Calls of the movie player for %d%d%dINTRO.DAT, EULOGY.DAT, %d%d%dOUTRO.DAT. */
static final List<Site> STORY_MOVIES = List.of(Site.STORY_INTRO, Site.STORY_EULOGY, Site.STORY_OUTRO);
@Override
public String id() {
@@ -75,15 +67,20 @@ public final class IntroSkipMod implements Mod {
@Override
public void apply(ModContext ctx) throws IOException {
ExeImage exe = ctx.exe();
GameExe g = exe.game();
if (ctx.optionBool("logos")) {
skipLogos(exe, "skip logo movies and images");
}
if (ctx.optionBool("demo")) {
exe.patch(DEMO_IDLE_JBE, "76 41", "EB 41", "main menu: never start the attract demo");
// jbe of the idle check (1800 frames since the last input → demo) becomes jmp
String jbe = g.original(Site.DEMO_IDLE_JBE, 2);
exe.patch(
g.va(Site.DEMO_IDLE_JBE), jbe, "EB" + jbe.substring(2), "main menu: never start the attract demo");
}
if (ctx.optionBool("story")) {
for (int i = 0; i < STORY_MOVIES.length; i++) {
exe.patch(STORY_MOVIES[i], STORY_MOVIE_CALLS[i], "90 90 90 90 90", "skip story movie " + i);
for (int i = 0; i < STORY_MOVIES.size(); i++) {
Site s = STORY_MOVIES.get(i);
exe.patch(g.va(s), g.original(s, 5), "90 90 90 90 90", "skip story movie " + i);
}
}
}
@@ -91,12 +88,18 @@ public final class IntroSkipMod implements Mod {
/**
* Jumps over the logo block unless that is already done (boot-to-level, applied earlier, makes
* the identical patch). push ebx keeps the stack as the skipped englogo call left it (cleaned by
* add esp,0Ch at 0x41D5E4).
* add esp,0Ch after the block, German exe 0x41D5E4).
*/
public static void skipLogos(ExeImage exe, String label) throws IOException {
String skip = String.format("53 EB %02X", AFTER_LOGOS - (LOGOS + 3));
if (!Arrays.equals(exe.read(LOGOS, 3), ExeImage.hex(skip))) {
exe.patch(LOGOS, "68 28 25", skip, label);
GameExe g = exe.game();
long logos = g.va(Site.LOGOS);
long jump = g.va(Site.AFTER_LOGOS) - (logos + 3);
if (jump < 0 || jump > 0x7f) {
throw new IOException("Intro skip: logo block of " + jump + " bytes does not fit a short jump");
}
String skip = String.format("53 EB %02X", jump);
if (!Arrays.equals(exe.read(logos, 3), ExeImage.hex(skip))) {
exe.patch(logos, g.original(Site.LOGOS, 3), skip, label);
}
}
}
@@ -5,28 +5,29 @@ import static com.eliasmueller.llamamod.mods.practice.Asm.ESI;
import com.eliasmueller.llamamod.exe.ExeImage;
import com.eliasmueller.llamamod.exe.ExePatchException;
import com.eliasmueller.llamamod.exe.GameExe;
import com.eliasmueller.llamamod.exe.Site;
import com.eliasmueller.llamamod.exe.X86;
/**
* Save/restore the llama's position with two hotkeys. The cave wraps the per-tick strat update
* call (FUN_0054c4a0) in the game loop FUN_0054b590.
* call ({@link Site#TICK_CALL}, German exe: FUN_0054c4a0 called in the game loop FUN_0054b590).
*
* <p>Strat layout used: +0x24 rotation y, +0x30/+0x34/+0x38 position (20.12 fixed), +0x44 and
* +0x50..+0x58 the previous-tick copies (wall collision sweeps from previous to current position,
* so both are set). Player strat pointer 0x6d9e1c, current world/chapter 0x584eb8/0x584ebc.
* so both are set). Player strat pointer {@link Site#PLAYER}, current world/chapter {@link
* Site#WORLD}/{@link Site#CHAPTER}.
*/
public final class SavePositionPatch {
public static final long TICK_CALL = 0x54b77e; // call FUN_0054c4a0 inside the tick loop
public static final long STRAT_UPDATE = 0x54c4a0;
public static final long PLAYER = 0x6d9e1c;
public static final long WORLD = 0x584eb8;
public static final long CHAPTER = 0x584ebc;
/** Ticks the restored position is held, so the llama's script sees the floor again. */
static final int PIN_TICKS = 4;
private SavePositionPatch() {}
public static void apply(ExeImage exe, int vkSave, int vkLoad) throws ExePatchException {
GameExe g = exe.game();
long player = g.va(Site.PLAYER), world = g.va(Site.WORLD), chapter = g.va(Site.CHAPTER);
long stratUpdate = g.va(Site.STRAT_UPDATE), tickCall = g.va(Site.TICK_CALL);
long gaks = exe.importAddress("user32.dll", "GetAsyncKeyState");
long d = exe.allocateData(48, "practice: saved position");
long valid = d, sx = d + 4, sy = d + 8, sz = d + 12, srot = d + 16, sWorld = d + 20, sChap = d + 24;
@@ -37,38 +38,38 @@ public final class SavePositionPatch {
X86.Label noSave = a.label(), noLoad = a.label(), done = a.label(), put = a.label();
a.pushad();
m.unlessPressed(gaks, vkSave, prevSave, noSave);
m.load(ESI, PLAYER).test(ESI, ESI);
m.load(ESI, player).test(ESI, ESI);
a.jcc(X86.JE, noSave);
m.loadField(EAX, ESI, 0x30).store(sx, EAX);
m.loadField(EAX, ESI, 0x34).store(sy, EAX);
m.loadField(EAX, ESI, 0x38).store(sz, EAX);
m.loadField(EAX, ESI, 0x24).store(srot, EAX);
m.load(EAX, WORLD).store(sWorld, EAX);
m.load(EAX, CHAPTER).store(sChap, EAX);
m.load(EAX, world).store(sWorld, EAX);
m.load(EAX, chapter).store(sChap, EAX);
m.storeImm(valid, 1);
a.bind(noSave);
m.unlessPressed(gaks, vkLoad, prevLoad, noLoad);
m.cmpMemImm(valid, 0);
a.jcc(X86.JE, noLoad);
m.load(EAX, WORLD).cmpMem(EAX, sWorld);
m.load(EAX, world).cmpMem(EAX, sWorld);
a.jcc(X86.JNE, noLoad);
m.load(EAX, CHAPTER).cmpMem(EAX, sChap);
m.load(EAX, chapter).cmpMem(EAX, sChap);
a.jcc(X86.JNE, noLoad);
m.load(ESI, PLAYER).test(ESI, ESI);
m.load(ESI, player).test(ESI, ESI);
a.jcc(X86.JE, noLoad);
m.storeImm(pin, PIN_TICKS);
a.call(put);
a.bind(noLoad);
a.popad();
a.call(STRAT_UPDATE);
a.call(stratUpdate);
a.pushad();
m.cmpMemImm(pin, 0);
a.jcc(X86.JE, done);
m.decMem(pin);
m.load(ESI, PLAYER).test(ESI, ESI);
m.load(ESI, player).test(ESI, ESI);
a.jcc(X86.JE, done);
a.call(put);
a.bind(done);
@@ -83,6 +84,6 @@ public final class SavePositionPatch {
m.load(EAX, srot).storeField(ESI, 0x24, EAX).storeField(ESI, 0x44, EAX);
a.ret();
});
exe.hookCall(TICK_CALL, "E8 1D 0D 00 00", fn, "practice: tick hook");
exe.hookCall(tickCall, g.original(Site.TICK_CALL, 5), fn, "practice: tick hook");
}
}
@@ -1,6 +1,8 @@
package com.eliasmueller.llamamod.platform;
import com.eliasmueller.llamamod.exe.ExeImage;
import com.eliasmueller.llamamod.exe.ExePatchException;
import com.eliasmueller.llamamod.exe.Site;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
@@ -15,13 +17,16 @@ import java.util.List;
* ({@code 68 <VA of "Software\Disney Interactive\Emperors New Groove\1.0">}) immediately
* followed by {@code push 80000002h} ({@code 68 02 00 00 80}). The patch turns 02 into 01
* (HKLM to HKCU) in those five instructions. Located by pattern rather than fixed offsets so other
* language versions work too; German CD: file offsets 0x579b, 0x57d0, 0x5810, 0x5859, 0x5880
* (the byte after 68).
* language versions work too; German CD: file offsets 0x579b, 0x57d0, 0x5810, 0x5859, 0x5880,
* English 0x579b, 0x57d0, 0x5810, 0x5859, 0x5880 (the byte after 68).
*
* <p>Second part ({@link #relocateLevelRecords}): the per-chapter coin counters (u32 0x584F1C) and
* flags (u8 0x584EC8), indexed world·6 + chapter, only have rows for worlds 0–8; TestLevel (world 9,
* chapters up to 49) wrote over progress, language and options. Every reference is moved to a
* 128-entry copy in the unused tail of .data's last page (notes/testlevel-chapters.md).
* <p>Second part ({@link #relocateLevelRecords}): the per-chapter coin counters ({@link
* Site#COINS}, u32, German exe 0x584F1C) and flags ({@link Site#FLAGS}, u8, 0x584EC8), indexed
* world·6 + chapter, only have rows for worlds 0–8; TestLevel (world 9, chapters up to 49) wrote
* over progress, language and options. Every reference is moved to a 128-entry copy in unused
* memory: the tail of .data's last page (German exe), or, where that is too small (English exe:
* 43 bytes), the tail of .rdata's last page, whose section is then made writable
* (notes/testlevel-chapters.md).
*/
public final class ExePatcher {
private ExePatcher() {}
@@ -36,10 +41,10 @@ public final class ExePatcher {
/** One changed byte run of the base patch (file offset). */
public record Change(int offset, byte[] original, byte[] replacement, String label) {}
static final long COINS = 0x584F1C;
static final long FLAGS = 0x584EC8;
static final int ROWS = 54; // worlds 0-8 x 6
static final int SLOTS = 128; // world 9 chapter 49 = index 103
/** Sections whose last page may hold the relocated arrays, in order of preference. */
static final List<String> SLACK_SECTIONS = List.of(".data", ".rdata");
/** The complete base patch: HKCU registry plus the level record relocation. */
public static byte[] patch(byte[] exe) throws IOException {
@@ -61,33 +66,53 @@ public final class ExePatcher {
}
/**
* Moves the coin and flag arrays to a buffer in the slack after .data's VirtualSize (zeroed and
* mapped read/write by the loader as part of the section's last page). Every absolute address of
* both arrays in .text is rewritten: bases 0x584F1C / 0x584EC8, the world-1 flag base 0x584ECE
* (selection map, gold count), the flag loop end 0x584EFE and the coin loop end 0x584FF4 (KSV
* Moves the coin and flag arrays to a buffer in the slack after a section's VirtualSize (mapped
* by the loader as part of the section's last page; zero in the file). Every absolute address of
* both arrays in .text is rewritten: bases COINS / FLAGS, the world-1 flag base FLAGS+6
* (selection map, gold count), the flag loop end FLAGS+54 and the coin loop end COINS+216 (KSV
* load/save; matched as {@code cmp eax, imm32; jl}, the address is also a separate counter).
* Returns nothing if the exe is already relocated or the references differ from the known build.
* Returns nothing if the exe is already relocated or the references differ from the known builds.
*/
public static List<Change> relocateLevelRecords(byte[] exe) throws IOException {
ExeImage img = ExeImage.parse(exe);
ExeImage.Section data;
long oldCoins, oldFlags;
try {
data = img.section(".data");
} catch (IllegalArgumentException e) {
return List.of();
}
long end = data.va() + data.virtualSize();
long coins = (end + 15) & ~15L;
long flags = coins + 4L * SLOTS;
if (data.virtualSize() == 0 || flags + SLOTS > ((end + 0xfff) & ~0xfffL)) {
oldCoins = img.game().va(Site.COINS);
oldFlags = img.game().va(Site.FLAGS);
} catch (ExePatchException e) {
return List.of();
}
List<Change> l = new ArrayList<>();
boolean ok = retarget(img, l, "%s", COINS, coins, 11, "coins")
&& retarget(img, l, "%s", FLAGS, flags, 9, "flags")
&& retarget(img, l, "%s", FLAGS + 6, flags + 6, 5, "flags from world 1")
&& retarget(img, l, "%s", FLAGS + ROWS, flags + ROWS, 3, "flags end")
&& retarget(img, l, "3D %s 7C", COINS + 4 * ROWS, coins + 4 * ROWS, 2, "coins end");
long coins = -1;
for (String name : SLACK_SECTIONS) {
ExeImage.Section s;
try {
s = img.section(name);
} catch (IllegalArgumentException e) {
continue;
}
long end = s.va() + s.virtualSize();
long start = (end + 15) & ~15L;
if (s.virtualSize() == 0 || start + 5L * SLOTS > ((end + 0xfff) & ~0xfffL) || !zero(img, exe, start)) {
continue;
}
coins = start;
if (!s.writable()) {
int at = img.sectionFlagsOffset(name);
long flags = u32(exe, at) & 0xffffffffL;
l.add(new Change(at, le32(flags), le32(flags | 0x80000000L), "level records: " + name + " writable"));
}
break;
}
if (coins < 0 || coins == oldCoins) {
return List.of();
}
long flags = coins + 4L * SLOTS;
boolean ok = retarget(img, l, "%s", oldCoins, coins, 11, "coins")
&& retarget(img, l, "%s", oldFlags, flags, 9, "flags")
&& retarget(img, l, "%s", oldFlags + 6, flags + 6, 5, "flags from world 1")
&& retarget(img, l, "%s", oldFlags + ROWS, flags + ROWS, 3, "flags end")
&& retarget(img, l, "3D %s 7C", oldCoins + 4 * ROWS, coins + 4 * ROWS, 2, "coins end");
if (!ok) {
return List.of();
}
@@ -95,6 +120,17 @@ public final class ExePatcher {
return l;
}
/** The relocated arrays' bytes at {@code va} are zero in the file (or not file-backed). */
private static boolean zero(ExeImage img, byte[] exe, long va) {
for (long a = va; a < va + 5L * SLOTS; a++) {
int off = img.fileOffset(a);
if (off >= 0 && exe[off] != 0) {
return false;
}
}
return true;
}
/** {@code pattern}: hex with {@code %s} = the address operand; all hits must be {@code expected}. */
private static boolean retarget(
ExeImage img, List<Change> out, String pattern, long from, long to, int expected, String label) {
@@ -62,6 +62,7 @@ final class MainFrame extends JFrame {
private boolean crashReported;
private boolean oldWineAccepted;
private final RecoveryNotice recoveryNotice = new RecoveryNotice();
final UnsupportedExeWarning exeWarning = new UnsupportedExeWarning();
private boolean recoveryRunning;
MainFrame(AppState state) {
@@ -223,6 +224,9 @@ final class MainFrame extends JFrame {
showRecovery(state.recovery);
state.recovery = ModManager.Recovery.NONE;
}
if (inst) {
SwingUtilities.invokeLater(() -> exeWarning.atStart(this, state));
}
}
/** Tells the user once that an interrupted apply was repaired; BUSY is retried later. */
@@ -677,6 +677,7 @@ final class ModsPanel extends JPanel {
}
List<Mod> mods = selected();
ModManager mm = frame.state.modManager;
frame.exeWarning.beforeApply(frame, frame.state);
if (!AntivirusDialogs.notice(frame, mm.exeMods(mods), true)) {
return;
}
@@ -0,0 +1,86 @@
package com.eliasmueller.llamamod.ui;
import com.eliasmueller.llamamod.exe.GameExe;
import com.eliasmueller.llamamod.platform.FileOps;
import com.eliasmueller.llamamod.platform.Integrity;
import java.awt.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.stream.Collectors;
import javax.swing.JOptionPane;
/**
* Warns (never blocks) when the installed groove.exe is not one of the supported builds: once at
* start and once before the first Apply of a session.
*/
final class UnsupportedExeWarning {
private boolean shownAtStart;
private boolean shownAtApply;
private String checkedSha;
private boolean unsupported;
/** The warning text, or null if the build is supported or unknown because nothing is installed. */
static String message(String sha256) {
if (sha256 == null || sha256.isEmpty()) {
return null;
}
for (GameExe.Build b : GameExe.Build.values()) {
if (b.sha256.equalsIgnoreCase(sha256)) {
return null;
}
}
return "You are running an unsupported version of the game. Mods, especially mods that modify"
+ " groove.exe, probably won't work.\n\nSupported are: "
+ Arrays.stream(GameExe.Build.values())
.map(GameExe.Build::toString)
.collect(Collectors.joining(", "))
+ ".";
}
/** SHA-256 of the set-up original groove.exe ("" if not installed or unreadable). */
static String installedSha(AppState state) {
if (!state.cfg.installed()) {
return "";
}
if (!state.cfg.exeSha256.isEmpty()) {
return state.cfg.exeSha256;
}
try {
Path hd = state.paths.originalHd();
Path exe = Files.isDirectory(hd) ? Integrity.find(hd, "groove.exe") : null;
return exe != null && Files.isRegularFile(exe) ? FileOps.sha256(exe) : "";
} catch (IOException e) {
return "";
}
}
private String current(AppState state) {
String sha = installedSha(state);
if (!sha.equals(checkedSha)) {
checkedSha = sha;
unsupported = message(sha) != null;
shownAtStart = false;
shownAtApply = false;
}
return unsupported ? message(sha) : null;
}
void atStart(Component parent, AppState state) {
String m = current(state);
if (m != null && !shownAtStart) {
shownAtStart = true;
JOptionPane.showMessageDialog(parent, m, "Unsupported game version", JOptionPane.WARNING_MESSAGE);
}
}
/** Shows the warning before the first Apply of a session; Apply always continues afterwards. */
void beforeApply(Component parent, AppState state) {
String m = current(state);
if (m != null && !shownAtApply) {
shownAtApply = true;
JOptionPane.showMessageDialog(parent, m, "Unsupported game version", JOptionPane.WARNING_MESSAGE);
}
}
}
@@ -95,8 +95,12 @@ class ExeImageTest {
assertTrue(e.getMessage().contains("expected 03"), e.getMessage());
img.patch(0x40579a, "68 02 ?? 00 80", "68 01 00 00 80", "hkcu");
assertEquals(1, img.readU8(0x40579b));
// .bss / header / out of range
assertThrows(IllegalArgumentException.class, () -> img.patch(0x400010, "00", "01", "header"));
// headers are mapped at the image base: section flags can be patched (base patch, English exe)
long flags = img.va(img.sectionFlagsOffset(".rdata"));
assertEquals(img.imageBase() + img.sectionFlagsOffset(".rdata"), flags);
img.patch(flags, "40 00 00 40", "40 00 00 C0", "rdata writable");
assertTrue(ExeImage.parse(img.build()).section(".rdata").writable());
// .bss / out of range
assertThrows(IllegalArgumentException.class, () -> img.patch(0x57f000, "00", "01", "bss"));
}
@@ -0,0 +1,153 @@
package com.eliasmueller.llamamod.exe;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import com.eliasmueller.llamamod.mods.GameData;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
/**
* The {@link Site} table: every site resolves in both known builds to the documented address, and
* disassembles (objdump) to the instruction the patches expect.
*/
class GameExeTest {
/** Instruction at each code site; %s = hex of another site. */
static final Map<Site, String> CODE = Map.ofEntries(
Map.entry(Site.MENU_FRAME, "mov eax,ds:0x"),
Map.entry(Site.GAME_FRAME, "mov eax,ds:0x"),
Map.entry(Site.DEMO_SWITCH, "call 0x{MUSIC_STOP}"),
Map.entry(Site.READ_SLOTS, "sub esp,0x100"),
Map.entry(Site.LOAD_SLOT, "mov eax,ds:0x{SLOT}"),
Map.entry(Site.STARTUP_CALL, "call 0x{STARTUP_TARGET}"),
Map.entry(Site.LOGOS, "push 0x"),
Map.entry(Site.AFTER_LOGOS, "call 0x"),
Map.entry(Site.DEMO_IDLE_JBE, "jbe 0x"),
Map.entry(Site.STORY_INTRO, "call 0x{MOVIE_PLAYER}"),
Map.entry(Site.STORY_EULOGY, "call 0x{MOVIE_PLAYER}"),
Map.entry(Site.STORY_OUTRO, "call 0x{MOVIE_PLAYER}"),
Map.entry(Site.TICK_CALL, "call 0x{STRAT_UPDATE}"),
Map.entry(Site.CAM_CALL_RUNNING, "call 0x{CAM_UPDATE}"),
Map.entry(Site.CAM_CALL_PAUSED, "call 0x{CAM_UPDATE}"),
Map.entry(Site.TICK_AFTER_STRATS, "call 0x{PARTICLES}"),
Map.entry(Site.AFTER_INPUT, "call 0x{AFTER_INPUT_TARGET}"),
Map.entry(Site.PAD_BITS, "mov eax,DWORD PTR [esp+0x4]"),
Map.entry(Site.REGIONS_CALL_FRAME, "call 0x{REGIONS}"),
Map.entry(Site.REGIONS_CALL_RUNNING, "call 0x{REGIONS}"),
Map.entry(Site.REGIONS_CALL_PAUSED, "call 0x{REGIONS}"),
Map.entry(Site.STRAT_RESET, "mov DWORD PTR ds:0x"));
static byte[] bytes(String version) throws IOException {
return Files.readAllBytes(GameData.exePath(version));
}
@ParameterizedTest
@ValueSource(strings = {"de", "en"})
void everySiteResolvesToTheTable(String version) throws IOException {
GameExe g = ExeImage.parse(bytes(version)).game();
GameExe.Build b = version.equals("de") ? GameExe.Build.GERMAN : GameExe.Build.ENGLISH;
assertEquals(b, g.build());
List<String> wrong = new ArrayList<>();
for (Site s : Site.values()) {
long va = g.va(s);
if (va != b.va(s)) {
wrong.add(String.format(Locale.ROOT, "%s: 0x%X, table 0x%X", s, va, b.va(s)));
}
}
assertTrue(wrong.isEmpty(), String.join("\n", wrong));
}
@ParameterizedTest
@ValueSource(strings = {"de", "en"})
void everySiteDisassemblesAsExpected(String version) throws Exception {
Path exe = GameData.exePath(version);
GameExe g = ExeImage.parse(Files.readAllBytes(exe)).game();
TreeMap<Long, String> dis = objdump(exe);
List<String> wrong = new ArrayList<>();
for (Site s : Site.values()) {
String got;
String want;
switch (s.rule.kind()) {
case CODE -> {
got = dis.get(g.va(s));
want = expand(g, CODE.get(s));
}
case OPERAND -> {
got = dis.floorEntry(g.anchor(s)).getValue();
want = String.format(Locale.ROOT, "0x%x", g.va(s));
}
case CALL_TARGET -> {
got = dis.get(g.anchor(s));
want = String.format(Locale.ROOT, "call 0x%x", g.va(s));
if (!dis.containsKey(g.va(s))) {
wrong.add(s + ": target is not an instruction");
}
}
default -> throw new IllegalStateException();
}
if (got == null || !(s.rule.kind() == Site.Kind.OPERAND ? got.contains(want) : got.startsWith(want))) {
wrong.add(s + ": \"" + got + "\", expected \"" + want + "\"");
}
}
assertTrue(wrong.isEmpty(), version + "\n" + String.join("\n", wrong));
}
@Test
void unknownVersionFailsWithAClearMessage() throws IOException {
byte[] b = bytes("de");
b[ExeImage.parse(b).fileOffset(Site.MENU_FRAME.german) + 5] ^= 0x55;
GameExe g = ExeImage.parse(b).game();
assertNull(g.build());
ExePatchException e = assertThrows(ExePatchException.class, () -> g.va(Site.MENU_FRAME));
assertTrue(e.getMessage().contains("unknown program version"), e.getMessage());
assertTrue(e.getMessage().contains("unknown version (PE date 2001-02-12, 1642496 bytes)"), e.getMessage());
assertTrue(e.getMessage().contains("German version of 2001-02-12, English version of 2000-10-17"));
}
private static String expand(GameExe g, String s) throws ExePatchException {
Matcher m = Pattern.compile("\\{(\\w+)}").matcher(s);
StringBuilder sb = new StringBuilder();
while (m.find()) {
m.appendReplacement(sb, Long.toHexString(g.va(Site.valueOf(m.group(1)))));
}
return m.appendTail(sb).toString();
}
/** VA → instruction text (single spaces), .text only. */
static TreeMap<Long, String> objdump(Path exe) throws Exception {
Process p;
try {
p = new ProcessBuilder("objdump", "-d", "-M", "intel", "--no-show-raw-insn", "-j", ".text", exe.toString())
.redirectErrorStream(true)
.start();
} catch (IOException e) {
assumeTrue(false, "objdump not installed");
return null;
}
TreeMap<Long, String> out = new TreeMap<>();
Pattern line = Pattern.compile("^\\s+([0-9a-f]+):\\t(.*)$");
for (String l : new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).split("\n")) {
Matcher m = line.matcher(l);
if (m.matches()) {
out.put(Long.parseLong(m.group(1), 16), m.group(2).trim().replaceAll("\\s+", " "));
}
}
assertEquals(0, p.waitFor());
return out;
}
}
@@ -17,14 +17,15 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
/**
* Every combination of the groove.exe mods (intro skip, cheats, practice, free camera, boot to
* level), with default options and with every exe-relevant option switched on, must apply, give
* an executable that re-parses with a consistent section layout, replay byte-identically from the
* cache and restore to the base exe (needs -Dllamamod.cd).
* cache and restore to the base exe (needs -Dllamamod.cd), for the German and the English exe.
*/
class ExeModCombinationsTest {
@TempDir
@@ -39,10 +40,11 @@ class ExeModCombinationsTest {
"practice", Map.of("checkpoints", "false"),
"boot-to-level", Map.of("logos", "true", "quitOnEscape", "true"));
private AppPaths install() throws IOException {
/** {@code version}: "de" or "en" (the English exe runs against the German CD data here). */
private AppPaths install(String version) throws IOException {
Path cd = Path.of(System.getProperty("llamamod.cd", "/nonexistent"));
Path exe = cd.resolveSibling("Hd").resolve("groove.exe");
assumeTrue(Files.isRegularFile(exe) && Files.isDirectory(cd.resolve("wads")), "game data not available");
assumeTrue(Files.isDirectory(cd.resolve("wads")), "game data not available");
Path exe = GameData.exePath(version);
AppPaths p = new AppPaths(tmp);
// symlinks: mods and ModManager only ever replace files, never write into them
try (Stream<Path> st = Files.walk(cd)) {
@@ -58,9 +60,10 @@ class ExeModCombinationsTest {
return p;
}
@Test
void allCombinationsApplyReparseReplayAndRestore() throws IOException {
AppPaths p = install();
@ParameterizedTest
@ValueSource(strings = {"de", "en"})
void allCombinationsApplyReparseReplayAndRestore(String version) throws IOException {
AppPaths p = install(version);
ModRegistry reg = new ModRegistry();
List<Mod> exeMods = new ArrayList<>();
for (String id : IDS) {
@@ -0,0 +1,184 @@
package com.eliasmueller.llamamod.mods;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.eliasmueller.llamamod.exe.ExeImage;
import com.eliasmueller.llamamod.exe.GameExe;
import com.eliasmueller.llamamod.platform.ExePatcher;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/**
* groove.exe versions: the German output of every exe mod (and all of them together) stays
* byte-identical, and every mod applies to the English exe too.
*/
class ExeVersionsTest {
@TempDir
Path tmp;
record Config(String name, List<String> mods, Map<String, Map<String, String>> options) {}
static final Map<String, Map<String, String>> ALL_ON = Map.of(
"intro-skip", Map.of("logos", "true", "demo", "true", "story", "true"),
"cheats",
Map.of(
"health", "true",
"collectibles", "true",
"rewards", "true",
"fruit", "true",
"charge", "true",
"diagnostics", "true"),
"practice", Map.of("checkpoints", "false"),
"freecam", Map.of("freeze", "true"),
"boot-to-level", Map.of("logos", "true", "quitOnEscape", "true", "world", "3", "chapter", "2"));
/** SHA-256 of the German output before the version layer (2026-11-07); must never change. */
static final Map<String, String> GERMAN_GOLDEN = Map.ofEntries(
Map.entry("base", "ace999d7ca6150369dcb1bdf1fb7400d13967c25c47af3399533229f5cd95d53"),
Map.entry("intro-skip defaults", "0f4a1645939d3dade40f7a7ee0b7f6ae30c2ff19197f69c6c1f46321e8e3a8d9"),
Map.entry("intro-skip all", "8fd531c96ba058e2587c23b76f47090760240fff5380f0e18c7907fed604d39c"),
Map.entry("cheats defaults", "fbde1f858d8621bc221178e37656331a51d0aea0f3bffa3df2cc57abae065f9c"),
Map.entry("cheats all", "368c47e3d0f15459d87bad7f5378eda57f19a3c41fe5feaddaee0b06fb3f71ed"),
Map.entry("practice defaults", "116d60f16ed0624abe93fdba4a3f440ad336d212971179f2c367c1bedd2bc2a6"),
Map.entry("practice all", "116d60f16ed0624abe93fdba4a3f440ad336d212971179f2c367c1bedd2bc2a6"),
Map.entry("freecam defaults", "b900dffd826ff8a97321baaecdc6298ea0474fcbf1b920f2abc35158dec0b58d"),
Map.entry("freecam all", "b900dffd826ff8a97321baaecdc6298ea0474fcbf1b920f2abc35158dec0b58d"),
Map.entry("boot-to-level defaults", "dc9104ea58596c45f92d203e4dddd855f2a10270cbd2b2b2c61663eb9f2ad2a8"),
Map.entry("boot-to-level all", "e5663a945871409e4f0b3a701bcd82372daa4ca9846c00104309d30615d9b0ca"),
Map.entry("freecam no freeze", "d93106a1b764a77e2e2ed7c01b9fc24e1b1f06112570074d04e6b005d806bd31"),
Map.entry("all mods defaults", "9c18d4f6ea86b5e9e42eba092d3b1020d80a61c735c0a827b0dbf5baf407d942"),
Map.entry("all mods all", "dc0637ea12677d6595a06332e42ac319cae99f9267918ee58fb2ea1d13209691"));
static final List<String> IDS = List.of("intro-skip", "cheats", "practice", "freecam", "boot-to-level");
static List<Config> configs() {
List<Config> l = new java.util.ArrayList<>();
l.add(new Config("base", List.of(), Map.of()));
for (String id : IDS) {
l.add(new Config(id + " defaults", List.of(id), Map.of("practice", Map.of("checkpoints", "false"))));
l.add(new Config(id + " all", List.of(id), ALL_ON));
}
l.add(new Config("freecam no freeze", List.of("freecam"), Map.of("freecam", Map.of("freeze", "false"))));
l.add(new Config("all mods defaults", IDS, Map.of("practice", Map.of("checkpoints", "false"))));
l.add(new Config("all mods all", IDS, ALL_ON));
return l;
}
static byte[] build(byte[] orig, Config c, Path tmp) throws Exception {
return apply(orig, c, tmp).build();
}
static ExeImage apply(byte[] orig, Config c, Path tmp) throws Exception {
ExeImage img = ExeImage.parse(orig);
img.setOwner("base");
for (ExePatcher.Change ch : ExePatcher.changes(orig)) {
img.patch(img.va(ch.offset()), ch.original(), ch.replacement(), ch.label());
}
ModRegistry reg = new ModRegistry();
for (Mod m : ModManager.sorted(c.mods().stream().map(reg::get).toList())) {
m.apply(ctx(m, c.options().getOrDefault(m.id(), Map.of()), img, tmp));
}
return img;
}
/** TestModContext with the options' real defaults (it turns empty values into "1"). */
static ModContext ctx(Mod m, Map<String, String> o, ExeImage exe, Path tmp) {
TestModContext t = new TestModContext(tmp, m, o, exe);
Map<String, String> v = new LinkedHashMap<>(t.optionValues());
for (ModOption opt : m.options()) {
if (!o.containsKey(opt.key()) && opt.defaultValue().isEmpty()) {
v.put(opt.key(), "");
}
}
return new ModContext() {
public Path originalCd() {
return t.originalCd();
}
public byte[] read(String p) throws IOException {
return t.read(p);
}
public boolean exists(String p) {
return t.exists(p);
}
public List<String> list(String d) throws IOException {
return t.list(d);
}
public void write(String p, byte[] d) {
t.write(p, d);
}
public void progress(double f, String msg) {}
public ExeImage exe() {
return exe;
}
public Map<String, String> optionValues() {
return v;
}
};
}
static String sha(byte[] b) throws Exception {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(b));
}
@Test
void germanOutputIsByteIdenticalForEveryMod() throws Exception {
byte[] de = Files.readAllBytes(GameData.germanExe());
for (Config c : configs()) {
assertEquals(GERMAN_GOLDEN.get(c.name()), sha(build(de, c, tmp)), c.name());
}
}
/** Same mod patches (owner, kind, label, size) as in the German exe; the result re-parses. */
@Test
void everyModAppliesToTheEnglishExe() throws Exception {
byte[] de = Files.readAllBytes(GameData.germanExe());
byte[] en = Files.readAllBytes(GameData.englishExe());
assertEquals(GameExe.Build.ENGLISH, ExeImage.parse(en).game().build());
for (Config c : configs()) {
ExeImage img = apply(en, c, tmp);
assertEquals(shape(apply(de, c, tmp)), shape(img), c.name());
ExeImage.parse(img.build());
}
}
private static List<String> shape(ExeImage img) {
return img.journal().stream()
.filter(o -> !o.owner().equals("base"))
.map(o -> switch (o) {
case ExeImage.Patch p -> p.owner() + " patch " + p.label() + " " + p.replacement().length;
case ExeImage.Allocation a -> a.owner() + " alloc " + a.label() + " " + a.size();
case ExeImage.DynamicImport d -> d.owner() + " import " + d.function();
})
.toList();
}
/** The English base patch changes the same five registry pushes and relocates the records into .rdata. */
@Test
void englishBasePatch() throws Exception {
byte[] en = Files.readAllBytes(GameData.englishExe());
assertTrue(ExePatcher.patchHkcu(en).patchedOffsets().size() == 5);
List<ExePatcher.Change> l = ExePatcher.relocateLevelRecords(en);
assertEquals(1 + 11 + 9 + 5 + 3 + 2, l.size());
assertEquals("level records: .rdata writable", l.getFirst().label());
byte[] out = ExePatcher.patch(en);
ExeImage img = ExeImage.parse(out);
assertTrue(img.section(".rdata").writable());
assertEquals(List.of(), ExePatcher.relocateLevelRecords(out), "already relocated");
}
}
@@ -21,12 +21,29 @@ public final class GameData {
}
public static ExeImage exe() throws IOException {
return ExeImage.parse(Files.readAllBytes(germanExe()));
}
/** The German groove.exe (Hd folder next to the CD). */
public static Path germanExe() {
Path cd = Path.of(System.getProperty("llamamod.cd", "/nonexistent"));
Path p = cd.resolveSibling("Hd").resolve("groove.exe");
if (!Files.isRegularFile(p)) {
p = cd.resolveSibling("hd").resolve("groove.exe");
}
assumeTrue(Files.isRegularFile(p), "groove.exe not available");
return ExeImage.parse(Files.readAllBytes(p));
return p;
}
/** The English groove.exe (2000-10-17) from {@code -Dllamamod.exeEn}, default re/groove_en.exe. */
public static Path englishExe() {
Path p = Path.of(System.getProperty("llamamod.exeEn", "re/groove_en.exe"));
assumeTrue(Files.isRegularFile(p), "English groove.exe not available (llamamod.exeEn)");
return p;
}
/** "de" or "en". */
public static Path exePath(String version) {
return version.equals("en") ? englishExe() : germanExe();
}
}
@@ -18,10 +18,10 @@ import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.editor.model.cmd.PlayerStart;
import com.eliasmueller.llamamod.exe.ExeImage;
import com.eliasmueller.llamamod.exe.Site;
import com.eliasmueller.llamamod.mods.GameData;
import com.eliasmueller.llamamod.mods.ModContext;
import com.eliasmueller.llamamod.mods.TestModContext;
import com.eliasmueller.llamamod.mods.introskip.IntroSkipMod;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
@@ -85,9 +85,10 @@ class BootToLevelModTest {
exe.setOwner(BootToLevelMod.ID);
BootToLevelMod m = new BootToLevelMod();
m.apply(ctx(m, Map.of("world", "1", "chapter", "2", "quitOnEscape", "true"), exe));
assertEquals(0xE9, exe.readU8(BootToLevelMod.MENU_FRAME));
assertEquals(0xE9, exe.readU8(BootToLevelMod.GAME_FRAME));
assertArrayEquals(new byte[] {0x53, (byte) 0xEB, 0x62}, exe.read(IntroSkipMod.LOGOS, 3));
assertEquals(0xE9, exe.readU8(exe.game().va(Site.MENU_FRAME)));
assertEquals(0xE9, exe.readU8(exe.game().va(Site.GAME_FRAME)));
assertArrayEquals(
new byte[] {0x53, (byte) 0xEB, 0x62}, exe.read(exe.game().va(Site.LOGOS), 3));
ExeImage.parse(exe.build());
}
@@ -97,8 +98,8 @@ class BootToLevelModTest {
exe.setOwner(BootToLevelMod.ID);
BootToLevelMod m = new BootToLevelMod();
m.apply(ctx(m, Map.of("logos", "false"), exe));
assertEquals(0xA1, exe.readU8(BootToLevelMod.GAME_FRAME));
assertEquals(0x68, exe.readU8(IntroSkipMod.LOGOS));
assertEquals(0xA1, exe.readU8(exe.game().va(Site.GAME_FRAME)));
assertEquals(0x68, exe.readU8(exe.game().va(Site.LOGOS)));
}
@Test
@@ -5,7 +5,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.eliasmueller.llamamod.exe.ExeImage;
import com.eliasmueller.llamamod.exe.Site;
import com.eliasmueller.llamamod.mods.GameData;
import com.eliasmueller.llamamod.mods.ModOption;
import com.eliasmueller.llamamod.mods.ModOptions;
import com.eliasmueller.llamamod.mods.TestModContext;
import java.nio.file.Path;
import java.util.Map;
@@ -42,13 +45,23 @@ class CheatsModTest {
exe.setOwner("cheats");
CheatsMod mod = new CheatsMod();
new CheatsMod().apply(new TestModContext(tmp, mod, Map.of("collectibles", "true", "diagnostics", "true"), exe));
assertEquals(0xE8, exe.readU8(CheatsMod.STARTUP_CALL));
long fn = CheatsMod.STARTUP_CALL + 5 + (int) exe.readU32(CheatsMod.STARTUP_CALL + 1);
long call = exe.game().va(Site.STARTUP_CALL);
assertEquals(0xE8, exe.readU8(call));
long fn = call + 5 + (int) exe.readU32(call + 1);
assertArrayEquals(new byte[] {(byte) 0x81, 0x0D, 0x10, 0x47, 0x58, 0x00, 0x03, 0, 0, 0}, exe.read(fn, 10));
assertTrue(exe.readU8(fn + 10) == 0xC6);
ExeImage.parse(exe.build());
}
/** -display_diagnostics blanks every monitor while it tests all screen modes: never on by default. */
@Test
void displayDiagnosticsIsOffByDefault() {
ModOption o = ModOptions.find(new CheatsMod(), "diagnostics");
assertEquals("false", o.defaultValue());
assertTrue(o.label().startsWith("Troubleshooting:"), o.label());
assertTrue(o.description().contains("go black"), o.description());
}
@Test
void nothingSelectedLeavesExe() throws Exception {
ExeImage exe = GameData.exe();
@@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.eliasmueller.llamamod.exe.ExeImage;
import com.eliasmueller.llamamod.exe.Site;
import com.eliasmueller.llamamod.mods.GameData;
import com.eliasmueller.llamamod.mods.TestModContext;
import java.nio.file.Path;
@@ -20,11 +21,13 @@ class IntroSkipModTest {
ExeImage exe = GameData.exe();
exe.setOwner("intro-skip");
new IntroSkipMod().apply(new TestModContext(tmp, new IntroSkipMod(), Map.of("story", "true"), exe));
assertArrayEquals(new byte[] {0x53, (byte) 0xEB, 0x62}, exe.read(IntroSkipMod.LOGOS, 3));
assertEquals(0xEB, exe.readU8(IntroSkipMod.DEMO_IDLE_JBE));
for (long site : IntroSkipMod.STORY_MOVIES) {
assertArrayEquals(
new byte[] {0x53, (byte) 0xEB, 0x62}, exe.read(exe.game().va(Site.LOGOS), 3));
assertEquals(0xEB, exe.readU8(exe.game().va(Site.DEMO_IDLE_JBE)));
for (Site site : IntroSkipMod.STORY_MOVIES) {
assertArrayEquals(
new byte[] {(byte) 0x90, (byte) 0x90, (byte) 0x90, (byte) 0x90, (byte) 0x90}, exe.read(site, 5));
new byte[] {(byte) 0x90, (byte) 0x90, (byte) 0x90, (byte) 0x90, (byte) 0x90},
exe.read(exe.game().va(site), 5));
}
ExeImage.parse(exe.build());
}
@@ -34,6 +37,6 @@ class IntroSkipModTest {
ExeImage exe = GameData.exe();
exe.setOwner("intro-skip");
new IntroSkipMod().apply(new TestModContext(tmp, new IntroSkipMod(), Map.of(), exe));
assertEquals(0xE8, exe.readU8(IntroSkipMod.STORY_MOVIES[0]));
assertEquals(0xE8, exe.readU8(exe.game().va(Site.STORY_INTRO)));
}
}
@@ -0,0 +1,28 @@
package com.eliasmueller.llamamod.ui;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.eliasmueller.llamamod.exe.GameExe;
import org.junit.jupiter.api.Test;
class UnsupportedExeWarningTest {
@Test
void supportedBuildsAndMissingInstallGiveNoWarning() {
for (GameExe.Build b : GameExe.Build.values()) {
assertNull(UnsupportedExeWarning.message(b.sha256));
assertNull(UnsupportedExeWarning.message(b.sha256.toUpperCase()));
}
assertNull(UnsupportedExeWarning.message(""));
assertNull(UnsupportedExeWarning.message(null));
}
@Test
void otherBuildsAreWarnedAboutAndTheSupportedOnesListed() {
String m = UnsupportedExeWarning.message("00".repeat(32));
assertNotNull(m);
assertTrue(m.startsWith("You are running an unsupported version of the game."), m);
assertTrue(m.contains("German version of 2001-02-12") && m.contains("English version of 2000-10-17"), m);
}
}