From ca22c75fac30f81420b5024bbbfa619a74bfe2f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sat, 7 Nov 2026 18:14:15 +0100 Subject: [PATCH] english groove.exe (2000-10-17) supported next to the german one: named exe sites found by signatures per build (german patches byte-identical), base patch and every exe mod on both, unknown builds get a clear message; display diagnostics moved to troubleshooting with a warning (it flashes every monitor black) --- CHANGELOG.md | 9 + build.gradle | 2 + docs/manual/18-mods.md | 2 +- docs/manual/20-troubleshooting.md | 1 + docs/reference/game-files.md | 12 +- notes/img/mods/english_exe.jpg | Bin 0 -> 99216 bytes notes/llamamod-app.md | 61 +++- notes/mod-cheats.md | 2 +- .../eliasmueller/llamamod/exe/ExeImage.java | 63 ++++- .../eliasmueller/llamamod/exe/GameExe.java | 202 +++++++++++++ .../com/eliasmueller/llamamod/exe/Site.java | 266 ++++++++++++++++++ .../llamamod/mods/ModManager.java | 13 +- .../mods/boottolevel/BootToLevelMod.java | 93 +++--- .../llamamod/mods/cheats/CheatsMod.java | 36 +-- .../llamamod/mods/freecam/FreeCamPatch.java | 186 ++++++------ .../llamamod/mods/introskip/IntroSkipMod.java | 41 +-- .../mods/practice/SavePositionPatch.java | 33 +-- .../llamamod/platform/ExePatcher.java | 90 ++++-- .../llamamod/exe/ExeImageTest.java | 8 +- .../llamamod/exe/GameExeTest.java | 153 ++++++++++ .../llamamod/mods/ExeModCombinationsTest.java | 19 +- .../llamamod/mods/ExeVersionsTest.java | 184 ++++++++++++ .../eliasmueller/llamamod/mods/GameData.java | 19 +- .../mods/boottolevel/BootToLevelModTest.java | 13 +- .../llamamod/mods/cheats/CheatsModTest.java | 17 +- .../mods/introskip/IntroSkipModTest.java | 13 +- 26 files changed, 1285 insertions(+), 253 deletions(-) create mode 100644 notes/img/mods/english_exe.jpg create mode 100644 src/main/java/com/eliasmueller/llamamod/exe/GameExe.java create mode 100644 src/main/java/com/eliasmueller/llamamod/exe/Site.java create mode 100644 src/test/java/com/eliasmueller/llamamod/exe/GameExeTest.java create mode 100644 src/test/java/com/eliasmueller/llamamod/mods/ExeVersionsTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index d1779c9..c29a2b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.2.2 – unreleased + +- **English game version supported.** All exe mods (boot to level, cheats, intro skip, practice + tools, free camera) and the base patch now also work with the English groove.exe of + 2000-10-17; other unknown versions still stop with a clear message. +- **Display diagnostics option renamed and explained.** The cheats option is now called + "Troubleshooting: write display diagnostics" and warns that the game switches through every + screen mode of every monitor at start (screens go black for a while). It stays off by default. + ## 0.2.1 – changes since 0.1.0 The first test release was 0.1.0 (launcher edition). Everything below is new for testers. diff --git a/build.gradle b/build.gradle index 8432be7..34f8ed1 100644 --- a/build.gradle +++ b/build.gradle @@ -99,6 +99,8 @@ test { systemProperty 'llamamod.ref', System.getProperty('llamamod.ref', '') // level builder references: python3 tools/levelbuild.py levels/testwelt.py /testwelt.wad etc. systemProperty 'llamamod.lbref', System.getProperty('llamamod.lbref', '') + // English groove.exe (2000-10-17) for the version tests; never committed, skipped when absent + systemProperty 'llamamod.exeEn', System.getProperty('llamamod.exeEn', "${projectDir}/re/groove_en.exe") maxHeapSize = '2g' } diff --git a/docs/manual/18-mods.md b/docs/manual/18-mods.md index 4154eb8..406c294 100644 --- a/docs/manual/18-mods.md +++ b/docs/manual/18-mods.md @@ -241,7 +241,7 @@ Switches on the game's secret cheat codes for you, so you don't have to type the | Complete chapter rewards | on / off | off | Finishing a chapter also gives you its collectible and gold coin (code → → ↓ ↓ ↓ ↑ ↓ ↓). | | Start with 500 fruit | on / off | off | The purple fruit counter starts at 500 in every chapter (code ↑ ↓ → ← ← ← ← ↓). | | Full charge meter | on / off | off | The charge bar starts full in every chapter (code ↑ ← ↓ → ↓ ↓ ↑ ↑). | -| Write display diagnostics | on / off | off | For graphics problems: the game writes a report of your graphics devices (.dispdiag) into the game folder. Same as its hidden -display_diagnostics switch. | +| Troubleshooting: write display diagnostics | on / off | off | Only for troubleshooting when asked to. At start the game switches through every screen mode of every monitor – all screens go black for a while (30 s or more) – and writes a report (.dispdiag) into the game folder, then starts normally. Same as its hidden -display_diagnostics switch. | #### Debug wireframe diff --git a/docs/manual/20-troubleshooting.md b/docs/manual/20-troubleshooting.md index f6f3e38..dcf4d37 100644 --- a/docs/manual/20-troubleshooting.md +++ b/docs/manual/20-troubleshooting.md @@ -11,6 +11,7 @@ | "Not enough free disk space" | The game copy needs about 900 MB. Free up space or choose another **Data folder**. | | The game hangs or its window is gone | Click **■ Quit game** in LlamaMod (the Play button while the game runs). | | "The game crashed" | Switch off the mods you turned on last, or click **Restore original**, and play again. If the original game crashes too, report it with `game.log` (**Settings → Logs**). | +| All monitors flash black for half a minute or more when the game starts | The **Built-in cheats** option *Troubleshooting: write display diagnostics* is on. It makes the game try every screen mode of every monitor before it starts. Switch it off; use it only when you are asked for a display report. | | "groove.exe was removed or changed" | Click **Rebuild**; LlamaMod writes it again and then starts the game. On Windows, see [Antivirus warnings](#antivirus-warnings). | ## The editor diff --git a/docs/reference/game-files.md b/docs/reference/game-files.md index 7c17f90..e7e1086 100644 --- a/docs/reference/game-files.md +++ b/docs/reference/game-files.md @@ -21,7 +21,17 @@ with `CDPath`, `HDPath`, screen size, volumes and devices. LlamaMod's base patch redirects it to `HKEY_CURRENT_USER`, so no admin rights are needed. Development used the **German CD**. Other language versions are expected to use the same formats -but were not tested. +but were not tested with their own data. + +### groove.exe versions + +| Build | PE date | Size | Notes | +|---|---|---|---| +| German | 2001-02-12 | 1,642,496 bytes | reference for all addresses in these pages | +| English (first release, menu "Start Game") | 2000-10-17 | 1,634,304 bytes | same code, shifted (e.g. main menu frame handler 0x549990 instead of 0x549C50); .data globals laid out differently (game state 0x583D74 instead of 0x5846E4, player strat 0x6D94A4 instead of 0x6D9E1C); .data 0xD000 bytes in the file instead of 0xE000 | + +LlamaMod supports both (`exe/Site` lists every address it uses in both builds). Addresses in the +reference pages are those of the German build. ## Level naming scheme diff --git a/notes/img/mods/english_exe.jpg b/notes/img/mods/english_exe.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ff5c22d697fd27ee2a6fda87c3ca45c27f0d8781 GIT binary patch literal 99216 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: patch 0x… n bytes – label`). @@ -270,6 +271,58 @@ in cave code are fine). Tests: `exe/ExeImageTest` (real exe, re-parse + `objdump `mods/ModOptionsAndExeTest`; in-game check: `mods/ExeSmokeMod` (test classes, see below; its call counter is an `allocateData` block). +### groove.exe versions (`exe/GameExe`, `exe/Site`) + +Two builds are supported; their code is the same but shifted, and the .data globals are laid +out differently, so **no patch uses a fixed address**: + +| Build | PE date | Size | SHA-256 | +|---|---|---|---| +| German (reference, all RE notes) | 2001-02-12 | 1,642,496 | `7832eda4ee47a717e94441f21b75a9f30d11445c9f2408c6135a8fc01812e95b` | +| English first release ("Start Game", "Chapter") | 2000-10-17 | 1,634,304 | `bf0eb8829ed3e371c4e951ece0a7753acf2c28d07001b842e079b098576403b0` | + +`exe.game()` (a `GameExe`, cached per `ExeImage`) identifies the build by SHA-256 (`build()`, +`describe()` – logged in apply.log as `groove.exe: English version of 2000-10-17`) and resolves +each named `Site` on the file **as parsed** (before any patch): `va(site)`, `original(site, n)` = +the original bytes there (expected bytes for `patch`/`hookCall`, and the stolen bytes a detour +re-emits, e.g. the frame handlers' `mov eax,[global]`). Rules in `Site`: `code(sig)` = unique +match in .text, `??` masking absolute addresses and rel32 operands; `operand(sig, off)` / +`operandAt(site, off)` = the absolute address an instruction uses (must lie in .data – globals +are always read from code, never guessed); `callTarget(site)` = target of a call; `.calling(s)` +also checks that the call at the site goes to `s`. Each site lists its German and English VA: +`exe/GameExeTest` checks that both builds resolve to them and that every site disassembles +(objdump) to the expected instruction; `mods/ExeVersionsTest` checks that the German output of +every exe mod and option set is **byte-identical** to the output before the version layer (SHA-256 +golden values) and that every mod makes the same patches in the English exe; +`ExeModCombinationsTest` runs all combinations on both. The English exe is read from +`-Dllamamod.exeEn` (default `re/groove_en.exe`, gitignored – never commit it or add it to test +resources); tests skip without it. + +An unknown build fails on the first site that does not match exactly once, e.g. +`groove.exe (unknown version (PE date …, … bytes)): MENU_FRAME (state 2 (main menu) frame handler …) +not found – unknown program version. LlamaMod supports: German version of 2001-02-12, English +version of 2000-10-17.` The base patch stays lenient (HKCU by pattern, relocation skipped). +The mod cache key includes the exe hash for every build except the German one (whose keys did +not change). + +**In game** (apptest :25, 2026-11-07, English exe in `original/hd` with the **German CD data** – +the English exe runs with it; menus are English because the strings are in the exe): main menu +without logos after 17 s and no attract demo after 100 s (intro skip); boot to level (Village 2, +TestLevel 9); practice F5/F8 restores the position; free camera F7 flies away from the llama; +cheats: winedbg showed cheat flags 0x583DA0 = the chosen bits, energy 0x584500 stayed 0x5000 +after falls into water with `health`, dropped to 0x4000 without it; TestLevel chapter 9 → "Quit to +Map Select" gives a sane map (Village / Chapter 1 / 55) instead of the broken progress. +![English exe: menu, practice restore, free camera, map after TestLevel 9](img/mods/english_exe.jpg) + +**Adding a build:** copy it to `re/` (gitignored), add a `GameExe.Build` (language, PE date, size, +SHA-256), run `GameExeTest`: every failing site needs a longer/other signature (keep it unique in +all builds; mask only addresses and rel32). For the new column, read each address off the +resolved sites (or with a script that pairs relocation entries of matched instructions, as done +for the English build: an operand is accepted only if most references to the German global agree), +then add the column to `Site` and a case to `Build.va`. Check the base patch's free space +(`ExePatcher.SLACK_SECTIONS`) and run `ExeVersionsTest` / `ExeModCombinationsTest`; then test in +game. + ### Antivirus false positives A changed groove.exe with extra code may be flagged by antivirus programs (Windows Defender). diff --git a/notes/mod-cheats.md b/notes/mod-cheats.md index c1c72de..573f2db 100644 --- a/notes/mod-cheats.md +++ b/notes/mod-cheats.md @@ -57,7 +57,7 @@ diagnostics) → `jmp 0x41C050`. The cave writes only game globals, so it needs | Feature | State | In the mod | |---|---|---| -| `-display_diagnostics` (DAT_005834D0) | works: writes `.dispdiag` (DDraw/D3D enumeration) into the game folder | option `diagnostics` (tested: ELIASPC.dispdiag, 60 KB) | +| `-display_diagnostics` (DAT_005834D0) | works: writes `.dispdiag` (DDraw/D3D enumeration) into the game folder; FUN_0040f4f0 opens **every display mode of every DirectDraw device × D3D device** (FUN_00410550/FUN_00410700 = mode switch, FUN_00411000), logs caps and texture formats, closes it again, then returns and the game starts normally. On Windows with two monitors both screens flashed black for 30+ s (tester, 2026-11); invisible under Wine | option `diagnostics`, label "Troubleshooting: write display diagnostics", off by default, warning in its description (tested: ELIASPC.dispdiag, 60 KB) | | `c:\kingdom_dbg.txt` | dead: FUN_0040d2c0 (called from FUN_0043c220) only creates the file with "Logfile %s started now/today (write this code!)"; the 43 debug prints (FUN_00414600) go to OutputDebugStringA (0x6DC3E8), never to the file | no | | FrameAdvanceDelay (FUN_0043cb40) | slide show `In\001.bmp…` + `In\In.wav` at start, `Out\…` at exit, delay from registry `FrameAdvanceDelay` (default 1.0 s); folders are empty | no (data, not a cheat) | | F4 in game | requests state 0x0C (world map) – the normal "back to map" | no | diff --git a/src/main/java/com/eliasmueller/llamamod/exe/ExeImage.java b/src/main/java/com/eliasmueller/llamamod/exe/ExeImage.java index 601c963..2e556ca 100644 --- a/src/main/java/com/eliasmueller/llamamod/exe/ExeImage.java +++ b/src/main/java/com/eliasmueller/llamamod/exe/ExeImage.java @@ -46,6 +46,10 @@ public final class ExeImage { return (characteristics & 0x20000000) != 0; } + public boolean writable() { + return (characteristics & 0x80000000) != 0; + } + /** Bytes of the section that come from the file. */ public long fileBackedSize() { return virtualSize == 0 ? rawSize : Math.min(virtualSize, rawSize); @@ -70,6 +74,9 @@ public final class ExeImage { public record DynamicImport(String owner, String dll, String function, long slot) implements Op {} private final byte[] data; + /** The file as parsed, before any patch: {@link GameExe} resolves its sites here. */ + private final byte[] original; + private final int peOff; private final int optOff; private final long imageBase; @@ -81,9 +88,11 @@ public final class ExeImage { private final Region dataRegion; private final List journal = new ArrayList<>(); private String owner = "base"; + private GameExe game; private ExeImage(byte[] exe) throws ExePatchException { data = exe.clone(); + original = exe.clone(); if (data.length < 0x40 || data[0] != 'M' || data[1] != 'Z') { throw new ExePatchException("Not a Windows executable (no MZ header)"); } @@ -157,12 +166,18 @@ public final class ExeImage { return va >= dataRegion.va; } - /** File offset of a VA in the original file, or -1 (not file-backed, e.g. .bss or the cave). */ + /** + * File offset of a VA in the original file, or -1 (not file-backed, e.g. .bss or the cave). The + * PE headers are mapped at the image base, so they have VAs too (section flags can be patched). + */ public int fileOffset(long va) { return fileOffset(va, 1); } private int fileOffset(long va, int len) { + if (va >= imageBase && va + len <= imageBase + headerSize()) { + return (int) (va - imageBase); + } for (Section s : sections) { if (s.containsFileBacked(va, len)) { return (int) (s.rawOffset() + (va - s.va())); @@ -171,8 +186,11 @@ public final class ExeImage { return -1; } - /** VA of a file offset inside a section, or -1. */ + /** VA of a file offset inside the headers or a section, or -1. */ public long va(int fileOffset) { + if (fileOffset >= 0 && fileOffset < headerSize()) { + return imageBase + fileOffset; + } for (Section s : sections) { if (fileOffset >= s.rawOffset() && fileOffset < s.rawOffset() + s.fileBackedSize()) { return s.va() + (fileOffset - s.rawOffset()); @@ -181,6 +199,21 @@ public final class ExeImage { return -1; } + private int headerSize() { + return i32(optOff + 60); // SizeOfHeaders + } + + /** File offset of the Characteristics field of section {@code name}'s header. */ + public int sectionFlagsOffset(String name) { + int table = optOff + u16(peOff + 20); + for (int k = 0; k < sections.size(); k++) { + if (sections.get(k).name().equals(name)) { + return table + 40 * k + 36; + } + } + throw new IllegalArgumentException("No section " + name); + } + // ---------------------------------------------------------------- reading public byte[] read(long va, int len) { @@ -195,6 +228,26 @@ public final class ExeImage { return out; } + /** Bytes of the file as parsed (before any patch); file-backed section data only. */ + public byte[] readOriginal(long va, int len) { + int off = fileOffsetChecked(va, len); + return Arrays.copyOfRange(original, off, off + len); + } + + /** Build identification and version-independent sites of this executable. */ + public GameExe game() { + if (game == null) { + game = new GameExe(this, original); + } + return game; + } + + /** Like {@link #find(String, String)}, but on the file as parsed and stopping after {@code limit} hits. */ + public List findOriginal(String sectionName, String pattern, int limit) { + Pattern p = Pattern.of(pattern); + return find(original, List.of(section(sectionName)), p.bytes, p.mask, limit); + } + public int readU8(long va) { return read(va, 1)[0] & 0xff; } @@ -237,8 +290,12 @@ public final class ExeImage { } private List find(byte[] pat, boolean[] mask, int limit) { + return find(data, sections, pat, mask, limit); + } + + private static List find(byte[] data, List
in, byte[] pat, boolean[] mask, int limit) { List out = new ArrayList<>(); - for (Section s : sections) { + for (Section s : in) { int start = s.rawOffset(); int end = s.rawOffset() + (int) s.fileBackedSize(); outer: diff --git a/src/main/java/com/eliasmueller/llamamod/exe/GameExe.java b/src/main/java/com/eliasmueller/llamamod/exe/GameExe.java new file mode 100644 index 0000000..3a94223 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/exe/GameExe.java @@ -0,0 +1,202 @@ +package com.eliasmueller.llamamod.exe; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.EnumMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Which groove.exe this is, and where the {@link Site}s are in it. Sites are resolved on the file + * as parsed (before any patch), so the order in which mods patch does not matter. A site that is + * not found exactly once means an unknown program version: {@link #va} throws with a message that + * names the build and the supported ones. + */ +public final class GameExe { + /** The known builds. */ + public enum Build { + GERMAN("German", "2001-02-12", 1_642_496, "7832eda4ee47a717e94441f21b75a9f30d11445c9f2408c6135a8fc01812e95b"), + ENGLISH("English", "2000-10-17", 1_634_304, "bf0eb8829ed3e371c4e951ece0a7753acf2c28d07001b842e079b098576403b0"); + + public final String language; + /** PE timestamp (UTC date). */ + public final String date; + + public final int size; + public final String sha256; + + Build(String language, String date, int size, String sha256) { + this.language = language; + this.date = date; + this.size = size; + this.sha256 = sha256; + } + + public long va(Site s) { + return this == GERMAN ? s.german : s.english; + } + + @Override + public String toString() { + return language + " version of " + date; + } + } + + private final ExeImage img; + private final byte[] original; + private final Build build; + private final String sha256; + private final Map sites = new EnumMap<>(Site.class); + private final Map matches = new EnumMap<>(Site.class); + + GameExe(ExeImage img, byte[] original) { + this.img = img; + this.original = original; + sha256 = sha256(original); + Build b = null; + for (Build k : Build.values()) { + if (k.sha256.equals(sha256)) { + b = k; + } + } + build = b; + } + + /** SHA-256 of the file as parsed (hex). */ + public String sha256() { + return sha256; + } + + /** The known build, or null for any other groove.exe. */ + public Build build() { + return build; + } + + /** "German version of 2001-02-12" or "unknown version (PE date …, … bytes)". */ + public String describe() { + if (build != null) { + return build.toString(); + } + int pe = ByteBuffer.wrap(original).order(ByteOrder.LITTLE_ENDIAN).getInt(0x3c); + long ts = ByteBuffer.wrap(original).order(ByteOrder.LITTLE_ENDIAN).getInt(pe + 8) & 0xffffffffL; + return String.format( + Locale.ROOT, + "unknown version (PE date %s, %d bytes)", + Instant.ofEpochSecond(ts).atZone(ZoneOffset.UTC).toLocalDate(), + original.length); + } + + /** Address of {@code s} in this executable. */ + public long va(Site s) throws ExePatchException { + Long v = sites.get(s); + if (v == null) { + v = resolve(s); + sites.put(s, v); + } + return v; + } + + /** Where the rule of {@code s} matched: its signature match, or the address in its base site. */ + public long anchor(Site s) throws ExePatchException { + va(s); + return matches.get(s); + } + + /** The original bytes at {@code s} as hex (expected bytes of a patch there). */ + public String original(Site s, int len) throws ExePatchException { + return HexFormat.ofDelimiter(" ") + .formatHex(img.readOriginal(va(s), len)) + .toUpperCase(Locale.ROOT); + } + + private long resolve(Site s) throws ExePatchException { + Site.Rule r = s.rule; + long at; + if (r.pattern() != null) { + List hits = img.findOriginal(".text", r.pattern(), 2); + if (hits.size() != 1) { + throw unknown(s, hits.isEmpty() ? "not found" : "found more than once"); + } + at = hits.getFirst() + r.offset(); + } else { + at = va(r.base()) + r.offset(); + } + matches.put(s, at); + long v = + switch (r.kind()) { + case CODE -> at; + case OPERAND -> { + long a = u32(at); + if (!inSection(a, ".data")) { + throw unknown(s, String.format(Locale.ROOT, "address 0x%X is not in .data", a)); + } + yield a; + } + case CALL_TARGET -> { + long t = relTarget(at); + if (t < 0) { + throw unknown(s, "no call at " + ExeImage.hexVa(at)); + } + yield t; + } + }; + if (r.calls() != null && relTarget(v) != va(r.calls())) { + throw unknown(s, "does not call " + r.calls().name()); + } + return v; + } + + /** Target of the call/jmp rel32 at {@code va}, or -1. */ + private long relTarget(long va) { + int op = img.readOriginal(va, 1)[0] & 0xff; + if (op != 0xE8 && op != 0xE9) { + return -1; + } + return (va + 5 + (int) u32(va + 1)) & 0xffffffffL; + } + + private long u32(long va) { + return ByteBuffer.wrap(img.readOriginal(va, 4)) + .order(ByteOrder.LITTLE_ENDIAN) + .getInt() + & 0xffffffffL; + } + + private boolean inSection(long va, String name) { + try { + ExeImage.Section sec = img.section(name); + return va >= sec.va() && va < sec.va() + Math.max(sec.virtualSize(), sec.rawSize()); + } catch (IllegalArgumentException e) { + return false; + } + } + + private ExePatchException unknown(Site s, String why) { + StringBuilder known = new StringBuilder(); + for (Build b : Build.values()) { + known.append(known.isEmpty() ? "" : ", ").append(b); + } + return new ExePatchException(String.format( + Locale.ROOT, + "groove.exe (%s): %s (%s) %s – unknown program version. LlamaMod supports: %s.", + describe(), + s.name(), + s.description, + why, + known)); + } + + private static String sha256(byte[] b) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(b)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/exe/Site.java b/src/main/java/com/eliasmueller/llamamod/exe/Site.java new file mode 100644 index 0000000..76cc16a --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/exe/Site.java @@ -0,0 +1,266 @@ +package com.eliasmueller.llamamod.exe; + +/** + * Named code sites and globals of groove.exe that LlamaMod patches or uses, with the rule that + * finds each one in any build ({@link GameExe#va}) and its address in the two known builds + * (German 2001-02-12 / English 2000-10-17; GameExeTest checks both). + * + *

Rules: {@code code} = unique match of a byte signature in .text ({@code ??} masks absolute + * addresses and rel32 operands, which differ between builds); {@code operand} = the absolute + * address stored at an offset of a signature match (or of another site); {@code callTarget} = the + * target of the rel32 call/jmp at a site. {@code calling(s)} additionally requires the instruction + * at the site to call {@code s}. Globals are never guessed: each is read from an instruction that + * uses it. To support another build see notes/llamamod-app.md ("groove.exe versions"). + */ +public enum Site { + // boot to level + MENU_FRAME( + code("A1 ?? ?? ?? ?? 57 33 FF 3B C7 0F 85 ?? ?? ?? ??"), + 0x549C50, + 0x549990, + "state 2 (main menu) frame handler, starts with mov eax,[dword]"), + GAME_FRAME( + code("A1 ?? ?? ?? ?? 85 C0 0F 85 ?? ?? ?? ?? 83 3D ?? ?? ?? ?? 01"), + 0x549F40, + 0x549C80, + "state 3 (in game) frame handler, starts with mov eax,[dword]"), + DEMO_SWITCH( + code( + "E8 ?? ?? ?? ?? 8B 15 ?? ?? ?? ?? 89 3D ?? ?? ?? ?? 52 E8 ?? ?? ?? ?? 83 C4 04 E8 ?? ?? ?? ?? A1 ?? ?? ?? ?? 83 F8 04 75 58 8B 0D ?? ?? ?? ?? A3 ?? ?? ?? ?? 8B C1 C1 E0 04 41 8B 90 ?? ?? ?? ?? 83 F9 03 89 15 ?? ?? ?? ?? 8B 90 ?? ?? ?? ?? 89 15 ?? ?? ?? ?? 8B 90 ?? ?? ?? ?? 8B 80 ?? ?? ?? ?? 89 15 ?? ?? ?? ?? A3 ?? ?? ?? ?? 89 0D ?? ?? ?? ??"), + 0x549E47, + 0x549B87, + "main menu: music shutdown and switch to the attract demo (state 2 -> 4)"), + MUSIC_STOP(callTarget(DEMO_SWITCH), 0x546CC0, 0x546AF0, "music shutdown, part 1 (call at DEMO_SWITCH)"), + MUSIC_TRACK(operandAt(DEMO_SWITCH, 7), 0x6D94B4, 0x6D8B44, "current music track (pushed for MUSIC_PLAY)"), + MUSIC_ID(operandAt(DEMO_SWITCH, 13), 0x586430, 0x585AC0, "music id, zeroed before the switch"), + MUSIC_PLAY(callTarget(DEMO_SWITCH, 18), 0x546990, 0x5467C0, "music shutdown, part 2 (cdecl, 1 argument)"), + MUSIC_UPDATE(callTarget(DEMO_SWITCH, 26), 0x5464D0, 0x546350, "music shutdown, part 3"), + NEXT_STATE(operandAt(DEMO_SWITCH, 32), 0x584700, 0x583D90, "requested next game state"), + STATE( + operandAt(DEMO_SWITCH, 48), + 0x5846E4, + 0x583D74, + "game state (2 main menu, 3 in game, 6 save slots, 12 world map)"), + TRIBE(operandAt(DEMO_SWITCH, 69), 0x584EB4, 0x584544, "current tribe"), + WORLD(operandAt(DEMO_SWITCH, 81), 0x584EB8, 0x584548, "current world"), + CHAPTER(operandAt(DEMO_SWITCH, 99), 0x584EBC, 0x58454C, "current chapter"), + MAP(operandAt(DEMO_SWITCH, 104), 0x584EC0, 0x584550, "current map"), + READ_SLOTS( + code("81 EC 00 01 00 00 53 56 57 33 DB BF ?? ?? ?? ??"), + 0x41C0E0, + 0x41C050, + "reads the three save slot files"), + LOAD_SLOT( + code("A1 ?? ?? ?? ?? C7 05 ?? ?? ?? ?? 00 00 00 00 C1 E0 08"), + 0x41CC70, + 0x41CB80, + "loads save slot SLOT (or starts a new game in it)"), + SUB_STATE(operand("C7 05 ?? ?? ?? ?? 04 00 00 00 EB 10", 2), 0x584638, 0x583CC8, "menu sub-state"), + MAP_TRIBE(operand("A0 ?? ?? ?? ?? 8D BA ?? ?? ?? ?? C1 E9 0C", 1), 0x585588, 0x584C18, "world map: tribe"), + MAP_WORLD(operand("89 15 ?? ?? ?? ?? A3 ?? ?? ?? ?? 5E", 2), 0x584C24, 0x5842B4, "world map: world"), + MAP_CHAPTER( + operand("8B 15 ?? ?? ?? ?? A3 ?? ?? ?? ?? A0 ?? ?? ?? ??", 2), 0x584C20, 0x5842B0, "world map: chapter"), + MAP_MAP(operand("A3 ?? ?? ?? ?? 5E 83 C4 60 C3 90 90", 1), 0x58557C, 0x584C0C, "world map: map"), + FROM_MAIN_MENU( + operand("39 1D ?? ?? ?? ?? 74 6E A1 ?? ?? ?? ??", 2), + 0x5855B0, + 0x584C40, + "set when the save slots are opened from the main menu"), + SLOT(operand("A1 ?? ?? ?? ?? 83 F8 FF 75 31 33 C9", 1), 0x584C28, 0x5842B8, "selected save slot (0-2)"), + PRESSED( + operand("A1 ?? ?? ?? ?? 25 00 40 00 00 74 36", 1), + 0x5FCF08, + 0x5FC598, + "control bits pressed this frame (0x8 = pause)"), + // cheats + STARTUP_CALL( + code("E8 ?? ?? ?? ?? E8 ?? ?? ?? ?? 84 C0 75 15"), + 0x41D3EA, + 0x41D2BA, + "WinMain: call right after the command line was parsed"), + STARTUP_TARGET(callTarget(STARTUP_CALL), 0x41C050, 0x41BFC0, "function called at STARTUP_CALL"), + CHEAT_FLAGS( + operand("8B 0D ?? ?? ?? ?? 85 C8 75 3A 6A 08", 2), + 0x584710, + 0x583DA0, + "cheat bits toggled by the main-menu codes"), + DISPLAY_DIAGNOSTICS( + operand("C6 05 ?? ?? ?? ?? 01 68 ?? ?? ?? ??", 2), + 0x5834D0, + 0x582B60, + "-display_diagnostics switch (byte)"), + // intro skip + LOGOS( + code("68 ?? ?? ?? ?? E8 ?? ?? ?? ?? A1 ?? ?? ?? ?? 83 C4 08"), + 0x41D56E, + 0x41D43E, + "WinMain: push offset disneylogompeg.dat, first instruction of the logo block"), + AFTER_LOGOS( + code("E8 ?? ?? ?? ?? E8 ?? ?? ?? ?? 6A 2C"), + 0x41D5D3, + 0x41D4A3, + "WinMain: first instruction after the logo block"), + DEMO_IDLE_JBE( + code("76 41 A1 ?? ?? ?? ?? 6A 01 A3 ?? ?? ?? ??"), + 0x549DC0, + 0x549B00, + "main menu: jbe of the idle check (1800 frames -> attract demo)"), + STORY_INTRO( + code("E8 ?? ?? ?? ?? A1 ?? ?? ?? ?? 83 C4 08 83 F8 04"), + 0x54AEC9, + 0x54ABD9, + "call of the movie player for %d%d%dINTRO.DAT"), + MOVIE_PLAYER(callTarget(STORY_INTRO), 0x41F630, 0x41F4B0, "movie player (called at STORY_INTRO)"), + STORY_EULOGY( + code("E8 ?? ?? ?? ?? A1 ?? ?? ?? ?? 68 ?? ?? ?? ?? A3 ?? ?? ?? ??").calling(MOVIE_PLAYER), + 0x54B138, + 0x54AE48, + "call of the movie player for EULOGY.DAT"), + STORY_OUTRO( + code("E8 ?? ?? ?? ?? 68 ?? ?? ?? ?? E8 ?? ?? ?? ?? 6A 03").calling(MOVIE_PLAYER), + 0x54B26F, + 0x54AF7F, + "call of the movie player for %d%d%dOUTRO.DAT"), + // practice + TICK_CALL( + code("E8 ?? ?? ?? ?? E8 ?? ?? ?? ?? 8B 15 ?? ?? ?? ?? A1 ?? ?? ?? ?? 42"), + 0x54B77E, + 0x54B48E, + "game loop: per-tick call of STRAT_UPDATE"), + STRAT_UPDATE(callTarget(TICK_CALL), 0x54C4A0, 0x54C1B0, "strat update (one logic tick of all objects)"), + PLAYER(operand("A1 ?? ?? ?? ?? 83 C4 0C 46 83 C7 10", 1), 0x6D9E1C, 0x6D94A4, "player (llama) strat pointer"), + // free camera + CAM_CALL_RUNNING( + code("E8 ?? ?? ?? ?? E8 ?? ?? ?? ?? 5F 5E 5D C3 89 2D ?? ?? ?? ??"), + 0x54B79F, + 0x54B4AF, + "frame: call of CAM_UPDATE while running"), + CAM_UPDATE(callTarget(CAM_CALL_RUNNING), 0x54BAD0, 0x54B7E0, "copies the camera strat into the render camera"), + CAM_CALL_PAUSED( + code("E8 ?? ?? ?? ?? E8 ?? ?? ?? ?? 5F 5E 5D C3 90").calling(CAM_UPDATE), + 0x54B7E0, + 0x54B4F0, + "frame: call of CAM_UPDATE while paused"), + TICK_AFTER_STRATS( + code("E8 ?? ?? ?? ?? 8B 15 ?? ?? ?? ?? A1 ?? ?? ?? ?? 42"), + 0x54B783, + 0x54B493, + "tick loop: call of PARTICLES right after TICK_CALL"), + PARTICLES(callTarget(TICK_AFTER_STRATS), 0x4287C0, 0x428630, "particle update"), + AFTER_INPUT( + code("E8 ?? ?? ?? ?? A0 ?? ?? ?? ?? 84 C0 74 09"), + 0x54B5FD, + 0x54B30D, + "frame: call right after the frame's input read"), + AFTER_INPUT_TARGET(callTarget(AFTER_INPUT), 0x427C40, 0x427AB0, "function called at AFTER_INPUT"), + PAD_BITS( + code("8B 44 24 04 83 EC 08 53 33 DB 3B C3"), + 0x4256C0, + 0x425540, + "pad structs -> control bits (int argument)"), + REGIONS_CALL_FRAME( + code("E8 ?? ?? ?? ?? A1 ?? ?? ?? ?? BF 04 00 00 00"), + 0x54B614, + 0x54B324, + "frame: call of REGIONS before the ticks"), + REGIONS(callTarget(REGIONS_CALL_FRAME), 0x41E020, 0x41DEA0, "region mask (rooms to draw) from the camera cell"), + REGIONS_CALL_RUNNING( + code("E8 ?? ?? ?? ?? 5F 5E 5D C3 89 2D ?? ?? ?? ??").calling(REGIONS), + 0x54B7A4, + 0x54B4B4, + "frame: call of REGIONS after CAM_CALL_RUNNING"), + REGIONS_CALL_PAUSED( + code("E8 ?? ?? ?? ?? 5F 5E 5D C3 90 90 E9 ?? ?? ?? ??").calling(REGIONS), + 0x54B7E5, + 0x54B4F5, + "frame: call of REGIONS after CAM_CALL_PAUSED"), + STRAT_RESET( + code( + "89 3D ?? ?? ?? ?? 89 3D ?? ?? ?? ?? 89 3D ?? ?? ?? ?? 89 3D ?? ?? ?? ?? 89 3D ?? ?? ?? ?? 89 3D ?? ?? ?? ?? 89 3D ?? ?? ?? ?? 89 3D ?? ?? ?? ?? 89 3D ?? ?? ?? ?? 89 3D ?? ?? ?? ?? 89 3D ?? ?? ?? ??"), + 0x54D0D5, + 0x54CDE5, + "level start (strat system reset): mov [dword],edi"), + TICKS( + operand("89 35 ?? ?? ?? ?? 8B 0D ?? ?? ?? ??", 2), + 0x5724D4, + 0x5724D8, + "logic ticks this frame (30 per second)"), + PAUSED(operand("A1 ?? ?? ?? ?? 83 C4 10 85 C0 74 53", 1), 0x584640, 0x583CD0, "pause flag"), + CONTROLS(operand("A3 ?? ?? ?? ?? 7E 0A B8 10 00 00 00", 1), 0x5FCF20, 0x5FC5B0, "held control bits"), + STICK(operand("8B 3D ?? ?? ?? ?? 83 FF 2A 0F 83 ?? ?? ?? ??", 2), 0x5FCEFC, 0x5FC58C, "stick magnitude"), + DEMO(operand("8B 0D ?? ?? ?? ?? 3B CB 89 1D ?? ?? ?? ??", 2), 0x5FCF3C, 0x5FC5CC, "demo playback active"), + PAD_COUNT(operand("3B 2D ?? ?? ?? ?? 0F 83 ?? ?? ?? ??", 2), 0x5FCF6C, 0x5FC5FC, "number of pad structs"), + PADS(operand("89 3D ?? ?? ?? ?? 75 04 32 C0 5F C3", 2), 0x5FCF70, 0x5FC600, "pad structs (12 bytes each)"), + PITCH( + operand("A3 ?? ?? ?? ?? A1 ?? ?? ?? ?? 89 0D ?? ?? ?? ??", 1), + 0x5790A0, + 0x5790A0, + "render camera pitch (4096 = 360 degrees)"), + YAW(operand("66 8B 0D ?? ?? ?? ?? 89 1D ?? ?? ?? ??", 3), 0x5790A4, 0x5790A4, "render camera yaw"), + CAM_X(operand("8B 15 ?? ?? ?? ?? 8B 01 8B 49 08 2B C2", 2), 0x5790B0, 0x5790B0, "render camera position x, y, z"), + TARGET_X( + operand("68 ?? ?? ?? ?? E8 ?? ?? ?? ?? 8B 54 24 1C", 1), + 0x6D9D80, + 0x6D9408, + "render camera target x, y, z"), + SINE(operand("8B 1C 8D ?? ?? ?? ?? 8B C8 0F AF CA", 3), 0x574318, 0x574318, "s32[4096] sine table, 4096 = 1.0"), + // base patch: level record relocation + COINS( + operand("8B 34 B5 ?? ?? ?? ?? C1 EE 0C 3B F2", 3), + 0x584F1C, + 0x5845AC, + "u32 coin counters [world*6 + chapter], 54 rows"), + FLAGS( + operand("BE ?? ?? ?? ?? C6 82 ?? ?? ?? ?? 01", 1), + 0x584EC8, + 0x584558, + "u8 chapter flags [world*6 + chapter], 54 rows"); + + final Rule rule; + /** Address in the German build (2001-02-12). */ + public final long german; + /** Address in the English build (2000-10-17). */ + public final long english; + + public final String description; + + Site(Rule rule, long german, long english, String description) { + this.rule = rule; + this.german = german; + this.english = english; + this.description = description; + } + + enum Kind { + CODE, + OPERAND, + CALL_TARGET + } + + /** {@code pattern} null: relative to {@code base}. */ + record Rule(Kind kind, String pattern, Site base, int offset, Site calls) { + Rule calling(Site s) { + return new Rule(kind, pattern, base, offset, s); + } + } + + private static Rule code(String pattern) { + return new Rule(Kind.CODE, pattern, null, 0, null); + } + + private static Rule operand(String pattern, int offset) { + return new Rule(Kind.OPERAND, pattern, null, offset, null); + } + + private static Rule operandAt(Site base, int offset) { + return new Rule(Kind.OPERAND, null, base, offset, null); + } + + private static Rule callTarget(Site base) { + return callTarget(base, 0); + } + + private static Rule callTarget(Site base, int offset) { + return new Rule(Kind.CALL_TARGET, null, base, offset, null); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/mods/ModManager.java b/src/main/java/com/eliasmueller/llamamod/mods/ModManager.java index 1f9b499..df9761b 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/ModManager.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/ModManager.java @@ -2,6 +2,7 @@ package com.eliasmueller.llamamod.mods; import com.eliasmueller.llamamod.BuildInfo; import com.eliasmueller.llamamod.exe.ExeImage; +import com.eliasmueller.llamamod.exe.GameExe; import com.eliasmueller.llamamod.platform.AppPaths; import com.eliasmueller.llamamod.platform.Config; import com.eliasmueller.llamamod.platform.ExePatcher; @@ -313,9 +314,15 @@ public final class ModManager { return l; } - /** Start of the cache key chain: a new CD, app build or base exe patch invalidates every cached output. */ + /** + * Start of the cache key chain: a new CD, app build, base exe patch or groove.exe build (other + * than the German one, whose keys stay as they were) invalidates every cached output. + */ private String rootKey() { - return FileOps.sha256(cfg.cdFingerprint + "|app " + appBuild + "|base " + ExePatcher.REVISION) + String exe = cfg.exeSha256.isEmpty() || cfg.exeSha256.equals(GameExe.Build.GERMAN.sha256) + ? "" + : "|exe " + cfg.exeSha256; + return FileOps.sha256(cfg.cdFingerprint + "|app " + appBuild + "|base " + ExePatcher.REVISION + exe) .substring(0, 24); } @@ -562,6 +569,8 @@ public final class ModManager { } byte[] b = Files.readAllBytes(orig); ExeImage img = ExeImage.parse(b); + cfg.exeSha256 = img.game().sha256(); + log.line("groove.exe: " + img.game().describe()); img.setOwner(BASE_EXE_OWNER); for (ExePatcher.Change c : ExePatcher.changes(b)) { img.patch(img.va(c.offset()), c.original(), c.replacement(), c.label()); diff --git a/src/main/java/com/eliasmueller/llamamod/mods/boottolevel/BootToLevelMod.java b/src/main/java/com/eliasmueller/llamamod/mods/boottolevel/BootToLevelMod.java index 45a87e0..010804e 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/boottolevel/BootToLevelMod.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/boottolevel/BootToLevelMod.java @@ -1,6 +1,8 @@ package com.eliasmueller.llamamod.mods.boottolevel; import com.eliasmueller.llamamod.exe.ExeImage; +import com.eliasmueller.llamamod.exe.GameExe; +import com.eliasmueller.llamamod.exe.Site; import com.eliasmueller.llamamod.exe.X86; import com.eliasmueller.llamamod.mods.Mod; import com.eliasmueller.llamamod.mods.ModContext; @@ -17,30 +19,19 @@ import java.util.Locale; * switches to game state 3 with the chosen world/chapter, like the map's Enter would. Optionally * moves the player start of that level's WAD (play-test "play from here"). * - *

Engine facts (groove.exe): game state 0x5846E4 (2 = main menu, 3 = in game, 6 = save slots, - * 12 = world map), frame handlers table 0x578F28 (return 1 = state changed, the main loop then - * runs the enter handler of table 0x578F7C; state 3 = FUN_0054ae20, which loads tribe/world/ - * chapter/map 0x584EB4/B8/BC/C0). FUN_0041c0e0 reads the three slot files, FUN_0041cc70 loads slot - * 0x584C28 (or starts a new game in it when empty). The demo switch at 0x549E5D (state 2 → 4) does - * the same music shutdown before it changes state. + *

Engine facts (German groove.exe addresses; {@link Site} has them for every build): game state + * 0x5846E4 (2 = main menu, 3 = in game, 6 = save slots, 12 = world map), frame handlers table + * 0x578F28 (return 1 = state changed, the main loop then runs the enter handler of table 0x578F7C; + * state 3 = FUN_0054ae20, which loads tribe/world/chapter/map 0x584EB4/B8/BC/C0). FUN_0041c0e0 + * reads the three slot files, FUN_0041cc70 loads slot 0x584C28 (or starts a new game in it when + * empty). The demo switch at 0x549E47 (state 2 → 4) does the same music shutdown before it changes + * state. */ public final class BootToLevelMod implements Mod { public static final String ID = "boot-to-level"; - static final long MENU_FRAME = 0x549C50; // state 2 frame handler: mov eax,[0x58372c] - static final long GAME_FRAME = 0x549F40; // state 3 frame handler: mov eax,[0x58372c] - static final long PRESSED = 0x5FCF08; // control bits pressed this frame; 0x8 = pause (Escape) static final int STATE_QUIT = 0x11; // main menu "Beenden": the main loop exits through its cleanup - static final String FRAME_PROLOGUE = "A1 2C 37 58 00"; // both frame handlers start with it - - static final long STATE = 0x5846E4, NEXT_STATE = 0x584700, SUB_STATE = 0x584638; - static final long TRIBE = 0x584EB4, WORLD = 0x584EB8, CHAPTER = 0x584EBC, MAP = 0x584EC0; - static final long MAP_TRIBE = 0x585588, MAP_WORLD = 0x584C24, MAP_CHAPTER = 0x584C20, MAP_MAP = 0x58557C; - static final long FROM_MAIN_MENU = 0x5855B0; - static final long SLOT = 0x584C28; - static final long READ_SLOTS = 0x41C0E0, LOAD_SLOT = 0x41CC70; - static final long MUSIC_A = 0x546CC0, MUSIC_B = 0x546990, MUSIC_C = 0x5464D0, MUSIC_ID = 0x586430; - static final long MUSIC_TRACK = 0x6D94B4; + static final int PAUSE_BIT = 0x08; // Escape in the pressed control bits @Override public String id() { @@ -136,6 +127,17 @@ public final class BootToLevelMod implements Mod { if (ctx.optionBool("logos")) { IntroSkipMod.skipLogos(exe, "skip logo movies"); } + GameExe g = exe.game(); + long menuFrame = g.va(Site.MENU_FRAME), gameFrame = g.va(Site.GAME_FRAME); + String menuPrologue = g.original(Site.MENU_FRAME, 5), gamePrologue = g.original(Site.GAME_FRAME, 5); + long state = g.va(Site.STATE), nextState = g.va(Site.NEXT_STATE), subState = g.va(Site.SUB_STATE); + long slotVar = g.va(Site.SLOT), readSlots = g.va(Site.READ_SLOTS), loadSlot = g.va(Site.LOAD_SLOT); + long[] level = {g.va(Site.TRIBE), g.va(Site.WORLD), g.va(Site.CHAPTER), g.va(Site.MAP)}; + long[] mapLevel = {g.va(Site.MAP_TRIBE), g.va(Site.MAP_WORLD), g.va(Site.MAP_CHAPTER), g.va(Site.MAP_MAP)}; + long fromMainMenu = g.va(Site.FROM_MAIN_MENU), musicId = g.va(Site.MUSIC_ID), track = g.va(Site.MUSIC_TRACK); + long musicStop = g.va(Site.MUSIC_STOP), + musicPlay = g.va(Site.MUSIC_PLAY), + musicUpdate = g.va(Site.MUSIC_UPDATE); long done = exe.allocateData(4, "boot-to-level: done flag"); long cave = exe.emit("boot-to-level: first menu frame", a -> { Asm m = new Asm(a); @@ -144,48 +146,49 @@ public final class BootToLevelMod implements Mod { a.jcc(X86.JNE, menu); m.storeImm(done, 1); a.pushad(); - a.call(READ_SLOTS); - m.storeImm(SLOT, slot - 1); - a.call(LOAD_SLOT); // loads the slot into the game variables (or a new game) - m.storeImm(TRIBE, 1) - .storeImm(WORLD, world) - .storeImm(CHAPTER, chapter) - .storeImm(MAP, 0); - m.storeImm(MAP_TRIBE, 1) - .storeImm(MAP_WORLD, world) - .storeImm(MAP_CHAPTER, chapter) - .storeImm(MAP_MAP, 0); - m.storeImm(FROM_MAIN_MENU, 0).storeImm(SUB_STATE, 7).storeImm(NEXT_STATE, 3); - a.call(MUSIC_A); - m.storeImm(MUSIC_ID, 0); - a.hex("FF 35").u32(MUSIC_TRACK); // push dword [track] - a.call(MUSIC_B); + a.call(readSlots); + m.storeImm(slotVar, slot - 1); + a.call(loadSlot); // loads the slot into the game variables (or a new game) + m.storeImm(level[0], 1) + .storeImm(level[1], world) + .storeImm(level[2], chapter) + .storeImm(level[3], 0); + m.storeImm(mapLevel[0], 1) + .storeImm(mapLevel[1], world) + .storeImm(mapLevel[2], chapter) + .storeImm(mapLevel[3], 0); + m.storeImm(fromMainMenu, 0).storeImm(subState, 7).storeImm(nextState, 3); + a.call(musicStop); + m.storeImm(musicId, 0); + a.hex("FF 35").u32(track); // push dword [track] + a.call(musicPlay); a.hex("83 C4 04"); - a.call(MUSIC_C); - m.storeImm(STATE, 3); + a.call(musicUpdate); + m.storeImm(state, 3); a.popad(); m.movImm(Asm.EAX, 1); a.ret(); a.bind(menu); - a.hex(FRAME_PROLOGUE); - a.jmp(MENU_FRAME + 5); + a.hex(menuPrologue); + a.jmp(menuFrame + 5); }); - exe.hookJmp(MENU_FRAME, FRAME_PROLOGUE, cave, "boot-to-level: main menu → level"); + exe.hookJmp(menuFrame, menuPrologue, cave, "boot-to-level: main menu → level"); if (ctx.optionBool("quitOnEscape")) { // same exit as the main menu's "Beenden": state 0x11, frame handler returns 1 + long pressed = g.va(Site.PRESSED); long quit = exe.emit("boot-to-level: Escape quits", a -> { Asm m = new Asm(a); X86.Label run = a.label(); - a.hex("F6 05").u32(PRESSED).u8(0x08); // test byte [pressed], 8 + a.hex("F6 05").u32(pressed).u8(PAUSE_BIT); // test byte [pressed], 8 a.jcc(X86.JE, run); - m.storeImm(STATE, STATE_QUIT); + m.storeImm(state, STATE_QUIT); m.movImm(Asm.EAX, 1); a.ret(); a.bind(run); - a.hex(FRAME_PROLOGUE); - a.jmp(GAME_FRAME + 5); + a.hex(gamePrologue); + a.jmp(gameFrame + 5); }); - exe.hookJmp(GAME_FRAME, FRAME_PROLOGUE, quit, "boot-to-level: Escape quits the game"); + exe.hookJmp(gameFrame, gamePrologue, quit, "boot-to-level: Escape quits the game"); } } diff --git a/src/main/java/com/eliasmueller/llamamod/mods/cheats/CheatsMod.java b/src/main/java/com/eliasmueller/llamamod/mods/cheats/CheatsMod.java index f87bfb0..f513a7b 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/cheats/CheatsMod.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/cheats/CheatsMod.java @@ -1,6 +1,8 @@ package com.eliasmueller.llamamod.mods.cheats; import com.eliasmueller.llamamod.exe.ExeImage; +import com.eliasmueller.llamamod.exe.GameExe; +import com.eliasmueller.llamamod.exe.Site; import com.eliasmueller.llamamod.mods.Mod; import com.eliasmueller.llamamod.mods.ModContext; import com.eliasmueller.llamamod.mods.ModOption; @@ -10,18 +12,12 @@ import java.util.List; /** * Turns on the game's built-in cheats at startup. The main menu matches arrow-key sequences - * (FUN_0041a160, table 0x571FBC, 6 × 9 dwords) and toggles bit i of the cheat word 0x584710; - * this mod sets the chosen bits before the main menu runs, so no sequence has to be typed (typing - * one still toggles the cheat off and on). + * (German exe: FUN_0041a160, table 0x571FBC, 6 × 9 dwords) and toggles bit i of the cheat word + * ({@link Site#CHEAT_FLAGS}); this mod sets the chosen bits before the main menu runs (wrapping + * {@link Site#STARTUP_CALL} in WinMain), so no sequence has to be typed (typing one still toggles the + * cheat off and on). */ public final class CheatsMod implements Mod { - static final long CHEAT_FLAGS = 0x584710; - static final long DISPLAY_DIAGNOSTICS = 0x5834D0; - /** {@code call FUN_0041c050} in WinMain, right after the command line has been parsed. */ - static final long STARTUP_CALL = 0x41D3EA; - - static final long STARTUP_TARGET = 0x41C050; - record Cheat(String key, int bit, String label, String description) {} static final List TABLE = List.of( @@ -83,11 +79,14 @@ public final class CheatsMod implements Mod { for (Cheat c : TABLE) { o.add(ModOption.bool(c.key(), c.label(), c.description(), c.bit() == 0)); } + // not a cheat: listed last under its own label prefix; the key stays "diagnostics" (stored configs) o.add(ModOption.bool( "diagnostics", - "Write display diagnostics", - "For graphics problems: the game writes a report of your graphics devices (.dispdiag) into the game folder. Same as its hidden -display_diagnostics switch.", + "Troubleshooting: write display diagnostics", + "Only for troubleshooting when asked to. At start the game switches through every screen mode" + + " of every monitor – all screens go black for a while (30 s or more) – and writes a" + + " report (.dispdiag) into the game folder, then starts normally. Same" + + " as its hidden -display_diagnostics switch.", false)); return o; } @@ -106,15 +105,18 @@ public final class CheatsMod implements Mod { } int flags = mask; ExeImage exe = ctx.exe(); + GameExe g = exe.game(); + long cheatFlags = g.va(Site.CHEAT_FLAGS), diagnostics = g.va(Site.DISPLAY_DIAGNOSTICS); + long target = g.va(Site.STARTUP_TARGET); long fn = exe.emit("cheats: set flags at startup", a -> { if (flags != 0) { - a.hex("81 0D").u32(CHEAT_FLAGS).u32(flags); // or dword [0x584710], flags + a.hex("81 0D").u32(cheatFlags).u32(flags); // or dword [cheat flags], flags } if (diag) { - a.hex("C6 05").u32(DISPLAY_DIAGNOSTICS).u8(1); // mov byte [0x5834D0], 1 + a.hex("C6 05").u32(diagnostics).u8(1); // mov byte [display diagnostics], 1 } - a.jmp(STARTUP_TARGET); + a.jmp(target); }); - exe.hookCall(STARTUP_CALL, "E8 61 EC FF FF", fn, "WinMain: set cheat flags"); + exe.hookCall(g.va(Site.STARTUP_CALL), g.original(Site.STARTUP_CALL, 5), fn, "WinMain: set cheat flags"); } } diff --git a/src/main/java/com/eliasmueller/llamamod/mods/freecam/FreeCamPatch.java b/src/main/java/com/eliasmueller/llamamod/mods/freecam/FreeCamPatch.java index 9f411fd..8f07d74 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/freecam/FreeCamPatch.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/freecam/FreeCamPatch.java @@ -9,11 +9,15 @@ import static com.eliasmueller.llamamod.mods.practice.Asm.ESI; import com.eliasmueller.llamamod.exe.ExeImage; import com.eliasmueller.llamamod.exe.ExePatchException; +import com.eliasmueller.llamamod.exe.GameExe; +import com.eliasmueller.llamamod.exe.Site; import com.eliasmueller.llamamod.exe.X86; import com.eliasmueller.llamamod.mods.practice.Asm; +import java.util.List; /** - * Free camera and noclip caves. + * Free camera and noclip caves. Addresses below are those of the German exe; the code uses the + * {@link Site}s, which exist in every supported build. * *

    *
  • Controls are the game's own control bits (0x5fcf20, built by FUN_004256c0 from the bound @@ -33,29 +37,9 @@ import com.eliasmueller.llamamod.mods.practice.Asm; *
*/ public final class FreeCamPatch { - static final long CAM_UPDATE = 0x54bad0; - static final long CAM_CALL_RUNNING = 0x54b79f; - static final long CAM_CALL_PAUSED = 0x54b7e0; - static final long TICK_AFTER_STRATS = 0x54b783; // call FUN_004287c0 (particles) - static final long PARTICLES = 0x4287c0; - static final long AFTER_INPUT = 0x54b5fd; // call FUN_00427c40, after the frame's input read - static final long AFTER_INPUT_TARGET = 0x427c40; - static final long PAD_BITS = 0x4256c0; // FUN_004256c0(int): pad structs -> control bits - static final long REGIONS = 0x41e020; // FUN_0041e020: region mask 0x58642c from the camera cell - static final long[] REGIONS_CALLS = {0x54b614, 0x54b7a4, 0x54b7e5}; - static final String[] REGIONS_BYTES = {"E8 07 2A ED FF", "E8 77 28 ED FF", "E8 36 28 ED FF"}; - static final long STRAT_UPDATE = 0x54c4a0; - static final long STRAT_RESET = 0x54d0d5; // mov [0x6d9e40], edi - static final long PLAYER = 0x6d9e1c; - static final long TICKS = 0x5724d4; // logic ticks this frame (30 per second) - static final long PAUSED = 0x584640; - static final long CONTROLS = 0x5fcf20; // held control bits - static final long STICK = 0x5fcefc; // stick magnitude (0xb5 for keyboard arrows) - static final long DEMO = 0x5fcf3c; // demo playback active - static final long PAD_COUNT = 0x5fcf6c, PADS = 0x5fcf70; // pad structs, 12 bytes each - static final long PITCH = 0x5790a0, YAW = 0x5790a4, CAM_X = 0x5790b0; - static final long TARGET_X = 0x6d9d80; - static final long SINE = 0x574318; // s32[4096], 4096 = 1.0; cos(a) = sine[a + 0x400] + /** Frame calls of REGIONS (before the ticks, after the running / paused camera update). */ + static final List REGIONS_CALLS = + List.of(Site.REGIONS_CALL_FRAME, Site.REGIONS_CALL_RUNNING, Site.REGIONS_CALL_PAUSED); /** Control bits (table 0x572100 for the 14 bindings). */ static final int C_UP = 0x10, @@ -82,6 +66,19 @@ public final class FreeCamPatch { public static void apply(ExeImage exe, int vkCam, int vkNoclip, boolean freeze, int camSpeed, int noclipSpeed) throws ExePatchException { + GameExe g = exe.game(); + long camUpdate = g.va(Site.CAM_UPDATE), + stratUpdate = g.va(Site.STRAT_UPDATE), + stratReset = g.va(Site.STRAT_RESET); + long afterInput = g.va(Site.AFTER_INPUT), afterInputTarget = g.va(Site.AFTER_INPUT_TARGET); + long particles = g.va(Site.PARTICLES), padBits = g.va(Site.PAD_BITS), regions = g.va(Site.REGIONS); + long player = g.va(Site.PLAYER), gameTicks = g.va(Site.TICKS), paused = g.va(Site.PAUSED); + long controls = g.va(Site.CONTROLS), stick = g.va(Site.STICK), demo = g.va(Site.DEMO); + long padCount = g.va(Site.PAD_COUNT), pads = g.va(Site.PADS), sine = g.va(Site.SINE); + long camPitch = g.va(Site.PITCH), + camYaw = g.va(Site.YAW), + camX = g.va(Site.CAM_X), + targetX = g.va(Site.TARGET_X); long gaks = exe.importAddress("user32.dll", "GetAsyncKeyState"); long d = exe.allocateData(192, "freecam: state"); long fcOn = d, ncOn = d + 4, prevCam = d + 8, prevNc = d + 12; @@ -106,14 +103,14 @@ public final class FreeCamPatch { m.storeImm(pad, 0); m.cmpMemImm(fcOn, 0); a.jcc(X86.JE, out); - m.cmpMemImm(PAUSED, 0); + m.cmpMemImm(paused, 0); a.jcc(X86.JNE, out); - m.cmpMemImm(DEMO, 0); + m.cmpMemImm(demo, 0); a.jcc(X86.JNE, out); // PgUp/PgDn (0x300) only exist in the keyboard pad word, not in the control bits - m.load(EAX, PADS).loadField(EAX, EAX, 0).andImm(EAX, C_PGUP | C_PGDN); - m.load(ECX, CONTROLS).add(EAX, ECX).store(pad, EAX); - m.load(ECX, PAD_COUNT).load(EAX, PADS); + m.load(EAX, pads).loadField(EAX, EAX, 0).andImm(EAX, C_PGUP | C_PGDN); + m.load(ECX, controls).add(EAX, ECX).store(pad, EAX); + m.load(ECX, padCount).load(EAX, pads); a.bind(loop); m.test(ECX, ECX); a.jcc(X86.JE, done); @@ -122,28 +119,28 @@ public final class FreeCamPatch { m.addImm(EAX, 12).addImm(ECX, -1); a.jmp(loop); a.bind(done); - a.push(0).call(PAD_BITS); + a.push(0).call(padBits); m.addImm(Asm.ESP, 4); - m.storeImm(STICK, 0); + m.storeImm(stick, 0); a.bind(out); a.popad(); - a.jmp(AFTER_INPUT_TARGET); + a.jmp(afterInputTarget); }); - exe.hookCall(AFTER_INPUT, "E8 3E C6 ED FF", inFn, "freecam: input"); + exe.hookCall(afterInput, g.original(Site.AFTER_INPUT, 5), inFn, "freecam: input"); long camFn = exe.emit("freecam: camera", a -> { Asm m = new Asm(a); X86.Label t1 = a.label(), t2 = a.label(), ncOff = a.label(), out = a.label(), chase = a.label(); - a.call(CAM_UPDATE); + a.call(camUpdate); a.pushad(); m.unlessPressed(gaks, vkCam, prevCam, t1); m.xorMemImm(fcOn, 1).cmpMemImm(fcOn, 0); a.jcc(X86.JE, t1); for (int i = 0; i < 3; i++) { - m.load(EAX, CAM_X + 4L * i).store(cam + 4L * i, EAX); + m.load(EAX, camX + 4L * i).store(cam + 4L * i, EAX); } - m.load(EAX, YAW).store(yaw, EAX); - m.load(EAX, PITCH); + m.load(EAX, camYaw).store(yaw, EAX); + m.load(EAX, camPitch); signedAngle(a, m); m.store(pitch, EAX); a.bind(t1); @@ -151,18 +148,18 @@ public final class FreeCamPatch { X86.Label ncOffNow = a.label(); m.unlessPressed(gaks, vkNoclip, prevNc, t2); m.xorMemImm(ncOn, 1); - m.load(ESI, PLAYER).test(ESI, ESI); + m.load(ESI, player).test(ESI, ESI); a.jcc(X86.JE, ncOff); m.cmpMemImm(ncOn, 0); a.jcc(X86.JE, ncOffNow); for (int i = 0; i < 3; i++) { m.loadField(EAX, ESI, 0x30 + 4 * i).store(nc + 4L * i, EAX); - m.load(ECX, TARGET_X + 4L * i).sub(ECX, EAX).store(offT + 4L * i, ECX); - m.load(EAX, CAM_X + 4L * i).load(ECX, TARGET_X + 4L * i); + m.load(ECX, targetX + 4L * i).sub(ECX, EAX).store(offT + 4L * i, ECX); + m.load(EAX, camX + 4L * i).load(ECX, targetX + 4L * i); m.sub(EAX, ECX).store(offC + 4L * i, EAX); } - m.load(EAX, YAW).store(chYaw, EAX).storeImm(chTurn, 0); - m.load(EAX, PITCH).store(chPitch, EAX); + m.load(EAX, camYaw).store(chYaw, EAX).storeImm(chTurn, 0); + m.load(EAX, camPitch).store(chPitch, EAX); m.loadField(EAX, ESI, 0xe8).andImm(EAX, COLLISION_FLAGS).store(ncFlags, EAX); m.store(ncPlayer, ESI); a.jmp(t2); @@ -176,7 +173,7 @@ public final class FreeCamPatch { m.storeImm(ncOn, 0); a.bind(t2); - clampTicks(a, m, ticks); + clampTicks(a, m, gameTicks, ticks); m.cmpMemImm(fcOn, 0); a.jcc(X86.JE, chase); @@ -203,8 +200,8 @@ public final class FreeCamPatch { a.bind(p2); m.load(EAX, yaw).andImm(EAX, 0xfff).store(yaw, EAX); - trig(m, yaw, sinY, cosY); - trig(m, pitch, sinP, cosP); + trig(m, sine, yaw, sinY, cosY); + trig(m, sine, pitch, sinP, cosP); m.load(EAX, sinY).load(ECX, cosP).imul(EAX, ECX).sar(EAX, 12).store(fwd, EAX); m.load(EAX, sinP).store(fwd + 4, EAX); m.load(EAX, cosY).load(ECX, cosP).imul(EAX, ECX).sar(EAX, 12).store(fwd + 8, EAX); @@ -220,54 +217,56 @@ public final class FreeCamPatch { ifBits(a, m, pad, C_ACTION, () -> m.mov(EAX, EBX).neg(EAX).addMem(cam + 4, EAX)); for (int i = 0; i < 3; i++) { - m.load(EAX, cam + 4L * i).store(CAM_X + 4L * i, EAX); - m.load(ECX, fwd + 4L * i).shl(ECX, 2).add(EAX, ECX).store(TARGET_X + 4L * i, EAX); + m.load(EAX, cam + 4L * i).store(camX + 4L * i, EAX); + m.load(ECX, fwd + 4L * i).shl(ECX, 2).add(EAX, ECX).store(targetX + 4L * i, EAX); } - m.load(EAX, yaw).store(YAW, EAX); - m.load(EAX, pitch).andImm(EAX, 0xfff).store(PITCH, EAX); + m.load(EAX, yaw).store(camYaw, EAX); + m.load(EAX, pitch).andImm(EAX, 0xfff).store(camPitch, EAX); a.jmp(out); // noclip chase camera: keep the offset to the llama, A/S orbit around it a.bind(chase); m.cmpMemImm(ncOn, 0); a.jcc(X86.JE, out); - m.load(ESI, PLAYER).test(ESI, ESI); + m.load(ESI, player).test(ESI, ESI); a.jcc(X86.JE, out); m.cmpMem(ESI, ncPlayer); a.jcc(X86.JNE, out); X86.Label noTurn = a.label(); - m.cmpMemImm(PAUSED, 0); + m.cmpMemImm(paused, 0); a.jcc(X86.JNE, noTurn); m.load(EDI, ticks).movImm(EAX, CHASE_TURN_PER_TICK).imul(EDI, EAX); - ifBits(a, m, CONTROLS, C_CAM_LEFT, () -> m.addMem(chTurn, EDI)); - ifBits(a, m, CONTROLS, C_CAM_RIGHT, () -> m.mov(EAX, EDI).neg(EAX).addMem(chTurn, EAX)); + ifBits(a, m, controls, C_CAM_LEFT, () -> m.addMem(chTurn, EDI)); + ifBits(a, m, controls, C_CAM_RIGHT, () -> m.mov(EAX, EDI).neg(EAX).addMem(chTurn, EAX)); a.bind(noTurn); - trig(m, chTurn, sinY, cosY); + trig(m, sine, chTurn, sinY, cosY); for (int i = 0; i < 3; i++) { m.loadField(EAX, ESI, 0x30 + 4 * i).load(ECX, offT + 4L * i).add(EAX, ECX); - m.store(TARGET_X + 4L * i, EAX); + m.store(targetX + 4L * i, EAX); } // x' = x cos + z sin, z' = z cos - x sin (yaw of the offset grows by the turn) m.load(EAX, offC).load(ECX, cosY).imul(EAX, ECX); m.load(EDX, offC + 8).load(ECX, sinY).imul(EDX, ECX).add(EAX, EDX).sar(EAX, 12); - m.load(ECX, TARGET_X).add(EAX, ECX).store(CAM_X, EAX); - m.load(EAX, offC + 4).load(ECX, TARGET_X + 4).add(EAX, ECX).store(CAM_X + 4, EAX); + m.load(ECX, targetX).add(EAX, ECX).store(camX, EAX); + m.load(EAX, offC + 4).load(ECX, targetX + 4).add(EAX, ECX).store(camX + 4, EAX); m.load(EAX, offC + 8).load(ECX, cosY).imul(EAX, ECX); m.load(EDX, offC).load(ECX, sinY).imul(EDX, ECX).sub(EAX, EDX).sar(EAX, 12); - m.load(ECX, TARGET_X + 8).add(EAX, ECX).store(CAM_X + 8, EAX); + m.load(ECX, targetX + 8).add(EAX, ECX).store(camX + 8, EAX); m.load(EAX, chYaw) .load(ECX, chTurn) .add(EAX, ECX) .andImm(EAX, 0xfff) - .store(YAW, EAX); - m.load(EAX, chPitch).store(PITCH, EAX); + .store(camYaw, EAX); + m.load(EAX, chPitch).store(camPitch, EAX); a.bind(out); a.popad(); a.ret(); }); - exe.hookCall(CAM_CALL_RUNNING, "E8 2C 03 00 00", camFn, "freecam: camera (running)"); - exe.hookCall(CAM_CALL_PAUSED, "E8 EB 02 00 00", camFn, "freecam: camera (paused)"); + exe.hookCall( + g.va(Site.CAM_CALL_RUNNING), g.original(Site.CAM_CALL_RUNNING, 5), camFn, "freecam: camera (running)"); + exe.hookCall( + g.va(Site.CAM_CALL_PAUSED), g.original(Site.CAM_CALL_PAUSED, 5), camFn, "freecam: camera (paused)"); // region mask (which rooms are drawn) from the llama's cell instead of the free/chase camera long rgFn = exe.emit("freecam: regions", a -> { @@ -275,23 +274,23 @@ public final class FreeCamPatch { X86.Label orig = a.label(); m.load(EAX, fcOn).load(ECX, ncOn).add(EAX, ECX); a.jcc(X86.JE, orig); - m.load(EAX, PLAYER).test(EAX, EAX); + m.load(EAX, player).test(EAX, EAX); a.jcc(X86.JE, orig); a.pushad(); - m.load(ECX, CAM_X).push(ECX).load(ECX, CAM_X + 8).push(ECX); + m.load(ECX, camX).push(ECX).load(ECX, camX + 8).push(ECX); m.loadField(ECX, EAX, 0x30) - .store(CAM_X, ECX) + .store(camX, ECX) .loadField(ECX, EAX, 0x38) - .store(CAM_X + 8, ECX); - a.call(REGIONS); - m.pop(ECX).store(CAM_X + 8, ECX).pop(ECX).store(CAM_X, ECX); + .store(camX + 8, ECX); + a.call(regions); + m.pop(ECX).store(camX + 8, ECX).pop(ECX).store(camX, ECX); a.popad(); a.ret(); a.bind(orig); - a.jmp(REGIONS); + a.jmp(regions); }); - for (int i = 0; i < REGIONS_CALLS.length; i++) { - exe.hookCall(REGIONS_CALLS[i], REGIONS_BYTES[i], rgFn, "freecam: regions"); + for (Site s : REGIONS_CALLS) { + exe.hookCall(g.va(s), g.original(s, 5), rgFn, "freecam: regions"); } long ncFn = exe.emit("freecam: noclip", a -> { @@ -300,21 +299,21 @@ public final class FreeCamPatch { a.pushad(); m.cmpMemImm(ncOn, 0); a.jcc(X86.JE, out); - m.load(ESI, PLAYER).test(ESI, ESI); + m.load(ESI, player).test(ESI, ESI); a.jcc(X86.JE, out); m.movImm(EBX, noclipSpeed * 128L); - boost(a, m, gaks, CONTROLS); + boost(a, m, gaks, controls); m.store(step, EBX); - trig(m, YAW, sinY, cosY); + trig(m, sine, camYaw, sinY, cosY); m.load(EAX, sinY).imul(EAX, EBX).sar(EAX, 12).store(mv, EAX); m.load(EAX, cosY).imul(EAX, EBX).sar(EAX, 12).store(mv + 8, EAX); rightVector(m, rv, sinY, cosY); - ifBits(a, m, CONTROLS, C_UP, () -> addXz(m, nc, mv, 1)); - ifBits(a, m, CONTROLS, C_DOWN, () -> addXz(m, nc, mv, -1)); - ifBits(a, m, CONTROLS, C_RIGHT, () -> addXz(m, nc, rv, 1)); - ifBits(a, m, CONTROLS, C_LEFT, () -> addXz(m, nc, rv, -1)); - ifBits(a, m, CONTROLS, C_JUMP, () -> m.addMem(nc + 4, EBX)); - ifBits(a, m, CONTROLS, C_ACTION, () -> m.mov(EAX, EBX).neg(EAX).addMem(nc + 4, EAX)); + ifBits(a, m, controls, C_UP, () -> addXz(m, nc, mv, 1)); + ifBits(a, m, controls, C_DOWN, () -> addXz(m, nc, mv, -1)); + ifBits(a, m, controls, C_RIGHT, () -> addXz(m, nc, rv, 1)); + ifBits(a, m, controls, C_LEFT, () -> addXz(m, nc, rv, -1)); + ifBits(a, m, controls, C_JUMP, () -> m.addMem(nc + 4, EBX)); + ifBits(a, m, controls, C_ACTION, () -> m.mov(EAX, EBX).neg(EAX).addMem(nc + 4, EAX)); m.andFieldImm(ESI, 0xe8, ~COLLISION_FLAGS & 0xffffffffL); for (int i = 0; i < 3; i++) { m.load(EAX, nc + 4L * i); @@ -323,9 +322,9 @@ public final class FreeCamPatch { } a.bind(out); a.popad(); - a.jmp(PARTICLES); + a.jmp(particles); }); - exe.hookCall(TICK_AFTER_STRATS, "E8 38 D0 ED FF", ncFn, "freecam: noclip tick"); + exe.hookCall(g.va(Site.TICK_AFTER_STRATS), g.original(Site.TICK_AFTER_STRATS, 5), ncFn, "freecam: noclip tick"); if (freeze) { long fz = exe.emit("freecam: freeze", a -> { @@ -336,18 +335,19 @@ public final class FreeCamPatch { a.ret(); a.bind(run); a.hex("55 8B EC 83 EC 4C"); - a.jmp(STRAT_UPDATE + 6); + a.jmp(stratUpdate + 6); }); - exe.hookJmp(STRAT_UPDATE, "55 8B EC 83 EC 4C", fz, "freecam: freeze strats"); + exe.hookJmp(stratUpdate, "55 8B EC 83 EC 4C", fz, "freecam: freeze strats"); } + String reset = g.original(Site.STRAT_RESET, 6); // mov [strat list], edi long rs = exe.emit("freecam: level reset", a -> { Asm m = new Asm(a); - a.hex("89 3D 40 9E 6D 00"); + a.hex(reset); m.storeImm(fcOn, 0).storeImm(ncOn, 0); - a.jmp(STRAT_RESET + 6); + a.jmp(stratReset + 6); }); - exe.hookJmp(STRAT_RESET, "89 3D 40 9E 6D 00", rs, "freecam: level reset"); + exe.hookJmp(stratReset, reset, rs, "freecam: level reset"); } /** eax: angle 0..4095 → -2048..2047. */ @@ -359,10 +359,10 @@ public final class FreeCamPatch { a.bind(pos); } - /** [ticks] = clamp([TICKS], 1, 4). */ - private static void clampTicks(X86 a, Asm m, long ticks) { + /** [ticks] = clamp([gameTicks], 1, 4). */ + private static void clampTicks(X86 a, Asm m, long gameTicks, long ticks) { X86.Label lo = a.label(), hi = a.label(); - m.load(EBX, TICKS).cmpImm(EBX, 1); + m.load(EBX, gameTicks).cmpImm(EBX, 1); a.jcc(X86.JGE, lo); m.movImm(EBX, 1); a.bind(lo); @@ -374,9 +374,9 @@ public final class FreeCamPatch { } /** sin/cos of the angle at {@code angle} (eax, ecx). */ - private static void trig(Asm m, long angle, long sinDst, long cosDst) { - m.load(EAX, angle).andImm(EAX, 0xfff).loadTable(ECX, SINE, EAX).store(sinDst, ECX); - m.addImm(EAX, 0x400).andImm(EAX, 0xfff).loadTable(ECX, SINE, EAX).store(cosDst, ECX); + private static void trig(Asm m, long sine, long angle, long sinDst, long cosDst) { + m.load(EAX, angle).andImm(EAX, 0xfff).loadTable(ECX, sine, EAX).store(sinDst, ECX); + m.addImm(EAX, 0x400).andImm(EAX, 0xfff).loadTable(ECX, sine, EAX).store(cosDst, ECX); } /** Screen-right on the ground for yaw (sin, cos): (-cos, sin) · ebx. */ diff --git a/src/main/java/com/eliasmueller/llamamod/mods/introskip/IntroSkipMod.java b/src/main/java/com/eliasmueller/llamamod/mods/introskip/IntroSkipMod.java index 6121c78..6ce9a08 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/introskip/IntroSkipMod.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/introskip/IntroSkipMod.java @@ -1,6 +1,8 @@ package com.eliasmueller.llamamod.mods.introskip; import com.eliasmueller.llamamod.exe.ExeImage; +import com.eliasmueller.llamamod.exe.GameExe; +import com.eliasmueller.llamamod.exe.Site; import com.eliasmueller.llamamod.mods.Mod; import com.eliasmueller.llamamod.mods.ModContext; import com.eliasmueller.llamamod.mods.ModOption; @@ -13,18 +15,8 @@ import java.util.List; * played before/after chapters and the attract demo that starts after idling in the main menu. */ public final class IntroSkipMod implements Mod { - /** {@code push offset "disneylogompeg.dat"} in WinMain, first byte of the logo block. */ - public static final long LOGOS = 0x41D56E; - /** First instruction after the logo block ({@code call FUN_00545030}). */ - static final long AFTER_LOGOS = 0x41D5D3; - - /** {@code call FUN_0041f630} (movie player) for %d%d%dINTRO.DAT, EULOGY.DAT, %d%d%dOUTRO.DAT. */ - static final long[] STORY_MOVIES = {0x54AEC9, 0x54B138, 0x54B26F}; - - static final String[] STORY_MOVIE_CALLS = {"E8 62 47 ED FF", "E8 F3 44 ED FF", "E8 BC 43 ED FF"}; - - /** {@code jbe} of the main-menu idle check (1800 frames since the last input → demo). */ - static final long DEMO_IDLE_JBE = 0x549DC0; + /** Calls of the movie player for %d%d%dINTRO.DAT, EULOGY.DAT, %d%d%dOUTRO.DAT. */ + static final List STORY_MOVIES = List.of(Site.STORY_INTRO, Site.STORY_EULOGY, Site.STORY_OUTRO); @Override public String id() { @@ -75,15 +67,20 @@ public final class IntroSkipMod implements Mod { @Override public void apply(ModContext ctx) throws IOException { ExeImage exe = ctx.exe(); + GameExe g = exe.game(); if (ctx.optionBool("logos")) { skipLogos(exe, "skip logo movies and images"); } if (ctx.optionBool("demo")) { - exe.patch(DEMO_IDLE_JBE, "76 41", "EB 41", "main menu: never start the attract demo"); + // jbe of the idle check (1800 frames since the last input → demo) becomes jmp + String jbe = g.original(Site.DEMO_IDLE_JBE, 2); + exe.patch( + g.va(Site.DEMO_IDLE_JBE), jbe, "EB" + jbe.substring(2), "main menu: never start the attract demo"); } if (ctx.optionBool("story")) { - for (int i = 0; i < STORY_MOVIES.length; i++) { - exe.patch(STORY_MOVIES[i], STORY_MOVIE_CALLS[i], "90 90 90 90 90", "skip story movie " + i); + for (int i = 0; i < STORY_MOVIES.size(); i++) { + Site s = STORY_MOVIES.get(i); + exe.patch(g.va(s), g.original(s, 5), "90 90 90 90 90", "skip story movie " + i); } } } @@ -91,12 +88,18 @@ public final class IntroSkipMod implements Mod { /** * Jumps over the logo block unless that is already done (boot-to-level, applied earlier, makes * the identical patch). push ebx keeps the stack as the skipped englogo call left it (cleaned by - * add esp,0Ch at 0x41D5E4). + * add esp,0Ch after the block, German exe 0x41D5E4). */ public static void skipLogos(ExeImage exe, String label) throws IOException { - String skip = String.format("53 EB %02X", AFTER_LOGOS - (LOGOS + 3)); - if (!Arrays.equals(exe.read(LOGOS, 3), ExeImage.hex(skip))) { - exe.patch(LOGOS, "68 28 25", skip, label); + GameExe g = exe.game(); + long logos = g.va(Site.LOGOS); + long jump = g.va(Site.AFTER_LOGOS) - (logos + 3); + if (jump < 0 || jump > 0x7f) { + throw new IOException("Intro skip: logo block of " + jump + " bytes does not fit a short jump"); + } + String skip = String.format("53 EB %02X", jump); + if (!Arrays.equals(exe.read(logos, 3), ExeImage.hex(skip))) { + exe.patch(logos, g.original(Site.LOGOS, 3), skip, label); } } } diff --git a/src/main/java/com/eliasmueller/llamamod/mods/practice/SavePositionPatch.java b/src/main/java/com/eliasmueller/llamamod/mods/practice/SavePositionPatch.java index f687528..559ebc4 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/practice/SavePositionPatch.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/practice/SavePositionPatch.java @@ -5,28 +5,29 @@ import static com.eliasmueller.llamamod.mods.practice.Asm.ESI; import com.eliasmueller.llamamod.exe.ExeImage; import com.eliasmueller.llamamod.exe.ExePatchException; +import com.eliasmueller.llamamod.exe.GameExe; +import com.eliasmueller.llamamod.exe.Site; import com.eliasmueller.llamamod.exe.X86; /** * Save/restore the llama's position with two hotkeys. The cave wraps the per-tick strat update - * call (FUN_0054c4a0) in the game loop FUN_0054b590. + * call ({@link Site#TICK_CALL}, German exe: FUN_0054c4a0 called in the game loop FUN_0054b590). * *

Strat layout used: +0x24 rotation y, +0x30/+0x34/+0x38 position (20.12 fixed), +0x44 and * +0x50..+0x58 the previous-tick copies (wall collision sweeps from previous to current position, - * so both are set). Player strat pointer 0x6d9e1c, current world/chapter 0x584eb8/0x584ebc. + * so both are set). Player strat pointer {@link Site#PLAYER}, current world/chapter {@link + * Site#WORLD}/{@link Site#CHAPTER}. */ public final class SavePositionPatch { - public static final long TICK_CALL = 0x54b77e; // call FUN_0054c4a0 inside the tick loop - public static final long STRAT_UPDATE = 0x54c4a0; - public static final long PLAYER = 0x6d9e1c; - public static final long WORLD = 0x584eb8; - public static final long CHAPTER = 0x584ebc; /** Ticks the restored position is held, so the llama's script sees the floor again. */ static final int PIN_TICKS = 4; private SavePositionPatch() {} public static void apply(ExeImage exe, int vkSave, int vkLoad) throws ExePatchException { + GameExe g = exe.game(); + long player = g.va(Site.PLAYER), world = g.va(Site.WORLD), chapter = g.va(Site.CHAPTER); + long stratUpdate = g.va(Site.STRAT_UPDATE), tickCall = g.va(Site.TICK_CALL); long gaks = exe.importAddress("user32.dll", "GetAsyncKeyState"); long d = exe.allocateData(48, "practice: saved position"); long valid = d, sx = d + 4, sy = d + 8, sz = d + 12, srot = d + 16, sWorld = d + 20, sChap = d + 24; @@ -37,38 +38,38 @@ public final class SavePositionPatch { X86.Label noSave = a.label(), noLoad = a.label(), done = a.label(), put = a.label(); a.pushad(); m.unlessPressed(gaks, vkSave, prevSave, noSave); - m.load(ESI, PLAYER).test(ESI, ESI); + m.load(ESI, player).test(ESI, ESI); a.jcc(X86.JE, noSave); m.loadField(EAX, ESI, 0x30).store(sx, EAX); m.loadField(EAX, ESI, 0x34).store(sy, EAX); m.loadField(EAX, ESI, 0x38).store(sz, EAX); m.loadField(EAX, ESI, 0x24).store(srot, EAX); - m.load(EAX, WORLD).store(sWorld, EAX); - m.load(EAX, CHAPTER).store(sChap, EAX); + m.load(EAX, world).store(sWorld, EAX); + m.load(EAX, chapter).store(sChap, EAX); m.storeImm(valid, 1); a.bind(noSave); m.unlessPressed(gaks, vkLoad, prevLoad, noLoad); m.cmpMemImm(valid, 0); a.jcc(X86.JE, noLoad); - m.load(EAX, WORLD).cmpMem(EAX, sWorld); + m.load(EAX, world).cmpMem(EAX, sWorld); a.jcc(X86.JNE, noLoad); - m.load(EAX, CHAPTER).cmpMem(EAX, sChap); + m.load(EAX, chapter).cmpMem(EAX, sChap); a.jcc(X86.JNE, noLoad); - m.load(ESI, PLAYER).test(ESI, ESI); + m.load(ESI, player).test(ESI, ESI); a.jcc(X86.JE, noLoad); m.storeImm(pin, PIN_TICKS); a.call(put); a.bind(noLoad); a.popad(); - a.call(STRAT_UPDATE); + a.call(stratUpdate); a.pushad(); m.cmpMemImm(pin, 0); a.jcc(X86.JE, done); m.decMem(pin); - m.load(ESI, PLAYER).test(ESI, ESI); + m.load(ESI, player).test(ESI, ESI); a.jcc(X86.JE, done); a.call(put); a.bind(done); @@ -83,6 +84,6 @@ public final class SavePositionPatch { m.load(EAX, srot).storeField(ESI, 0x24, EAX).storeField(ESI, 0x44, EAX); a.ret(); }); - exe.hookCall(TICK_CALL, "E8 1D 0D 00 00", fn, "practice: tick hook"); + exe.hookCall(tickCall, g.original(Site.TICK_CALL, 5), fn, "practice: tick hook"); } } diff --git a/src/main/java/com/eliasmueller/llamamod/platform/ExePatcher.java b/src/main/java/com/eliasmueller/llamamod/platform/ExePatcher.java index eb844d5..c1b527c 100644 --- a/src/main/java/com/eliasmueller/llamamod/platform/ExePatcher.java +++ b/src/main/java/com/eliasmueller/llamamod/platform/ExePatcher.java @@ -1,6 +1,8 @@ package com.eliasmueller.llamamod.platform; import com.eliasmueller.llamamod.exe.ExeImage; +import com.eliasmueller.llamamod.exe.ExePatchException; +import com.eliasmueller.llamamod.exe.Site; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -15,13 +17,16 @@ import java.util.List; * ({@code 68 }) immediately * followed by {@code push 80000002h} ({@code 68 02 00 00 80}). The patch turns 02 into 01 * (HKLM to HKCU) in those five instructions. Located by pattern rather than fixed offsets so other - * language versions work too; German CD: file offsets 0x579b, 0x57d0, 0x5810, 0x5859, 0x5880 - * (the byte after 68). + * language versions work too; German CD: file offsets 0x579b, 0x57d0, 0x5810, 0x5859, 0x5880, + * English 0x579b, 0x57d0, 0x5810, 0x5859, 0x5880 (the byte after 68). * - *

Second part ({@link #relocateLevelRecords}): the per-chapter coin counters (u32 0x584F1C) and - * flags (u8 0x584EC8), indexed world·6 + chapter, only have rows for worlds 0–8; TestLevel (world 9, - * chapters up to 49) wrote over progress, language and options. Every reference is moved to a - * 128-entry copy in the unused tail of .data's last page (notes/testlevel-chapters.md). + *

Second part ({@link #relocateLevelRecords}): the per-chapter coin counters ({@link + * Site#COINS}, u32, German exe 0x584F1C) and flags ({@link Site#FLAGS}, u8, 0x584EC8), indexed + * world·6 + chapter, only have rows for worlds 0–8; TestLevel (world 9, chapters up to 49) wrote + * over progress, language and options. Every reference is moved to a 128-entry copy in unused + * memory: the tail of .data's last page (German exe), or, where that is too small (English exe: + * 43 bytes), the tail of .rdata's last page, whose section is then made writable + * (notes/testlevel-chapters.md). */ public final class ExePatcher { private ExePatcher() {} @@ -36,10 +41,10 @@ public final class ExePatcher { /** One changed byte run of the base patch (file offset). */ public record Change(int offset, byte[] original, byte[] replacement, String label) {} - static final long COINS = 0x584F1C; - static final long FLAGS = 0x584EC8; static final int ROWS = 54; // worlds 0-8 x 6 static final int SLOTS = 128; // world 9 chapter 49 = index 103 + /** Sections whose last page may hold the relocated arrays, in order of preference. */ + static final List SLACK_SECTIONS = List.of(".data", ".rdata"); /** The complete base patch: HKCU registry plus the level record relocation. */ public static byte[] patch(byte[] exe) throws IOException { @@ -61,33 +66,53 @@ public final class ExePatcher { } /** - * Moves the coin and flag arrays to a buffer in the slack after .data's VirtualSize (zeroed and - * mapped read/write by the loader as part of the section's last page). Every absolute address of - * both arrays in .text is rewritten: bases 0x584F1C / 0x584EC8, the world-1 flag base 0x584ECE - * (selection map, gold count), the flag loop end 0x584EFE and the coin loop end 0x584FF4 (KSV + * Moves the coin and flag arrays to a buffer in the slack after a section's VirtualSize (mapped + * by the loader as part of the section's last page; zero in the file). Every absolute address of + * both arrays in .text is rewritten: bases COINS / FLAGS, the world-1 flag base FLAGS+6 + * (selection map, gold count), the flag loop end FLAGS+54 and the coin loop end COINS+216 (KSV * load/save; matched as {@code cmp eax, imm32; jl}, the address is also a separate counter). - * Returns nothing if the exe is already relocated or the references differ from the known build. + * Returns nothing if the exe is already relocated or the references differ from the known builds. */ public static List relocateLevelRecords(byte[] exe) throws IOException { ExeImage img = ExeImage.parse(exe); - ExeImage.Section data; + long oldCoins, oldFlags; try { - data = img.section(".data"); - } catch (IllegalArgumentException e) { - return List.of(); - } - long end = data.va() + data.virtualSize(); - long coins = (end + 15) & ~15L; - long flags = coins + 4L * SLOTS; - if (data.virtualSize() == 0 || flags + SLOTS > ((end + 0xfff) & ~0xfffL)) { + oldCoins = img.game().va(Site.COINS); + oldFlags = img.game().va(Site.FLAGS); + } catch (ExePatchException e) { return List.of(); } List l = new ArrayList<>(); - boolean ok = retarget(img, l, "%s", COINS, coins, 11, "coins") - && retarget(img, l, "%s", FLAGS, flags, 9, "flags") - && retarget(img, l, "%s", FLAGS + 6, flags + 6, 5, "flags from world 1") - && retarget(img, l, "%s", FLAGS + ROWS, flags + ROWS, 3, "flags end") - && retarget(img, l, "3D %s 7C", COINS + 4 * ROWS, coins + 4 * ROWS, 2, "coins end"); + long coins = -1; + for (String name : SLACK_SECTIONS) { + ExeImage.Section s; + try { + s = img.section(name); + } catch (IllegalArgumentException e) { + continue; + } + long end = s.va() + s.virtualSize(); + long start = (end + 15) & ~15L; + if (s.virtualSize() == 0 || start + 5L * SLOTS > ((end + 0xfff) & ~0xfffL) || !zero(img, exe, start)) { + continue; + } + coins = start; + if (!s.writable()) { + int at = img.sectionFlagsOffset(name); + long flags = u32(exe, at) & 0xffffffffL; + l.add(new Change(at, le32(flags), le32(flags | 0x80000000L), "level records: " + name + " writable")); + } + break; + } + if (coins < 0 || coins == oldCoins) { + return List.of(); + } + long flags = coins + 4L * SLOTS; + boolean ok = retarget(img, l, "%s", oldCoins, coins, 11, "coins") + && retarget(img, l, "%s", oldFlags, flags, 9, "flags") + && retarget(img, l, "%s", oldFlags + 6, flags + 6, 5, "flags from world 1") + && retarget(img, l, "%s", oldFlags + ROWS, flags + ROWS, 3, "flags end") + && retarget(img, l, "3D %s 7C", oldCoins + 4 * ROWS, coins + 4 * ROWS, 2, "coins end"); if (!ok) { return List.of(); } @@ -95,6 +120,17 @@ public final class ExePatcher { return l; } + /** The relocated arrays' bytes at {@code va} are zero in the file (or not file-backed). */ + private static boolean zero(ExeImage img, byte[] exe, long va) { + for (long a = va; a < va + 5L * SLOTS; a++) { + int off = img.fileOffset(a); + if (off >= 0 && exe[off] != 0) { + return false; + } + } + return true; + } + /** {@code pattern}: hex with {@code %s} = the address operand; all hits must be {@code expected}. */ private static boolean retarget( ExeImage img, List out, String pattern, long from, long to, int expected, String label) { diff --git a/src/test/java/com/eliasmueller/llamamod/exe/ExeImageTest.java b/src/test/java/com/eliasmueller/llamamod/exe/ExeImageTest.java index 713eadd..177c50b 100644 --- a/src/test/java/com/eliasmueller/llamamod/exe/ExeImageTest.java +++ b/src/test/java/com/eliasmueller/llamamod/exe/ExeImageTest.java @@ -95,8 +95,12 @@ class ExeImageTest { assertTrue(e.getMessage().contains("expected 03"), e.getMessage()); img.patch(0x40579a, "68 02 ?? 00 80", "68 01 00 00 80", "hkcu"); assertEquals(1, img.readU8(0x40579b)); - // .bss / header / out of range - assertThrows(IllegalArgumentException.class, () -> img.patch(0x400010, "00", "01", "header")); + // headers are mapped at the image base: section flags can be patched (base patch, English exe) + long flags = img.va(img.sectionFlagsOffset(".rdata")); + assertEquals(img.imageBase() + img.sectionFlagsOffset(".rdata"), flags); + img.patch(flags, "40 00 00 40", "40 00 00 C0", "rdata writable"); + assertTrue(ExeImage.parse(img.build()).section(".rdata").writable()); + // .bss / out of range assertThrows(IllegalArgumentException.class, () -> img.patch(0x57f000, "00", "01", "bss")); } diff --git a/src/test/java/com/eliasmueller/llamamod/exe/GameExeTest.java b/src/test/java/com/eliasmueller/llamamod/exe/GameExeTest.java new file mode 100644 index 0000000..96a205e --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/exe/GameExeTest.java @@ -0,0 +1,153 @@ +package com.eliasmueller.llamamod.exe; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.eliasmueller.llamamod.mods.GameData; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * The {@link Site} table: every site resolves in both known builds to the documented address, and + * disassembles (objdump) to the instruction the patches expect. + */ +class GameExeTest { + /** Instruction at each code site; %s = hex of another site. */ + static final Map CODE = Map.ofEntries( + Map.entry(Site.MENU_FRAME, "mov eax,ds:0x"), + Map.entry(Site.GAME_FRAME, "mov eax,ds:0x"), + Map.entry(Site.DEMO_SWITCH, "call 0x{MUSIC_STOP}"), + Map.entry(Site.READ_SLOTS, "sub esp,0x100"), + Map.entry(Site.LOAD_SLOT, "mov eax,ds:0x{SLOT}"), + Map.entry(Site.STARTUP_CALL, "call 0x{STARTUP_TARGET}"), + Map.entry(Site.LOGOS, "push 0x"), + Map.entry(Site.AFTER_LOGOS, "call 0x"), + Map.entry(Site.DEMO_IDLE_JBE, "jbe 0x"), + Map.entry(Site.STORY_INTRO, "call 0x{MOVIE_PLAYER}"), + Map.entry(Site.STORY_EULOGY, "call 0x{MOVIE_PLAYER}"), + Map.entry(Site.STORY_OUTRO, "call 0x{MOVIE_PLAYER}"), + Map.entry(Site.TICK_CALL, "call 0x{STRAT_UPDATE}"), + Map.entry(Site.CAM_CALL_RUNNING, "call 0x{CAM_UPDATE}"), + Map.entry(Site.CAM_CALL_PAUSED, "call 0x{CAM_UPDATE}"), + Map.entry(Site.TICK_AFTER_STRATS, "call 0x{PARTICLES}"), + Map.entry(Site.AFTER_INPUT, "call 0x{AFTER_INPUT_TARGET}"), + Map.entry(Site.PAD_BITS, "mov eax,DWORD PTR [esp+0x4]"), + Map.entry(Site.REGIONS_CALL_FRAME, "call 0x{REGIONS}"), + Map.entry(Site.REGIONS_CALL_RUNNING, "call 0x{REGIONS}"), + Map.entry(Site.REGIONS_CALL_PAUSED, "call 0x{REGIONS}"), + Map.entry(Site.STRAT_RESET, "mov DWORD PTR ds:0x")); + + static byte[] bytes(String version) throws IOException { + return Files.readAllBytes(GameData.exePath(version)); + } + + @ParameterizedTest + @ValueSource(strings = {"de", "en"}) + void everySiteResolvesToTheTable(String version) throws IOException { + GameExe g = ExeImage.parse(bytes(version)).game(); + GameExe.Build b = version.equals("de") ? GameExe.Build.GERMAN : GameExe.Build.ENGLISH; + assertEquals(b, g.build()); + List wrong = new ArrayList<>(); + for (Site s : Site.values()) { + long va = g.va(s); + if (va != b.va(s)) { + wrong.add(String.format(Locale.ROOT, "%s: 0x%X, table 0x%X", s, va, b.va(s))); + } + } + assertTrue(wrong.isEmpty(), String.join("\n", wrong)); + } + + @ParameterizedTest + @ValueSource(strings = {"de", "en"}) + void everySiteDisassemblesAsExpected(String version) throws Exception { + Path exe = GameData.exePath(version); + GameExe g = ExeImage.parse(Files.readAllBytes(exe)).game(); + TreeMap dis = objdump(exe); + List wrong = new ArrayList<>(); + for (Site s : Site.values()) { + String got; + String want; + switch (s.rule.kind()) { + case CODE -> { + got = dis.get(g.va(s)); + want = expand(g, CODE.get(s)); + } + case OPERAND -> { + got = dis.floorEntry(g.anchor(s)).getValue(); + want = String.format(Locale.ROOT, "0x%x", g.va(s)); + } + case CALL_TARGET -> { + got = dis.get(g.anchor(s)); + want = String.format(Locale.ROOT, "call 0x%x", g.va(s)); + if (!dis.containsKey(g.va(s))) { + wrong.add(s + ": target is not an instruction"); + } + } + default -> throw new IllegalStateException(); + } + if (got == null || !(s.rule.kind() == Site.Kind.OPERAND ? got.contains(want) : got.startsWith(want))) { + wrong.add(s + ": \"" + got + "\", expected \"" + want + "\""); + } + } + assertTrue(wrong.isEmpty(), version + "\n" + String.join("\n", wrong)); + } + + @Test + void unknownVersionFailsWithAClearMessage() throws IOException { + byte[] b = bytes("de"); + b[ExeImage.parse(b).fileOffset(Site.MENU_FRAME.german) + 5] ^= 0x55; + GameExe g = ExeImage.parse(b).game(); + assertNull(g.build()); + ExePatchException e = assertThrows(ExePatchException.class, () -> g.va(Site.MENU_FRAME)); + assertTrue(e.getMessage().contains("unknown program version"), e.getMessage()); + assertTrue(e.getMessage().contains("unknown version (PE date 2001-02-12, 1642496 bytes)"), e.getMessage()); + assertTrue(e.getMessage().contains("German version of 2001-02-12, English version of 2000-10-17")); + } + + private static String expand(GameExe g, String s) throws ExePatchException { + Matcher m = Pattern.compile("\\{(\\w+)}").matcher(s); + StringBuilder sb = new StringBuilder(); + while (m.find()) { + m.appendReplacement(sb, Long.toHexString(g.va(Site.valueOf(m.group(1))))); + } + return m.appendTail(sb).toString(); + } + + /** VA → instruction text (single spaces), .text only. */ + static TreeMap objdump(Path exe) throws Exception { + Process p; + try { + p = new ProcessBuilder("objdump", "-d", "-M", "intel", "--no-show-raw-insn", "-j", ".text", exe.toString()) + .redirectErrorStream(true) + .start(); + } catch (IOException e) { + assumeTrue(false, "objdump not installed"); + return null; + } + TreeMap out = new TreeMap<>(); + Pattern line = Pattern.compile("^\\s+([0-9a-f]+):\\t(.*)$"); + for (String l : new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).split("\n")) { + Matcher m = line.matcher(l); + if (m.matches()) { + out.put(Long.parseLong(m.group(1), 16), m.group(2).trim().replaceAll("\\s+", " ")); + } + } + assertEquals(0, p.waitFor()); + return out; + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/mods/ExeModCombinationsTest.java b/src/test/java/com/eliasmueller/llamamod/mods/ExeModCombinationsTest.java index 3699cbe..80efbee 100644 --- a/src/test/java/com/eliasmueller/llamamod/mods/ExeModCombinationsTest.java +++ b/src/test/java/com/eliasmueller/llamamod/mods/ExeModCombinationsTest.java @@ -17,14 +17,15 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.stream.Stream; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; /** * Every combination of the groove.exe mods (intro skip, cheats, practice, free camera, boot to * level), with default options and with every exe-relevant option switched on, must apply, give * an executable that re-parses with a consistent section layout, replay byte-identically from the - * cache and restore to the base exe (needs -Dllamamod.cd). + * cache and restore to the base exe (needs -Dllamamod.cd), for the German and the English exe. */ class ExeModCombinationsTest { @TempDir @@ -39,10 +40,11 @@ class ExeModCombinationsTest { "practice", Map.of("checkpoints", "false"), "boot-to-level", Map.of("logos", "true", "quitOnEscape", "true")); - private AppPaths install() throws IOException { + /** {@code version}: "de" or "en" (the English exe runs against the German CD data here). */ + private AppPaths install(String version) throws IOException { Path cd = Path.of(System.getProperty("llamamod.cd", "/nonexistent")); - Path exe = cd.resolveSibling("Hd").resolve("groove.exe"); - assumeTrue(Files.isRegularFile(exe) && Files.isDirectory(cd.resolve("wads")), "game data not available"); + assumeTrue(Files.isDirectory(cd.resolve("wads")), "game data not available"); + Path exe = GameData.exePath(version); AppPaths p = new AppPaths(tmp); // symlinks: mods and ModManager only ever replace files, never write into them try (Stream st = Files.walk(cd)) { @@ -58,9 +60,10 @@ class ExeModCombinationsTest { return p; } - @Test - void allCombinationsApplyReparseReplayAndRestore() throws IOException { - AppPaths p = install(); + @ParameterizedTest + @ValueSource(strings = {"de", "en"}) + void allCombinationsApplyReparseReplayAndRestore(String version) throws IOException { + AppPaths p = install(version); ModRegistry reg = new ModRegistry(); List exeMods = new ArrayList<>(); for (String id : IDS) { diff --git a/src/test/java/com/eliasmueller/llamamod/mods/ExeVersionsTest.java b/src/test/java/com/eliasmueller/llamamod/mods/ExeVersionsTest.java new file mode 100644 index 0000000..4e06c2a --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/mods/ExeVersionsTest.java @@ -0,0 +1,184 @@ +package com.eliasmueller.llamamod.mods; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eliasmueller.llamamod.exe.ExeImage; +import com.eliasmueller.llamamod.exe.GameExe; +import com.eliasmueller.llamamod.platform.ExePatcher; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * groove.exe versions: the German output of every exe mod (and all of them together) stays + * byte-identical, and every mod applies to the English exe too. + */ +class ExeVersionsTest { + @TempDir + Path tmp; + + record Config(String name, List mods, Map> options) {} + + static final Map> ALL_ON = Map.of( + "intro-skip", Map.of("logos", "true", "demo", "true", "story", "true"), + "cheats", + Map.of( + "health", "true", + "collectibles", "true", + "rewards", "true", + "fruit", "true", + "charge", "true", + "diagnostics", "true"), + "practice", Map.of("checkpoints", "false"), + "freecam", Map.of("freeze", "true"), + "boot-to-level", Map.of("logos", "true", "quitOnEscape", "true", "world", "3", "chapter", "2")); + + /** SHA-256 of the German output before the version layer (2026-11-07); must never change. */ + static final Map GERMAN_GOLDEN = Map.ofEntries( + Map.entry("base", "ace999d7ca6150369dcb1bdf1fb7400d13967c25c47af3399533229f5cd95d53"), + Map.entry("intro-skip defaults", "0f4a1645939d3dade40f7a7ee0b7f6ae30c2ff19197f69c6c1f46321e8e3a8d9"), + Map.entry("intro-skip all", "8fd531c96ba058e2587c23b76f47090760240fff5380f0e18c7907fed604d39c"), + Map.entry("cheats defaults", "fbde1f858d8621bc221178e37656331a51d0aea0f3bffa3df2cc57abae065f9c"), + Map.entry("cheats all", "368c47e3d0f15459d87bad7f5378eda57f19a3c41fe5feaddaee0b06fb3f71ed"), + Map.entry("practice defaults", "116d60f16ed0624abe93fdba4a3f440ad336d212971179f2c367c1bedd2bc2a6"), + Map.entry("practice all", "116d60f16ed0624abe93fdba4a3f440ad336d212971179f2c367c1bedd2bc2a6"), + Map.entry("freecam defaults", "b900dffd826ff8a97321baaecdc6298ea0474fcbf1b920f2abc35158dec0b58d"), + Map.entry("freecam all", "b900dffd826ff8a97321baaecdc6298ea0474fcbf1b920f2abc35158dec0b58d"), + Map.entry("boot-to-level defaults", "dc9104ea58596c45f92d203e4dddd855f2a10270cbd2b2b2c61663eb9f2ad2a8"), + Map.entry("boot-to-level all", "e5663a945871409e4f0b3a701bcd82372daa4ca9846c00104309d30615d9b0ca"), + Map.entry("freecam no freeze", "d93106a1b764a77e2e2ed7c01b9fc24e1b1f06112570074d04e6b005d806bd31"), + Map.entry("all mods defaults", "9c18d4f6ea86b5e9e42eba092d3b1020d80a61c735c0a827b0dbf5baf407d942"), + Map.entry("all mods all", "dc0637ea12677d6595a06332e42ac319cae99f9267918ee58fb2ea1d13209691")); + + static final List IDS = List.of("intro-skip", "cheats", "practice", "freecam", "boot-to-level"); + + static List configs() { + List l = new java.util.ArrayList<>(); + l.add(new Config("base", List.of(), Map.of())); + for (String id : IDS) { + l.add(new Config(id + " defaults", List.of(id), Map.of("practice", Map.of("checkpoints", "false")))); + l.add(new Config(id + " all", List.of(id), ALL_ON)); + } + l.add(new Config("freecam no freeze", List.of("freecam"), Map.of("freecam", Map.of("freeze", "false")))); + l.add(new Config("all mods defaults", IDS, Map.of("practice", Map.of("checkpoints", "false")))); + l.add(new Config("all mods all", IDS, ALL_ON)); + return l; + } + + static byte[] build(byte[] orig, Config c, Path tmp) throws Exception { + return apply(orig, c, tmp).build(); + } + + static ExeImage apply(byte[] orig, Config c, Path tmp) throws Exception { + ExeImage img = ExeImage.parse(orig); + img.setOwner("base"); + for (ExePatcher.Change ch : ExePatcher.changes(orig)) { + img.patch(img.va(ch.offset()), ch.original(), ch.replacement(), ch.label()); + } + ModRegistry reg = new ModRegistry(); + for (Mod m : ModManager.sorted(c.mods().stream().map(reg::get).toList())) { + m.apply(ctx(m, c.options().getOrDefault(m.id(), Map.of()), img, tmp)); + } + return img; + } + + /** TestModContext with the options' real defaults (it turns empty values into "1"). */ + static ModContext ctx(Mod m, Map o, ExeImage exe, Path tmp) { + TestModContext t = new TestModContext(tmp, m, o, exe); + Map v = new LinkedHashMap<>(t.optionValues()); + for (ModOption opt : m.options()) { + if (!o.containsKey(opt.key()) && opt.defaultValue().isEmpty()) { + v.put(opt.key(), ""); + } + } + return new ModContext() { + public Path originalCd() { + return t.originalCd(); + } + + public byte[] read(String p) throws IOException { + return t.read(p); + } + + public boolean exists(String p) { + return t.exists(p); + } + + public List list(String d) throws IOException { + return t.list(d); + } + + public void write(String p, byte[] d) { + t.write(p, d); + } + + public void progress(double f, String msg) {} + + public ExeImage exe() { + return exe; + } + + public Map optionValues() { + return v; + } + }; + } + + static String sha(byte[] b) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(b)); + } + + @Test + void germanOutputIsByteIdenticalForEveryMod() throws Exception { + byte[] de = Files.readAllBytes(GameData.germanExe()); + for (Config c : configs()) { + assertEquals(GERMAN_GOLDEN.get(c.name()), sha(build(de, c, tmp)), c.name()); + } + } + + /** Same mod patches (owner, kind, label, size) as in the German exe; the result re-parses. */ + @Test + void everyModAppliesToTheEnglishExe() throws Exception { + byte[] de = Files.readAllBytes(GameData.germanExe()); + byte[] en = Files.readAllBytes(GameData.englishExe()); + assertEquals(GameExe.Build.ENGLISH, ExeImage.parse(en).game().build()); + for (Config c : configs()) { + ExeImage img = apply(en, c, tmp); + assertEquals(shape(apply(de, c, tmp)), shape(img), c.name()); + ExeImage.parse(img.build()); + } + } + + private static List shape(ExeImage img) { + return img.journal().stream() + .filter(o -> !o.owner().equals("base")) + .map(o -> switch (o) { + case ExeImage.Patch p -> p.owner() + " patch " + p.label() + " " + p.replacement().length; + case ExeImage.Allocation a -> a.owner() + " alloc " + a.label() + " " + a.size(); + case ExeImage.DynamicImport d -> d.owner() + " import " + d.function(); + }) + .toList(); + } + + /** The English base patch changes the same five registry pushes and relocates the records into .rdata. */ + @Test + void englishBasePatch() throws Exception { + byte[] en = Files.readAllBytes(GameData.englishExe()); + assertTrue(ExePatcher.patchHkcu(en).patchedOffsets().size() == 5); + List l = ExePatcher.relocateLevelRecords(en); + assertEquals(1 + 11 + 9 + 5 + 3 + 2, l.size()); + assertEquals("level records: .rdata writable", l.getFirst().label()); + byte[] out = ExePatcher.patch(en); + ExeImage img = ExeImage.parse(out); + assertTrue(img.section(".rdata").writable()); + assertEquals(List.of(), ExePatcher.relocateLevelRecords(out), "already relocated"); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/mods/GameData.java b/src/test/java/com/eliasmueller/llamamod/mods/GameData.java index 3773ed1..4d5f23c 100644 --- a/src/test/java/com/eliasmueller/llamamod/mods/GameData.java +++ b/src/test/java/com/eliasmueller/llamamod/mods/GameData.java @@ -21,12 +21,29 @@ public final class GameData { } public static ExeImage exe() throws IOException { + return ExeImage.parse(Files.readAllBytes(germanExe())); + } + + /** The German groove.exe (Hd folder next to the CD). */ + public static Path germanExe() { Path cd = Path.of(System.getProperty("llamamod.cd", "/nonexistent")); Path p = cd.resolveSibling("Hd").resolve("groove.exe"); if (!Files.isRegularFile(p)) { p = cd.resolveSibling("hd").resolve("groove.exe"); } assumeTrue(Files.isRegularFile(p), "groove.exe not available"); - return ExeImage.parse(Files.readAllBytes(p)); + return p; + } + + /** The English groove.exe (2000-10-17) from {@code -Dllamamod.exeEn}, default re/groove_en.exe. */ + public static Path englishExe() { + Path p = Path.of(System.getProperty("llamamod.exeEn", "re/groove_en.exe")); + assumeTrue(Files.isRegularFile(p), "English groove.exe not available (llamamod.exeEn)"); + return p; + } + + /** "de" or "en". */ + public static Path exePath(String version) { + return version.equals("en") ? englishExe() : germanExe(); } } diff --git a/src/test/java/com/eliasmueller/llamamod/mods/boottolevel/BootToLevelModTest.java b/src/test/java/com/eliasmueller/llamamod/mods/boottolevel/BootToLevelModTest.java index 5dde48f..4dab5d9 100644 --- a/src/test/java/com/eliasmueller/llamamod/mods/boottolevel/BootToLevelModTest.java +++ b/src/test/java/com/eliasmueller/llamamod/mods/boottolevel/BootToLevelModTest.java @@ -18,10 +18,10 @@ import com.eliasmueller.llamamod.editor.model.cmd.Command; import com.eliasmueller.llamamod.editor.model.cmd.Edits; import com.eliasmueller.llamamod.editor.model.cmd.PlayerStart; import com.eliasmueller.llamamod.exe.ExeImage; +import com.eliasmueller.llamamod.exe.Site; import com.eliasmueller.llamamod.mods.GameData; import com.eliasmueller.llamamod.mods.ModContext; import com.eliasmueller.llamamod.mods.TestModContext; -import com.eliasmueller.llamamod.mods.introskip.IntroSkipMod; import java.io.IOException; import java.nio.file.Path; import java.util.List; @@ -85,9 +85,10 @@ class BootToLevelModTest { exe.setOwner(BootToLevelMod.ID); BootToLevelMod m = new BootToLevelMod(); m.apply(ctx(m, Map.of("world", "1", "chapter", "2", "quitOnEscape", "true"), exe)); - assertEquals(0xE9, exe.readU8(BootToLevelMod.MENU_FRAME)); - assertEquals(0xE9, exe.readU8(BootToLevelMod.GAME_FRAME)); - assertArrayEquals(new byte[] {0x53, (byte) 0xEB, 0x62}, exe.read(IntroSkipMod.LOGOS, 3)); + assertEquals(0xE9, exe.readU8(exe.game().va(Site.MENU_FRAME))); + assertEquals(0xE9, exe.readU8(exe.game().va(Site.GAME_FRAME))); + assertArrayEquals( + new byte[] {0x53, (byte) 0xEB, 0x62}, exe.read(exe.game().va(Site.LOGOS), 3)); ExeImage.parse(exe.build()); } @@ -97,8 +98,8 @@ class BootToLevelModTest { exe.setOwner(BootToLevelMod.ID); BootToLevelMod m = new BootToLevelMod(); m.apply(ctx(m, Map.of("logos", "false"), exe)); - assertEquals(0xA1, exe.readU8(BootToLevelMod.GAME_FRAME)); - assertEquals(0x68, exe.readU8(IntroSkipMod.LOGOS)); + assertEquals(0xA1, exe.readU8(exe.game().va(Site.GAME_FRAME))); + assertEquals(0x68, exe.readU8(exe.game().va(Site.LOGOS))); } @Test diff --git a/src/test/java/com/eliasmueller/llamamod/mods/cheats/CheatsModTest.java b/src/test/java/com/eliasmueller/llamamod/mods/cheats/CheatsModTest.java index fbdba52..2b77886 100644 --- a/src/test/java/com/eliasmueller/llamamod/mods/cheats/CheatsModTest.java +++ b/src/test/java/com/eliasmueller/llamamod/mods/cheats/CheatsModTest.java @@ -5,7 +5,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import com.eliasmueller.llamamod.exe.ExeImage; +import com.eliasmueller.llamamod.exe.Site; import com.eliasmueller.llamamod.mods.GameData; +import com.eliasmueller.llamamod.mods.ModOption; +import com.eliasmueller.llamamod.mods.ModOptions; import com.eliasmueller.llamamod.mods.TestModContext; import java.nio.file.Path; import java.util.Map; @@ -42,13 +45,23 @@ class CheatsModTest { exe.setOwner("cheats"); CheatsMod mod = new CheatsMod(); new CheatsMod().apply(new TestModContext(tmp, mod, Map.of("collectibles", "true", "diagnostics", "true"), exe)); - assertEquals(0xE8, exe.readU8(CheatsMod.STARTUP_CALL)); - long fn = CheatsMod.STARTUP_CALL + 5 + (int) exe.readU32(CheatsMod.STARTUP_CALL + 1); + long call = exe.game().va(Site.STARTUP_CALL); + assertEquals(0xE8, exe.readU8(call)); + long fn = call + 5 + (int) exe.readU32(call + 1); assertArrayEquals(new byte[] {(byte) 0x81, 0x0D, 0x10, 0x47, 0x58, 0x00, 0x03, 0, 0, 0}, exe.read(fn, 10)); assertTrue(exe.readU8(fn + 10) == 0xC6); ExeImage.parse(exe.build()); } + /** -display_diagnostics blanks every monitor while it tests all screen modes: never on by default. */ + @Test + void displayDiagnosticsIsOffByDefault() { + ModOption o = ModOptions.find(new CheatsMod(), "diagnostics"); + assertEquals("false", o.defaultValue()); + assertTrue(o.label().startsWith("Troubleshooting:"), o.label()); + assertTrue(o.description().contains("go black"), o.description()); + } + @Test void nothingSelectedLeavesExe() throws Exception { ExeImage exe = GameData.exe(); diff --git a/src/test/java/com/eliasmueller/llamamod/mods/introskip/IntroSkipModTest.java b/src/test/java/com/eliasmueller/llamamod/mods/introskip/IntroSkipModTest.java index f4f483b..b73bb15 100644 --- a/src/test/java/com/eliasmueller/llamamod/mods/introskip/IntroSkipModTest.java +++ b/src/test/java/com/eliasmueller/llamamod/mods/introskip/IntroSkipModTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import com.eliasmueller.llamamod.exe.ExeImage; +import com.eliasmueller.llamamod.exe.Site; import com.eliasmueller.llamamod.mods.GameData; import com.eliasmueller.llamamod.mods.TestModContext; import java.nio.file.Path; @@ -20,11 +21,13 @@ class IntroSkipModTest { ExeImage exe = GameData.exe(); exe.setOwner("intro-skip"); new IntroSkipMod().apply(new TestModContext(tmp, new IntroSkipMod(), Map.of("story", "true"), exe)); - assertArrayEquals(new byte[] {0x53, (byte) 0xEB, 0x62}, exe.read(IntroSkipMod.LOGOS, 3)); - assertEquals(0xEB, exe.readU8(IntroSkipMod.DEMO_IDLE_JBE)); - for (long site : IntroSkipMod.STORY_MOVIES) { + assertArrayEquals( + new byte[] {0x53, (byte) 0xEB, 0x62}, exe.read(exe.game().va(Site.LOGOS), 3)); + assertEquals(0xEB, exe.readU8(exe.game().va(Site.DEMO_IDLE_JBE))); + for (Site site : IntroSkipMod.STORY_MOVIES) { assertArrayEquals( - new byte[] {(byte) 0x90, (byte) 0x90, (byte) 0x90, (byte) 0x90, (byte) 0x90}, exe.read(site, 5)); + new byte[] {(byte) 0x90, (byte) 0x90, (byte) 0x90, (byte) 0x90, (byte) 0x90}, + exe.read(exe.game().va(site), 5)); } ExeImage.parse(exe.build()); } @@ -34,6 +37,6 @@ class IntroSkipModTest { ExeImage exe = GameData.exe(); exe.setOwner("intro-skip"); new IntroSkipMod().apply(new TestModContext(tmp, new IntroSkipMod(), Map.of(), exe)); - assertEquals(0xE8, exe.readU8(IntroSkipMod.STORY_MOVIES[0])); + assertEquals(0xE8, exe.readU8(exe.game().va(Site.STORY_INTRO))); } }