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 0000000..ff5c22d Binary files /dev/null and b/notes/img/mods/english_exe.jpg differ diff --git a/notes/llamamod-app.md b/notes/llamamod-app.md index a3b1ea9..3b9203d 100644 --- a/notes/llamamod-app.md +++ b/notes/llamamod-app.md @@ -123,8 +123,8 @@ on Windows. German CD (`groove.exe` MD5 697d67a31c20079838c662d8187dbd86, 1,642, file offsets of the changed bytes: **0x579B, 0x57D0, 0x5810, 0x5859, 0x5880** (original 02, new 01; MD5 with only this part 6199a42eb7582b2a70b1ac6e29f00f43; the base patch also relocates the world-9 level records, see "groove.exe patches" below). The patcher searches the pattern -(push of the subkey string VA + push HKLM) instead of fixed offsets, so other language builds work -if their code is the same. +(push of the subkey string VA + push HKLM) instead of fixed offsets; the English build has the +changed bytes at the same file offsets. Supported builds: see "groove.exe versions" below. File lookup (FUN_00415690): tries `HDPath\dir\file`, then `CDPath\dir\file` (checked with `_access`), and only then loops over drive letters with `GetDriveTypeA == DRIVE_CDROM`. A plain @@ -225,8 +225,9 @@ reads a CD folder, collects writes in `ctx.written`, resolves options (defaults (`platform/ExePatcher`, owner "base", always first) → exe changes of the enabled mods in apply order. Without exe mods the result is byte-identical to `ExePatcher.patch` (installer output); "Restore original" goes back to it. The base patch (REVISION 2) = HKCU + `relocateLevelRecords` -(world-9 coin/flag arrays moved into .data's page slack, 4-byte address operands only, see -testlevel-chapters.md); `ExePatcher.changes` lists both as byte runs. A mod gets the shared image with `ctx.exe()`; all changes are +(world-9 coin/flag arrays moved into .data's page slack – English build: .rdata's page slack, and +.rdata's section flags get the write bit, because its .data slack is only 43 bytes; 4-byte address +operands only, see testlevel-chapters.md); `ExePatcher.changes` lists both as byte runs. A mod gets the shared image with `ctx.exe()`; all changes are attributed to the mod id, journaled into the mod's cache manifest (`"exe": [...]`) and replayed (with full verification) on a cache hit. Every patch/allocation is logged in apply.log (`exe : 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))); } }