diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e4c2e7..a2df3f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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* diff --git a/docs/manual/18-mods.md b/docs/manual/18-mods.md index e5a895f..e8d6449 100644 --- a/docs/manual/18-mods.md +++ b/docs/manual/18-mods.md @@ -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). diff --git a/notes/img/mods/devmode_keys.jpg b/notes/img/mods/devmode_keys.jpg new file mode 100644 index 0000000..dbec081 Binary files /dev/null and b/notes/img/mods/devmode_keys.jpg differ diff --git a/notes/img/mods/devmode_teleport.jpg b/notes/img/mods/devmode_teleport.jpg new file mode 100644 index 0000000..4b76f70 Binary files /dev/null and b/notes/img/mods/devmode_teleport.jpg differ diff --git a/notes/mod-cheats.md b/notes/mod-cheats.md index 178af14..1624c4f 100644 --- a/notes/mod-cheats.md +++ b/notes/mod-cheats.md @@ -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, diff --git a/notes/mod-devmode.md b/notes/mod-devmode.md new file mode 100644 index 0000000..5cc1f78 --- /dev/null +++ b/notes/mod-devmode.md @@ -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 ; 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. diff --git a/src/main/java/com/eliasmueller/llamamod/exe/Site.java b/src/main/java/com/eliasmueller/llamamod/exe/Site.java index 76cc16a..082c042 100644 --- a/src/main/java/com/eliasmueller/llamamod/exe/Site.java +++ b/src/main/java/com/eliasmueller/llamamod/exe/Site.java @@ -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), diff --git a/src/main/java/com/eliasmueller/llamamod/mods/ModRegistry.java b/src/main/java/com/eliasmueller/llamamod/mods/ModRegistry.java index 31d872c..c794ea1 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/ModRegistry.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/ModRegistry.java @@ -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). */ diff --git a/src/main/java/com/eliasmueller/llamamod/mods/devmode/DevModeMod.java b/src/main/java/com/eliasmueller/llamamod/mods/devmode/DevModeMod.java new file mode 100644 index 0000000..6aeeca1 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/mods/devmode/DevModeMod.java @@ -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 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 options() { + List 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 keys = new ArrayList<>(); + Set 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")); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/mods/devmode/DevModePatch.java b/src/main/java/com/eliasmueller/llamamod/mods/devmode/DevModePatch.java new file mode 100644 index 0000000..d14568c --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/mods/devmode/DevModePatch.java @@ -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). + * + *
    + *
  • 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. + *
  • 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. + *
+ */ +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 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 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)}; + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/exe/GameExeTest.java b/src/test/java/com/eliasmueller/llamamod/exe/GameExeTest.java index 96a205e..61985ba 100644 --- a/src/test/java/com/eliasmueller/llamamod/exe/GameExeTest.java +++ b/src/test/java/com/eliasmueller/llamamod/exe/GameExeTest.java @@ -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)); diff --git a/src/test/java/com/eliasmueller/llamamod/mods/ExeModCombinationsTest.java b/src/test/java/com/eliasmueller/llamamod/mods/ExeModCombinationsTest.java index 80efbee..178831d 100644 --- a/src/test/java/com/eliasmueller/llamamod/mods/ExeModCombinationsTest.java +++ b/src/test/java/com/eliasmueller/llamamod/mods/ExeModCombinationsTest.java @@ -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> 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 { diff --git a/src/test/java/com/eliasmueller/llamamod/mods/ExeVersionsTest.java b/src/test/java/com/eliasmueller/llamamod/mods/ExeVersionsTest.java index 4e06c2a..d4985c0 100644 --- a/src/test/java/com/eliasmueller/llamamod/mods/ExeVersionsTest.java +++ b/src/test/java/com/eliasmueller/llamamod/mods/ExeVersionsTest.java @@ -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 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 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 withDev = new java.util.ArrayList<>(IDS); + withDev.add("devmode"); + l.add(new Config("all mods and devmode all", withDev, ALL_ON)); return l; } diff --git a/src/test/java/com/eliasmueller/llamamod/mods/devmode/DevModeModTest.java b/src/test/java/com/eliasmueller/llamamod/mods/devmode/DevModeModTest.java new file mode 100644 index 0000000..1697bde --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/mods/devmode/DevModeModTest.java @@ -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 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 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)}; + } +}