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

@@ -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)};
}
}