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:
1 parent
e36ff2d2c2
commit
ec2876d9c8
14 files changed
+525
-13
No files matched your search
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user