Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d32d197b54 | ||
|
|
0568ae7f24 | ||
|
|
64259ea15b | ||
|
|
322e36c0f3 | ||
|
|
52c70e817d | ||
|
|
1cc59bfb78 | ||
|
|
932ccf5fc8 | ||
|
|
ff6673af66 | ||
|
|
ca22c75fac | ||
|
|
f984019199 |
No files matched your search
@@ -1,5 +1,6 @@
|
||||
# game data (copyrighted) - never commit
|
||||
re/groove.exe
|
||||
re/groove_en.exe
|
||||
re/groove_decomp.c
|
||||
re/proj/
|
||||
re/ghidra.log
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.3 – 2026-11-07
|
||||
|
||||
- **Editor:** flying with the right mouse button + W A S D / Q E now works even when another part of
|
||||
the window (a text field, a dock) still had the keyboard focus.
|
||||
- **Editor:** the block, pillar and invisible-box icons sat too low in the toolbars (drawn outside
|
||||
their box); a test now keeps every icon inside its 16 × 16 box.
|
||||
- **Tutorials** say where the Objects panel is (the ⊙ tab next to *Outliner*).
|
||||
|
||||
## 0.2.2 – 2026-11-07
|
||||
|
||||
- **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. With any other version LlamaMod warns at start and before the first Apply (nothing
|
||||
is blocked); mods that change groove.exe then 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.
|
||||
|
||||
@@ -47,8 +47,14 @@ More pictures:
|
||||
## Get it
|
||||
|
||||
You need **your own copy of the game** (the CD or an ISO image of it). Nothing else – LlamaMod
|
||||
brings everything it needs (also Java). Development used the German CD; other language versions
|
||||
should work but are untested.
|
||||
brings everything it needs (also Java).
|
||||
|
||||
**Supported game versions:** the **German CD** (*Ein Königreich für ein Lama*, `groove.exe` of
|
||||
2001-02-12 – the version everything was developed and tested with) and the **English first release**
|
||||
(*The Emperor's New Groove*, `groove.exe` of 2000-10-17). With any other version the level mods
|
||||
(Test World, Arena, Coaster, your own levels, Mirror, Randomizer, Draw distance, Debug wireframe)
|
||||
should still work; the mods marked *modifies groove.exe* stop with a clear message instead – then
|
||||
please send us that `groove.exe` so we can add it.
|
||||
|
||||
### Install in 5 steps
|
||||
|
||||
|
||||
+3
-1
@@ -5,7 +5,7 @@ plugins {
|
||||
|
||||
group = 'com.eliasmueller.llamamod'
|
||||
// Release builds pass -PreleaseVersion=<tag without v> (see .gitea/workflows/release.yml).
|
||||
version = findProperty('releaseVersion') ?: '0.2.1'
|
||||
version = findProperty('releaseVersion') ?: '0.2.3'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
@@ -99,6 +99,8 @@ test {
|
||||
systemProperty 'llamamod.ref', System.getProperty('llamamod.ref', '')
|
||||
// level builder references: python3 tools/levelbuild.py levels/testwelt.py <lbref>/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'
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -72,6 +72,6 @@ test harnesses
|
||||
## Status and honesty
|
||||
|
||||
Everything here was found by reverse engineering and tested against the game; where something is
|
||||
a guess, the page says so (*likely*, *unknown*). Development used the German CD; other language
|
||||
versions are expected to work but were not tested. The project is not affiliated with Disney or
|
||||
a guess, the page says so (*likely*, *unknown*). Development used the German CD; the English
|
||||
first release is supported too (see [game versions](reference/game-files.md#grooveexe-versions)). The project is not affiliated with Disney or
|
||||
Argonaut and contains no game data.
|
||||
@@ -98,7 +98,7 @@ and press **Play** to play the level – or press **F5** in the editor. See
|
||||
|
||||
## What you need
|
||||
|
||||
* The PC game (CD or ISO image; developed with the German version).
|
||||
* The PC game (CD or ISO image): the German or the English version (see [FAQ](20-troubleshooting.md#faq)).
|
||||
* LlamaMod – see [Installing LlamaMod](02-installing.md).
|
||||
* A graphics card with **OpenGL 3.3** for the editor's 3D view.
|
||||
* On Linux, Wine to run the game (LlamaMod sets it up).
|
||||
@@ -1,7 +1,7 @@
|
||||
# Installing LlamaMod and the first start
|
||||
|
||||
LlamaMod runs on **Windows** and **Linux**. It needs your own copy of the game – the CD or an ISO
|
||||
image of it. The game itself does not have to be installed: LlamaMod copies what it needs into
|
||||
image of it, German or English version. The game itself does not have to be installed: LlamaMod copies what it needs into
|
||||
its own data folder and never changes the original.
|
||||
|
||||
## Download and start
|
||||
|
||||
@@ -144,6 +144,12 @@ Turns the hidden TestLevel (world 9, chapter 1) into a playground island: a gall
|
||||
|
||||
*Id* `testworld`
|
||||
|
||||
#### Village Chapter 1 (play-test)
|
||||
|
||||
Temporary play-test copy from the level editor – removed when the play-test ends. Changes the original level t1l1m001.
|
||||
|
||||
*Id* `playtest-village-chapter-1`
|
||||
|
||||
### Gameplay mods
|
||||
|
||||
#### Boot to level
|
||||
@@ -241,7 +247,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 (<computer name>.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 (<computer name>.dispdiag) into the game folder, then starts normally. Same as its hidden -display_diagnostics switch. |
|
||||
|
||||
#### Debug wireframe
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -101,9 +102,13 @@ after an update, **Settings → Clear cache** throws it away; active mods are re
|
||||
|
||||
## FAQ
|
||||
|
||||
**Do I need the German version of the game?**
|
||||
The tools were developed with the German CD. Other language versions use the same engine and
|
||||
should work; if one does not, please report it.
|
||||
**Which versions of the game work?**
|
||||
The German CD (`groove.exe` of 2001-02-12) and the English first release (2000-10-17) are
|
||||
supported, including every mod. With another version LlamaMod shows a warning at start and before
|
||||
the first Apply (nothing is blocked); the level mods still work, but the mods
|
||||
marked *modifies groove.exe* stop with "unknown program version" – the first lines of
|
||||
`logs/apply.log` name the version LlamaMod found. Please report it together with that
|
||||
`groove.exe`, then it can be added.
|
||||
|
||||
**Can I share levels with people who do not have the game?**
|
||||
No – levels are built from the receiver's own game copy. That is also what makes sharing legal:
|
||||
|
||||
@@ -40,8 +40,9 @@ Every change can be undone with **Ctrl+Z**.
|
||||
|
||||
## Step 4: A coin trail
|
||||
|
||||
1. Open the **Objects** panel (left) and click **Coin line** under the quick setups – or press
|
||||
**O** for the **Coin line / arc** tool.
|
||||
1. Open the **Objects** tab in the left dock – the ⊙ icon right next to *Outliner* (inactive tabs show only
|
||||
their icon) and click **Coin line** under **Quick setups** – or press **O** for the
|
||||
**Coin line / arc** tool.
|
||||
2. Click once at the start of the trail and once at its end. Coins appear every *spacing* cells
|
||||
on the ground (also up the ramp).
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ Every drag is one undo step – **Ctrl+Z** takes it back.
|
||||
|
||||
## Step 3: Add a checkpoint
|
||||
|
||||
1. Open the **Objects** panel (left dock) and click the quick setup **Checkpoint**.
|
||||
1. Open the **Objects** tab in the left dock – the ⊙ icon right next to *Outliner* (inactive tabs show only
|
||||
their icon) and click the quick setup **Checkpoint**.
|
||||
2. The Place tool (**P**) is now active. Click on the ground where the checkpoint should stand.
|
||||
|
||||
The editor places a checkpoint post together with its respawn point a little in front of it.
|
||||
|
||||
@@ -69,7 +69,8 @@ plane shows as material 30. Look for:
|
||||
|
||||
The llama should not have to start over after every fall.
|
||||
|
||||
1. Open the **Objects** panel (left dock) and click **Checkpoint** in **Quick setups**.
|
||||
1. Open the **Objects** tab in the left dock – the ⊙ icon right next to *Outliner* (inactive tabs show only
|
||||
their icon) and click **Checkpoint** in **Quick setups**.
|
||||
2. Click on a platform in the middle of the course.
|
||||
|
||||
You get a checkpoint post with its respawn point just in front of it. Falling after passing it
|
||||
|
||||
@@ -20,8 +20,19 @@ Registry key (all values): `HKEY_LOCAL_MACHINE\Software\Disney Interactive\Emper
|
||||
with `CDPath`, `HDPath`, screen size, volumes and devices. LlamaMod's base patch of `groove.exe`
|
||||
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.
|
||||
Development used the **German CD**. The English first release is supported as well: all exe
|
||||
patches find their places in both builds (table below). Its own CD data has not been tested yet –
|
||||
the formats are expected to be the same.
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
+37
-15
@@ -81,27 +81,49 @@ nothing solid right behind the llama (the camera moves aside).
|
||||
| cameras | `CameraSide`, `CutSceneCameraDirectional`, `CutSceneTrigger` | camera zones and cut scenes |
|
||||
| always present | `CheatTeleport`, `Wompy`, `CutSceneTrigger` | |
|
||||
|
||||
### The exit (LevelDoor) – *certain*
|
||||
### The exit (LevelDoor)
|
||||
|
||||
The LevelDoor has no model. Every frame it checks:
|
||||
An exit is **two parts working together** (decompiled strat code; the details below were checked
|
||||
in game in Jungle Day 2 with a debugger, the basic rule also in Village 1):
|
||||
|
||||
* The **LevelDoor** object (no model) writes its three parameters – the next level (tribe, world,
|
||||
chapter) – to globals g101–g103 and its grid cell to g104/g105. It does **not** end the level
|
||||
itself in the cases we tested (see the details below).
|
||||
* **Kuzco's own script** ends the level when **all** of these hold:
|
||||
|
||||
```
|
||||
game state is "running"
|
||||
and the floor polygon under the llama has collision material 16
|
||||
and floor(llama.x) == floor(door.x) and floor(llama.z) == floor(door.z) ; same grid cell
|
||||
-> endLevel(tribe, world, chapter) ; its three parameters
|
||||
floor material under Kuzco == 16 ; the remembered floor (see "floor test" below)
|
||||
and floor(x), floor(z) == the cell in g104/g105 ; the exit's grid cell, any height
|
||||
and Kuzco is at most ~0.6 above that floor's height
|
||||
-> level change to g101..g103 (tally screen)
|
||||
```
|
||||
|
||||
So an exit needs **a floor with collision material 16 in the door's grid cell** – height does not
|
||||
matter. In several original levels the pad only appears when a tile swap opens the door (Village 1:
|
||||
the RubyIdolDoor swaps the tile to a model that has the material-16 pad). The parameters are the
|
||||
next level; the originals use (1, world, chapter + 1), and **(7, 7, 7)** in the last chapter of a
|
||||
world means "world finished" – in TestLevel that crashes, so custom levels use (1, 9, chapter).
|
||||
`endLevel` shows the tally (coins x / LNFO total, secrets) and unlocks the next chapter.
|
||||
On a material-16 floor **outside** the exit cell the same check is a house door: fade-out and
|
||||
the teleport of the environment zone of that cell.
|
||||
|
||||
A second path in the Llama's own code ends the level when it stands on material 16 in the cell
|
||||
stored in globals g104/g105 with the target in g101–g103 – the arena uses this
|
||||
([Arena](../strats/arena.md)).
|
||||
So a normal exit needs **a floor with collision material 16 in the exit's grid cell**. In several
|
||||
original levels that floor only exists after a tile swap opens the door (Village 1 and Jungle Day 2:
|
||||
the RubyIdolDoor swaps the tile to a model that has the material-16 pad; checked in game in Village
|
||||
1). The parameters of the originals are (1, world, chapter + 1); **(7, 7, 7)** in the last chapter of
|
||||
a world means "world finished" – in TestLevel that crashes, so custom levels use (1, 9, chapter).
|
||||
The tally shows coins x / LNFO total and secrets and unlocks the next chapter. The arena sets
|
||||
g101–g105 itself to make its own exit ([Arena](../strats/arena.md)).
|
||||
|
||||
**Details checked in game (Jungle Day 2, t1l4m002, 2026-11-07):**
|
||||
|
||||
* The LevelDoor stores its parameters and cell in g101–g105, then checks global 0x21 **once**
|
||||
(after 5 ticks); if it is not 3 the door removes itself. In Jungle Day 2 it was gone after the
|
||||
start, so the Llama's check is what ends the level. That check needs floor material 16, the
|
||||
door's cell, and the llama no more than 0.6 above the recorded floor height. Off the exit cell
|
||||
the same material-16 check is the house door (fade-out and zone teleport).
|
||||
* The floor test only looks at tiles in the player's cell and takes the **nearest upward floor
|
||||
below the player, at any distance**. Material 17 is ignored for the llama. A llama hovering 3.7
|
||||
units above a house-door pad records material 16.
|
||||
* Each tick runs all strat scripts first and then the floor tests. A position change made after
|
||||
the floor tests is seen by the next tick's scripts with the **old** floor record. So hovering
|
||||
above any house-door pad and then moving the llama straight to floor height in the exit cell
|
||||
ends the level, even though the exit cell has no material 16 yet (it appears only in the swap
|
||||
model when the RubyIdolDoor opens). Moving there from normal ground does nothing.
|
||||
|
||||
## Environment zones (90 bytes, *certain* for the fields below)
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
@@ -173,6 +173,11 @@ stairs, platforms register as shadow casters (`solids`). 1.0 = texture colour
|
||||
|
||||
## Exit (LevelDoor) – how it works
|
||||
|
||||
> **Corrected 2026-11-07 (in game, Jungle Day 2):** the listing below was the first reading. The
|
||||
> LevelDoor only sets g101–g105 and checks global 0x21 *once*; the level is ended by the **Llama's
|
||||
> script** (material 16 + exit cell + at most ~0.6 above the floor). See "Exit via stale floor
|
||||
> material" further down and docs/reference/objects.md.
|
||||
|
||||
Strat bytecode (village, STPC 0x430332, 3 parameters, invisible, no model), decoded via the
|
||||
VM `FUN_0054d180` (opcode < 0x45: table 0x5796bc with 16-bit operand, otherwise 0x578ff4+op·4):
|
||||
```
|
||||
@@ -199,6 +204,46 @@ loop: if g21 == 3 ; game state "running"
|
||||
(zone teleport). t1l3m003: 2 of the 4 doors (cells 71,48/71,49) have no pad at all.
|
||||
* Second path (strat code at 0x4252aa, player logic [likely]): floor material 16 and cell =
|
||||
g104/g105 (set by the LevelDoor at start, Llama sets them to −1) → level change(g101..g103).
|
||||
|
||||
### Exit via stale floor material (hover + position jump, t1l4m002) – checked 2026-11-07
|
||||
|
||||
Speedrun video (position/hover hack): Kuzco hovers above a house-door stump, is moved to the exit and
|
||||
the level ends. Reproduced in game (apptest, gdb through `winedbg --gdb`, position written by gdb):
|
||||
* **Jungle Day chapter 2 = t1l4m002** (LNFO 95 coins / 2 secrets; tally "Dschungeltag Kapitel 2,
|
||||
0/95, 0/2"). LevelDoor at 44.5/25.5 (cell 44,25), params (1,4,3); RubyIdolDoor in the same
|
||||
cell. Cell 44,25 (tile 255) has material 16 **only in swap model 95** (pad x 44.67–45.0,
|
||||
z 25.25–25.75, y 2.25); main model 94 has none → with the door closed the exit cannot fire.
|
||||
House-door pads (material 16, main models) in cells 38,20 / 51,0 / 52,5 / 41,24 / 44,27 /
|
||||
50,27 / 41,29 / 57,55 / 41,38 / 41,44 plus swap pads in 22,21 / 26,21 / 23,23 / 50,1 / 43,21 /
|
||||
46,30 / 58,49 / 31,50; the zones pair them (e.g. 44,27 → 50.5/27.5, 50,27 → 44.5/27.5).
|
||||
* **Floor test (FUN_004036d0):** only tiles of the player's own cell; per upward polygon the plane
|
||||
height at (x,z) is compared with a probe point (y + 0.25, or y + (floor record +0x18)/2 with
|
||||
+0xe8 flag 0x200000 as for the llama);
|
||||
the **nearest floor below the probe wins, with no distance limit**. Material 17 is skipped for
|
||||
the llama (+0xe8 = 0x24882a, no 0x8000000). If nothing is below: for the llama (+0xe8 & 0x40000)
|
||||
material/height are **not** updated (stale) and x/z go back to the previous position.
|
||||
In game: llama held at 44.9/**6.0**/27.5 (3.7 above the pad of the stump at 44,27) → floor
|
||||
record h 2.30, **material 16**, tile 282 poly 15, every frame.
|
||||
* **Order per tick (FUN_0054c4a0):** all strat scripts (VM FUN_0054d180) first, then the
|
||||
collision pass (FUN_00403020 for every strat with +0xe8 & 0x8000). A position written after the
|
||||
collision pass is seen by the scripts of the next tick with the **old floor record**.
|
||||
In game: after hovering above the 44,27 pad, llama set to 44.5/2.3/25.5 right after its floor
|
||||
test → next event endLevel `FUN_0054d620` (called from the VM, floor record still 16), before
|
||||
any new floor test → tally (twice with screenshot, once traced). Control: same jump from normal
|
||||
ground (material 0) → no exit; jump written just *before* the floor test → record becomes 0, no exit.
|
||||
* **Who ends the level here: the Llama, not the LevelDoor.** LevelDoor bytecode: wait 5 ticks,
|
||||
g101..g105 = params + cell, then **once** `if global 0x21 != 3 → end` (so "g21 == 3" above is a
|
||||
one-time check, and global 0x21 is not the game state 0x5846e4), then the per-tick loop. In
|
||||
t1l4m002 the LevelDoor was gone from the strat list (scan of 0x6d9e38) while g101..g105 =
|
||||
1,4,3,44,25 and global 0x21 = 0. The Llama block at STPC 0x39ff4c–0x3a0064: if floorMaterial
|
||||
== 16 and health > 0 and (y < floorHeight + 0.6 and global 0x21 != 2, or y < floorHeight + 0.025
|
||||
and (g1 == 0 or global 0x23)): cell == (g104, g105) → level change(g101..g103), else fade-out +
|
||||
`getDoorPos` (op 0x100, zone teleport = house door) + `forceCollision`. The 0.6 limit is why
|
||||
hovering high above a house pad does not trigger the house teleport (bytecode; not separately
|
||||
tested), while landing at floor height in the exit cell with the stale record does.
|
||||
* Not explained: in two sessions with a moved player start (boot-to-level `start` 43.3/25.5) the
|
||||
same jump did not end the level (Llama script ran with record 16 in cell 44,25, no endLevel).
|
||||
* Images: `img/debug/exit-hover-trick.jpg` (top: video; bottom: our pool/door platform, tally).
|
||||
* Parameters in the originals: (1, world, chapter+1) = next chapter; in the last chapter of a
|
||||
world **(7,7,7)** = "world completed" → back to the selection map, unlock the next world.
|
||||
* `FUN_0054d620`: remembers the current level (0x585008..14), sets the next (0x584eb4/b8/bc), shows the
|
||||
|
||||
+57
-4
@@ -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 <mod>: 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.
|
||||

|
||||
|
||||
**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).
|
||||
|
||||
+1
-1
@@ -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 `<COMPUTER>.dispdiag` (DDraw/D3D enumeration) into the game folder | option `diagnostics` (tested: ELIASPC.dispdiag, 60 KB) |
|
||||
| `-display_diagnostics` (DAT_005834D0) | works: writes `<COMPUTER>.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 |
|
||||
|
||||
@@ -7,6 +7,8 @@ import java.awt.Color;
|
||||
import java.awt.Component;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.KeyEventDispatcher;
|
||||
import java.awt.KeyboardFocusManager;
|
||||
import java.awt.event.ComponentAdapter;
|
||||
import java.awt.event.ComponentEvent;
|
||||
import java.awt.event.InputEvent;
|
||||
@@ -93,6 +95,38 @@ public final class Viewport extends JPanel {
|
||||
private boolean dirty = true;
|
||||
private long lastTick = System.nanoTime();
|
||||
private final Set<Integer> keys = new HashSet<>();
|
||||
|
||||
/**
|
||||
* While flying, WASD/QE/Shift reach the fly loop even if another component (a text field, a
|
||||
* dock) still has the keyboard focus: with some window managers the heavyweight GL canvas does
|
||||
* not get the focus from a right-click.
|
||||
*/
|
||||
private final KeyEventDispatcher flyKeys = e -> {
|
||||
if (!this.flying) {
|
||||
return false;
|
||||
}
|
||||
if (e.getID() == KeyEvent.KEY_PRESSED) {
|
||||
keys.add(e.getKeyCode());
|
||||
} else if (e.getID() == KeyEvent.KEY_RELEASED) {
|
||||
keys.remove(e.getKeyCode());
|
||||
}
|
||||
// modifiers pass on (others track Shift, e.g. the play-here crosshair); typed keys do not
|
||||
int k = e.getKeyCode();
|
||||
return k != KeyEvent.VK_SHIFT && k != KeyEvent.VK_CONTROL && k != KeyEvent.VK_ALT && k != KeyEvent.VK_META;
|
||||
};
|
||||
|
||||
@Override
|
||||
public void addNotify() {
|
||||
super.addNotify();
|
||||
KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher(flyKeys);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeNotify() {
|
||||
KeyboardFocusManager.getCurrentKeyboardFocusManager().removeKeyEventDispatcher(flyKeys);
|
||||
super.removeNotify();
|
||||
}
|
||||
|
||||
private boolean flying;
|
||||
private BufferedImage capture;
|
||||
private boolean captureNext;
|
||||
@@ -1131,6 +1165,7 @@ public final class Viewport extends JPanel {
|
||||
}
|
||||
if (drag == Drag.FLY) {
|
||||
flying = false;
|
||||
keys.clear(); // a key released after the button may have gone to another component
|
||||
canvas.setCursor(Cursor.getDefaultCursor());
|
||||
}
|
||||
if (drag != Drag.NONE && buttonOf(drag, e)) {
|
||||
|
||||
@@ -19,8 +19,8 @@ final class GeoIcons {
|
||||
private static final Color RED = new Color(0xD93F3F);
|
||||
|
||||
static void register() {
|
||||
Icons.register("prim-box", (g, fg) -> block(g, 2, 5, 12, 8, TOP, SIDE));
|
||||
Icons.register("prim-platform", (g, fg) -> block(g, 1, 4, 14, 4, TOP, SIDE));
|
||||
Icons.register("prim-box", (g, fg) -> block(g, 2, 1, 12, 8, TOP, SIDE));
|
||||
Icons.register("prim-platform", (g, fg) -> block(g, 1, 2.5, 14, 4, TOP, SIDE));
|
||||
Icons.register("prim-floor", (g, fg) -> {
|
||||
Path2D p = poly(1, 10, 8, 6.5, 15, 10, 8, 13.5);
|
||||
g.setColor(TOP);
|
||||
@@ -47,7 +47,7 @@ final class GeoIcons {
|
||||
g.setColor(fg);
|
||||
g.draw(p);
|
||||
});
|
||||
Icons.register("prim-pillar", (g, fg) -> block(g, 5, 2, 6, 12, STONE, STONE.darker()));
|
||||
Icons.register("prim-pillar", (g, fg) -> block(g, 5, 0.5, 6, 12, STONE, STONE.darker()));
|
||||
Icons.register("prim-cylinder", (g, fg) -> {
|
||||
g.setColor(STONE.darker());
|
||||
g.fill(new java.awt.geom.Rectangle2D.Double(2, 6, 12, 5));
|
||||
@@ -122,7 +122,7 @@ final class GeoIcons {
|
||||
Icons.register("prim-invbox", (g, fg) -> {
|
||||
g.setStroke(
|
||||
new BasicStroke(1.2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND, 1, new float[] {2, 1.5f}, 0));
|
||||
outlineBlock(g, 2, 5, 12, 8, new Color(255, 60, 60, 60), RED);
|
||||
outlineBlock(g, 2, 1, 12, 8, new Color(255, 60, 60, 60), RED);
|
||||
});
|
||||
Icons.register("geo-collision", (g, fg) -> {
|
||||
Path2D p = poly(1, 10, 8, 6.5, 15, 10, 8, 13.5);
|
||||
@@ -221,6 +221,7 @@ final class GeoIcons {
|
||||
}
|
||||
|
||||
/** Isometric block: x, y of the top-left, width w, side height h. */
|
||||
/** An isometric block filling y .. y + w / 2 + h; keep that within the 16 × 16 icon box. */
|
||||
private static void block(Graphics2D g, double x, double y, double w, double h, Color top, Color side) {
|
||||
Color fg = g.getColor();
|
||||
double d = w / 4;
|
||||
|
||||
@@ -40,6 +40,11 @@ public final class Icons {
|
||||
|
||||
private Icons() {}
|
||||
|
||||
/** Names of all registered icons (sorted). */
|
||||
public static java.util.List<String> names() {
|
||||
return PAINTERS.keySet().stream().sorted().toList();
|
||||
}
|
||||
|
||||
public static void register(String name, Painter p) {
|
||||
PAINTERS.put(name, p);
|
||||
CACHE.keySet().removeIf(k -> k.startsWith(name + "@"));
|
||||
|
||||
@@ -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<Op> 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<Long> 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<Long> find(byte[] pat, boolean[] mask, int limit) {
|
||||
return find(data, sections, pat, mask, limit);
|
||||
}
|
||||
|
||||
private static List<Long> find(byte[] data, List<Section> in, byte[] pat, boolean[] mask, int limit) {
|
||||
List<Long> out = new ArrayList<>();
|
||||
for (Section s : sections) {
|
||||
for (Section s : in) {
|
||||
int start = s.rawOffset();
|
||||
int end = s.rawOffset() + (int) s.fileBackedSize();
|
||||
outer:
|
||||
|
||||
@@ -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<Site, Long> sites = new EnumMap<>(Site.class);
|
||||
private final Map<Site, Long> 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<Long> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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").
|
||||
*
|
||||
* <p>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.
|
||||
* <p>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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Cheat> 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 (<computer"
|
||||
+ " name>.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 (<computer name>.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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <ul>
|
||||
* <li>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;
|
||||
* </ul>
|
||||
*/
|
||||
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<Site> 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. */
|
||||
|
||||
@@ -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<Site> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
*
|
||||
* <p>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");
|
||||
}
|
||||
}
|
||||
@@ -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 <VA of "Software\Disney Interactive\Emperors New Groove\1.0">}) 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).
|
||||
*
|
||||
* <p>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).
|
||||
* <p>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<String> 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<Change> 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<Change> 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<Change> out, String pattern, long from, long to, int expected, String label) {
|
||||
|
||||
@@ -62,6 +62,7 @@ final class MainFrame extends JFrame {
|
||||
private boolean crashReported;
|
||||
private boolean oldWineAccepted;
|
||||
private final RecoveryNotice recoveryNotice = new RecoveryNotice();
|
||||
final UnsupportedExeWarning exeWarning = new UnsupportedExeWarning();
|
||||
private boolean recoveryRunning;
|
||||
|
||||
MainFrame(AppState state) {
|
||||
@@ -223,6 +224,9 @@ final class MainFrame extends JFrame {
|
||||
showRecovery(state.recovery);
|
||||
state.recovery = ModManager.Recovery.NONE;
|
||||
}
|
||||
if (inst) {
|
||||
SwingUtilities.invokeLater(() -> exeWarning.atStart(this, state));
|
||||
}
|
||||
}
|
||||
|
||||
/** Tells the user once that an interrupted apply was repaired; BUSY is retried later. */
|
||||
|
||||
@@ -677,6 +677,7 @@ final class ModsPanel extends JPanel {
|
||||
}
|
||||
List<Mod> mods = selected();
|
||||
ModManager mm = frame.state.modManager;
|
||||
frame.exeWarning.beforeApply(frame, frame.state);
|
||||
if (!AntivirusDialogs.notice(frame, mm.exeMods(mods), true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.eliasmueller.llamamod.ui;
|
||||
|
||||
import com.eliasmueller.llamamod.exe.GameExe;
|
||||
import com.eliasmueller.llamamod.platform.FileOps;
|
||||
import com.eliasmueller.llamamod.platform.Integrity;
|
||||
import java.awt.Component;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.swing.JOptionPane;
|
||||
|
||||
/**
|
||||
* Warns (never blocks) when the installed groove.exe is not one of the supported builds: once at
|
||||
* start and once before the first Apply of a session.
|
||||
*/
|
||||
final class UnsupportedExeWarning {
|
||||
private boolean shownAtStart;
|
||||
private boolean shownAtApply;
|
||||
private String checkedSha;
|
||||
private boolean unsupported;
|
||||
|
||||
/** The warning text, or null if the build is supported or unknown because nothing is installed. */
|
||||
static String message(String sha256) {
|
||||
if (sha256 == null || sha256.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
for (GameExe.Build b : GameExe.Build.values()) {
|
||||
if (b.sha256.equalsIgnoreCase(sha256)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return "You are running an unsupported version of the game. Mods, especially mods that modify"
|
||||
+ " groove.exe, probably won't work.\n\nSupported are: "
|
||||
+ Arrays.stream(GameExe.Build.values())
|
||||
.map(GameExe.Build::toString)
|
||||
.collect(Collectors.joining(", "))
|
||||
+ ".";
|
||||
}
|
||||
|
||||
/** SHA-256 of the set-up original groove.exe ("" if not installed or unreadable). */
|
||||
static String installedSha(AppState state) {
|
||||
if (!state.cfg.installed()) {
|
||||
return "";
|
||||
}
|
||||
if (!state.cfg.exeSha256.isEmpty()) {
|
||||
return state.cfg.exeSha256;
|
||||
}
|
||||
try {
|
||||
Path hd = state.paths.originalHd();
|
||||
Path exe = Files.isDirectory(hd) ? Integrity.find(hd, "groove.exe") : null;
|
||||
return exe != null && Files.isRegularFile(exe) ? FileOps.sha256(exe) : "";
|
||||
} catch (IOException e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private String current(AppState state) {
|
||||
String sha = installedSha(state);
|
||||
if (!sha.equals(checkedSha)) {
|
||||
checkedSha = sha;
|
||||
unsupported = message(sha) != null;
|
||||
shownAtStart = false;
|
||||
shownAtApply = false;
|
||||
}
|
||||
return unsupported ? message(sha) : null;
|
||||
}
|
||||
|
||||
void atStart(Component parent, AppState state) {
|
||||
String m = current(state);
|
||||
if (m != null && !shownAtStart) {
|
||||
shownAtStart = true;
|
||||
JOptionPane.showMessageDialog(parent, m, "Unsupported game version", JOptionPane.WARNING_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the warning before the first Apply of a session; Apply always continues afterwards. */
|
||||
void beforeApply(Component parent, AppState state) {
|
||||
String m = current(state);
|
||||
if (m != null && !shownAtApply) {
|
||||
shownAtApply = true;
|
||||
JOptionPane.showMessageDialog(parent, m, "Unsupported game version", JOptionPane.WARNING_MESSAGE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.geometry;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.ui.Icons;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Every vector icon stays inside its 16 × 16 box, so toolbar icons line up. */
|
||||
class IconBoundsTest {
|
||||
@Test
|
||||
void iconsStayInsideTheirBox() {
|
||||
GeoIcons.register();
|
||||
List<String> outside = new ArrayList<>();
|
||||
for (String name : Icons.names()) {
|
||||
BufferedImage img = new BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
Icons.get(name, 16).paintIcon(null, g, 8, 8);
|
||||
g.dispose();
|
||||
int minY = 32, maxY = -1, minX = 32, maxX = -1;
|
||||
for (int y = 0; y < 32; y++) {
|
||||
for (int x = 0; x < 32; x++) {
|
||||
if ((img.getRGB(x, y) >>> 24) > 40) {
|
||||
minY = Math.min(minY, y);
|
||||
maxY = Math.max(maxY, y);
|
||||
minX = Math.min(minX, x);
|
||||
maxX = Math.max(maxX, x);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxY >= 0 && (minY < 7 || maxY > 24 || minX < 7 || maxX > 24)) {
|
||||
outside.add(name + " x " + (minX - 8) + ".." + (maxX - 8) + " y " + (minY - 8) + ".." + (maxY - 8));
|
||||
}
|
||||
}
|
||||
assertTrue(outside.isEmpty(), "icons drawn outside their 16x16 box: " + outside);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Site, String> 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<String> 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<Long, String> dis = objdump(exe);
|
||||
List<String> 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<Long, String> 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<Long, String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<Path> 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<Mod> exeMods = new ArrayList<>();
|
||||
for (String id : IDS) {
|
||||
|
||||
@@ -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<String> mods, Map<String, Map<String, String>> options) {}
|
||||
|
||||
static final Map<String, Map<String, String>> 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<String, String> 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<String> IDS = List.of("intro-skip", "cheats", "practice", "freecam", "boot-to-level");
|
||||
|
||||
static List<Config> configs() {
|
||||
List<Config> 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<String, String> o, ExeImage exe, Path tmp) {
|
||||
TestModContext t = new TestModContext(tmp, m, o, exe);
|
||||
Map<String, String> 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<String> 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<String, String> 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<String> 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<ExePatcher.Change> 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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.eliasmueller.llamamod.ui;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.eliasmueller.llamamod.exe.GameExe;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class UnsupportedExeWarningTest {
|
||||
@Test
|
||||
void supportedBuildsAndMissingInstallGiveNoWarning() {
|
||||
for (GameExe.Build b : GameExe.Build.values()) {
|
||||
assertNull(UnsupportedExeWarning.message(b.sha256));
|
||||
assertNull(UnsupportedExeWarning.message(b.sha256.toUpperCase()));
|
||||
}
|
||||
assertNull(UnsupportedExeWarning.message(""));
|
||||
assertNull(UnsupportedExeWarning.message(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherBuildsAreWarnedAboutAndTheSupportedOnesListed() {
|
||||
String m = UnsupportedExeWarning.message("00".repeat(32));
|
||||
assertNotNull(m);
|
||||
assertTrue(m.startsWith("You are running an unsupported version of the game."), m);
|
||||
assertTrue(m.contains("German version of 2001-02-12") && m.contains("English version of 2000-10-17"), m);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user