developer mode mod: game's developer switch on (warp with a+s, river log cheat), keys for the hidden aux inputs (fly, heart and fruit, idols, coins) on both exe versions

This commit is contained in:
MineTec committed 2026-11-08 18:09:03 +01:00
1 parent e36ff2d2c2
commit ec2876d9c8
14 files changed
+525 -13

No files matched your search

+4
View File
@@ -2,6 +2,10 @@
## 0.2.4 – unreleased
- **New mod "Developer mode"** (Cheats): turns on the game's hidden developer switch. Hold A+S to
warp to the developers' warp points, F1 to fly, F2 for a heart and 5 fruit, F3/F11 to count idols
and coins up; Space on the river log makes it invincible and fast.
- **Debug wireframe: new option "Allowance areas"** (off by default). The game counts the llama as
standing on a floor even a little outside its edges; at sharp corners this margin becomes a long
sliver where the llama can stand in the air. *Floating spots* marks those places in pink, *All edges*
+14 -6
View File
@@ -124,12 +124,6 @@ A minigame on a TestLevel chapter: alligators come in waves into a walled pit. E
| Coins per hit | 0 – 5 | 1 | Coins that pop out per hit (5 more at the end). |
| TestLevel chapter | 32 – 49 | 49 | Chapter of world 9 the arena replaces (32–49; chapter 1 is the Test World's). |
#### Jungle Day Chapter 2 (play-test)
Temporary play-test copy from the level editor – removed when the play-test ends. Changes the original level t1l4m002.
*Id* `playtest-jungle-day-chapter-2`
#### Llama Coaster
A roller coaster ride on a TestLevel chapter, with the cart from the Catacombs coaster levels: drops, climbs, boost arrows, a spin pad that turns the cart backwards and a jump over a gap. Steer left and right to stay on the track and collect coins; the ride ends at the station. Unlock TestLevel on the Save games tab.
@@ -259,6 +253,20 @@ Shows what is normally invisible: the walls and floors the llama collides with (
|---|---|---|---|
| Allowance areas | Off, Floating spots, All edges | Off | Pink marks where the game still counts the llama as standing on a floor although it is just beside it (the collision test accepts a small margin outside every floor edge). Off: nothing extra. Floating spots: only the places where that margin reaches noticeably far, past sharp corners, and the llama would stand in the air above the real floor. All edges: a thin pink band along every floor edge, also where it does not matter. Off by default because it adds geometry to every level. |
#### Developer mode
Turns on the game's hidden developer switch and puts its extra buttons on the keyboard. Hold A+S to warp the llama to the developers' warp points (22 levels, they go round in a circle). Hold F1 to fly, press F2 for a heart and 5 fruit, hold F3 for idols and F11 for coins (keys can be changed below). On the river log (world 3) hold Space: no damage, through the banks, very fast. In the finale, holding Space+Z ends the level. The developers' level-skip and music menu stays unavailable (it needs a second switch that is always off).
*Id* `devmode` · **modifies groove.exe** (see [antivirus warnings](20-troubleshooting.md))
| Option | Values | Default | Description |
|---|---|---|---|
| Fly key | F1, F2, F3, F4, F5, F7, F8, F9, F11, F12, Insert, Delete, Home, End | F1 | Hold to fly: the llama (also from standing), the bunny and the frog (after a jump) rise while the key is held. |
| Heart and fruit key | F1, F2, F3, F4, F5, F7, F8, F9, F11, F12, Insert, Delete, Home, End | F2 | Each press gives one heart and 5 purple fruit. |
| Idol key | F1, F2, F3, F4, F5, F7, F8, F9, F11, F12, Insert, Delete, Home, End | F3 | Hold to count the pink idols up (to 99). |
| Coin key | F1, F2, F3, F4, F5, F7, F8, F9, F11, F12, Insert, Delete, Home, End | F11 | Hold to count the coins up fast. |
| Fly key switches on and off | on / off | off | Press the fly key once to start flying and again to stop, instead of holding it. |
#### Free camera & noclip
Press F7 to fly the camera freely around the level: arrow keys move, A/S turn, PgUp/PgDn look up and down, X/Z rise and sink, Shift or Space go faster. Press F9 for noclip: the llama flies through walls (arrow keys, X/Z up and down). Press the key again to switch off (the keys can be changed below).
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

+1 -1
View File
@@ -166,7 +166,7 @@ layout or low-memory mode), not a debug flag. Other constant getter cases: 0x5E/
0x98, 0x9E, 0xC1, 0xD8, 0xED, 0xEE, 0x102 = 0; of the zero ids only 0 and 0x102 are read by any strat.
No exe code writes padAux, and no other dev key handler was found beyond the table above (F4, F6, F7).
Suggested "Developer mode" option for the cheats mod (not implemented): patch byte 0x552320 to 1
Implemented as the separate mod `devmode` (see `mod-devmode.md`); original suggestion: patch byte 0x552320 to 1
(or point jump-table entry 0x552A8C at a cave that pushes 0x1000, leaving the shared handler alone),
and map keys to padAux in the pad poll: at the end of FUN_004256C0 (0x425B88, where padAux is only
cleared when >1 pad) jump to a cave that builds padAux from the DIK state array 0x58226C + 4·DIK,
+57
View File
@@ -0,0 +1,57 @@
# Developer mode (`devmode`, CHEATS)
`mods/devmode/DevModeMod` + `DevModePatch` – two groove.exe patches, no data changes. Background and
the full list of what the switch unlocks: `mod-cheats.md`, "Developer switch (property 258)".
## Options
| Key | Default | padAux bit | Effect |
|---|---|---|---|
| `flyKey` | F1 | 0x800 held | Llama (also from standing), LlamaChase, bunny rise while held; frog after a jump |
| `heartKey` | F2 | 0x400 (pressed edge) | +1 heart, +5 purple fruit |
| `idolKey` | F3 | 0x200 held | pink idols +1 per frame, max 99 |
| `coinKey` | F11 | 0x100 held | coins +1 per frame |
| `flyToggle` | off | – | fly key toggles bit 0x800 instead of hold |
Keys are `Hotkeys.option` choices (cross-mod conflict check); defaults avoid practice F5/F8, freecam
F7/F9 and the game's F4/F6/F10. The developer switch itself has no option (always on with the mod).
Two equal keys → apply fails with a message.
## Patches (German / English addresses, all via `Site`)
1. **Property 258 = 1.0.** `PROP_GET` (0x550E60 / 0x550B80) is the strat property getter; its
`jmp [eax*4 + table]` has the table address at +21 (0x552684 / 0x552390, in .text). Entry
0x102 (0x552A8C / 0x552798) pointed at the shared `push 0` handler (0x55231B / 0x55198F; the
patch checks its bytes) and now points at a cave: `mov eax,[esp+14h]; push 0x1000; push eax;
call <push result>; add esp,8; add esp,10h; ret`. The other ids of the shared handler
(de: 0x75, 0x9E, 0xC1, 0xEE; en: 0xC1, 0xEE) stay 0. The table entry has a .reloc entry; the
exe always loads at its image base, so the absolute cave address is fine.
2. **padAux from keys.** `PAD_POLL_END` (0x425B88 / 0x425A08, tail of the pad poll FUN_004256c0:
`mov eax,[padCount]; pop ebx; cmp eax,1; jbe; mov [padAux],0; add esp,8; ret`, 25 bytes) →
`jmp` cave + NOPs. Cave: `pop ebx`, ecx = OR of the bits whose DIK slot in `KEY_STATE`
(0x58226C / 0x5818FC, s32[256] frames held, all 0 while Alt is held) is > 0, 0 while `DEMO`;
`mov [PAD_AUX],ecx` (0x5FCF58 / 0x5FC5E8); `mov eax,[padCount]` (keeps the old return value);
`add esp,8; ret`. The edge detection padAuxPressed (FUN_00425c00, start of each tick) is
untouched and works as before. Toggle mode: 8 data bytes (previous key state, on flag).
Hotkey names → DirectInput scan codes in `DevModePatch.DIK` (test: covers every `Hotkeys.VK` key).
## Verified in game (apptest `devmode-a`, :25, German exe, village 1-1, defaults)
* A+S three times: three different warp points (`img/mods/devmode_teleport.jpg`).
* F2 twice: fruit 0 → 5 → 10, energy bar refilled.
* F1 held 2.5 s: the llama rises above the village, falls back after release.
* F3 held 1.5 s: idols 46; F11 held 1 s: coins 1 → 32 (`img/mods/devmode_keys.jpg`).
* No crash; game starts normally with the mod (one crash in a Wine DLL at 68E9306B on the very
first start of a fresh session prefix, not reproducible in 3 later starts with the mod).
Not checked in game: English exe (unit tests only), toggle mode, river log Space, 8-6 Space+Z,
frog/bunny, LlamaChase.
## Limits
* Level-skip/music debug menu needs property 0 ≠ 0 as well (also hard-wired 0, used by 131 strat
reads incl. cameras) – deliberately not touched.
* Toggle state survives level changes (flying continues in the next level until pressed again).
* With the switch on, CheatTeleport stays alive in 22 levels; A+S (camera left+right together) is
the warp combo, so pressing both camera keys at once now teleports.
@@ -204,6 +204,23 @@ public enum Site {
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"),
// developer mode
PROP_GET(
code("8B 44 24 0C 83 EC 10 3D 09 01 00 00 0F 87 ?? ?? ?? ?? FF 24 85 ?? ?? ?? ??"),
0x550E60,
0x550B80,
"strat property getter: switch over ids 0..0x109, jump table address (in .text) at +21"),
PAD_POLL_END(
code("A1 ?? ?? ?? ?? 5B 83 F8 01 76 0A C7 05 ?? ?? ?? ?? 00 00 00 00 83 C4 08 C3"),
0x425B88,
0x425A08,
"end of PAD_BITS: clears PAD_AUX if more than one pad, then returns"),
PAD_AUX(operandAt(PAD_POLL_END, 13), 0x5FCF58, 0x5FC5E8, "aux pad bits (strat property 88), never set on PC"),
KEY_STATE(
operand("B9 00 01 00 00 33 C0 BF ?? ?? ?? ?? F3 AB B9 0F 00 00 00", 8),
0x58226C,
0x5818FC,
"s32[256] DirectInput keyboard state by DIK code (frames held, 0 = up)"),
// base patch: level record relocation
COINS(
operand("8B 34 B5 ?? ?? ?? ?? C1 EE 0C 3B F2", 3),
@@ -25,6 +25,7 @@ public final class ModRegistry {
"com.eliasmueller.llamamod.mods.freecam.FreeCamMod",
"com.eliasmueller.llamamod.mods.arena.ArenaMod",
"com.eliasmueller.llamamod.mods.coaster.CoasterMod",
"com.eliasmueller.llamamod.mods.devmode.DevModeMod",
};
/** {@link ModSource}s loaded by class name (dynamic mods, e.g. level projects). */
@@ -0,0 +1,92 @@
package com.eliasmueller.llamamod.mods.devmode;
import com.eliasmueller.llamamod.mods.Hotkeys;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModContext;
import com.eliasmueller.llamamod.mods.ModOption;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/** The game's hidden developer switch and its PC-less aux pad buttons (see {@link DevModePatch}). */
public final class DevModeMod implements Mod {
record AuxKey(String option, String label, String description, String def, int bit) {}
static final List<AuxKey> KEYS = List.of(
new AuxKey(
"flyKey",
"Fly key",
"Hold to fly: the llama (also from standing), the bunny and the frog (after a jump) rise"
+ " while the key is held.",
"F1",
DevModePatch.AUX_FLY),
new AuxKey(
"heartKey",
"Heart and fruit key",
"Each press gives one heart and 5 purple fruit.",
"F2",
DevModePatch.AUX_HEART),
new AuxKey("idolKey", "Idol key", "Hold to count the pink idols up (to 99).", "F3", DevModePatch.AUX_IDOLS),
new AuxKey("coinKey", "Coin key", "Hold to count the coins up fast.", "F11", DevModePatch.AUX_COINS));
@Override
public String id() {
return "devmode";
}
@Override
public String name() {
return "Developer mode";
}
@Override
public String category() {
return CHEATS;
}
@Override
public String description() {
return "Turns on the game's hidden developer switch and puts its extra buttons on the keyboard."
+ " Hold A+S to warp the llama to the developers' warp points (22 levels, they go round in a"
+ " circle). Hold F1 to fly, press F2 for a heart and 5 fruit, hold F3 for idols and F11 for"
+ " coins (keys can be changed below). On the river log (world 3) hold Space: no damage, through"
+ " the banks, very fast. In the finale, holding Space+Z ends the level. The developers'"
+ " level-skip and music menu stays unavailable (it needs a second switch that is always off).";
}
@Override
public boolean modifiesExecutable() {
return true;
}
@Override
public List<ModOption> options() {
List<ModOption> o = new ArrayList<>();
for (AuxKey k : KEYS) {
o.add(Hotkeys.option(k.option(), k.label(), k.description(), k.def()));
}
o.add(ModOption.bool(
"flyToggle",
"Fly key switches on and off",
"Press the fly key once to start flying and again to stop, instead of holding it.",
false));
return o;
}
@Override
public void apply(ModContext ctx) throws IOException {
List<DevModePatch.Key> keys = new ArrayList<>();
Set<String> used = new HashSet<>();
for (AuxKey k : KEYS) {
String v = ctx.option(k.option());
if (!used.add(v)) {
throw new IllegalArgumentException("Developer mode: " + v + " is used for two keys");
}
keys.add(new DevModePatch.Key(DevModePatch.dik(v), k.bit()));
}
DevModePatch.enableDeveloperFlag(ctx.exe());
DevModePatch.mapAuxKeys(ctx.exe(), keys, ctx.optionBool("flyToggle"));
}
}
@@ -0,0 +1,162 @@
package com.eliasmueller.llamamod.mods.devmode;
import static com.eliasmueller.llamamod.mods.practice.Asm.EAX;
import static com.eliasmueller.llamamod.mods.practice.Asm.EBX;
import static com.eliasmueller.llamamod.mods.practice.Asm.ECX;
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.HexFormat;
import java.util.List;
import java.util.Map;
/**
* Developer switch and aux pad keys (German exe addresses; the code uses the {@link Site}s).
*
* <ul>
* <li>Strat property 258 (0x102) is a developer flag the getter FUN_00550e60 hard-wires to 0:
* jump table 0x552684[0x102] (= 0x552A8C) → shared handler 0x55231B {@code push 0} (also
* ids 0x75, 0x9E, 0xC1, 0xEE). The table entry is pointed at a cave that pushes 0x1000 (1.0
* in 20.12); the shared handler stays untouched.
* <li>padAux (property 88, 0x5FCF58) is never set on PC. The tail of the pad poll FUN_004256c0
* (0x425B88: {@code if (padCount > 1) padAux = 0; return}) is replaced by a cave that builds
* padAux from the DirectInput key state 0x58226C[DIK] (0 while a demo plays). The edge
* detection padAuxPressed (0x5FCF60, FUN_00425c00 at the start of every tick) runs after
* it as before.
* </ul>
*/
public final class DevModePatch {
/** Developer flag property id and the value the cave pushes. */
static final int PROP_DEV = 0x102, ONE = 0x1000;
/** padAux bits read by the strats. */
static final int AUX_COINS = 0x100, AUX_IDOLS = 0x200, AUX_HEART = 0x400, AUX_FLY = 0x800;
/** Original shared handler of property 258: {@code mov eax,[esp+14h]; push 0; push eax; call push; ...; ret}. */
static final String ZERO_HANDLER = "8B 44 24 14 6A 00 50 E8 ?? ?? ?? ?? 83 C4 08 83 C4 10 C3";
/** DirectInput scan codes of the {@link com.eliasmueller.llamamod.mods.Hotkeys} choices. */
static final Map<String, Integer> DIK = Map.ofEntries(
Map.entry("F1", 0x3B),
Map.entry("F2", 0x3C),
Map.entry("F3", 0x3D),
Map.entry("F4", 0x3E),
Map.entry("F5", 0x3F),
Map.entry("F7", 0x41),
Map.entry("F8", 0x42),
Map.entry("F9", 0x43),
Map.entry("F11", 0x57),
Map.entry("F12", 0x58),
Map.entry("Insert", 0xD2),
Map.entry("Delete", 0xD3),
Map.entry("Home", 0xC7),
Map.entry("End", 0xCF));
/** A key that sets padAux {@code bit} while held. */
record Key(int dik, int bit) {}
private DevModePatch() {}
static int dik(String hotkey) {
Integer v = DIK.get(hotkey);
if (v == null) {
throw new IllegalArgumentException("unknown hotkey " + hotkey);
}
return v;
}
/** Sets property 258 to 1.0. */
public static void enableDeveloperFlag(ExeImage exe) throws ExePatchException {
GameExe g = exe.game();
long table = exe.readU32(g.va(Site.PROP_GET) + 21);
long entry = table + 4L * PROP_DEV;
long handler = exe.readU32(entry);
String got = hex(exe.readOriginal(handler, 19));
if (!matches(got, ZERO_HANDLER)) {
throw new ExePatchException("devmode: property 258 handler at " + ExeImage.hexVa(handler) + " is " + got
+ ", expected " + ZERO_HANDLER + " – unknown program version");
}
long push = handler + 12 + (int) exe.readU32(handler + 8);
long fn = exe.emit("devmode: property 258 = 1", a -> {
a.hex("8B 44 24 14"); // mov eax,[esp+14h]
a.push(ONE);
a.u8(0x50); // push eax
a.call(push);
a.hex("83 C4 08 83 C4 10"); // add esp,8; add esp,10h
a.ret();
});
exe.patch(entry, le(handler), le(fn), "devmode: property 258 jump table entry");
}
/**
* padAux = OR of the bits whose key is held; {@code toggleFly}: the {@link #AUX_FLY} key (if in
* {@code keys}) switches the bit on and off instead.
*/
public static void mapAuxKeys(ExeImage exe, List<Key> keys, boolean toggleFly) throws ExePatchException {
GameExe g = exe.game();
long tail = g.va(Site.PAD_POLL_END), aux = g.va(Site.PAD_AUX), state = g.va(Site.KEY_STATE);
long demo = g.va(Site.DEMO), padCount = g.va(Site.PAD_COUNT);
long d = toggleFly ? exe.allocateData(8, "devmode: fly toggle") : 0;
long prev = d, flyOn = d + 4;
long fn = exe.emit("devmode: aux pad keys", a -> {
Asm m = new Asm(a);
X86.Label store = a.label();
m.pop(EBX); // the original tail's pop ebx
m.push(ECX);
a.hex("33 C9"); // xor ecx,ecx
m.cmpMemImm(demo, 0);
a.jcc(X86.JNE, store);
for (Key k : keys) {
X86.Label skip = a.label();
long slot = state + 4L * k.dik();
if (toggleFly && k.bit() == AUX_FLY) {
X86.Label same = a.label();
a.hex("33 C0"); // xor eax,eax
m.cmpMemImm(slot, 0);
a.hex("0F 9F C0"); // setg al
m.cmpMem(EAX, prev);
m.store(prev, EAX);
a.jcc(X86.JBE, same);
m.xorMemImm(flyOn, 1);
a.bind(same);
m.cmpMemImm(flyOn, 0);
a.jcc(X86.JE, skip);
} else {
m.cmpMemImm(slot, 0);
a.jcc(X86.JLE, skip);
}
a.hex("81 C9").u32(k.bit()); // or ecx,bit
a.bind(skip);
}
a.bind(store);
m.store(aux, ECX);
m.pop(ECX);
m.load(EAX, padCount);
a.hex("83 C4 08"); // add esp,8
a.ret();
});
exe.hookJmp(tail, g.original(Site.PAD_POLL_END, 25), fn, "devmode: pad poll tail");
}
private static boolean matches(String got, String pattern) {
String[] g = got.split(" "), p = pattern.split(" ");
for (int i = 0; i < p.length; i++) {
if (!p[i].equals("??") && !p[i].equals(g[i])) {
return false;
}
}
return true;
}
private static String hex(byte[] b) {
return HexFormat.ofDelimiter(" ").withUpperCase().formatHex(b);
}
private static byte[] le(long v) {
return new byte[] {(byte) v, (byte) (v >> 8), (byte) (v >> 16), (byte) (v >> 24)};
}
}
@@ -50,7 +50,9 @@ class GameExeTest {
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"));
Map.entry(Site.STRAT_RESET, "mov DWORD PTR ds:0x"),
Map.entry(Site.PROP_GET, "mov eax,DWORD PTR [esp+0xc]"),
Map.entry(Site.PAD_POLL_END, "mov eax,ds:0x{PAD_COUNT}"));
static byte[] bytes(String version) throws IOException {
return Files.readAllBytes(GameData.exePath(version));
@@ -23,7 +23,7 @@ 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
* level, developer mode), 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), for the German and the English exe.
*/
@@ -31,14 +31,15 @@ class ExeModCombinationsTest {
@TempDir
Path tmp;
private static final String[] IDS = {"intro-skip", "cheats", "practice", "freecam", "boot-to-level"};
private static final String[] IDS = {"intro-skip", "cheats", "practice", "freecam", "boot-to-level", "devmode"};
/** Options that switch on every exe change of a mod (data-only options stay at their defaults). */
private static final Map<String, Map<String, String>> ALL_ON = Map.of(
"intro-skip", Map.of("logos", "true", "demo", "true", "story", "true"),
"cheats", Map.of("diagnostics", "true"),
"practice", Map.of("checkpoints", "false"),
"boot-to-level", Map.of("logos", "true", "quitOnEscape", "true"));
"boot-to-level", Map.of("logos", "true", "quitOnEscape", "true"),
"devmode", Map.of("flyToggle", "true"));
/** {@code version}: "de" or "en" (the English exe runs against the German CD data here). */
private AppPaths install(String version) throws IOException {
@@ -39,7 +39,8 @@ class ExeVersionsTest {
"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"));
"boot-to-level", Map.of("logos", "true", "quitOnEscape", "true", "world", "3", "chapter", "2"),
"devmode", Map.of("flyToggle", "true", "coinKey", "End"));
/** 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(
@@ -56,7 +57,11 @@ class ExeVersionsTest {
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"));
Map.entry("all mods all", "dc0637ea12677d6595a06332e42ac319cae99f9267918ee58fb2ea1d13209691"),
// added later (developer mode, 2026-11-08)
Map.entry("devmode defaults", "0cbd6ef8cbeeb69d9187d50d1c292d7c31d1b0ebf79376b1ddc67e27b1e66d60"),
Map.entry("devmode all", "919fed192f1120301487292a38b2f7cd60480166140193f865f49b0ec7c94c1a"),
Map.entry("all mods and devmode all", "5c9a6d4e2687fe31fe944635c088287d979a0edf5b18abdcd7d0d5b4c3bb43dd"));
static final List<String> IDS = List.of("intro-skip", "cheats", "practice", "freecam", "boot-to-level");
@@ -70,6 +75,11 @@ class ExeVersionsTest {
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));
l.add(new Config("devmode defaults", List.of("devmode"), Map.of()));
l.add(new Config("devmode all", List.of("devmode"), ALL_ON));
List<String> withDev = new java.util.ArrayList<>(IDS);
withDev.add("devmode");
l.add(new Config("all mods and devmode all", withDev, ALL_ON));
return l;
}
@@ -0,0 +1,158 @@
package com.eliasmueller.llamamod.mods.devmode;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.eliasmueller.llamamod.exe.ExeImage;
import com.eliasmueller.llamamod.exe.GameExe;
import com.eliasmueller.llamamod.exe.Site;
import com.eliasmueller.llamamod.mods.GameData;
import com.eliasmueller.llamamod.mods.Hotkeys;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModRegistry;
import com.eliasmueller.llamamod.mods.TestModContext;
import com.eliasmueller.llamamod.platform.Config;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
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;
class DevModeModTest {
@TempDir
Path tmp;
private static ExeImage exe(String version) throws Exception {
return ExeImage.parse(Files.readAllBytes(GameData.exePath(version)));
}
private static ExeImage applied(String version, Map<String, String> opts, Path tmp) throws Exception {
ExeImage img = exe(version);
img.setOwner("devmode");
DevModeMod mod = new DevModeMod();
mod.apply(new TestModContext(tmp, mod, opts, img));
return img;
}
private static long rel(ExeImage img, long callAt) {
return callAt + 5 + (int) img.readU32(callAt + 1);
}
/** The aux key cave's code. */
private static String auxCave(ExeImage img) throws Exception {
ExeImage.Allocation a = img.journal("devmode").stream()
.filter(o -> o instanceof ExeImage.Allocation x && x.label().equals("devmode: aux pad keys"))
.map(o -> (ExeImage.Allocation) o)
.findFirst()
.orElseThrow();
assertEquals(rel(img, img.game().va(Site.PAD_POLL_END)), a.va());
return hex(img.read(a.va(), a.size()));
}
private static String hex(byte[] b) {
return HexFormat.ofDelimiter(" ").withUpperCase().formatHex(b);
}
@ParameterizedTest
@ValueSource(strings = {"de", "en"})
void property258PushesOneAndTheSharedHandlerStays(String version) throws Exception {
ExeImage orig = exe(version);
ExeImage img = applied(version, Map.of(), tmp);
GameExe g = img.game();
long table = img.readU32(g.va(Site.PROP_GET) + 21);
long handler = orig.readU32(table + 4L * 0x102);
assertEquals(version.equals("de") ? 0x55231BL : 0x55198FL, handler);
long cave = img.readU32(table + 4L * 0x102);
assertTrue(cave >= img.caveStart(), ExeImage.hexVa(cave));
assertEquals("8B 44 24 14 68 00 10 00 00 50 E8", hex(img.read(cave, 11)));
assertEquals(rel(orig, handler + 7), rel(img, cave + 10));
assertEquals("83 C4 08 83 C4 10 C3", hex(img.read(cave + 15, 7)));
// every other table entry (the shared handler's other ids among them) is unchanged
for (int id = 0; id < 0x10A; id++) {
if (id != 0x102) {
assertEquals(orig.readU32(table + 4L * id), img.readU32(table + 4L * id), "id " + id);
}
}
assertArrayEquals(orig.read(handler, 19), img.read(handler, 19));
ExeImage.parse(img.build());
}
@ParameterizedTest
@ValueSource(strings = {"de", "en"})
void padPollTailBuildsPadAuxFromTheKeyState(String version) throws Exception {
ExeImage img = applied(version, Map.of(), tmp);
GameExe g = img.game();
long tail = g.va(Site.PAD_POLL_END);
assertEquals(0xE9, img.readU8(tail));
for (int i = 5; i < 25; i++) {
assertEquals(0x90, img.readU8(tail + i));
}
String h = auxCave(img);
assertTrue(h.startsWith("5B 51 33 C9 81 3D"), h); // pop ebx; push ecx; xor ecx,ecx; cmp [demo],0
long keys = g.va(Site.KEY_STATE);
int[][] expect = {{0x3B, 0x800}, {0x3C, 0x400}, {0x3D, 0x200}, {0x57, 0x100}};
int at = 0;
for (int[] e : expect) {
// cmp dword [keys + 4*dik], 0; jle; or ecx, bit
String want = "81 3D " + hex(le(keys + 4L * e[0])) + " 00 00 00 00 0F 8E";
at = h.indexOf(want, at);
assertTrue(at > 0, "key " + Integer.toHexString(e[0]));
assertTrue(h.indexOf("81 C9 " + hex(le(e[1])), at) > at);
}
String end =
"89 0D " + hex(le(g.va(Site.PAD_AUX))) + " 59 8B 05 " + hex(le(g.va(Site.PAD_COUNT))) + " 83 C4 08 C3";
assertTrue(h.contains(end), h);
ExeImage.parse(img.build());
}
@Test
void customKeysAndFlyToggle() throws Exception {
ExeImage img = applied("de", Map.of("flyKey", "Home", "coinKey", "F12", "flyToggle", "true"), tmp);
String h = auxCave(img);
long keys = img.game().va(Site.KEY_STATE);
assertTrue(h.contains("33 C0 81 3D " + hex(le(keys + 4L * 0xC7)) + " 00 00 00 00 0F 9F C0"), h);
assertTrue(h.contains("81 3D " + hex(le(keys + 4L * 0x58))), h);
assertTrue(img.journal("devmode").stream()
.anyMatch(o -> o instanceof ExeImage.Allocation a && a.label().equals("devmode: fly toggle")));
}
@Test
void sameKeyTwiceIsRejected() {
assertThrows(IllegalArgumentException.class, () -> applied("de", Map.of("idolKey", "F1"), tmp));
}
@Test
void everyHotkeyHasAScanCode() {
assertEquals(Hotkeys.VK.keySet(), DevModePatch.DIK.keySet());
}
@Test
void defaultKeysDoNotClashWithTheOtherHotkeyMods() {
ModRegistry reg = new ModRegistry();
List<Mod> mods = List.of(reg.get("practice"), reg.get("freecam"), reg.get("devmode"));
assertTrue(
Hotkeys.conflicts(mods, new Config()).isEmpty(),
Hotkeys.conflicts(mods, new Config()).toString());
for (DevModeMod.AuxKey k : DevModeMod.KEYS) {
assertTrue(!List.of("F4", "F6", "F7", "F10").contains(k.def()), k.def());
}
}
@Test
void registeredAsCheat() {
Mod m = new ModRegistry().get("devmode");
assertEquals(Mod.CHEATS, m.category());
assertTrue(m.modifiesExecutable());
assertTrue(m.description().contains("A+S") && m.description().contains("level-skip"));
}
private static byte[] le(long v) {
return new byte[] {(byte) v, (byte) (v >> 8), (byte) (v >> 16), (byte) (v >> 24)};
}
}