23 Commits
Author SHA1 Message Date
MineTec 165134c0d8 0.2.4
build / build (push) Failing after 1m9s
release / release (push) Failing after 51s
2026-11-08 18:28:02 +01:00
MineTec 526059c70a mountain 3 crash fix mod (keeps the boulder spawners alive, one word of t1l5m003), object palette test skips without a display 2026-11-08 18:28:02 +01:00
MineTec ec2876d9c8 developer mode mod: game's developer switch on (warp with a+s, river log cheat), keys for the hidden aux inputs (fly, heart and fruit, idols, coins) on both exe versions 2026-11-08 18:09:03 +01:00
MineTec e36ff2d2c2 mountain 3 crash: rollingbouldermountain spawners end while their boulders still write parent.v13 (use after free), seen in game, one-word level fix tested; diag tooling in notes/dev 2026-11-08 17:41:19 +01:00
MineTec 8b3c38fe56 debug wireframe: allowance areas option (off, floating spots, all edges) from an exact port of the engine floor test, capped by level memory 2026-11-08 17:41:19 +01:00
MineTec db08cf8bf3 developer switch (strat property 258): 101 reads, a+s dev warp points, aux bits for hearts, fruit, coins, idols and flying, river log invincible noclip, flipped and tested in game 2026-11-08 17:33:05 +01:00
MineTec b5b6e22957 frog moonjump: kuscofrog's mid-air code rises while padaux bit 0x800 is set, nothing on pc ever sets it (verified in game by poking the bit) 2026-11-08 16:44:54 +01:00
MineTec 15ad050005 floor explainer: no side view or header, pink labelled allowance, all polygon numbers in the zoom 2026-11-08 16:29:59 +01:00
MineTec 74f8fec341 nicer floor explainer: terrain overview with grid and polygon numbers, zoom on both crate needles with the accepted slivers, side view 2026-11-08 16:26:16 +01:00
MineTec 8baed2e87c floor test explainer image (needle triangle, accepted sliver, kuzco, heights), poly 11 is the same needle on the crate's other side 2026-11-08 16:21:37 +01:00
MineTec 9e8e7f9a10 floor test inclusion rule from the code (edge planes with a small tolerance, highest plane wins): explains kuzco floating next to the crate in jungle day 2 (needle triangle tip), tools/floortest.py re-implements the engine test 2026-11-08 16:15:28 +01:00
MineTec c39d58bdac boxedwine notes: real amd gpu shows a black window, software gl works 2026-11-07 23:01:02 +01:00
MineTec 0c2920dfe4 browser feasibility notes: boxedwine runs the game natively into the 3d level, its web build has no opengl/webgl bridge so direct3d fails 2026-11-07 22:44:04 +01:00
MineTec d32d197b54 0.2.3
build / build (push) Successful in 1m5s
release / release (push) Successful in 3m18s
2026-11-07 21:18:12 +01:00
MineTec 0568ae7f24 exit logic corrected from in-game debugging: the leveldoor only sets g101-g105, the llama's script ends the level (material 16, exit cell, near the floor); stale floor record after a position jump explains the hover exit skip in jungle day 2 2026-11-07 20:57:15 +01:00
MineTec 64259ea15b fly key capture lets shift, ctrl and alt through, so the play-here crosshair no longer sticks after flying fast 2026-11-07 19:32:45 +01:00
MineTec 322e36c0f3 toolbar block icons centred in their 16px box (test for all icons), tutorials say where the objects tab is 2026-11-07 19:09:39 +01:00
MineTec 52c70e817d editor: fly keys work while another component has the keyboard focus (window-wide key dispatcher during right-mouse fly), held keys cleared when the flight ends 2026-11-07 19:03:20 +01:00
MineTec 1cc59bfb78 0.2.2
build / build (push) Successful in 49s
release / release (push) Successful in 1m31s
2026-11-07 18:34:32 +01:00
MineTec 932ccf5fc8 warning dialog for unsupported groove.exe versions at start and before the first apply of a session (nothing blocked) 2026-11-07 18:32:42 +01:00
MineTec ff6673af66 readme and docs: german and english game versions supported, what happens with other versions 2026-11-07 18:17:31 +01:00
MineTec ca22c75fac english groove.exe (2000-10-17) supported next to the german one: named exe sites found by signatures per build (german patches byte-identical), base patch and every exe mod on both, unknown builds get a clear message; display diagnostics moved to troubleshooting with a warning (it flashes every monitor black) 2026-11-07 18:14:15 +01:00
MineTec f984019199 gitignore the english groove.exe used for version support 2026-11-07 17:31:02 +01:00
84 changed files with 4313 additions and 301 deletions

No files matched your search

+1
View File
@@ -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
+32
View File
@@ -1,5 +1,37 @@
# Changelog
## 0.2.4 – 2026-11-08
- **New mod "Mountain 3 crash fix"** (Gameplay): the rolling boulder spawners in Mountain chapter 3
deleted themselves too early and their boulders later wrote into the freed memory (use after free),
which can crash the game in `FreeLocalMem`. One word of level data keeps the spawners alive.
- **New mod "Developer mode"** (Cheats): turns on the game's hidden developer switch. Hold A+S to
warp to the developers' warp points, F1 to fly, F2 for a heart and 5 fruit, F3/F11 to count idols
and coins up; Space on the river log makes it invincible and fast.
- **Debug wireframe: new option "Allowance areas"** (off by default). The game counts the llama as
standing on a floor even a little outside its edges; at sharp corners this margin becomes a long
sliver where the llama can stand in the air. *Floating spots* marks those places in pink, *All edges*
draws the margin as a thin pink band along every floor edge.
## 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.
+8 -2
View File
@@ -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
View File
@@ -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.4'
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
View File
@@ -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.
+1 -1
View File
@@ -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 -1
View File
@@ -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
+25 -1
View File
@@ -185,6 +185,12 @@ Plays every level mirrored left to right, like the mirror mode of racing games.
| Readable textures | on / off | off | Flips writing and pictures back so they read normally. Some textures (water edges, paths) then show seams. Off: a true mirror image. |
| Mirror object models | on / off | off | Also mirrors the shape of objects such as huts and crates (characters stay as they are). Off: objects are only placed mirrored. |
#### Mountain 3 crash fix
Fixes a bug in Mountain chapter 3 that can crash the game: the rolling boulder spawners delete themselves too early and their boulders later write into the freed memory. With the fix the spawners stay alive; the boulders roll as before.
*Id* `mountain3-fix`
#### Practice tools
Practise hard jumps: press F5 to remember where the llama stands and F8 to jump back there (in the same level; the keys can be changed below). Also adds extra checkpoints along the coin trail of levels that have checkpoints.
@@ -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
@@ -249,6 +255,24 @@ Shows what is normally invisible: the walls and floors the llama collides with (
*Id* `debug-wireframe`
| Option | Values | Default | Description |
|---|---|---|---|
| Allowance areas | Off, Floating spots, All edges | Off | Pink marks where the game still counts the llama as standing on a floor although it is just beside it (the collision test accepts a small margin outside every floor edge). Off: nothing extra. Floating spots: only the places where that margin reaches noticeably far, past sharp corners, and the llama would stand in the air above the real floor. All edges: a thin pink band along every floor edge, also where it does not matter. Off by default because it adds geometry to every level. |
#### Developer mode
Turns on the game's hidden developer switch and puts its extra buttons on the keyboard. Hold A+S to warp the llama to the developers' warp points (22 levels, they go round in a circle). Hold F1 to fly, press F2 for a heart and 5 fruit, hold F3 for idols and F11 for coins (keys can be changed below). On the river log (world 3) hold Space: no damage, through the banks, very fast. In the finale, holding Space+Z ends the level. The developers' level-skip and music menu stays unavailable (it needs a second switch that is always off).
*Id* `devmode` · **modifies groove.exe** (see [antivirus warnings](20-troubleshooting.md))
| Option | Values | Default | Description |
|---|---|---|---|
| Fly key | F1, F2, F3, F4, F5, F7, F8, F9, F11, F12, Insert, Delete, Home, End | F1 | Hold to fly: the llama (also from standing), the bunny and the frog (after a jump) rise while the key is held. |
| Heart and fruit key | F1, F2, F3, F4, F5, F7, F8, F9, F11, F12, Insert, Delete, Home, End | F2 | Each press gives one heart and 5 purple fruit. |
| Idol key | F1, F2, F3, F4, F5, F7, F8, F9, F11, F12, Insert, Delete, Home, End | F3 | Hold to count the pink idols up (to 99). |
| Coin key | F1, F2, F3, F4, F5, F7, F8, F9, F11, F12, Insert, Delete, Home, End | F11 | Hold to count the coins up fast. |
| Fly key switches on and off | on / off | off | Press the fly key once to start flying and again to stop, instead of holding it. |
#### Free camera & noclip
Press F7 to fly the camera freely around the level: arrow keys move, A/S turn, PgUp/PgDn look up and down, X/Z rise and sink, Shift or Space go faster. Press F9 for noclip: the llama flies through walls (arrow keys, X/Z up and down). Press the key again to switch off (the keys can be changed below).
+8 -3
View File
@@ -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:
+3 -2
View File
@@ -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).
+2 -1
View File
@@ -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.
+2 -1
View File
@@ -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
+13 -2
View File
@@ -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
View File
@@ -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)
+34
View File
@@ -149,6 +149,40 @@ CutSceneTrigger camera moves only as points).
(≤ 2000 extra vertices per tile model, on average ~1–2× as many triangles as the original).
* Test level T1L9 (test world) has no debug version (the testlevel mod takes precedence).
## Allowance areas (LlamaMod option `debug-wireframe.allowance`, 2026-11-08)
Choice `off` (default) / `tips` ("Floating spots") / `all` ("All edges"). Shows the floor test's edge
allowance: the engine accepts a floor polygon up to `tol = −0x40 − ((0x1000 − ny·32) >> 6)` (≈0.016–0.023)
outside each edge plane; at sharp corners this becomes a sliver up to tol / sin(θ/2) long
(geometry.md "Floor test: inclusion rule …"). Colour **pink 255,60,200**.
* Code: `mods/debug/FloorTest` = bit-exact port of FUN_004036d0's floor branch (same as `tools/floortest.py`;
the sine table is `rint(sin(2πi/4096)·4096)`, identical to DAT_00574318), `mods/debug/AllowanceAreas`.
All geometry uses the engine's plane scale (n·p = d/32 with |n| = 127), not `Collision.decode`'s d/4096,
so the bands sit ~0.8 % further from the model origin than the green wireframe.
* **tips**: for every placed tile and every up-facing collision polygon (not material 17 = skipped for the
llama, not 30 = no floor) and every corner whose tolerance corner lies ≥ `MIN_REACH` (0.05) beyond the
true corner, the area beyond the corner (tolerance region ∩ half-plane past the corner) is sampled in up to
40 rows × 7 points. A sample counts when the exact engine test, with the feet on that polygon's plane,
picks this polygon, the point is outside the polygon without tolerance, and no other passing floor of the
cell is less than `MIN_GAP` (0.05) below (material-30 floors count as none). Samples outside the tile's
cell are dropped (the engine only tests the cell's tiles). Kept when the winning samples reach ≥ 0.05
outside the polygon: translucent strip (alpha 150) through the winning part of each row + an opaque
centre line (4 mm), at the plane height + 0.012, in the tile's marker cell model (no collision).
Strat object collision (crates as strats, platforms) is not considered.
* **all**: per TRAK model, every up-facing floor polygon (same material filter) gets an opaque ring from
its true corners to its tolerance corners (2n vertices, 2n triangles), appended to the model overlay like
the wireframe (tile transform, alt models free). Tolerance corners further than 1.5 out are clamped.
* Budget: if TEXT+TRAK+MAP+STPC+LGPC would exceed 12 MB (16 MB level arena) the level is rebuilt without
allowance geometry and the apply log says "allowance areas left out: level too large". Not reached in
any original level; a model whose overlay would pass 7900 vertices keeps its wireframe without bands.
* Numbers (all 33 levels): tips 0–1454 slivers per level (river t1l3m00x most, t1l4m002 394),
WAD +0–2.0 MB, ≤ 2 s per level; all: +0.02–2.9 MB, arena max 11.1 MB (t1l3m003).
t1l4m002 crate (tile 443): poly 15 corner 2 (reach 0.18, contains Kuzco's spot 57.204/43.733),
poly 11 corner 2 (0.175), polys 10/14 (0.11/0.13) on the other end, 12/13 barely (0.05).
* Tests: `AllowanceAreasTest` (exact port vs. 44 floortest.py points, sine table vs. groove.exe,
slivers of t1l4m002, no slivers on the flat start ground, band counts, process in each mode).
## Statistics per level (build of 2026-11-06)
Marker categories are the internal keys of `debugviz.py`: ausgang = exit, geheimnis = secret,
+119
View File
@@ -0,0 +1,119 @@
# Browser feasibility: Boxedwine (2026-11-07)
Question: can *The Emperor's New Groove* (groove.exe, D3D7/DirectDraw, DirectInput, DirectSound) run in a
web browser through [Boxedwine](https://github.com/danoon2/Boxedwine) (Wine + x86 emulator, also built to WebAssembly)?
## Verdict
**Not with today's Boxedwine web build: Direct3D is missing in the browser.**
- **Web (Emscripten) build 26R1:** the game starts and shows Wine dialogs, then stops with the game's own
message *"Direct Draw kann nicht gestartet werden."* ("Direct Draw cannot be started"). The web build has no
OpenGL bridge: the Emscripten makefile defines none of the `BOXEDWINE_OPENGL_*` switches, the stripped file
system has no `libGL.so.1` (Wine logs `Failed to load libGL … OpenGL support is disabled`), and the release
readme says *"OpenGL and Direct3D are not supported in the Web/Emscripten build because they can not currently be
converted to WebGL."* Without OpenGL, wined3d has no D3D device, so groove.exe finds no 3D device.
- **Native Linux build (same emulator, x86 JIT), Wine 11 file system:** **works.** After three small Boxedwine
fixes (see below) it reaches the intro, the main menu, the save slot screen, the map and in-game
Village chapter 1 at ~30 fps (llvmpipe on Xvfb, load average ~15). Keyboard input works. One visible problem:
in-game the camera seems to sit at Kuzco's position (the llama is not visible, the view pitches up/down
with Up/Down); in the attract-mode demo Kuzco is drawn normally. Not investigated.
With the Wine 6 file system it hangs on the loading screen (emulator livelock, see below).
- **Software renderer (plan B) does not help:** no own rasterizer found in groove.exe. `FUN_0040ffd0`
chooses a DirectDraw device + Direct3D device by name (`DisplayDevice`/`D3DDevice`). The "software" flag
(`SoftwareScreenBPP/W/H`, device record +0x10) is derived from the enumerated **D3D** device description
(`FUN_00412a10`: `(desc+0xf4 >> 3) & 1`), most likely D3D's own software device (RGB/MMX emulation) – in any case a device the game gets from Direct3D enumeration, not an own rasterizer (no other draw path was found by grep). In Wine that
device is wined3d too, which needs OpenGL (or Vulkan). Wine has no GL-free D3D rasterizer, so the software
path also needs a GL bridge in the browser.
- **Path in Boxedwine:** yes, D3D7 → Wine `ddraw` → `wined3d` → OpenGL. Natively, Boxedwine passes the
emulated GL calls to the host GL through SDL (`source/opengl/sdl`). In the browser that bridge would have
to become WebGL (GL ES 2/3), which Boxedwine does not do. The source has an unused ES path
(`source/opengl/es`: glshim/esopengl) that is not part of the Emscripten build.
### What it would take to ship
1. A GL → WebGL bridge for the Emscripten build (upstream work in Boxedwine: wined3d needs desktop GL 2.1+
features; a translation layer like gl4es/glshim or a WebGL2 backend for the marshalled GL calls). This is
the blocker, weeks of emulator work at least, not something LlamaMod can patch around.
2. Speed: the web build has no JIT (wasm interpreter, `TOTAL_MEMORY` 512 MB). Native JIT gives ~30 fps with
1.2 cores; the interpreter is typically several times slower, so even with WebGL the 3D game would likely
be far below playable on average hardware. Unmeasured.
3. Distribution page: a static page where the user drops their own ISO/folder; JS builds the app zip in
memory (game folder + CD data, ~400 MB without movies) plus a root zip with the registry values, never
uploaded anywhere. Never host game data. Needs COOP/COEP headers for the multi-threaded build.
4. Sound and movies: DirectSound opened 17 SDL audio streams natively (silent dummy driver here, audibility
not checked). Intro movies (`movies/*.dat`) do not play in Wine 11 under Boxedwine (quartz: `Failed to
create filter … hr 0x8007000e`); the game skips them.
Alternatives that do not need a GL bridge are out of scope here (e.g. a native port, or a WebGL renderer
re-implementation of the engine's D3D7 calls).
## Versions
| Item | Version / source |
|---|---|
| Boxedwine | 26R1.0 (2026-04-22). GitHub releases have Windows, Mac and Web builds, **no Linux build** → built from the source tarball |
| Linux build | GCC 11.4 (Mint 21.3). Official requirement is GCC 12; one fix needed (below). `libminizip-dev` missing → linked against the installed `libminizip.so.1` via a local symlink (no system packages installed) |
| File systems | from Boxedwine's own list `http://www.boxedwine.org/v2/26R1/filesV2.xml`: `TinyCore15Wine6.0.zip`, `TinyCore15Wine11.0.zip`; web: `boxedwine.zip` (stripped Wine 6) from `Boxedwine26R1Web.zip` |
| Browser | Chromium (`/usr/bin/chromium`, own profile) on Xvfb :26, page served by a local Python server with COOP/COEP headers on 127.0.0.1, MultiThreaded web build |
| Game | German CD via `tools/apptest.py setup boxedwine`; LlamaMod's patched `game/groove.exe` (HKCU registry) |
## Procedure (native)
```
Xvfb :26 -screen 0 1920x1080x24 -nolisten tcp +extension GLX
# prefix: copy user.reg out of the file-system zip into <root>/home/username/.wine/, append
# [Software\\Disney Interactive\\Emperors New Groove\\1.0] CDPath="Z:\\mnt\\lama\\cd-active\\" HDPath=".\\"
# ScreenW=dword:280 ScreenH=dword:1e0
# and write "disable" into .update-timestamp (otherwise wineboot updates the prefix: >15 min and it hung)
SDL_AUDIODRIVER=dummy DISPLAY=:26 boxedwine -root <root> -zip TinyCore15Wine11.0.zip \
-env WINEDLLOVERRIDES=mscoree,mshtml= -mount <apptest>/data /mnt/lama -w /mnt/lama/game /bin/wine groove.exe
```
Keys via `xdotool keydown/keyup` with the pointer over the window (X focus follows the pointer, no WM).
The game renders 640x480 into the top-left of the 800x600 Boxedwine window.
Boxedwine fixes needed (patch: `$CLAUDE_JOB_DIR/tmp/boxedwine/results/boxedwine-26R1-linux-xvfb.patch`, not upstreamed):
1. `readIcons.cpp`: `std::make_shared<U8[]>` → `std::shared_ptr<U8[]>(new U8[n]())` (GCC 11).
2. `platform/linux/platformOpenGL.cpp`: skip GLX fbconfigs with `GLX_SLOW_CONFIG` caveat and without
`GLX_WINDOW_BIT`. Under Xvfb/llvmpipe, Wine otherwise picks an accum-buffer config (SDL refuses it as
"accelerated") or a non-window config (`set_pixel_format … not compatible for window rendering`) and wined3d
reports `Failed to get a GL context`. Probably Xvfb-specific; real GPUs may not need it.
3. `KMemory::mapNativeMemory`: when no free pages are left above 0xD0000000, reserve in the user area
instead. Without it the first `glMapBufferRange` of wined3d (Wine 6) fails → `Failed to map bo` → page fault.
Not checked whether Wine 11 still needs it.
Wine 6 file system with these fixes: loading screen drawn through D3D7, ~10–25 presents/s for ~30 s, then
every emulated thread waits for the JIT memory mutex (`JitCodeGen::doJIT`) while one thread spins — the game
never leaves the loading screen. Not investigated further; Wine 11 does not show it.
## Procedure (web)
Web page = release `MultiThreaded/` folder plus `lamaroot.zip` (= `boxedwine.zip` with the user.reg above, CDPath
`C:\\files\\lama\\cd\\`) and `lama.zip` (`lama/` = game folder + `lama/cd/` = CD data without movies, stored,
415 MB). URL: `boxedwine.html?root=lamaroot&app=lama&p=groove.exe&sound=false`.
The `overlay=` parameter did **not** override `user.reg` of the root zip (game asked for the CD), a modified
root zip did. Loading 415 MB into the page took a few seconds locally.
## Screenshots
- `../img/dev/boxedwine-native-menu.jpg` – main menu (native Boxedwine, Wine 11)
- `../img/dev/boxedwine-native-demo.jpg` – attract-mode demo in 3D (the right/bottom strips are a stale frame
outside the 640x480 game area)
- `../img/dev/boxedwine-native-village.jpg` – Village chapter 1 in game (Kuzco not visible, see above)
- `../img/dev/boxedwine-web-ddraw-error.jpg` – web build in Chromium: "Direct Draw kann nicht gestartet werden."
## Running it locally
A durable native setup lives in `~/opt/boxedwine-26R1/` (rebuilt 2026-11-07 from the procedure above):
| Path | Content |
|---|---|
| `bin/boxedwine` | Boxedwine 26R1.0 built from `dl-src/` (GitHub tag tarball) with `boxedwine-26R1-linux-xvfb.patch`, GCC 11.4. The sources are CRLF: convert the five patched files to LF (`sed -i 's/\r$//'`) before `patch -p1`; `project/linux/linux_build/lib/libminizip.so` → system `libminizip.so.1` |
| `dl-fs/TinyCore15Wine11.0.zip` | Wine 11 file system (+ `filesV2.xml`) |
| `root/home/username/.wine/` | `user.reg` from the zip + the game's HKCU key, `.update-timestamp` = `disable` |
| `apptest/lama/data/` | `APPTEST_ROOT=~/opt/boxedwine-26R1/apptest tools/apptest.py setup lama`: `game/` (patched groove.exe), `cd-active/`; mounted as `/mnt/lama` |
Start: `lama-boxedwine` (`~/.local/bin`, bash) on the current `$DISPLAY`, normal sound
(`SDL_AUDIODRIVER=dummy lama-boxedwine` for silence). Log: `~/opt/boxedwine-26R1/last-run.log`;
PID file `~/opt/boxedwine-26R1/boxedwine.pid` blocks a second instance. Known issues: in-game camera at
Kuzco's position, no intro movies (see above).
**Real screen (AMD RX 480, Mesa radeonsi):** the window stayed black and the game hung after the movie attempts; with `LIBGL_ALWAYS_SOFTWARE=1` (llvmpipe) it shows the title and runs. `lama-boxedwine` therefore defaults to the software renderer (`LAMA_BW_GPU=1` to try the GPU).
+44
View File
@@ -0,0 +1,44 @@
package mtn3;
import com.eliasmueller.llamamod.exe.ExeImage;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModContext;
import com.eliasmueller.llamamod.mods.ModOption;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/** Throwaway diagnostics: stale parent-variable access log + local heap consistency check. */
public final class Mtn3DiagMod implements Mod {
static final Path DIR = Path.of("/home/elias/.claude/jobs/90c6eed7/tmp/m3/diag");
public String id() { return "mtn3-diag"; }
public String name() { return "Mountain 3 diagnostics"; }
public String description() { return "Logs stale parent writes and checks the local heap (test only)."; }
public boolean modifiesExecutable() { return true; }
public List<ModOption> options() {
return List.of(ModOption.bool("fix", "Fix", "Redirect stale parent writes to a dummy", false),
ModOption.string("rev", "Rev", "cache buster", "1"));
}
public void apply(ModContext ctx) throws IOException {
ExeImage exe = ctx.exe();
long data = exe.allocateData(8192, "mtn3 diag data");
long cave = exe.nextAllocation();
String fix = ctx.optionBool("fix") ? "1" : "0";
Path bin = DIR.resolve(String.format("cave_%s_%x_%x.bin", fix, cave, data));
Files.writeString(DIR.resolve("addresses.txt"), String.format("CAVE=0x%x DATA=0x%x FIX=%s%n", cave, data, fix));
if (!Files.exists(bin)) {
throw new IOException("assemble " + bin);
}
long at = exe.place(Files.readAllBytes(bin), "mtn3 diag code");
if (at != cave) {
throw new IOException("cave moved " + Long.toHexString(at));
}
exe.hookJmp(0x553770, "8B 44 24 04 8B", cave, "pushParentVarAddr");
exe.hookJmp(0x553a40, "56 8B 74 24 08", cave + 5, "popParentVar");
exe.hookJmp(0x41ada0, "8B 44 24 04 56", cave + 10, "FreeLocalMem");
exe.hookJmp(0x41ae20, "8B 0D 9C 49 58 00", cave + 15, "AllocLocalMem");
}
}
+285
View File
@@ -0,0 +1,285 @@
; Mountain 3 crash diagnostics (German groove.exe). nasm -f bin -DCAVE=.. -DDATA=.. -DFIX=0/1
bits 32
org CAVE
%define D(o) dword [DATA+o]
; DATA layout: 0 magic, 16 cnt_parent_ok, 20 cnt_stale_addr, 24 cnt_stale_pop, 28 cnt_free, 32 cnt_anom,
; 36 cnt_alloc, 40 stale ring idx, 44 all ring idx, 48 dummy, 52 last free ptr, 56 last alloc size, 60 tmp idx,
; 64 stale ring 32x32, 1088 anomaly record 64 bytes, 1152 dump 256 bytes (hdr-128..+128), 1408 all-ring 64x32 (2048)
; 3456 free history 64 x 16
entry_addr: jmp near push_addr
entry_pop: jmp near pop_var
entry_free: jmp near free_hook
entry_alloc: jmp near alloc_hook
push_addr:
mov eax,[esp+4]
mov ecx,[eax+0xc]
mov edx,[esp+8]
mov [DATA+60],edx
mov edx,0x11
call record_all
test ecx,ecx
jz .stale
cmp dword [ecx+0xec],0
jz .stale
inc D(16)
.ok:
mov edx,[ecx+0xf4]
mov ecx,[esp+8]
lea edx,[edx+ecx*4]
.push:
push edx
push eax
call 0x54bbd0
add esp,8
ret
.stale:
inc D(20)
mov edx,1
call record
call stale_info
call check_heap
%if FIX
mov edx,DATA+48
jmp .push
%else
test ecx,ecx
jnz .ok
mov edx,DATA+48
jmp .push
%endif
pop_var:
push esi
mov esi,[esp+8]
push esi
call 0x54bc00
add esp,4
mov ecx,[esi+0xc]
mov edx,[esp+0xc]
mov [DATA+60],edx
push eax
mov eax,esi
mov edx,0x12
call record_all
pop eax
test ecx,ecx
jz .stale
cmp dword [ecx+0xec],0
jz .stale
inc D(16)
.ok:
mov edx,[ecx+0xf4]
mov ecx,[esp+0xc]
lea edx,[edx+ecx*4]
.store:
pop esi
mov [edx],eax
ret
.stale:
inc D(24)
push eax
mov eax,esi
mov edx,2
call record
call stale_info
call check_heap
pop eax
%if FIX
mov edx,DATA+48
jmp .store
%else
test ecx,ecx
jnz .ok
mov edx,DATA+48
jmp .store
%endif
; record: eax strat, ecx parent, edx kind, [DATA+60] idx. Preserves eax, ecx.
record:
push esi
push edi
push ebx
mov esi,[DATA+40]
and esi,31
shl esi,5
lea edi,[DATA+64+esi]
jmp rec_common
record_all:
push esi
push edi
push ebx
mov esi,[DATA+44]
and esi,63
shl esi,5
lea edi,[DATA+1408+esi]
inc D(44)
dec D(40) ; compensated below
rec_common:
inc D(40)
mov [edi],eax
mov [edi+4],ecx
mov [edi+8],edx
mov ebx,[DATA+60]
mov [edi+12],ebx
mov ebx,[eax+0x18]
mov [edi+16],ebx
mov dword [edi+20],0
mov dword [edi+24],0
mov dword [edi+28],0
test ecx,ecx
jz .np
mov ebx,[ecx+0x18]
mov [edi+20],ebx
mov ebx,[ecx+0xf4]
mov [edi+24],ebx
mov ebx,[ecx+0xec]
mov [edi+28],ebx
.np:
pop ebx
pop edi
pop esi
ret
free_hook:
pushad
inc D(28)
mov eax,[esp+36]
mov [DATA+52],eax
mov esi,[DATA+28]
and esi,63
shl esi,4
mov [DATA+3456+esi],eax
mov ebx,[esp+32] ; caller
mov [DATA+3456+esi+4],ebx
mov ebx,[eax-4] ; size|used
mov [DATA+3456+esi+8],ebx
mov ebx,[eax-12] ; next
mov [DATA+3456+esi+12],ebx
call check_heap
popad
mov eax,[esp+4]
push esi
jmp 0x41ada5
alloc_hook:
pushad
inc D(36)
mov eax,[esp+36]
mov [DATA+56],eax
call check_heap
popad
mov ecx,[0x58499c]
jmp 0x41ae26
; walks the local heap; on the first inconsistency records it (code in anomaly+0)
check_heap:
pushad
mov esi,[0x58499c]
test esi,esi
jz .done
xor edi,edi
mov ecx,70000
.loop:
mov edx,1
cmp [esi+4],edi
jne .bad
mov edx,2
movzx eax,word [esi+10]
cmp eax,1
ja .bad
mov ebx,[esi]
test ebx,ebx
jz .done
mov edx,3
movzx eax,word [esi+8]
lea eax,[esi+eax*4+12]
cmp ebx,eax
jne .bad
mov edi,esi
mov esi,ebx
dec ecx
jnz .loop
.done:
popad
ret
.bad:
inc D(32)
cmp dword [DATA+32],1
jne .done
mov [DATA+1088],edx
mov [DATA+1092],esi
mov [DATA+1096],edi
mov eax,[esi]
mov [DATA+1100],eax
mov eax,[esi+4]
mov [DATA+1104],eax
mov eax,[esi+8]
mov [DATA+1108],eax
mov eax,[DATA+28]
mov [DATA+1112],eax
mov eax,[DATA+36]
mov [DATA+1116],eax
mov eax,[DATA+40]
mov [DATA+1120],eax
mov eax,[esp+32] ; return address of check_heap caller
mov [DATA+1124],eax
mov eax,[DATA+52]
mov [DATA+1128],eax
mov eax,[DATA+56]
mov [DATA+1132],eax
; dump 256 bytes from edi (previous header) if set, else esi-128
test edi,edi
jnz .src
lea edi,[esi-128]
.src:
mov esi,edi
lea edi,[DATA+1152]
mov ecx,64
rep movsd
jmp .done
; stale_info: ecx parent, [DATA+60] idx -> DATA+4480 + (n&31)*16: target, block hdr, size|used, value before
stale_info:
pushad
test ecx,ecx
jz .out
mov edi,[ecx+0xf4]
mov eax,[DATA+60]
lea edi,[edi+eax*4]
mov ebx,[DATA+40]
dec ebx
and ebx,31
shl ebx,4
lea ebx,[DATA+4480+ebx]
mov [ebx],edi
mov eax,[edi]
mov [ebx+12],eax
mov dword [ebx+4],0
mov dword [ebx+8],0
mov esi,[0x58499c]
mov ecx,70000
.l:
test esi,esi
jz .out
mov edx,[esi]
test edx,edx
jnz .h
movzx eax,word [esi+8]
lea edx,[esi+eax*4+12]
.h:
cmp edi,esi
jb .next
cmp edi,edx
jae .next
mov [ebx+4],esi
mov eax,[esi+8]
mov [ebx+8],eax
jmp .out
.next:
mov esi,[esi]
dec ecx
jnz .l
.out:
popad
ret
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""rd.py SESSION : attach briefly with winedbg, dump diag data, print summary"""
import os, re, subprocess, sys, struct
S = sys.argv[1]; DATA = 0x70f010; N = 4992
env = dict(os.environ, WINEPREFIX='/home/elias/.claude/jobs/90c6eed7/tmp/%s/data/wine' % S, WINEDEBUG='-all')
def dbg(cmds):
out = subprocess.run(['winedbg'], input='\n'.join(cmds) + '\ndetach\nquit\n', env=env, capture_output=True, text=True, timeout=60).stdout
return out
pl = dbg(['info proc'])
m = re.search(r'([0-9a-f]{8})\s+\d+\s+\\_ \'groove.exe\'', pl)
if not m: print('no groove.exe'); sys.exit(1)
pid = m.group(1)
out = dbg(['attach 0x' + pid, 'x/%dx 0x%x' % (N // 4, DATA), 'x/16x 0x58499c'])
words = {}
for l in out.splitlines():
mm = re.match(r'.*?0x([0-9a-f]{8}):\s+((?:[0-9a-f]{8}\s*)+)$', l)
if mm:
a = int(mm.group(1), 16)
for i, w in enumerate(mm.group(2).split()): words[a + 4 * i] = int(w, 16)
def W(o): return words.get(DATA + o, 0)
names = set()
for base, cnt in ((64, 32), (1408, 64)):
for i in range(cnt):
e = DATA + base + 32 * i
for k in (16, 20):
if words.get(e + k): names.add(words[e + k])
nm = {}
if names:
o2 = dbg(['attach 0x' + pid] + ['x/s 0x%x' % a for a in sorted(names)])
ss = [l for l in o2.splitlines() if l.startswith('Wine-dbg>') and '"' not in l or True]
segs = o2.split('Wine-dbg>')
for i, a in enumerate(sorted(names)):
nm[a] = segs[2 + i].strip() if 2 + i < len(segs) else hex(a)
print('parent_ok=%d stale_addr=%d stale_pop=%d free=%d alloc=%d anomalies=%d staleidx=%d allidx=%d' % (W(16), W(20), W(24), W(28), W(36), W(32), W(40), W(44)))
def ring(base, cnt, idx, label, last=12):
print('--', label)
n = min(idx, cnt)
for j in range(max(0, idx - last), idx):
e = base + 32 * (j % cnt)
s, p, k, vi, sn, pn, pv, pec = (W(e + 4 * q) for q in range(8))
print(' #%d kind=%x strat=%x(%s) parent=%x(%s) v%d parentvars=%x parent_ec=%x target=%x' % (j, k, s, nm.get(sn, '-'), p, nm.get(pn, '-'), vi, pv, pec, pv + 4 * vi))
ring(64, 32, W(40), 'stale', 32)
for j in range(max(0, W(40) - 32), W(40)):
e = 4480 + 16 * (j % 32); t, h, su, v = (W(e + 4 * q) for q in range(4))
print(' stale#%d target=%x block=%x off=+0x%x %s size=%d value_before=%x%s' % (j, t, h, t - h if h else 0, 'USED' if su >> 16 else 'FREE', su & 0xffff, v, ' <<< HEADER HIT' if h and t - h < 12 else ''))
ring(1408, 64, W(44), 'all parent accesses (last 16)', 16)
if W(32):
print('ANOMALY code=%d hdr=%x prevhdr=%x hdr.next=%x hdr.prev=%x sizeused=%x frees=%d allocs=%d staleidx=%d caller=%x lastfree=%x lastalloc=%d' % tuple(W(1088 + 4 * q) for q in range(12)))
d = [W(1152 + 4 * q) for q in range(64)]
for q in range(0, 64, 8): print(' ' + ' '.join('%08x' % x for x in d[q:q + 8]))
fh = [(W(3456 + 16 * q), W(3456 + 16 * q + 4), W(3456 + 16 * q + 8), W(3456 + 16 * q + 12)) for q in range(64)]
print('heapbase', hex(words.get(0x58499c, 0)))
if '-f' in sys.argv:
for q in range(64): print(' free ptr=%x caller=%x sizeused=%x next=%x' % fh[q])
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env python3
"""wadfix.py IN OUT: RollingBoulderMountain never ends (its final 'wait until v13 == 0' compares with 0.5)."""
import struct, sys
d = bytearray(open(sys.argv[1], 'rb').read())
pat = struct.pack('<6I', 0x1002f, 0xd0004, 0x44, 0x4e, 0xfffc0012, 0xb4) # freezeOffCamera 1; v13; pushShort 0; eq; jzYield -4; end
i = d.find(pat)
assert i >= 0 and d.find(pat, i + 1) < 0, 'pattern not unique'
struct.pack_into('<I', d, i + 8, 0x08000044) # pushShort 0.5
open(sys.argv[2], 'wb').write(d)
print('patched at 0x%x' % (i + 8))
+94 -1
View File
@@ -88,7 +88,97 @@ and are set on load.
* normals ×127 (code multiplies by 32 → 4096 scale), d ×4096 (fixed point) → local coordinates only ±8 units!
* type bit0 = quad (4 edges), otherwise triangle (3 edges, 5th plane = 0). Bit2 ≈ normal points down (ny<0; ceilings/overhangs) [likely].
* material: ground type (0 = normal; 0x11/0x12 are ignored depending on WFPC 0x8000000, 0x1e = water/death plane [likely]; the value ends up in the player's ground state).
* Collision queries: `FUN_004036d0` (floor/ceiling), `FUN_00403ad0` (walls), `FUN_004042f0`, `FUN_004046e0`. Only **the tiles of the grid cell the player stands in** are tested (cell list from MAP), point relative to the tile's B position, rotated by −rot.
* Collision queries: `FUN_004036d0` (floor/ceiling against the map tiles), `FUN_00403ad0` (the same floor test against strat objects with a collision model, not walls – see the floor test section below), `FUN_004042f0`, `FUN_004046e0`. Only **the tiles of the grid cell the player stands in** are tested (cell list from MAP), point relative to the tile's B position, rotated by −rot.
### Floor test: inclusion rule and the floating-next-to-the-log case
![top and side view of the needle triangle next to the crate](img/floor_explained_t1l4m002.png)
The crate has two such needles, one per long side: poly 15 (z 43.68–43.73, measured live) and poly 11 (z 43.29–43.38, same shape, where a user got stuck). Drawn with `tools/floorre/explain_t1l4m002.py` (builds on the floor-test agent's plot).
(2026-11-08, from the decompile + objdump of 0x4036d0–0x403ad0, a bit-exact Python re-implementation
`tools/floortest.py`, and live reads in game. [certain] unless marked.)
**Who calls what.** Per strat and frame `FUN_00403020(strat, models=DAT_005846ec, world=DAT_00584648)`:
`FUN_004036d0` = floor test against the **map tiles of the strat's cell**; `FUN_00403ad0` = the same test
against **strat objects with a collision model** (strat+0xec bit 0x20, within the box radius strat+0x13c;
result index = (strat − DAT_006d9dc0)/0x154), skipped when strat+0xe8 bit 0x400. Neither is a wall test.
The map result wins unless the object floor is higher. Result → floor record *(strat+0xc0):
+0 height, +4 material (record byte 1), +6 flags (bit0 map floor, bit1 object floor), +0xc tile, +0xe polygon.
Material 0x1e → treated as "no floor" (height 0xffff0000).
**FUN_004036d0, per tile of the cell, per collision record** (all ints, 20.12):
1. Probe point, tile-local: `lx,lz` = (strat x/z − tile B x/z) rotated by −ry with the static sine table
`DAT_00574318` (4096 entries, cos = sin[i+0x400]), `ly = strat y − tile y + DAT_0057dd98`;
DAT_0057dd98 = **0x400 (0.25)**, or floor-record+0x18 / 2 for strats with flag 0x200000.
2. Skip material 0x11 (0x12 instead when strat+0xe8 bit 0x8000000). Skip ny = 0; skip ny < 0 unless flag 0x200000.
3. Height of the probe above the surface plane, measured **vertically**:
`i6 = (d0·4096 − nz0·32·lz − nx0·32·lx − ny0·32·ly) / (ny0·32)` (C division), `dist = −i6`.
The plane point under/over the probe is `Y = ly + i6` (plane equation solved for y at (lx,lz)).
4. `dist < −0x80` (plane more than 1/32 above the probe) → ceiling/"surface above" branch (below).
Else, if `dist < best` (strictly; initial 1000000) and ny > 0 → edge test.
5. **Edge test** (3 edges, 4 if type bit0), on the point (lx, Y, lz) *on the plane*:
`e_k = ((nyk·32·Y + nzk·32·lz + nxk·32·lx) >> 12) − dk`; **fail if e_k < tol** with
`tol = −0x40 − ((0x1000 − ny0·32) >> 6)` — i.e. 64 + (4096−4064·ny)/64 units of 1/4064:
**≈ 0.016 for flat floors, 0.023 at ny 0.5** (perpendicular distance outside the edge plane).
No radius, no vertex test, no other use of the record (bytes 5, 11, … are padding; for triangles the
5th plane is never read). The point must be inside (within tol) **every** edge plane.
6. All edges pass → `best = dist`, height = tile y + Y, tile, polygon index (counted over groups A+B+C).
**Selection rule: the passing plane with the smallest dist, i.e. the highest surface not more than
0.25 + 1/32 above the feet.** Consequence: a passing surface up to 0.28 above the feet wins over the
ground under the llama, and the llama is put onto it (verified, step 3 below).
7. Ceiling branch (dist < −0x80): passing up-facing planes are kept as "lowest surface above"
(local_70/local_5c). `FUN_00403020` uses that as the floor when no floor below was found
(unless strat+0xe8 & 0x40200) – from the decompile only, not observed.
**Why polygon edges reach out: the tolerance turns sharp corners into long spikes.**
Every edge is moved outwards by `tol`. At a corner with interior angle θ the accepted area therefore
sticks out by ≈ tol / sin(θ/2): 0.023 at a right angle (every normal log/crate edge, all quads of the
path), but ~0.19 at a 13.5° corner.
**The case (t1l4m002, world 4 chapter 2).** Cell 57,43 contains only tile 443 = model 129 (the
wooden crate/log with its piece of path) at B (57.5, 2.0, 43.5), ry 3072. The transform was checked
three ways: render faces (with z mirrored, see §2) and collision of model 129 line up (crate box on the
crate box); the bit-exact engine re-implementation gives exactly the live floor record; the debug-wireframe
screenshot shows the crate's cut end right next to the llama. Collision polygon 15 (type 0 = triangle, mat 0,
plane (108,65,10)/127 → ny 0.51, a steep bevel strip along the crate's side) is a **needle triangle**:
corners (world x/y/z) (57.725, 2.553, 43.734), (57.750, 2.651, 43.677), (57.315, 2.497, 43.729); the corner at
57.315 has **13.5°** and points away from the crate, exactly along the crate's side face (x ≈ 57.31).
Its tolerance zone is a 0.01–0.02 wide sliver that continues ~0.19 beyond the tip, over the path
(pink in `img/floor_needle_t1l4m002.png`). Kuzco's position 57.204 / 43.733:
* local (0.2329, ·, 0.2959); poly 15 edges e = (−37, 2005, −72) ≥ tol −95 → **passes**; Y = 2.4744 (the
steep plane extended 0.11 beyond its tip), dist 0.2498.
* poly 2 (path, ny 0.99) also passes, e = (525, 304, 850, 1101), height 2.270, dist 0.454 → loses
because 2.474 is higher and still within 0.28 of the feet.
* The sliver at x = 57.204 is only z 43.7300–43.7402 wide; x reaches down to ≈57.12.
* Why exactly x = 57.204 [verified, mechanism not decoded]: putting the llama into the crate
(x 57.25, z 43.60 / 43.70 / 43.733) the game pushes it back to x = 57.202–57.204 (wall push-out from the
crate side at ~0.11 distance). So walking along/against the crate's side face puts the llama exactly on
the line the needle's tolerance sliver extends to; only the ~1 cm band at the corner's z catches it.
**In game** (apptest :25, boot-to-level start "57.204, 2.474, 43.733, 0 @ 4/2", winedbg --gdb read/write of
strat +0x30/+0x50 and the floor record):
| written pos (x y z) | game afterwards: pos / floor h / poly | re-implementation |
|---|---|---|
| boot start 57.204 2.474 43.733 | 57.204/2.474/43.733, h 2.474, mat 0, tile 443 **poly 15** | poly 15, 2.4744 |
| 57.204 **2.27** 43.733 (on the path) | snapped **up** to y 2.474, poly 15 | poly 15, 2.4744 |
| 57.204 2.27 43.736 | y 2.469, poly 15 | poly 15, 2.4690 |
| 57.204 2.27 43.745 | y 2.270, poly 2 | poly 2, 2.2700 |
| 57.15 2.27 43.733 | y 2.266, poly 2 | poly 2, 2.2661 |
| 57.25 2.3 43.70 | pushed to x 57.204, y 2.270, poly 2 | – |
Screenshots: `img/floor_float_t1l4m002.jpg` (left: boot at the floating spot, poly 15; right: 0.012 further
in z on the path, poly 2; debug wireframe on), `img/floor_float_t1l4m002_full.jpg`.
After teleporting onto poly 15 with the debugger the llama sometimes slid off within seconds (steep
plane, ny 0.51); started there by boot-to-level it stays [observed, not analysed].
**How common.** Scan of all 31 t1 level WADs (sample points 0.3/0.6/0.9 of the way from each corner to its
tolerance tip, exact engine test over all tiles of that cell, counted when this polygon wins, the point
is ≥ 0.08 outside the real polygon and the next lower floor is > 0.05 below): 2–526 polygons per level,
t1l4m002: 110 (of 6468 floor-polygon instances), poly 443/15 among them (predicted spot 57.199/2.469/43.735).
Most are sharp triangle tips of terrain/rock meshes at ledges; normal right-angled log/crate edges
reach only 0.023 and never show it. [Counts are a heuristic scan; reachability of the spots not checked.]
Scripts: `tools/floortest.py` (single position); scan code: `$CLAUDE_JOB_DIR/tmp/floorre/exposed.py` + `reach.py` (scratch, not in the repo).
### Face flags (u16)
| Bit | Meaning | Confidence |
@@ -217,3 +307,6 @@ Implemented in `tools/geom_import.py` (OBJ → TRAK + MAP tile part, rest of the
* `geom_import.py BASE.wad MESH.obj NEW.wad [--coll-obj C.obj] [--keep-tiles] [--zones flat|keep] [--default-tex N] [--export-space]`
* `geom_demo.py BASE.wad demo.obj [--plaza]` – example geometry made of texture-tile quads.
* `geomlib.py` – parser/builder (TRAK, STPC models, MAP tile part, TEXT table/pages, collision).
* `floortest.py WAD X Y Z` – bit-exact re-implementation of the engine's map floor test (FUN_004036d0) at a world
position: every candidate polygon of the cell, its edge values/tolerance, and the chosen floor. Note: the engine's
plane scale is n/127 with d/4064 (normal×32 vs d×4096), `geomlib.coll_decode` uses d/4096 (0.8 % off).
Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+45
View File
@@ -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
View File
@@ -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.
![English exe: menu, practice restore, free camera, map after TestLevel 9](img/mods/english_exe.jpg)
**Adding a build:** copy it to `re/` (gitignored), add a `GameExe.Build` (language, PE date, size,
SHA-256), run `GameExeTest`: every failing site needs a longer/other signature (keep it unique in
all builds; mask only addresses and rel32). For the new column, read each address off the
resolved sites (or with a script that pairs relocation entries of matched instructions, as done
for the English build: an operand is accepted only if most references to the German global agree),
then add the column to `Site` and a case to `Build.va`. Check the base patch's free space
(`ExePatcher.SLACK_SECTIONS`) and run `ExeVersionsTest` / `ExeModCombinationsTest`; then test in
game.
### Antivirus false positives
A changed groove.exe with extra code may be flagged by antivirus programs (Windows Defender).
+99 -2
View File
@@ -57,13 +57,13 @@ 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 |
| F7 in game | toggles 0x584708 → start row of the strat debug print (0x6D9DB8 = 0x20 / 0x48); nothing visible in retail strats | no |
| F6 | screenshot `capture%d.tga` | no |
| `CheatTeleport`, `CheatCharge` strats | present in most level WADs; strat-only logic, not decoded | no |
| `CheatTeleport`, `CheatCharge` strats | present in most level WADs; gated by property 258, see "Developer switch (property 258)" | no |
Level select / unlock-all is the save editor's job (`--unlock`), not duplicated here.
@@ -76,3 +76,100 @@ Level select / unlock-all is the save editor's job (`--unlock`), not duplicated
loss (`img/mods/cheats_fall.jpg`).
* `rewards` (bit 3) decoded only, not played through to a chapter end.
* Diagnostics: file written, game runs normally.
## Frog moonjump (PS second controller)
Question from speedrunners: is the frog moonjump (PS: needs a controller in port 2) reachable on PC?
Answer: **the logic is in the PC data, but its input can never be set on PC without patching.**
Strat side (all 34 level WADs disassembled, 2026-11-08). Property 88 `padAux` (DAT_005FCF58) is read
only by Llama (23 WADs), LlamaChase (t1l2m003), KuscoBunny (t1l7m004, t1l8m001, t1l8m002), KuscoFrog
(t1l4m003, t1l8m003) and CheatTeleport (26 WADs, also `padAuxPressed`). Everything except the frog's
air handler is additionally gated by property 258 (0x102), which the exe getter hard-wires to 0
(jump table 0x552684[0x102] → 0x55231B `push 0`); CheatTeleport removes itself when it is 0.
KuscoFrog (t1l4m003 unit 44), airborne handler L_136c – **no prop-258 check**:
```
if (self.padAux & 0.5) { ; bit 0x800
g22 = 0.055 ; vertical speed upward, every frame while the bit is set
v31 = 1.0
}
self.y = max(self.y + g22, v44)
```
Ground handler L_1d6c (take-off): `(padPressed & 4.0 /*jump*/) | ((padAux & 0.5) && prop258)` → the
aux bit alone cannot start a jump, it only keeps the frog rising once it is in the air (= moonjump:
jump normally, then hold the aux button). Llama/Bunny/LlamaChase have the same code (Llama: g22 = 0.0698)
but behind `prop258`, so they never moon-jump.
Exe side: 0x5FCF58 is referenced exactly 3× in groove.exe (all 4-byte immediates scanned): 0x425B93
`mov dword [0x5FCF58], 0` (end of the pad poll FUN_004256C0, only if pad count 0x5FCF6C > 1, i.e. a
DirectInput joystick is present), 0x425C1E (edge detect → 0x5FCF60 `padAuxPressed`), 0x551403 (strat
getter, prop 0x58). It lives in .bss → always 0. No keyboard key, joystick (1st or 2nd), debug key or
demo (.dem) playback writes it. Under Wine without a joystick: pad count 1, padAux 0 (read in game).
Verified in game (apptest :25, t1l4m003 Jungle day ch. 3, teleported onto PotionPadFrog → FrogPotion
→ frog): `set {int}0x5FCF58 = 0x800` via the winedbg gdb proxy. Frog on the ground: nothing happens.
Frog jumps (X): it keeps rising – y 13460 → 41617 (~2 s) → 59842 (~4 s), far above the level; clearing
the bit makes it fall back. Same write as the llama: normal jump, no effect (prop 258 = 0).
`img/mods/frog_moonjump.jpg` (top: normal frog jump, bottom: bit 0x800 set).
To enable on PC a patch is required, e.g. make the pad poll map a key to padAux bit 0x800. Which PS
button of pad 2 feeds bit 0x800 is not known (no PS binary analysed) – hypothesis only.
## Developer switch (property 258)
Strat property 258 (0x102) is a hard-wired developer flag: getter FUN_00550e60, jump table
0x552684[0x102] → 0x55231B `mov eax,[esp+14h]; push 0; push eax; call 0x54BBD0` (the `push 0` byte is
at **0x552320**; the same handler also serves the unread ids 0x75, 0x9E, 0xC1, 0xEE). It is read
101× in the 34 level WADs (all units disassembled; `pushProp` only, no player/parent/... variants).
Every use is a logical test (`and`, `not`, `jz`), so any non-zero value enables it.
Keys (in-game `padHeld` 0x5FCF20, measured with gdb while holding the key): A 0x400, S 0x800
(camera left/right), Return 0x1000, Z 0x2000 (action), X 0x4000 (jump), Space 0x8000 (power),
P 0x08 (pause). No keyboard key gives 0x100/0x200. `padAux` (prop 88, 0x5FCF58) and
`padAuxPressed` (prop 90, 0x5FCF60 = edge of padAux, computed every frame at 0x425C1E) are never
set on PC (see above). Strat constants are 20.12: `0.25` = 0x400, `0.5` = 0x800, `0.75` = 0xC00,
`10.0` = 0xA000, `256r` = raw 0x100.
| Reader strat | Levels | Gate | Bits / keys | Effect | Status |
|---|---|---|---|---|---|
| CheatTeleport (+CheatCharge, same unit) | placed in 22 levels: 1-1..1-4, 2-1, 2-2, 4-1..4-3, 5-1..5-3, 6-1..6-3, 7-2, 7-4, 8-1..8-5 (code also in 3-1..3-4, not placed) | entry `if (!prop258) remove` at level start | hold **A+S** (padHeld 0xC00) | teleports the llama to the next node of the strat's own path (hand-placed warp list, 1–18 nodes per level, often next to checkpoints, doors, puzzles; wraps around); sets `g0 = 14` | verified 1-1 (4 nodes, cycled twice) |
| CheatTeleport | same | prop258 | padAuxPressed 0x400 | +1 heart (if alive), +5 purple fruit, hudDigit = charge | verified (fruit 0 → 5, HUD shows it) |
| CheatTeleport | same | prop258 (+ prop 0 == 0) | padAux 0x100 held | +1 coin per frame (prop 190, coins of this level) | verified (HUD 307 coins after 0.5 s) |
| CheatTeleport | same | prop258 (+ prop 0 == 0) | padAux 0x200 held | +1 pink idol per frame, max 99 (prop 250) | verified (0 → 65 → 99) |
| CheatTeleport | same | prop258 | hold Space+Z (0xA000) | only `print` debug lists (player/camera state dump: "gFreezePlayer", "gCamAng", "teleported to location (" …) that the PC VM skips → nothing visible | static |
| CheatTeleport | same | prop258 **and prop 0 ≠ 0** | padAux 0x100 held = debug menu; then padPressed Space/Z = previous/next music track (spawns unit 48 = track display, `musicPlay`), X = lose heart, Return = +1 idol, A = +1 coin (+10 charge every 5), S = full charge, 0x100 = **`endLevel(g101,g102,g103)`** (finish level), 0x200 = +1 counterC; combo becomes 0xF00 | unreachable even with prop258 = 1 (prop 0 is also hard-wired 0, handler 0x552192) | static |
| Llama | 23 levels (all with a llama) | prop258 | padAux 0x800 held | moonjump/fly: starts a jump on the ground without pressing X (no cooldown check) and sets vertical speed 0.0698/frame every frame in the air → rises until released | verified 1-1: y 8.25 → 20.6 in ~2 s without touching a key |
| LlamaChase | 2-3 (night chase) | prop258 | padAux 0x800 | same moonjump; also allows jumping anywhere (normally only on floor material 11) | static (chase intro did not start under load) |
| KuscoBunny | 7-4, 8-1, 8-2 (potion) | prop258 | padAux 0x800 | rabbit jumps/rises with speed 0.045 while held | static |
| KuscoFrog | 4-3, 8-3 (potion) | prop258 only for take-off | padAux 0x800 | air rise has **no** gate (moonjump agent, verified); with prop258 the bit also starts the jump | static |
| KuscoAndPachaTogetherOnATree (river log) | 3-1..3-4 | prop258 | hold **Space** (0x8000) | damage handling skipped (invincible), +1 heart every frame, collision flag 0x40000 off (passes through banks), horizontal movement ×8, no current/steering term | verified 3-1: with Space the log shot through the river bank out of the level |
| RiverWhirlpool | 3-2 (36×) | prop258 | hold Space | whirlpool does not pull the log | static |
| CondorManDoor | 5-2 | prop258 | padHeld 0x100 (no PC key) | a hit always takes the "straight hit" branch (`v16 = 1`, later `otherPiece()` on impact; hypothesis: the door breaks) instead of needing the angle within 96 | static |
| EndGameTriggerBox | 8-6 (finale, 15×) | prop258 | hold Space+Z during the box's wait time | `endLevel(1,1,1)` at once (hypothesis: skips the rest of the finale) | static |
How it was verified (apptest `devflag-a`, :25, German exe): `set {char}0x552320 = 1` via the winedbg
gdb proxy at the main menu (CheatTeleport removes itself at level start, so the patch must be in
before the level loads), then `set {int}0x5FCF58 = 0x100/0x200/0x400/0x800` one at a time.
`img/mods/devflag_teleport.jpg` (1-1 start → three A+S warps), `devflag_aux_moonjump.jpg` (before /
after padAux 0x400+0x100: fruit 5 and 307 coins on the HUD / bit 0x800: llama flying above the
village), `devflag_river.jpg` (3-1 log normal / after Space boost in the bank / outside the level).
Note: the exe run by LlamaMod relocates the per-level array 0x584F1C to 0x6DB940 (seen at 0x550BC2),
so `levelRecord`/coins are not at the address of `re/groove.exe`.
Property 0 is a second hard-wired 0 read by strats (131×: Camera, CameraSide, CheatTeleport, birds,
butterflies, ladybirds, moths, Bunny, Panther, Yzma…). Ambient critters with a parameter set remove
themselves when it is non-zero, the camera uses alternative heights/distances when it is 0, and
CheatTeleport switches its combo to 0xF00 – **hypothesis**: a platform/hardware flag (PS button
layout or low-memory mode), not a debug flag. Other constant getter cases: 0x5E/0x60 = 84,
0x5F/0x61 = 123, 0x82/0x83 = 42 (stick thresholds), and 0, 0x13, 0x15, 0x47, 0x75, 0x76, 0x77,
0x98, 0x9E, 0xC1, 0xD8, 0xED, 0xEE, 0x102 = 0; of the zero ids only 0 and 0x102 are read by any strat.
No exe code writes padAux, and no other dev key handler was found beyond the table above (F4, F6, F7).
Implemented as the separate mod `devmode` (see `mod-devmode.md`); original suggestion: patch byte 0x552320 to 1
(or point jump-table entry 0x552A8C at a cave that pushes 0x1000, leaving the shared handler alone),
and map keys to padAux in the pad poll: at the end of FUN_004256C0 (0x425B88, where padAux is only
cleared when >1 pad) jump to a cave that builds padAux from the DIK state array 0x58226C + 4·DIK,
e.g. F1 → 0x800 (moonjump), F2 → 0x400 (heart + fruit), F3 → 0x200 (idols), F5 → 0x100 (coins).
A+S teleport and Space (river) work with the normal keys once prop 258 is 1. Note that F4/F6/F7
are taken; the frog's ungated moonjump would also react to the 0x800 key.
+57
View File
@@ -0,0 +1,57 @@
# Developer mode (`devmode`, CHEATS)
`mods/devmode/DevModeMod` + `DevModePatch` – two groove.exe patches, no data changes. Background and
the full list of what the switch unlocks: `mod-cheats.md`, "Developer switch (property 258)".
## Options
| Key | Default | padAux bit | Effect |
|---|---|---|---|
| `flyKey` | F1 | 0x800 held | Llama (also from standing), LlamaChase, bunny rise while held; frog after a jump |
| `heartKey` | F2 | 0x400 (pressed edge) | +1 heart, +5 purple fruit |
| `idolKey` | F3 | 0x200 held | pink idols +1 per frame, max 99 |
| `coinKey` | F11 | 0x100 held | coins +1 per frame |
| `flyToggle` | off | – | fly key toggles bit 0x800 instead of hold |
Keys are `Hotkeys.option` choices (cross-mod conflict check); defaults avoid practice F5/F8, freecam
F7/F9 and the game's F4/F6/F10. The developer switch itself has no option (always on with the mod).
Two equal keys → apply fails with a message.
## Patches (German / English addresses, all via `Site`)
1. **Property 258 = 1.0.** `PROP_GET` (0x550E60 / 0x550B80) is the strat property getter; its
`jmp [eax*4 + table]` has the table address at +21 (0x552684 / 0x552390, in .text). Entry
0x102 (0x552A8C / 0x552798) pointed at the shared `push 0` handler (0x55231B / 0x55198F; the
patch checks its bytes) and now points at a cave: `mov eax,[esp+14h]; push 0x1000; push eax;
call <push result>; add esp,8; add esp,10h; ret`. The other ids of the shared handler
(de: 0x75, 0x9E, 0xC1, 0xEE; en: 0xC1, 0xEE) stay 0. The table entry has a .reloc entry; the
exe always loads at its image base, so the absolute cave address is fine.
2. **padAux from keys.** `PAD_POLL_END` (0x425B88 / 0x425A08, tail of the pad poll FUN_004256c0:
`mov eax,[padCount]; pop ebx; cmp eax,1; jbe; mov [padAux],0; add esp,8; ret`, 25 bytes) →
`jmp` cave + NOPs. Cave: `pop ebx`, ecx = OR of the bits whose DIK slot in `KEY_STATE`
(0x58226C / 0x5818FC, s32[256] frames held, all 0 while Alt is held) is > 0, 0 while `DEMO`;
`mov [PAD_AUX],ecx` (0x5FCF58 / 0x5FC5E8); `mov eax,[padCount]` (keeps the old return value);
`add esp,8; ret`. The edge detection padAuxPressed (FUN_00425c00, start of each tick) is
untouched and works as before. Toggle mode: 8 data bytes (previous key state, on flag).
Hotkey names → DirectInput scan codes in `DevModePatch.DIK` (test: covers every `Hotkeys.VK` key).
## Verified in game (apptest `devmode-a`, :25, German exe, village 1-1, defaults)
* A+S three times: three different warp points (`img/mods/devmode_teleport.jpg`).
* F2 twice: fruit 0 → 5 → 10, energy bar refilled.
* F1 held 2.5 s: the llama rises above the village, falls back after release.
* F3 held 1.5 s: idols 46; F11 held 1 s: coins 1 → 32 (`img/mods/devmode_keys.jpg`).
* No crash; game starts normally with the mod (one crash in a Wine DLL at 68E9306B on the very
first start of a fresh session prefix, not reproducible in 3 later starts with the mod).
Not checked in game: English exe (unit tests only), toggle mode, river log Space, 8-6 Space+Z,
frog/bunny, LlamaChase.
## Limits
* Level-skip/music debug menu needs property 0 ≠ 0 as well (also hard-wired 0, used by 131 strat
reads incl. cameras) – deliberately not touched.
* Toggle state survives level changes (flying continues in the next level until pressed again).
* With the switch on, CheatTeleport stays alive in 22 levels; A+S (camera left+right together) is
the warp combo, so pressing both camera keys at once now teleports.
+150
View File
@@ -0,0 +1,150 @@
# "Mountain 3 crash" (speedrun community) – investigation 2026-11-08
Short version: **Mountain 3 = `t1l5m003`** (world 5, chapter 3). Its boulder spawner
(`RollingBoulderMountain`) can end while a boulder it spawned is still alive. When that boulder
finishes its path, it decrements `parent.v13` of the dead spawner. That write
(`-= 1.0`, i.e. `-0x1000`) goes into freed or reused memory (use after free). If it hits a header of
the game's local heap, a later `FreeLocalMem` crashes. That matches the community crash dump.
The stale write is **verified in game**. A full crash was **not** reproduced. A one-word level-data
patch removes the stale write (verified in game).
## Evidence from the community
* DebugDiag dump (English exe, 2000-10-17): access violation **writing** `0x0000340d` at
`groove+0x1ad52` = VA `0x41AD52`, thread 0, Argonaut name `FreeLocalMem`.
* English `0x41AD10..0x41AD89` is byte-identical to German **`FUN_0041ada0`** (only the rel32 of
the "ITS NOT USED! %08x" debug printf differs). Crash instruction = German `0x41ADE2`
`mov [eax+4], esi`.
* The local heap (`FUN_0041af00` init, `FUN_0041ae20` alloc, `FUN_0041ada0` free) holds blocks with a
12-byte header `{next, prev, u16 size (dwords), u16 used}`. The blocks are contiguous
(`next == hdr + (size+3)*4`). On free, the routine merges with the **next** block if
`next.used == 0`, then does `next->next->prev = hdr`. The crash means the header after the
freed block had `used == 0` and `next == 0x3409`. 0x3409 is not 4-aligned, so it can't be a real
block pointer. **The heap metadata was corrupted earlier, and the free only shows it.**
* Clips: index.mp4 is in a snowy canyon with a pushable boulder (red X), wall shards
(`SmashWallShrapnel`), a ruby-idol pickup (HUD), and a crash at the cave about 6 s later.
index2.mp4 is the Kronk ice race (`KronkOnIceDoor`/`KronkOnIce`, same WAD): black screen about
2 s after the race starts. Both are in `t1l5m003`. Our boot to (74.6, 14.8, 6.3) shows the same canyon
and boulder as clip 1, so clip 1 is most likely the `#47` push-boulder section (hypothesis,
based on what the scenes look like).
* Side note: the heap is `operator_new(0x200000)` but is initialised with size `0x800000` into a u16
field, so it is really 0xFFFD dwords (≈256 KB). Mountain 3 uses about 88 KB of it, so the heap
is not exhausted. No leak was seen: block count stayed stable over minutes.
## The bug (strat code of unit 1 of t1l5m003, verified by disassembly)
`RollingBoulderMountain` (6 MAP instances, params v0..v9). `v13` is set to 0 and never
incremented anywhere. The only other access is the boulder's `parent.v13 -= 1.0`
(`pushParentVarAddr v13; dec`) in `L_0574`, which runs when the boulder ends its path or falls:
```
spawner: ... spawn loop (L_0c24: spawn<7,31,4,0x14c000,0>(…RollingBoulderForMountain)) ...
freezeOffCamera 1
L_0d90: pushVar v13 / pushShort 0 / eq / jzYield L_0d90 / end ; ends at once when v13 == 0
boulder: L_0574: parent.v13 -= 1.0 ; ... ; remove
```
* With `v1 == 0` (instances #17 and #35) or `v4 != 0` (#22 and #47, the pushable "red X" boulders,
which wait to be charged), the spawner spawns **one** boulder and **ends in the same frame**
(v13 is still 0).
* Instances #1 and #27 (`v1` = 8.3 / 8.75) loop: a boulder about every 8–10 s, until the player
is near the path start. They end the same way if no boulder has died yet when the loop exits.
* Strat removal (strat update loop `FUN_0054c4a0`) frees the var block
(`+0xF4`) and the collision block (`+0xC0`) and puts the struct on the free list (LIFO). It does
**not** clear the children's `+0xC` parent pointers. The parent opcodes (`0x553750`/`0x553770`/
`0x553a40`) have no check.
* So the boulder's write goes to `deadSpawner.vars + 0x34`: either memory of the freed var block
(now part of a free block or of another allocation, possibly a **block header**), or, if the
struct was already reused, `v13` of another strat. For a boulder, `v13` is its sound address
(`v13 = sound:4`, used by `playSoundChannel`).
* "Predictable intervals" (runner's guess) fits: the stale writes happen a fixed time after you
pass a spawner, or after you push a boulder, when that boulder reaches the end of its path.
Whether a write hurts depends on the heap layout at that moment, so it depends on timing.
It is not deterministic across runs.
## In-game verification (apptest, Xvfb :26, sessions `mtn3-*`)
Tooling (throwaway, see `notes/dev/mountain3-diag/`): a test-only mod `Mtn3DiagMod` (loaded through
`APPTEST_CP` + `-Dllamamod.extraMods`). It installs a code cave (`cave.asm`, nasm) that replaces the
`pushParentVarAddr` and `popParentVar` handlers. They log every access, flag **stale** ones
(parent `+0xEC == 0`, i.e. removed) together with the block that the target address falls in, and
walk the whole heap on every alloc and free (`next/prev/used` consistency).
`rd.py` reads the log by attaching `winedbg` for a moment (stdin script `attach`/`x`/`detach`;
works under ptrace_scope 1). `apptest.py` in the agent copy got an env switch
`APPTEST_IGNORE_WINEDBG` so the short attach is not taken for a crash (not copied back).
A gdb-over-winedbg monitor with breakpoints was too slow: the game froze.
Boot-to-level `start=29.5, 14, 23.2, 180 @ 5/3` (next to spawners #1 and #17), no input, 70–85 s:
| | runs | runs with a stale write | heap anomalies | crash |
|---|---|---|---|---|
| original WAD | 6 (1 manual + 5) | 5 | 0 | 0 |
| level fix (below) | 3 | 0 | 0 | 0 |
Every stale record was the same and deterministic for this start (logged by the cave):
`RollingBoulderForMountain` → parent `RollingBoulderMountain` (struct `+0xEC == 0`, removed), `v13`,
target = `freed var block + 0x34`. At the time of the write this address was inside a **used
16-dword block** (a `0x40000` collision block, most likely the next boulder's), at offset +0x40 from its
header, which is 12 bytes before the next block header. In this layout it corrupts collision data
(u16 fields `+0x34/+0x36` become 0xF000/0xFFFF), not the heap. Any other layout (other timing,
other route, other spawns in between, e.g. an 11-dword allocation such as a `0x28000` pickup's
collision block puts a remainder header's size/used word exactly there) turns the same write into
header corruption. **Not verified:** a crash caused by this write. No natural run crashed, and the
real runner route (from the chapter start at full speed, pushing #22/#47, then the race) could not
be driven reliably by key scripts (the push needs speed and alignment; several attempts missed).
Also seen in game: strat structs are recycled LIFO (the dead spawner's struct was later reused by a
boulder).
Other checks in this WAD: every `collisionOn` user is spawned or placed with collision type flags
(no `typeFlags 0 + collisionOn` case like in the arena), and all `extra` operands are 0. Other
parent-variable writers (`KronkOnIcePickup` → `parent.v7`, `CheckPointHead`, `RubyIdolStatue`,
`AxeGuardAxe`) have parents that do not end while they live, as far as the code shows. That was
checked by reading the code only; the cave saw no stale access from them.
Side finding (not part of the crash): boot-to-level `start=28.5, 10.5, 22.5, 0 @ 5/3` passes the
floor check, but the game crashes while loading (`page fault read 0x00000002 at 0x405527`, floor/
tile lookup in `FUN_004054c0`). Out of scope here.
## Fix / avoidance
* **Level data (tested):** in `RollingBoulderMountain`, change `pushShort 0` before `eq` to
`pushShort 0.5` (word `0x00000044` → `0x08000044`, the only match of the 6-word pattern
`0x1002f 0xd0004 0x44 0x4e 0xfffc0012 0xb4`, file offset 0x4a0a27 in the German
`t1l5m003.wad`). `v13` only takes whole numbers (0, -1, -2 …), so the spawner never ends. It
stays frozen off camera and costs nothing, and every boulder writes into a live spawner. The
boulders still roll (in-game: 6–7 normal parent writes per run, 0 stale). `notes/dev/mountain3-diag/wadfix.py IN OUT`.
The English CD's WAD is not checked; the patch looks for the pattern and refuses if it isn't unique.
* **Exe (general, untested):** let removal (`+0xE8 & 4` path) clear `+0xC` of all strats whose
parent is the removed one (pool `DAT_006d9dc0`, stride 0x154), and let the parent opcodes
redirect a NULL or removed parent to a dummy dword. This also covers similar bugs in other levels.
* **Runners (hypothesis):** there's no reliable avoidance. The write happens when the boulders of
#17 and #35 (spawned when you pass their trigger spots) and the pushed boulders #22 and #47 reach
the end of their path. Not pushing #22/#47 removes two of the four guaranteed stale writes, if
the route allows that.
Diag tooling notes: the files in `notes/dev/mountain3-diag/` are as used, with absolute paths
of the agent job dir. The mod writes `addresses.txt` and expects `cave_<fix>_<CAVE>_<DATA>.bin`
(`nasm -f bin -DCAVE=0x6ef0e0 -DDATA=0x70f010 -DFIX=0 cave.asm`; these addresses are valid with
boot-to-level + mtn3-diag on the German exe). This is only for the German exe; nothing is registered in the app.
## Discord answer (draft)
> We looked into the Mountain 3 crash. Your dump crashes in the game's own memory manager
> (`FreeLocalMem`, writing to 0x340d). That is the symptom: its bookkeeping was overwritten
> earlier. **Verified:** in Mountain 3 the rolling-boulder spawner can delete itself while
> its boulder is still rolling. When that boulder reaches the end of its path (or falls off), it
> writes into the spawner's memory, which has already been freed and reused. We saw this
> write in the game with a debug build, and a one-value level patch makes it go away.
> **Not verified yet:** that this exact write causes your crash. We never got the game to
> crash. Where the write lands depends on what else is in memory at that moment, so it
> breaks things only sometimes, and the crash can come seconds later somewhere else
> (e.g. in the Kronk race). That would explain why it's Mountain 3 only, at different spots,
> without an error message, and "every so often" around boulders. The likely triggers are the
> one-shot boulders near the start/top of the slope and the two red-X push boulders. We'll
> probably ship the fix in LlamaMod.
## In LlamaMod
The fix is the mod `mountain3-fix` (`mods/fixes/Mountain3FixMod`, Gameplay): it finds the unique pattern in
`t1l5m003.wad` and replaces `pushShort 0` with `pushShort 0.5`; if the pattern is missing (other game
version, file already changed) it leaves the file unchanged and says so in the apply log.
@@ -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,283 @@
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"),
// developer mode
PROP_GET(
code("8B 44 24 0C 83 EC 10 3D 09 01 00 00 0F 87 ?? ?? ?? ?? FF 24 85 ?? ?? ?? ??"),
0x550E60,
0x550B80,
"strat property getter: switch over ids 0..0x109, jump table address (in .text) at +21"),
PAD_POLL_END(
code("A1 ?? ?? ?? ?? 5B 83 F8 01 76 0A C7 05 ?? ?? ?? ?? 00 00 00 00 83 C4 08 C3"),
0x425B88,
0x425A08,
"end of PAD_BITS: clears PAD_AUX if more than one pad, then returns"),
PAD_AUX(operandAt(PAD_POLL_END, 13), 0x5FCF58, 0x5FC5E8, "aux pad bits (strat property 88), never set on PC"),
KEY_STATE(
operand("B9 00 01 00 00 33 C0 BF ?? ?? ?? ?? F3 AB B9 0F 00 00 00", 8),
0x58226C,
0x5818FC,
"s32[256] DirectInput keyboard state by DIK code (frames held, 0 = up)"),
// base patch: level record relocation
COINS(
operand("8B 34 B5 ?? ?? ?? ?? C1 EE 0C 3B F2", 3),
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());
@@ -25,6 +25,8 @@ public final class ModRegistry {
"com.eliasmueller.llamamod.mods.freecam.FreeCamMod",
"com.eliasmueller.llamamod.mods.arena.ArenaMod",
"com.eliasmueller.llamamod.mods.coaster.CoasterMod",
"com.eliasmueller.llamamod.mods.devmode.DevModeMod",
"com.eliasmueller.llamamod.mods.fixes.Mountain3FixMod",
};
/** {@link ModSource}s loaded by class name (dynamic mods, e.g. level projects). */
@@ -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");
}
}
@@ -0,0 +1,328 @@
package com.eliasmueller.llamamod.mods.debug;
import com.eliasmueller.llamamod.format.Bytes;
import com.eliasmueller.llamamod.format.Model;
import com.eliasmueller.llamamod.mods.debug.FloorTest.Cells;
import com.eliasmueller.llamamod.mods.debug.FloorTest.Hit;
import com.eliasmueller.llamamod.mods.debug.FloorTest.Tile;
import com.eliasmueller.llamamod.mods.debug.Palette.Rgb;
import com.eliasmueller.llamamod.mods.debug.StratMarkers.Cell;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* The floor test's edge allowance: the engine accepts a floor polygon up to {@link FloorTest#tolerance}
* outside each edge plane, which at sharp corners becomes a long sliver beyond the tip. TIPS draws
* the slivers where the polygon also beats the floor below (the llama stands in the air there), ALL
* draws the allowance band along every edge of every floor polygon. notes/debugviz.md.
*/
final class AllowanceAreas {
private AllowanceAreas() {}
/** Slivers that reach less far beyond the polygon are not drawn. */
static final double MIN_REACH = 0.05;
/** The next floor must be at least this far below the winning plane. */
static final double MIN_GAP = 0.05;
/** Implausibly long tips (near-degenerate polygons) are cut here. */
static final double MAX_REACH = 1.5;
static final Rgb COLOR = new Rgb(255, 60, 200);
static final int ALPHA = 150;
static final double LIFT = 0.012;
private static final int ACROSS = 7;
private static final double ROW_STEP = 0.01;
private static final int MAX_ROWS = 40;
/** One drawn sliver: quads in game space (z positive), reach = farthest winning sample outside the polygon. */
record Tip(int tile, int poly, int corner, double reach, List<double[][]> quads) {}
/** Polygon outline in tile-local units: plane height function and edge lines a·(x,z) ≥ c. */
private static final class Shape {
final double n0x, n0y, n0z, d0;
final double[][] a;
final double[] c0, ct;
final double[][] v, w;
private Shape(byte[] r) {
n0x = r[2];
n0y = r[3];
n0z = r[4];
d0 = Bytes.i16(r, 6) / 32.0;
int n = (r[0] & 1) != 0 ? 4 : 3;
int tol = FloorTest.tolerance(r);
a = new double[n][];
c0 = new double[n];
ct = new double[n];
for (int k = 0; k < n; k++) {
int o = 8 + 6 * k;
double nx = r[o], ny = r[o + 1], nz = r[o + 2];
int d = Bytes.i16(r, o + 4);
a[k] = new double[] {nx - ny * n0x / n0y, nz - ny * n0z / n0y};
c0[k] = d / 32.0 - ny * d0 / n0y;
ct[k] = (d + tol) / 32.0 - ny * d0 / n0y;
}
v = corners(c0);
w = corners(ct);
}
/** {@code null} unless the record is an up-facing, plausible convex polygon. */
static Shape of(byte[] r) {
if (r[3] <= 0) {
return null;
}
Shape s = new Shape(r);
if (s.v == null || s.w == null) {
return null;
}
double area = 0, cx = 0, cz = 0;
for (int k = 0; k < s.v.length; k++) {
double[] p = s.v[k], q = s.v[(k + 1) % s.v.length];
area += p[0] * q[1] - q[0] * p[1];
cx += p[0] / s.v.length;
cz += p[1] / s.v.length;
}
if (Math.abs(area) < 2e-6) {
return null;
}
for (int k = 0; k < s.a.length; k++) {
if (s.a[k][0] * cx + s.a[k][1] * cz < s.c0[k]) {
return null;
}
}
return s;
}
/** Corner k between edge k and edge k+1 (like Collision.polygon). */
private double[][] corners(double[] c) {
int n = a.length;
double[][] out = new double[n][];
for (int k = 0; k < n; k++) {
double[] e1 = a[k], e2 = a[(k + 1) % n];
double det = e1[0] * e2[1] - e1[1] * e2[0];
if (Math.abs(det) < 1e-9) {
return null;
}
double x = (c[k] * e2[1] - e1[1] * c[(k + 1) % n]) / det;
double z = (e1[0] * c[(k + 1) % n] - c[k] * e2[0]) / det;
if (Math.abs(x) > 12 || Math.abs(z) > 12) {
return null;
}
out[k] = new double[] {x, z};
}
return out;
}
double y(double x, double z) {
return (d0 - n0x * x - n0z * z) / n0y;
}
/** Horizontal distance from (x, z) to the polygon without tolerance (0 inside). */
double outside(double x, double z) {
boolean in = true;
for (int k = 0; k < a.length; k++) {
in &= a[k][0] * x + a[k][1] * z >= c0[k];
}
if (in) {
return 0;
}
double best = Double.MAX_VALUE;
for (int k = 0; k < v.length; k++) {
double[] p = v[k], q = v[(k + 1) % v.length];
double ex = q[0] - p[0], ez = q[1] - p[1];
double len2 = ex * ex + ez * ez;
double t = len2 > 0 ? Math.max(0, Math.min(1, ((x - p[0]) * ex + (z - p[1]) * ez) / len2)) : 0;
best = Math.min(best, Math.hypot(x - p[0] - t * ex, z - p[1] - t * ez));
}
return best;
}
/** Interval [t0, t1] of t where (x, z) + t·(qx, qz) lies inside the tolerance region; null if empty. */
double[] span(double x, double z, double qx, double qz) {
double t0 = -1, t1 = 1;
for (int k = 0; k < a.length; k++) {
double aq = a[k][0] * qx + a[k][1] * qz;
double rhs = ct[k] - (a[k][0] * x + a[k][1] * z);
if (Math.abs(aq) < 1e-12) {
if (rhs > 1e-12) {
return null;
}
} else if (aq > 0) {
t0 = Math.max(t0, rhs / aq);
} else {
t1 = Math.min(t1, rhs / aq);
}
}
return t0 <= t1 ? new double[] {t0, t1} : null;
}
}
private static boolean candidate(byte[] r) {
int mat = r[1] & 0xff;
return r[3] > 0 && mat != FloorTest.MAT_SKIP && mat != FloorTest.MAT_NO_FLOOR;
}
/** All slivers of the level where the polygon wins in the air (TIPS mode). */
static List<Tip> tips(Cells cells) {
List<Tip> out = new ArrayList<>();
for (Tile t : cells.tiles) {
for (int i = 0; i < t.recs().size(); i++) {
byte[] r = t.recs().get(i);
Shape s = candidate(r) ? Shape.of(r) : null;
if (s == null) {
continue;
}
for (int k = 0; k < s.v.length; k++) {
Tip tip = tip(cells, t, i, r, s, k);
if (tip != null) {
out.add(tip);
}
}
}
}
return out;
}
private static Tip tip(Cells cells, Tile t, int poly, byte[] r, Shape s, int k) {
double[] v = s.v[k], w = s.w[k];
double len = Math.hypot(w[0] - v[0], w[1] - v[1]);
if (len < MIN_REACH) {
return null;
}
double bx = (w[0] - v[0]) / len, bz = (w[1] - v[1]) / len, qx = -bz, qz = bx;
len = Math.min(len, MAX_REACH);
int rows = (int) Math.max(3, Math.min(MAX_ROWS, Math.ceil(len / ROW_STEP)));
long cell = t.cell();
double reach = 0;
double[][] rowCenter = new double[rows + 1][];
double[][] rowSpan = new double[rows + 1][];
for (int j = 0; j <= rows; j++) {
double cx = v[0] + bx * len * j / rows, cz = v[1] + bz * len * j / rows;
double[] sp = s.span(cx, cz, qx, qz);
if (sp == null) {
continue;
}
double step = (sp[1] - sp[0]) / ACROSS;
double lo = Double.NaN, hi = Double.NaN;
for (int m = 0; m < ACROSS; m++) {
double tt = sp[0] + (m + 0.5) * step;
double lx = cx + tt * qx, lz = cz + tt * qz;
if (wins(cells, t, poly, r, lx, lz, cell)) {
reach = Math.max(reach, s.outside(lx, lz));
lo = Double.isNaN(lo) ? tt : lo;
hi = tt;
}
}
if (!Double.isNaN(lo)) {
rowCenter[j] = new double[] {cx, cz};
rowSpan[j] = new double[] {Math.max(sp[0], lo - step / 2), Math.min(sp[1], hi + step / 2)};
}
}
if (reach < MIN_REACH) {
return null;
}
List<double[][]> quads = new ArrayList<>();
for (int j = 0; j < rows; j++) {
if (rowSpan[j] == null || rowSpan[j + 1] == null) {
continue;
}
double[][] q = new double[4][];
q[0] = world(t, s, rowCenter[j], rowSpan[j][0], qx, qz);
q[1] = world(t, s, rowCenter[j], rowSpan[j][1], qx, qz);
q[2] = world(t, s, rowCenter[j + 1], rowSpan[j + 1][1], qx, qz);
q[3] = world(t, s, rowCenter[j + 1], rowSpan[j + 1][0], qx, qz);
quads.add(q);
}
return quads.isEmpty() ? null : new Tip(t.index(), poly, k, reach, quads);
}
/** Exact engine check: standing on the polygon at local (lx, lz), outside it, it wins with air below. */
private static boolean wins(Cells cells, Tile t, int poly, byte[] r, double lx, double lz, long cell) {
double[] wxz = FloorTest.toWorld(lx, lz, t);
int px = FloorTest.fx(wxz[0]), pz = FloorTest.fx(wxz[1]);
if (FloorTest.cellKey(px >> 12, pz >> 12) != cell) {
return false;
}
int[] l = FloorTest.toLocal(px, pz, t);
int[] own = FloorTest.test(r, l[0], 0, l[1]);
List<Hit> hits = FloorTest.candidates(cells, px, t.y() + own[1], pz);
Hit win = FloorTest.winner(hits);
if (win == null || win.tile() != t.index() || win.poly() != poly || win.inside()) {
return false;
}
int gap = (int) Math.round(MIN_GAP * 4096);
for (Hit h : hits) {
if (h != win && h.passed() && h.mat() != FloorTest.MAT_NO_FLOOR && h.height() > win.height() - gap) {
return false;
}
}
return true;
}
private static double[] world(Tile t, Shape s, double[] c, double tt, double qx, double qz) {
double lx = c[0] + tt * qx, lz = c[1] + tt * qz;
double[] wxz = FloorTest.toWorld(lx, lz, t);
return new double[] {wxz[0], t.y() / 4096.0 + s.y(lx, lz) + LIFT, wxz[1]};
}
/** Adds the slivers as translucent strips plus an opaque centre line, bucketed by the tile's cell. */
static int addTips(Map<Cell, Mesh> cells, List<Tip> tips, Cells level, Palette pal) {
int fill = pal.get(COLOR, ALPHA), line = pal.get(COLOR);
double[] up = {0.0, 1.0, 0.0};
int quads = 0;
for (Tip tip : tips) {
int[] b = level.tiles.get(tip.tile()).b();
Mesh m = cells.computeIfAbsent(new Cell(b[3] >> 12, b[5] >> 12), k -> new Mesh());
for (double[][] q : tip.quads()) {
m.quad(new double[][] {export(q[0]), export(q[1]), export(q[2]), export(q[3])}, up, fill);
quads++;
}
double[][] first = tip.quads().get(0),
last = tip.quads().get(tip.quads().size() - 1);
m.beam(export(mid(first[0], first[1])), export(mid(last[2], last[3])), 0.004, line);
}
return quads;
}
/** ALL mode: allowance band (true corners to tolerance corners) of every floor polygon, model space. */
static void addBands(Mesh mesh, Model model, Palette pal) {
int col = pal.get(COLOR);
for (List<byte[]> grp : model.coll) {
for (byte[] r : grp) {
Shape s = candidate(r) ? Shape.of(r) : null;
if (s == null) {
continue;
}
double nl = Math.sqrt(s.n0x * s.n0x + s.n0y * s.n0y + s.n0z * s.n0z);
double[] n = {s.n0x / nl, s.n0y / nl, -s.n0z / nl};
int m = s.v.length;
int[] in = new int[m], out = new int[m];
for (int k = 0; k < m; k++) {
double[] wk = s.w[k];
if (Math.hypot(wk[0] - s.v[k][0], wk[1] - s.v[k][1]) > MAX_REACH) {
wk = new double[] {s.v[k][0], s.v[k][1]};
}
in[k] = mesh.v(vertex(s, s.v[k], n), n);
out[k] = mesh.v(vertex(s, wk, n), n);
}
for (int k = 0; k < m; k++) {
int k2 = (k + 1) % m;
mesh.tri(in[k], in[k2], out[k2], col);
mesh.tri(in[k], out[k2], out[k], col);
}
}
}
}
private static double[] vertex(Shape s, double[] p, double[] n) {
return new double[] {p[0] + n[0] * LIFT, s.y(p[0], p[1]) + n[1] * LIFT, -p[1] + n[2] * LIFT};
}
private static double[] mid(double[] a, double[] b) {
return new double[] {(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2};
}
private static double[] export(double[] q) {
return new double[] {q[0], q[1], -q[2]};
}
}
@@ -23,7 +23,6 @@ import com.eliasmueller.llamamod.mods.debug.StratMarkers.Category;
import com.eliasmueller.llamamod.mods.debug.StratMarkers.Cell;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
@@ -38,11 +37,31 @@ import java.util.TreeMap;
public final class DebugWireframe {
private DebugWireframe() {}
public record Options(
boolean noColl, boolean noMarkers, boolean noFill, boolean opaque, double width, double offset) {
public static final Options DEFAULT = new Options(false, false, false, false, 0.007, 0.012);
/** Edge allowance of the floor test (see {@link AllowanceAreas}). */
public enum Allowance {
OFF,
TIPS,
ALL
}
public record Options(
boolean noColl,
boolean noMarkers,
boolean noFill,
boolean opaque,
double width,
double offset,
Allowance allowance) {
public static final Options DEFAULT = new Options(false, false, false, false, 0.007, 0.012, Allowance.OFF);
public Options withAllowance(Allowance a) {
return new Options(noColl, noMarkers, noFill, opaque, width, offset, a);
}
}
/** Level arena (16 MB) share left for TEXT+TRAK+MAP+STPC+LGPC; allowance geometry is dropped above it. */
static final int ARENA_BUDGET = 12 << 20;
/** Counters for the report (debugviz --report). */
public static final class Stats {
public int collPolys;
@@ -57,6 +76,13 @@ public final class DebugWireframe {
public int markersTruncated;
public int newTextures;
public int maxVerts;
public int allowanceTips;
public int allowanceQuads;
public int allowanceBandModels;
public int allowanceBandsTooLarge;
/** Allowance geometry left out because the level would exceed {@link #ARENA_BUDGET}. */
public boolean allowanceDropped;
public final Map<Category, Integer> markers = new EnumMap<>(Category.class);
public final Map<Integer, Integer> materials = new TreeMap<>();
@@ -331,11 +357,24 @@ public final class DebugWireframe {
return out;
}
static long arenaBytes(Wad ch) {
long n = 0;
for (String tag : new String[] {"TEXT", "TRAK", "MAP", "STPC", "LGPC"}) {
byte[] d = ch.get(tag);
n += d == null ? 0 : d.length;
}
return n;
}
public static Result process(byte[] src) {
return process(src, Options.DEFAULT);
}
public static Result process(byte[] src, Options opt) {
return process(src, opt, ARENA_BUDGET);
}
static Result process(byte[] src, Options opt, long arenaBudget) {
Wad ch = Wad.parse(src);
int wf = ch.wfpcFlags();
byte[] origMap = ch.require("MAP");
@@ -381,8 +420,37 @@ public final class DebugWireframe {
}
}
if (opt.allowance() == Allowance.ALL) {
for (int mi = 0; mi < trak.size(); mi++) {
Mesh band = new Mesh();
AllowanceAreas.addBands(band, trak.get(mi), pal);
if (band.verts.isEmpty()) {
continue;
}
Mesh ov = overlays[mi] != null ? overlays[mi] : new Mesh();
if (trak.get(mi).verts.size() + ov.verts.size() + band.verts.size() > VERT_LIMIT) {
stats.allowanceBandsTooLarge++;
continue;
}
int off = ov.verts.size();
ov.verts.addAll(band.verts);
for (int[] t : band.tris) {
ov.tri(t[0] + off, t[1] + off, t[2] + off, t[3]);
}
overlays[mi] = ov;
addV[mi] = ov.verts.size();
stats.allowanceBandModels++;
}
}
Map<Cell, Mesh> cells =
opt.noMarkers() ? Collections.emptyMap() : StratMarkers.build(ch.require("STPC"), wf, mp, pal, stats);
opt.noMarkers() ? new TreeMap<>() : StratMarkers.build(ch.require("STPC"), wf, mp, pal, stats);
if (opt.allowance() == Allowance.TIPS) {
FloorTest.Cells level = new FloorTest.Cells(mp, trak);
List<AllowanceAreas.Tip> tips = AllowanceAreas.tips(level);
stats.allowanceTips = tips.size();
stats.allowanceQuads = AllowanceAreas.addTips(cells, tips, level, pal);
}
TextChunk.Appended text = TextChunk.appendFlats(ch.require("TEXT"), pal.entries);
ch.set("TEXT", text.text());
@@ -470,6 +538,11 @@ public final class DebugWireframe {
for (Model m : trak) {
stats.maxVerts = Math.max(stats.maxVerts, m.verts.size());
}
if (opt.allowance() != Allowance.OFF && arenaBytes(ch) > arenaBudget) {
Result plain = process(src, opt.withAllowance(Allowance.OFF));
plain.stats().allowanceDropped = true;
return plain;
}
return new Result(ch.toBytes(), stats);
}
}
@@ -5,6 +5,7 @@ import static com.eliasmueller.llamamod.mods.LevelWads.fileName;
import com.eliasmueller.llamamod.mods.LevelWads;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModContext;
import com.eliasmueller.llamamod.mods.ModOption;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.ArrayDeque;
@@ -41,7 +42,34 @@ public final class DebugWireframeMod implements Mod {
+ " ones) as coloured lines, and markers for cameras, triggers, exits and checkpoints.";
}
private record Done(byte[] wad, long millis) {}
static final String ALLOWANCE = "allowance";
@Override
public List<ModOption> options() {
return List.of(ModOption.choice(
ALLOWANCE,
"Allowance areas",
"Pink marks where the game still counts the llama as standing on a floor although it is just"
+ " beside it (the collision test accepts a small margin outside every floor edge)."
+ " Off: nothing extra. Floating spots: only the places where that margin reaches noticeably"
+ " far, past sharp corners, and the llama would stand in the air above the real floor."
+ " All edges: a thin pink band along every floor edge, also where it does not matter."
+ " Off by default because it adds geometry to every level.",
"off",
ModOption.of("off", "Off"),
ModOption.of("tips", "Floating spots"),
ModOption.of("all", "All edges")));
}
static DebugWireframe.Allowance allowance(String value) {
return switch (value == null ? "off" : value) {
case "tips" -> DebugWireframe.Allowance.TIPS;
case "all" -> DebugWireframe.Allowance.ALL;
default -> DebugWireframe.Allowance.OFF;
};
}
private record Done(byte[] wad, long millis, DebugWireframe.Stats stats) {}
private record Job(String path, Future<Done> result) {}
@@ -51,6 +79,8 @@ public final class DebugWireframeMod implements Mod {
if (files.isEmpty()) {
return;
}
DebugWireframe.Options opt = DebugWireframe.Options.DEFAULT.withAllowance(
allowance(ctx.optionValues().get(ALLOWANCE)));
// ModContext is not thread-safe: only this thread calls it; workers just transform bytes.
int threads = Math.max(1, Math.min(files.size(), Runtime.getRuntime().availableProcessors()));
ExecutorService pool = Executors.newFixedThreadPool(threads, r -> {
@@ -66,8 +96,8 @@ public final class DebugWireframeMod implements Mod {
byte[] in = ctx.read(f);
pending.add(new Job(f, pool.submit(() -> {
long t0 = System.nanoTime();
byte[] out = DebugWireframe.process(in).wad();
return new Done(out, (System.nanoTime() - t0) / 1_000_000);
DebugWireframe.Result out = DebugWireframe.process(in, opt);
return new Done(out.wad(), (System.nanoTime() - t0) / 1_000_000, out.stats());
})));
// bounded look-ahead keeps at most ~2 WADs per worker in memory
while (pending.size() >= 2 * threads) {
@@ -100,8 +130,13 @@ public final class DebugWireframeMod implements Mod {
throw new IOException(fileName(job.path()), c);
}
ctx.write(job.path(), out.wad());
DebugWireframe.Stats st = out.stats();
String extra = st.allowanceDropped
? ", allowance areas left out: level too large"
: st.allowanceTips > 0 ? ", " + st.allowanceTips + " floating spots" : "";
ctx.progress(
(double) done / total,
"Debug wireframe: " + fileName(job.path()) + " (" + done + "/" + total + ", " + out.millis() + " ms)");
"Debug wireframe: " + fileName(job.path()) + " (" + done + "/" + total + ", " + out.millis() + " ms"
+ extra + ")");
}
}
@@ -0,0 +1,187 @@
package com.eliasmueller.llamamod.mods.debug;
import com.eliasmueller.llamamod.format.Bytes;
import com.eliasmueller.llamamod.format.MapChunk;
import com.eliasmueller.llamamod.format.Model;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Bit-exact port of the engine's map floor test (FUN_004036d0, floor branch, strat flag 0x200000 off),
* same as tools/floortest.py. All values 20.12 fixed point, game space (z positive). See
* notes/geometry.md "Floor test: inclusion rule and the floating-next-to-the-log case".
*/
final class FloorTest {
/** DAT_0057dd98: probe height above the feet. */
static final int PROBE = 0x400;
/** Skipped for the llama (0x12 instead for strats with flag 0x8000000). */
static final int MAT_SKIP = 0x11;
/** Floor treated as "no floor". */
static final int MAT_NO_FLOOR = 0x1e;
/** DAT_00574318 equals round(sin(2πi/4096)·4096) for every entry. */
private static final int[] SIN = new int[4096];
static {
for (int i = 0; i < SIN.length; i++) {
SIN[i] = (int) Math.rint(Math.sin(2 * Math.PI * i / 4096) * 4096);
}
}
static int sin(int u) {
return SIN[u & 0xfff];
}
static int cos(int u) {
return SIN[(u + 0x400) & 0xfff];
}
/** One placed tile: B record (rx, ry, rz, x, y, z fixed) and its collision records (groups A+B+C). */
record Tile(int index, int[] b, List<byte[]> recs) {
int x() {
return b[3];
}
int y() {
return b[4];
}
int z() {
return b[5];
}
int rot() {
return b[1];
}
long cell() {
return cellKey(b[3] >> 12, b[5] >> 12);
}
}
/** Candidate plane: dist = probe height above the plane, height = world plane y under the probe. */
record Hit(int tile, int poly, int mat, int dist, int height, boolean passed, boolean inside) {}
/** Tiles grouped by the cell of their position (the tiles the engine tests for a probe in that cell). */
static final class Cells {
final Map<Long, List<Tile>> byCell = new HashMap<>();
final List<Tile> tiles = new ArrayList<>();
Cells(MapChunk mp, List<Model> trak) {
Map<Integer, List<byte[]>> recs = new HashMap<>();
for (int t = 0; t < mp.tilesFixed.size(); t++) {
int mi = mp.tileModels[t];
List<byte[]> r = recs.computeIfAbsent(mi, k -> {
List<byte[]> all = new ArrayList<>();
trak.get(k).coll.forEach(all::addAll);
return all;
});
Tile tile = new Tile(t, mp.tilesFixed.get(t), r);
tiles.add(tile);
byCell.computeIfAbsent(tile.cell(), k -> new ArrayList<>()).add(tile);
}
}
List<Tile> at(int px, int pz) {
return byCell.getOrDefault(cellKey(px >> 12, pz >> 12), List.of());
}
}
static long cellKey(int cx, int cz) {
return ((long) cx << 32) ^ (cz & 0xffffffffL);
}
/** World x/z (fixed) to tile-local, exactly like 0x4037b3..0x4037f1 (rotation by −ry). */
static int[] toLocal(int px, int pz, Tile t) {
int lx = px - t.x(), lz = pz - t.z();
int u = -t.rot();
if (u != 0) {
int s = sin(u), c = cos(u);
int nx = (s * lz + c * lx) >> 12;
int nz = (c * lz - s * lx) >> 12;
lx = nx;
lz = nz;
}
return new int[] {lx, lz};
}
/** Inverse of {@link #toLocal} in floating point: local units to world units (game space). */
static double[] toWorld(double lx, double lz, Tile t) {
double a = -t.rot() / 4096.0 * 2 * Math.PI;
double s = Math.sin(a), c = Math.cos(a);
return new double[] {t.x() / 4096.0 + c * lx - s * lz, t.z() / 4096.0 + s * lx + c * lz};
}
static int tolerance(byte[] r) {
return -0x40 - ((0x1000 - r[3] * 32) >> 6);
}
/**
* One 32-byte record at the tile-local probe (lx, ly, lz); ly = feet − tile y + {@link #PROBE}.
* Returns {dist, plane y local, passed (1/0), inside without tolerance (1/0)} or {@code null} when
* the plane does not face up.
*/
static int[] test(byte[] r, int lx, int ly, int lz) {
int ny32 = r[3] * 32;
if (ny32 <= 0) {
return null;
}
long num = (long) Bytes.i16(r, 6) * 4096 - (long) r[4] * 32 * lz - (long) r[2] * 32 * lx - (long) ny32 * ly;
int i6 = (int) (num / ny32);
int y = ly + i6;
int tol = -0x40 - ((0x1000 - ny32) >> 6);
boolean pass = true, inside = true;
int n = (r[0] & 1) != 0 ? 4 : 3;
for (int k = 0; k < n; k++) {
int o = 8 + 6 * k;
long v = (((long) r[o + 1] * 32 * y + (long) r[o + 2] * 32 * lz + (long) r[o] * 32 * lx) >> 12)
- Bytes.i16(r, o + 4);
pass &= v >= tol;
inside &= v >= 0;
}
return new int[] {-i6, y, pass ? 1 : 0, inside ? 1 : 0};
}
/** Every floor candidate of the probe's cell (planes at most 1/32 above the probe), in engine order. */
static List<Hit> candidates(Cells cells, int px, int py, int pz) {
List<Hit> out = new ArrayList<>();
for (Tile t : cells.at(px, pz)) {
int[] l = toLocal(px, pz, t);
int ly = py - t.y() + PROBE;
for (int i = 0; i < t.recs().size(); i++) {
byte[] r = t.recs().get(i);
int mat = r[1] & 0xff;
if (mat == MAT_SKIP) {
continue;
}
int[] res = test(r, l[0], ly, l[1]);
if (res == null || res[0] < -0x80) {
continue;
}
out.add(new Hit(t.index(), i, mat, res[0], t.y() + res[1], res[2] != 0, res[3] != 0));
}
}
return out;
}
/** The engine's choice: the passing plane with the smallest dist (first one on ties); null = none. */
static Hit winner(List<Hit> hits) {
Hit best = null;
for (Hit h : hits) {
if (h.passed() && (best == null || h.dist() < best.dist())) {
best = h;
}
}
return best;
}
static Hit floor(Cells cells, double x, double y, double z) {
return winner(candidates(cells, fx(x), fx(y), fx(z)));
}
static int fx(double v) {
return (int) Math.round(v * 4096);
}
}
@@ -0,0 +1,92 @@
package com.eliasmueller.llamamod.mods.devmode;
import com.eliasmueller.llamamod.mods.Hotkeys;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModContext;
import com.eliasmueller.llamamod.mods.ModOption;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/** The game's hidden developer switch and its PC-less aux pad buttons (see {@link DevModePatch}). */
public final class DevModeMod implements Mod {
record AuxKey(String option, String label, String description, String def, int bit) {}
static final List<AuxKey> KEYS = List.of(
new AuxKey(
"flyKey",
"Fly key",
"Hold to fly: the llama (also from standing), the bunny and the frog (after a jump) rise"
+ " while the key is held.",
"F1",
DevModePatch.AUX_FLY),
new AuxKey(
"heartKey",
"Heart and fruit key",
"Each press gives one heart and 5 purple fruit.",
"F2",
DevModePatch.AUX_HEART),
new AuxKey("idolKey", "Idol key", "Hold to count the pink idols up (to 99).", "F3", DevModePatch.AUX_IDOLS),
new AuxKey("coinKey", "Coin key", "Hold to count the coins up fast.", "F11", DevModePatch.AUX_COINS));
@Override
public String id() {
return "devmode";
}
@Override
public String name() {
return "Developer mode";
}
@Override
public String category() {
return CHEATS;
}
@Override
public String description() {
return "Turns on the game's hidden developer switch and puts its extra buttons on the keyboard."
+ " Hold A+S to warp the llama to the developers' warp points (22 levels, they go round in a"
+ " circle). Hold F1 to fly, press F2 for a heart and 5 fruit, hold F3 for idols and F11 for"
+ " coins (keys can be changed below). On the river log (world 3) hold Space: no damage, through"
+ " the banks, very fast. In the finale, holding Space+Z ends the level. The developers'"
+ " level-skip and music menu stays unavailable (it needs a second switch that is always off).";
}
@Override
public boolean modifiesExecutable() {
return true;
}
@Override
public List<ModOption> options() {
List<ModOption> o = new ArrayList<>();
for (AuxKey k : KEYS) {
o.add(Hotkeys.option(k.option(), k.label(), k.description(), k.def()));
}
o.add(ModOption.bool(
"flyToggle",
"Fly key switches on and off",
"Press the fly key once to start flying and again to stop, instead of holding it.",
false));
return o;
}
@Override
public void apply(ModContext ctx) throws IOException {
List<DevModePatch.Key> keys = new ArrayList<>();
Set<String> used = new HashSet<>();
for (AuxKey k : KEYS) {
String v = ctx.option(k.option());
if (!used.add(v)) {
throw new IllegalArgumentException("Developer mode: " + v + " is used for two keys");
}
keys.add(new DevModePatch.Key(DevModePatch.dik(v), k.bit()));
}
DevModePatch.enableDeveloperFlag(ctx.exe());
DevModePatch.mapAuxKeys(ctx.exe(), keys, ctx.optionBool("flyToggle"));
}
}
@@ -0,0 +1,162 @@
package com.eliasmueller.llamamod.mods.devmode;
import static com.eliasmueller.llamamod.mods.practice.Asm.EAX;
import static com.eliasmueller.llamamod.mods.practice.Asm.EBX;
import static com.eliasmueller.llamamod.mods.practice.Asm.ECX;
import com.eliasmueller.llamamod.exe.ExeImage;
import com.eliasmueller.llamamod.exe.ExePatchException;
import com.eliasmueller.llamamod.exe.GameExe;
import com.eliasmueller.llamamod.exe.Site;
import com.eliasmueller.llamamod.exe.X86;
import com.eliasmueller.llamamod.mods.practice.Asm;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
/**
* Developer switch and aux pad keys (German exe addresses; the code uses the {@link Site}s).
*
* <ul>
* <li>Strat property 258 (0x102) is a developer flag the getter FUN_00550e60 hard-wires to 0:
* jump table 0x552684[0x102] (= 0x552A8C) → shared handler 0x55231B {@code push 0} (also
* ids 0x75, 0x9E, 0xC1, 0xEE). The table entry is pointed at a cave that pushes 0x1000 (1.0
* in 20.12); the shared handler stays untouched.
* <li>padAux (property 88, 0x5FCF58) is never set on PC. The tail of the pad poll FUN_004256c0
* (0x425B88: {@code if (padCount > 1) padAux = 0; return}) is replaced by a cave that builds
* padAux from the DirectInput key state 0x58226C[DIK] (0 while a demo plays). The edge
* detection padAuxPressed (0x5FCF60, FUN_00425c00 at the start of every tick) runs after
* it as before.
* </ul>
*/
public final class DevModePatch {
/** Developer flag property id and the value the cave pushes. */
static final int PROP_DEV = 0x102, ONE = 0x1000;
/** padAux bits read by the strats. */
static final int AUX_COINS = 0x100, AUX_IDOLS = 0x200, AUX_HEART = 0x400, AUX_FLY = 0x800;
/** Original shared handler of property 258: {@code mov eax,[esp+14h]; push 0; push eax; call push; ...; ret}. */
static final String ZERO_HANDLER = "8B 44 24 14 6A 00 50 E8 ?? ?? ?? ?? 83 C4 08 83 C4 10 C3";
/** DirectInput scan codes of the {@link com.eliasmueller.llamamod.mods.Hotkeys} choices. */
static final Map<String, Integer> DIK = Map.ofEntries(
Map.entry("F1", 0x3B),
Map.entry("F2", 0x3C),
Map.entry("F3", 0x3D),
Map.entry("F4", 0x3E),
Map.entry("F5", 0x3F),
Map.entry("F7", 0x41),
Map.entry("F8", 0x42),
Map.entry("F9", 0x43),
Map.entry("F11", 0x57),
Map.entry("F12", 0x58),
Map.entry("Insert", 0xD2),
Map.entry("Delete", 0xD3),
Map.entry("Home", 0xC7),
Map.entry("End", 0xCF));
/** A key that sets padAux {@code bit} while held. */
record Key(int dik, int bit) {}
private DevModePatch() {}
static int dik(String hotkey) {
Integer v = DIK.get(hotkey);
if (v == null) {
throw new IllegalArgumentException("unknown hotkey " + hotkey);
}
return v;
}
/** Sets property 258 to 1.0. */
public static void enableDeveloperFlag(ExeImage exe) throws ExePatchException {
GameExe g = exe.game();
long table = exe.readU32(g.va(Site.PROP_GET) + 21);
long entry = table + 4L * PROP_DEV;
long handler = exe.readU32(entry);
String got = hex(exe.readOriginal(handler, 19));
if (!matches(got, ZERO_HANDLER)) {
throw new ExePatchException("devmode: property 258 handler at " + ExeImage.hexVa(handler) + " is " + got
+ ", expected " + ZERO_HANDLER + " – unknown program version");
}
long push = handler + 12 + (int) exe.readU32(handler + 8);
long fn = exe.emit("devmode: property 258 = 1", a -> {
a.hex("8B 44 24 14"); // mov eax,[esp+14h]
a.push(ONE);
a.u8(0x50); // push eax
a.call(push);
a.hex("83 C4 08 83 C4 10"); // add esp,8; add esp,10h
a.ret();
});
exe.patch(entry, le(handler), le(fn), "devmode: property 258 jump table entry");
}
/**
* padAux = OR of the bits whose key is held; {@code toggleFly}: the {@link #AUX_FLY} key (if in
* {@code keys}) switches the bit on and off instead.
*/
public static void mapAuxKeys(ExeImage exe, List<Key> keys, boolean toggleFly) throws ExePatchException {
GameExe g = exe.game();
long tail = g.va(Site.PAD_POLL_END), aux = g.va(Site.PAD_AUX), state = g.va(Site.KEY_STATE);
long demo = g.va(Site.DEMO), padCount = g.va(Site.PAD_COUNT);
long d = toggleFly ? exe.allocateData(8, "devmode: fly toggle") : 0;
long prev = d, flyOn = d + 4;
long fn = exe.emit("devmode: aux pad keys", a -> {
Asm m = new Asm(a);
X86.Label store = a.label();
m.pop(EBX); // the original tail's pop ebx
m.push(ECX);
a.hex("33 C9"); // xor ecx,ecx
m.cmpMemImm(demo, 0);
a.jcc(X86.JNE, store);
for (Key k : keys) {
X86.Label skip = a.label();
long slot = state + 4L * k.dik();
if (toggleFly && k.bit() == AUX_FLY) {
X86.Label same = a.label();
a.hex("33 C0"); // xor eax,eax
m.cmpMemImm(slot, 0);
a.hex("0F 9F C0"); // setg al
m.cmpMem(EAX, prev);
m.store(prev, EAX);
a.jcc(X86.JBE, same);
m.xorMemImm(flyOn, 1);
a.bind(same);
m.cmpMemImm(flyOn, 0);
a.jcc(X86.JE, skip);
} else {
m.cmpMemImm(slot, 0);
a.jcc(X86.JLE, skip);
}
a.hex("81 C9").u32(k.bit()); // or ecx,bit
a.bind(skip);
}
a.bind(store);
m.store(aux, ECX);
m.pop(ECX);
m.load(EAX, padCount);
a.hex("83 C4 08"); // add esp,8
a.ret();
});
exe.hookJmp(tail, g.original(Site.PAD_POLL_END, 25), fn, "devmode: pad poll tail");
}
private static boolean matches(String got, String pattern) {
String[] g = got.split(" "), p = pattern.split(" ");
for (int i = 0; i < p.length; i++) {
if (!p[i].equals("??") && !p[i].equals(g[i])) {
return false;
}
}
return true;
}
private static String hex(byte[] b) {
return HexFormat.ofDelimiter(" ").withUpperCase().formatHex(b);
}
private static byte[] le(long v) {
return new byte[] {(byte) v, (byte) (v >> 8), (byte) (v >> 16), (byte) (v >> 24)};
}
}
@@ -0,0 +1,84 @@
package com.eliasmueller.llamamod.mods.fixes;
import com.eliasmueller.llamamod.mods.LevelWads;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModContext;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.List;
import java.util.regex.Pattern;
/**
* Fixes the boulder use-after-free in Mountain 3 (t1l5m003): RollingBoulderMountain ends right after
* spawning because it waits for its never-incremented counter v13 to be 0, while its boulders later
* still write {@code parent.v13 -= 1} into the freed spawner. Comparing with 0.5 instead keeps the
* spawner alive (frozen off camera). See notes/mountain3-crash.md.
*/
public final class Mountain3FixMod implements Mod {
static final Pattern LEVEL = Pattern.compile("(?i)t1l5m003\\.wad");
/** freezeOffCamera 1; push v13; pushShort 0; eq; jzYield -4; end. */
static final int[] PATTERN = {0x1002f, 0xd0004, 0x44, 0x4e, 0xfffc0012, 0xb4};
/** pushShort 0.5 (20.12) replacing pushShort 0 (third word of the pattern). */
static final int FIXED = 0x08000044;
@Override
public String id() {
return "mountain3-fix";
}
@Override
public String name() {
return "Mountain 3 crash fix";
}
@Override
public String description() {
return "Fixes a bug in Mountain chapter 3 that can crash the game: the rolling boulder spawners delete"
+ " themselves too early and their boulders later write into the freed memory. With the fix the"
+ " spawners stay alive; the boulders roll as before.";
}
@Override
public String category() {
return GAMEPLAY;
}
@Override
public void apply(ModContext ctx) throws IOException {
List<String> files = LevelWads.list(ctx, LEVEL);
for (String f : files) {
byte[] wad = ctx.read(f);
int at = find(wad);
if (at < 0) {
ctx.progress(1, "Mountain 3 fix: pattern not found in " + LevelWads.fileName(f) + " – left unchanged");
continue;
}
byte[] out = wad.clone();
ByteBuffer.wrap(out).order(ByteOrder.LITTLE_ENDIAN).putInt(at + 8, FIXED);
ctx.write(f, out);
ctx.progress(1, "Mountain 3 fix applied");
}
}
/** Offset of the unique pattern, or -1 if it is missing or not unique. */
static int find(byte[] wad) {
ByteBuffer b = ByteBuffer.wrap(wad).order(ByteOrder.LITTLE_ENDIAN);
int found = -1;
outer:
for (int i = 0; i + PATTERN.length * 4 <= wad.length; i++) {
for (int k = 0; k < PATTERN.length; k++) {
if (b.getInt(i + 4 * k) != PATTERN[k]) {
continue outer;
}
}
if (found >= 0) {
return -1;
}
found = i;
}
return found;
}
}
@@ -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);
}
}
@@ -3,12 +3,14 @@ package com.eliasmueller.llamamod.editor.tools.objects;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeFalse;
import com.eliasmueller.llamamod.editor.io.Bases;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.EditorPrefs;
import java.awt.GraphicsEnvironment;
import java.awt.Rectangle;
import java.awt.event.InputEvent;
import java.awt.event.MouseEvent;
@@ -23,6 +25,7 @@ import org.junit.jupiter.api.Test;
class ObjectPaletteTest {
@Test
void rightClickDoesNotPickTheType() throws Exception {
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display (Swing popup)");
Scene s = Project.create(Bases.fromCd(ObjectToolsTest.cd()), "new", "t1l1m001")
.scene();
List<String> tools = new ArrayList<>();
@@ -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,155 @@
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"),
Map.entry(Site.PROP_GET, "mov eax,DWORD PTR [esp+0xc]"),
Map.entry(Site.PAD_POLL_END, "mov eax,ds:0x{PAD_COUNT}"));
static byte[] bytes(String version) throws IOException {
return Files.readAllBytes(GameData.exePath(version));
}
@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,32 +17,35 @@ 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
* level, developer mode), with default options and with every exe-relevant option switched on, must apply, give
* an executable that re-parses with a consistent section layout, replay byte-identically from the
* cache and restore to the base exe (needs -Dllamamod.cd).
* cache and restore to the base exe (needs -Dllamamod.cd), for the German and the English exe.
*/
class ExeModCombinationsTest {
@TempDir
Path tmp;
private static final String[] IDS = {"intro-skip", "cheats", "practice", "freecam", "boot-to-level"};
private static final String[] IDS = {"intro-skip", "cheats", "practice", "freecam", "boot-to-level", "devmode"};
/** Options that switch on every exe change of a mod (data-only options stay at their defaults). */
private static final Map<String, Map<String, String>> ALL_ON = Map.of(
"intro-skip", Map.of("logos", "true", "demo", "true", "story", "true"),
"cheats", Map.of("diagnostics", "true"),
"practice", Map.of("checkpoints", "false"),
"boot-to-level", Map.of("logos", "true", "quitOnEscape", "true"));
"boot-to-level", Map.of("logos", "true", "quitOnEscape", "true"),
"devmode", Map.of("flyToggle", "true"));
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 +61,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,194 @@
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"),
"devmode", Map.of("flyToggle", "true", "coinKey", "End"));
/** SHA-256 of the German output before the version layer (2026-11-07); must never change. */
static final Map<String, String> GERMAN_GOLDEN = Map.ofEntries(
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"),
// added later (developer mode, 2026-11-08)
Map.entry("devmode defaults", "0cbd6ef8cbeeb69d9187d50d1c292d7c31d1b0ebf79376b1ddc67e27b1e66d60"),
Map.entry("devmode all", "919fed192f1120301487292a38b2f7cd60480166140193f865f49b0ec7c94c1a"),
Map.entry("all mods and devmode all", "5c9a6d4e2687fe31fe944635c088287d979a0edf5b18abdcd7d0d5b4c3bb43dd"));
static final List<String> IDS = List.of("intro-skip", "cheats", "practice", "freecam", "boot-to-level");
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));
l.add(new Config("devmode defaults", List.of("devmode"), Map.of()));
l.add(new Config("devmode all", List.of("devmode"), ALL_ON));
List<String> withDev = new java.util.ArrayList<>(IDS);
withDev.add("devmode");
l.add(new Config("all mods and devmode all", withDev, ALL_ON));
return l;
}
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();
@@ -0,0 +1,234 @@
package com.eliasmueller.llamamod.mods.debug;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import com.eliasmueller.llamamod.format.Bytes;
import com.eliasmueller.llamamod.format.MapChunk;
import com.eliasmueller.llamamod.format.Model;
import com.eliasmueller.llamamod.format.Trak;
import com.eliasmueller.llamamod.format.Wad;
import com.eliasmueller.llamamod.mods.ModOption;
import com.eliasmueller.llamamod.mods.debug.DebugWireframe.Allowance;
import com.eliasmueller.llamamod.mods.debug.FloorTest.Hit;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
/** FloorTest against tools/floortest.py and the allowance regions of t1l4m002 (needs -Dllamamod.cd). */
class AllowanceAreasTest {
private static byte[] wad;
private static FloorTest.Cells cells;
private static List<AllowanceAreas.Tip> tips;
@BeforeAll
static void load() throws IOException {
Path p = Path.of(System.getProperty("llamamod.cd", ""), "wads", "t1l4m002.wad");
if (!Files.isRegularFile(p)) {
return;
}
wad = Files.readAllBytes(p);
Wad ch = Wad.parse(wad);
List<Model> trak = Trak.parse(ch.require("TRAK"));
MapChunk mp = MapChunk.parse(ch.require("MAP"), ch.wfpcFlags(), Trak.vertCounts(trak));
cells = new FloorTest.Cells(mp, trak);
tips = AllowanceAreas.tips(cells);
}
@Test
void sineTableMatchesTheExe() throws IOException {
Path exe = Path.of("re", "groove.exe");
assumeTrue(Files.isRegularFile(exe));
byte[] d = Files.readAllBytes(exe);
for (int i = 0; i < 4096; i++) {
assertEquals(Bytes.i32(d, 0x174318 + 4 * i), FloorTest.sin(i), "sin " + i);
}
}
/** {x, y, z, tile, poly, height fixed} from tools/floortest.py. */
private static final double[][] REFERENCE = {
{57.204, 2.474, 43.733, 443, 15, 10135},
{57.204, 2.27, 43.745, 443, 2, 9298},
{57.204, 2.27, 43.736, 443, 15, 10113},
{57.15, 2.27, 43.733, 443, 2, 9282},
{52.094, 2.865, 50.583, 548, 6, 9365},
{28.551, 2.223, 23.059, 92, 12, 8914},
{52.639, 2.322, 52.372, 584, 0, 9372},
{36.564, 2.12, 20.619, 64, 15, 9027},
{32.362, 2.27, 46.248, 464, 16, 5080},
{42.082, 2.243, 24.3, 249, 1, 9225},
{36.512, 1.013, 29.165, 120, 0, 5129},
{37.594, 2.184, 34.58, 358, 0, 9586},
{31.945, 2.371, 23.474, 95, 0, 8899},
{34.731, 2.242, 20.31, 62, 16, 8916},
{50.285, 2.503, 45.386, 456, 10, 9385},
{26.941, 2.291, 16.355, 23, 0, 2520},
{42.398, 2.245, 25.917, 253, 5, 9225},
{48.449, 2.825, 8.549, 170, 20, 9108},
{39.864, 2.123, 43.278, 440, 0, 9284},
{36.683, 1.846, 36.38, 367, 21, 8225},
{48.419, 2.35, 5.369, 149, 13, 8192},
{33.69, 1.426, 47.515, 128, 0, 5129},
{26.457, 2.803, 20.871, 55, 7, 9447},
{23.07, 1.939, 17.208, 26, 0, 2520},
{37.849, 1.199, 28.993, 118, 17, 4918},
{51.479, 2.275, 26.692, 271, 0, 9475},
{44.951, 1.72, 15.528, 210, 16, 8192},
{29.696, 1.925, 23.261, 93, 16, 2520},
{45.356, 2.312, 9.223, 171, 17, 8192},
{48.33, 2.717, 48.223, 514, 0, 9592},
{26.79, 1.665, 17.472, 28, 4, 2520},
{30.479, 0.699, 16.653, 1, 0, 1033},
{33.835, 1.958, 22.12, 86, 23, 2520},
{46.478, 2.684, 14.179, 204, 0, 8192},
{42.401, 2.587, 46.947, 474, 1, 9281},
{47.807, 2.74, 46.146, 476, 3, 9468},
{32.657, 2.823, 47.35, 485, 6, 11164},
{51.021, 2.59, 1.799, 134, 6, 9225},
{26.527, 2.026, 25.934, 105, 0, 2520},
{27.501, 1.989, 29.764, 299, 0, 9204},
{29.419, 3.365, 51.131, 557, 2, 11324},
{36.898, 2.723, 35.662, 362, 2, 9268},
{48.421, 2.252, 49.918, 529, 28, 8192},
{43.152, 2.809, 50.511, 544, 8, 9762}
};
@Test
void floorTestMatchesThePythonReimplementation() {
assumeTrue(cells != null);
for (double[] r : REFERENCE) {
Hit h = FloorTest.floor(cells, r[0], r[1], r[2]);
String at = r[0] + "/" + r[1] + "/" + r[2];
assertNotNull(h, at);
assertEquals((int) r[3], h.tile(), at);
assertEquals((int) r[4], h.poly(), at);
assertEquals((int) r[5], h.height(), at);
}
}
@Test
void kuzcosFloatingSpotIsInsidePoly15sSliver() {
assumeTrue(cells != null);
AllowanceAreas.Tip t = tip(443, 15);
assertNotNull(t, "poly 15 needle");
assertTrue(t.reach() > 0.1, "reach " + t.reach());
assertTrue(contains(t, 57.204, 43.733), "Kuzco's spot 57.204/43.733");
assertFalse(contains(t, 57.204, 43.745), "on the path, poly 2 wins");
assertNotNull(tip(443, 11), "needle on the crate's other side");
}
@Test
void noSliversOnPlainGround() {
assumeTrue(cells != null);
for (AllowanceAreas.Tip t : tips) {
assertTrue(t.reach() >= AllowanceAreas.MIN_REACH);
for (double[][] q : t.quads()) {
for (double[] p : q) {
// the big flat ground of the start area (tile 23 poly 0, floor 0.615) has no tips
assertFalse(p[0] > 26.0 && p[0] < 27.0 && p[2] > 16.0 && p[2] < 17.0, "tip at " + t);
}
}
}
assertTrue(tips.size() < 600, "tips " + tips.size());
}
@Test
void allModeDrawsABandPerFloorPolygon() {
assumeTrue(cells != null);
Model crate = null;
for (FloorTest.Tile t : cells.tiles) {
if (t.index() == 443) {
crate = Trak.parse(Wad.parse(wad).require("TRAK")).get(129);
}
}
assertNotNull(crate);
Mesh m = new Mesh();
AllowanceAreas.addBands(m, crate, new Palette());
int expect = 0;
for (List<byte[]> g : crate.coll) {
for (byte[] r : g) {
if (r[3] > 0 && (r[1] & 0xff) != 0x11 && (r[1] & 0xff) != 0x1e) {
expect += (r[0] & 1) != 0 ? 4 : 3;
}
}
}
assertTrue(expect > 0);
assertTrue(m.verts.size() <= 2 * expect && m.verts.size() >= expect, m.verts.size() + " vs " + expect);
assertEquals(m.verts.size(), m.tris.size());
}
@Test
void processWithEachMode() {
assumeTrue(wad != null);
DebugWireframe.Result off = DebugWireframe.process(wad);
DebugWireframe.Result t =
DebugWireframe.process(wad, DebugWireframe.Options.DEFAULT.withAllowance(Allowance.TIPS));
DebugWireframe.Result all =
DebugWireframe.process(wad, DebugWireframe.Options.DEFAULT.withAllowance(Allowance.ALL));
assertEquals(0, off.stats().allowanceTips);
assertEquals(tips.size(), t.stats().allowanceTips);
assertTrue(t.stats().allowanceQuads > 0);
assertTrue(t.wad().length > off.wad().length);
assertTrue(all.stats().allowanceBandModels > 0);
assertTrue(all.wad().length > t.wad().length);
assertFalse(t.stats().allowanceDropped || all.stats().allowanceDropped);
assertTrue(DebugWireframe.arenaBytes(Wad.parse(all.wad())) < DebugWireframe.ARENA_BUDGET);
// the game-relevant data stays untouched
assertTrue(java.util.Arrays.equals(
Wad.parse(off.wad()).require("STPC"), Wad.parse(all.wad()).require("STPC")));
}
@Test
void tooLargeLevelsGetNoAllowanceGeometry() {
assumeTrue(wad != null);
DebugWireframe.Result off = DebugWireframe.process(wad);
long plain = DebugWireframe.arenaBytes(Wad.parse(off.wad()));
DebugWireframe.Result capped =
DebugWireframe.process(wad, DebugWireframe.Options.DEFAULT.withAllowance(Allowance.ALL), plain + 1000);
assertTrue(capped.stats().allowanceDropped);
assertTrue(java.util.Arrays.equals(off.wad(), capped.wad()));
}
@Test
void optionDefaultsToOff() {
DebugWireframeMod mod = new DebugWireframeMod();
ModOption o = mod.options().get(0);
assertEquals(DebugWireframeMod.ALLOWANCE, o.key());
assertEquals("off", o.defaultValue());
assertEquals(Allowance.OFF, DebugWireframeMod.allowance(null));
assertEquals(Allowance.TIPS, DebugWireframeMod.allowance("tips"));
assertEquals(Allowance.ALL, DebugWireframeMod.allowance("all"));
}
private static AllowanceAreas.Tip tip(int tile, int poly) {
AllowanceAreas.Tip best = null;
for (AllowanceAreas.Tip t : tips) {
if (t.tile() == tile && t.poly() == poly && (best == null || t.reach() > best.reach())) {
best = t;
}
}
return best;
}
private static boolean contains(AllowanceAreas.Tip t, double x, double z) {
for (double[][] q : t.quads()) {
boolean pos = false, neg = false;
for (int k = 0; k < 4; k++) {
double[] a = q[k], b = q[(k + 1) % 4];
double c = (b[0] - a[0]) * (z - a[2]) - (b[2] - a[2]) * (x - a[0]);
pos |= c > 0;
neg |= c < 0;
}
if (!(pos && neg)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,158 @@
package com.eliasmueller.llamamod.mods.devmode;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.eliasmueller.llamamod.exe.ExeImage;
import com.eliasmueller.llamamod.exe.GameExe;
import com.eliasmueller.llamamod.exe.Site;
import com.eliasmueller.llamamod.mods.GameData;
import com.eliasmueller.llamamod.mods.Hotkeys;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModRegistry;
import com.eliasmueller.llamamod.mods.TestModContext;
import com.eliasmueller.llamamod.platform.Config;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class DevModeModTest {
@TempDir
Path tmp;
private static ExeImage exe(String version) throws Exception {
return ExeImage.parse(Files.readAllBytes(GameData.exePath(version)));
}
private static ExeImage applied(String version, Map<String, String> opts, Path tmp) throws Exception {
ExeImage img = exe(version);
img.setOwner("devmode");
DevModeMod mod = new DevModeMod();
mod.apply(new TestModContext(tmp, mod, opts, img));
return img;
}
private static long rel(ExeImage img, long callAt) {
return callAt + 5 + (int) img.readU32(callAt + 1);
}
/** The aux key cave's code. */
private static String auxCave(ExeImage img) throws Exception {
ExeImage.Allocation a = img.journal("devmode").stream()
.filter(o -> o instanceof ExeImage.Allocation x && x.label().equals("devmode: aux pad keys"))
.map(o -> (ExeImage.Allocation) o)
.findFirst()
.orElseThrow();
assertEquals(rel(img, img.game().va(Site.PAD_POLL_END)), a.va());
return hex(img.read(a.va(), a.size()));
}
private static String hex(byte[] b) {
return HexFormat.ofDelimiter(" ").withUpperCase().formatHex(b);
}
@ParameterizedTest
@ValueSource(strings = {"de", "en"})
void property258PushesOneAndTheSharedHandlerStays(String version) throws Exception {
ExeImage orig = exe(version);
ExeImage img = applied(version, Map.of(), tmp);
GameExe g = img.game();
long table = img.readU32(g.va(Site.PROP_GET) + 21);
long handler = orig.readU32(table + 4L * 0x102);
assertEquals(version.equals("de") ? 0x55231BL : 0x55198FL, handler);
long cave = img.readU32(table + 4L * 0x102);
assertTrue(cave >= img.caveStart(), ExeImage.hexVa(cave));
assertEquals("8B 44 24 14 68 00 10 00 00 50 E8", hex(img.read(cave, 11)));
assertEquals(rel(orig, handler + 7), rel(img, cave + 10));
assertEquals("83 C4 08 83 C4 10 C3", hex(img.read(cave + 15, 7)));
// every other table entry (the shared handler's other ids among them) is unchanged
for (int id = 0; id < 0x10A; id++) {
if (id != 0x102) {
assertEquals(orig.readU32(table + 4L * id), img.readU32(table + 4L * id), "id " + id);
}
}
assertArrayEquals(orig.read(handler, 19), img.read(handler, 19));
ExeImage.parse(img.build());
}
@ParameterizedTest
@ValueSource(strings = {"de", "en"})
void padPollTailBuildsPadAuxFromTheKeyState(String version) throws Exception {
ExeImage img = applied(version, Map.of(), tmp);
GameExe g = img.game();
long tail = g.va(Site.PAD_POLL_END);
assertEquals(0xE9, img.readU8(tail));
for (int i = 5; i < 25; i++) {
assertEquals(0x90, img.readU8(tail + i));
}
String h = auxCave(img);
assertTrue(h.startsWith("5B 51 33 C9 81 3D"), h); // pop ebx; push ecx; xor ecx,ecx; cmp [demo],0
long keys = g.va(Site.KEY_STATE);
int[][] expect = {{0x3B, 0x800}, {0x3C, 0x400}, {0x3D, 0x200}, {0x57, 0x100}};
int at = 0;
for (int[] e : expect) {
// cmp dword [keys + 4*dik], 0; jle; or ecx, bit
String want = "81 3D " + hex(le(keys + 4L * e[0])) + " 00 00 00 00 0F 8E";
at = h.indexOf(want, at);
assertTrue(at > 0, "key " + Integer.toHexString(e[0]));
assertTrue(h.indexOf("81 C9 " + hex(le(e[1])), at) > at);
}
String end =
"89 0D " + hex(le(g.va(Site.PAD_AUX))) + " 59 8B 05 " + hex(le(g.va(Site.PAD_COUNT))) + " 83 C4 08 C3";
assertTrue(h.contains(end), h);
ExeImage.parse(img.build());
}
@Test
void customKeysAndFlyToggle() throws Exception {
ExeImage img = applied("de", Map.of("flyKey", "Home", "coinKey", "F12", "flyToggle", "true"), tmp);
String h = auxCave(img);
long keys = img.game().va(Site.KEY_STATE);
assertTrue(h.contains("33 C0 81 3D " + hex(le(keys + 4L * 0xC7)) + " 00 00 00 00 0F 9F C0"), h);
assertTrue(h.contains("81 3D " + hex(le(keys + 4L * 0x58))), h);
assertTrue(img.journal("devmode").stream()
.anyMatch(o -> o instanceof ExeImage.Allocation a && a.label().equals("devmode: fly toggle")));
}
@Test
void sameKeyTwiceIsRejected() {
assertThrows(IllegalArgumentException.class, () -> applied("de", Map.of("idolKey", "F1"), tmp));
}
@Test
void everyHotkeyHasAScanCode() {
assertEquals(Hotkeys.VK.keySet(), DevModePatch.DIK.keySet());
}
@Test
void defaultKeysDoNotClashWithTheOtherHotkeyMods() {
ModRegistry reg = new ModRegistry();
List<Mod> mods = List.of(reg.get("practice"), reg.get("freecam"), reg.get("devmode"));
assertTrue(
Hotkeys.conflicts(mods, new Config()).isEmpty(),
Hotkeys.conflicts(mods, new Config()).toString());
for (DevModeMod.AuxKey k : DevModeMod.KEYS) {
assertTrue(!List.of("F4", "F6", "F7", "F10").contains(k.def()), k.def());
}
}
@Test
void registeredAsCheat() {
Mod m = new ModRegistry().get("devmode");
assertEquals(Mod.CHEATS, m.category());
assertTrue(m.modifiesExecutable());
assertTrue(m.description().contains("A+S") && m.description().contains("level-skip"));
}
private static byte[] le(long v) {
return new byte[] {(byte) v, (byte) (v >> 8), (byte) (v >> 16), (byte) (v >> 24)};
}
}
@@ -0,0 +1,43 @@
package com.eliasmueller.llamamod.mods.fixes;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.eliasmueller.llamamod.mods.GameData;
import com.eliasmueller.llamamod.mods.TestModContext;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import org.junit.jupiter.api.Test;
class Mountain3FixModTest {
@Test
void changesExactlyOneWordInMountain3() throws IOException {
Path cd = GameData.cd();
Mountain3FixMod mod = new Mountain3FixMod();
TestModContext ctx = new TestModContext(cd, mod, Map.of(), null);
mod.apply(ctx);
assertEquals(1, ctx.written.size(), "only t1l5m003 is written: " + ctx.written.keySet());
String f = ctx.written.keySet().iterator().next();
byte[] o = Files.readAllBytes(cd.resolve(f));
byte[] n = ctx.written.get(f);
int at = Mountain3FixMod.find(o);
assertTrue(at >= 0);
assertEquals(o.length, n.length);
int diff = 0;
for (int i = 0; i < o.length; i++) {
if (o[i] != n[i]) {
diff++;
assertTrue(i >= at + 8 && i < at + 12, "change outside the fixed word at " + i);
}
}
assertTrue(diff > 0 && diff <= 4);
assertEquals(
Mountain3FixMod.FIXED,
ByteBuffer.wrap(n).order(ByteOrder.LITTLE_ENDIAN).getInt(at + 8));
assertEquals(-1, Mountain3FixMod.find(n), "pattern is gone after the fix (applying twice is a no-op)");
}
}
@@ -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);
}
}
+25
View File
@@ -0,0 +1,25 @@
import sys,struct,numpy as np; sys.path.insert(0,'.'); from floor import *
t=443; x,y,z,rot,mi,_=mp['A'][t]; recs=[r for g in trak[mi].coll for r in g]
def planes(r):
return [struct.unpack_from('<bbbBh',r,2+6*k) for k in range(5)]
def corners(r):
P=planes(r); ne=4 if r[0]&1 else 3; E=P[1:1+ne]; S=P[0]; pts=[]
for i in range(ne):
a,b=E[i],E[(i+1)%ne]
A=np.array([S[:3],a[:3],b[:3]],float); B=np.array([S[4],a[4],b[4]],float)/32
pts.append(np.linalg.solve(A,B)) # n127*32*p = d -> n127.p = d/32 (p in units)...
return pts
# local (engine) -> world: local=(lz_w', -lx_w') for rot 3072 => dx=-lz, dz=lx
a=-rot/4096*2*math.pi
def toworld(p):
lx,ly,lz=p
# invert lx=c*dx+s*dz, lz=c*dz-s*dx with s=sin(a'),c=cos(a'), a'=(-rot)*2pi/4096
s,c=math.sin(a),math.cos(a)
dx=c*lx-s*lz; dz=s*lx+c*lz
return (x+dx,y+ly,-z+dz)
for i in [int(v) for v in sys.argv[1:]]:
r=recs[i]; P=planes(r)
print('poly',i,'type',r[0],'surface n',[v/127 for v in P[0][:3]])
for p in corners(r):
print(' local',np.round(p,4),' world',np.round(toworld(p),4))
+119
View File
@@ -0,0 +1,119 @@
# Usage: python3 tools/floorre/explain_t1l4m002.py tools/floorre OUT.png (WAD via env WAD, default t1l4m002 path in floor.py; needs Pillow, numpy)
# Explainer: Kuzco floating next to the crate in t1l4m002 (builds on floorre/plot.py)
import sys, math
sys.path.insert(0, sys.argv[1]) # floorre dir (floor.py, corners.py)
OUT = sys.argv[2]
sys.argv = sys.argv[:1]
from floor import * # mp, trak, floor_test
from corners import corners
from PIL import Image, ImageDraw, ImageFont
SS = 2 # supersampling
F = lambda n, b=False: ImageFont.truetype('DejaVuSans-Bold.ttf' if b else 'DejaVuSans.ttf', n * SS)
K = (57.204, 2.474, 43.733)
T = 443
TX, TY, TZ, ROT, MI, _ = mp['A'][T]
recs = [r for g in trak[MI].coll for r in g]
def xf(B):
rot = B[1]; tx, ty, tz = B[3] / 4096, B[4] / 4096, B[5] / 4096
a = -rot / 4096 * 2 * math.pi; s, c = math.sin(a), math.cos(a)
return lambda p: (tx + c * p[0] - s * p[2], ty + p[1], tz + s * p[0] + c * p[2])
RED, BLUE, MAG, PINK = (215, 25, 25), (20, 90, 230), (210, 0, 210), (255, 70, 150)
def panel(X0, Z0, span, size, detail):
S = size / span
img = Image.new('RGB', (size * SS, size * SS), 'white'); d = ImageDraw.Draw(img, 'RGBA')
P = lambda x, z: ((x - X0) * S * SS, (size - (z - Z0) * S) * SS)
# rendered terrain coloured by height (like the agent's plot)
for t in range(mp['N']):
B = mp['B'][t]
if not (X0 - 2 <= B[3] / 4096 <= X0 + span + 2 and Z0 - 2 <= B[5] / 4096 <= Z0 + span + 2): continue
m = trak[mp['midx'][t]]; f = xf(B)
for fc in sorted(m.faces, key=lambda fc: sum(m.verts[v][1] for v in fc['v'])):
Q = [f((m.verts[v][0], m.verts[v][1], -m.verts[v][2])) for v in fc['v']]
h = sum(p[1] for p in Q) / len(Q); g = int(max(0, min(255, (h - 1.5) / 2 * 255)))
d.polygon([P(p[0], p[2]) for p in Q], fill=(g, 200 - g // 2, 255 - g, 255),
outline=(130, 130, 130) if not detail else None)
# cell grid
for k in range(int(X0) - 1, int(X0 + span) + 2):
d.line([P(k, Z0 - 1), P(k, Z0 + span + 1)], fill=(0, 140, 0), width=SS * (2 if detail else 1))
for k in range(int(Z0) - 1, int(Z0 + span) + 2):
d.line([P(X0 - 1, k), P(X0 + span + 1, k)], fill=(0, 140, 0), width=SS * (2 if detail else 1))
return img, d, P, S
def collision(d, P, labels, sliver=None, fsz=12):
f = xf(mp['B'][T])
if sliver:
for (x, z, w, i) in sliver:
a, b = P(x, z), P(x + w, z - w)
d.rectangle([a[0], a[1], b[0], b[1]], fill=PINK + (170,))
for i, r in enumerate(recs):
C = [f(p) for p in corners(r)]
col = RED if i in (11, 15) else BLUE if i == 2 else (20, 20, 20)
wid = 3 if i in (2, 11, 15) else 1
pts = [P(p[0], p[2]) for p in C]
if i in (11, 15): d.polygon(pts, fill=RED + (70,))
d.line(pts + pts[:1], fill=col, width=wid * SS)
if labels:
cx = sum(p[0] for p in pts) / len(pts); cy = sum(p[1] for p in pts) / len(pts)
lim = d.im.size
if not (10 * SS < cx < lim[0] - 10 * SS and 10 * SS < cy < lim[1] - (80 if labels == 'zoom' else 10) * SS):
continue
txt = f'poly {i} · mat {r[1]}' if labels == 'zoom' and i in (2, 11, 15) else str(i)
d.text((cx, cy), txt, fill=col, font=F(fsz, i in (2, 11, 15)), anchor='mm',
stroke_width=SS * 2, stroke_fill='white')
def sliver_cells(x0, z0, x1, z1, step):
out = []; tx, ty, tz = int(round(TX * 4096)), int(round(TY * 4096)), int(round(-TZ * 4096))
z = z0
while z < z1:
x = x0
while x < x1:
if int(x) == 57 and int(z) == 43:
_, res = floor_test(int(round(x * 4096)), int(round(2.27 * 4096)), int(round(z * 4096)), tx, ty, tz, ROT, recs)
best = None
for i, i7, ok, vals, tol, Y in res:
if ok and i7 >= -0x80 and (best is None or i7 < best[0]): best = (i7, i)
if best and best[1] in (11, 15): out.append((x, z, step, best[1]))
x += step
z += step
return out
W = 1500
# --- left: overview (3 x 3 cells) ---
ov, d, P, S = panel(56.0, 42.0, 3.0, 640, False)
collision(d, P, 'all', fsz=11)
for t in range(mp['N']):
B = mp['B'][t]; x, z = B[3] / 4096, B[5] / 4096
if 56 <= x <= 59 and 42 <= z <= 45:
d.text(P(x - 0.45, z - 0.38), f'tile {t}', fill=(110, 0, 140), font=F(12, True), stroke_width=SS * 2, stroke_fill='white')
k = P(K[0], K[2]); d.ellipse([k[0] - 7 * SS, k[1] - 7 * SS, k[0] + 7 * SS, k[1] + 7 * SS], fill=MAG, outline='black', width=SS)
ZX0, ZZ0, ZSP = 57.06, 43.16, 0.76
a, b = P(ZX0, ZZ0 + ZSP), P(ZX0 + ZSP, ZZ0)
d.rectangle([a, b], outline=(255, 140, 0), width=3 * SS)
ov = ov.resize((640, 640), Image.LANCZOS)
# --- right: zoom on the crate's side with both needles ---
zm, d, P, S = panel(ZX0, ZZ0, ZSP, 820, True)
collision(d, P, 'zoom', sliver=sliver_cells(ZX0, ZZ0, ZX0 + ZSP, ZZ0 + ZSP, 0.0012), fsz=15)
k = P(K[0], K[2]); r = 9 * SS
d.ellipse([k[0] - r, k[1] - r, k[0] + r, k[1] + r], fill=MAG, outline='black', width=2 * SS)
d.text((k[0], k[1] + 30 * SS), 'Kuzco\n57.204 / 43.733\n(measured live)', fill=MAG, font=F(15, True), anchor='ma',
align='center', stroke_width=SS * 2, stroke_fill='white')
tip = P(57.315, 43.729)
y = tip[1] - 34 * SS
d.line([(k[0], y), (tip[0], y)], fill='black', width=2 * SS)
for xq in (k[0], tip[0]): d.line([(xq, y - 8 * SS), (xq, y + 8 * SS)], fill='black', width=2 * SS)
d.text(((k[0] + tip[0]) / 2, y - 6 * SS), '0.11 outside poly 15', fill='black', font=F(14, True), anchor='md',
stroke_width=SS * 2, stroke_fill='white')
d.text((P(57.38, 43.80)), 'needle tips 13.5°', fill=RED, font=F(14, True), stroke_width=SS * 2, stroke_fill='white')
d.text((P(ZX0 + 0.01, ZZ0 + 0.035)), 'pink = allowance: where the engine still takes the needle as floor\n(edge tolerance ≈ 0.02 → ≈ 0.19 long, 0.01 wide at a 13.5° tip)',
fill=(190, 20, 110), font=F(14, True), stroke_width=SS * 2, stroke_fill='white')
zm = zm.resize((820, 820), Image.LANCZOS)
out = Image.new('RGB', (20 + 640 + 20 + 820 + 20, 820 + 40), 'white')
d = ImageDraw.Draw(out)
out.paste(ov, (20, 20 + 90)); out.paste(zm, (680, 20))
d.text((20, 20 + 60), 'Overview (3 × 3 cells), orange box = zoom →', fill='black', font=ImageFont.truetype('DejaVuSans-Bold.ttf', 17))
out.save(OUT); print('saved', OUT, out.size)
+40
View File
@@ -0,0 +1,40 @@
import sys,struct,math; sys.path.insert(0,'/home/elias/projects/llamamod/tools'); import geomlib as G
ch,wf,trak,mp=G.load_level(__import__('os').environ.get('WAD','/home/elias/.claude/jobs/90c6eed7/tmp/exitre/t1l4m002.wad'))
_exe=open('/home/elias/projects/llamamod/re/groove.exe','rb').read()
SIN=list(struct.unpack_from('<4096i',_exe,0x174318))
def c8(b): return b-256 if b>127 else b
def floor_test(px,py,pz,tx,ty,tz,rot,recs,dbg=False,probe_add=0x400):
"""exact re-implementation of FUN_004036d0 floor branch (flags 0x200000 off). ints 20.12"""
lx=px-tx; lz=pz-tz; ly=py-ty+probe_add
u=(-rot)&0xffffffff
if u:
s=SIN[u&0xfff]; c=SIN[(u+0x400)&0xfff]
lx,lz=(s*lz+c*lx)>>12,(c*lz-s*lx)>>12
out=[]
for i,r in enumerate(recs):
if r[1]==0x11: continue
ny=c8(r[3]); n14=ny*32
if n14<=0: continue
h=(((struct.unpack_from('<h',r,6)[0]*4096 - c8(r[4])*32*lz - c8(r[2])*32*lx) - n14*ly))
q=abs(h)//abs(n14); i6= q if (h>=0)==(n14>=0) else -q # C truncating division
i7=-i6
Y=ly+i6
tol=-0x40-((ny*-0x20+0x1000)>>6)
ne=4 if r[0]&1 else 3
vals=[]
ok=True
for k in range(ne):
nx,nyy,nz,_,d=struct.unpack_from('<bbbBh',r,8+6*k)
v=((nyy*32*Y+nz*32*lz+nx*32*lx)>>12)-d
vals.append(v)
if v<tol: ok=False
out.append((i,i7,ok,vals,tol,ty+Y))
return (lx,ly,lz),out
if __name__=='__main__':
t=443; x,y,z,rot,mi,_=mp['A'][t]
recs=[r for g in trak[mi].coll for r in g]
P=[float(a) for a in sys.argv[1:4]]
loc,out=floor_test(*[int(round(v*4096)) for v in P],int(round(x*4096)),int(round(y*4096)),int(round(-z*4096)),rot,recs)
print('local',[v/4096 for v in loc])
for i,i7,ok,vals,tol,Y in out:
if i7>=-0x80: print(f'poly {i:2d} dist {i7/4096:+.4f} planeY {Y/4096:.4f} pass={ok} edges {vals} tol {tol}')
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Exact re-implementation of the engine's map floor test (FUN_004036d0, floor branch) on raw collision records.
floortest.py LEVEL.wad X Y Z world position in cells (game space, z = -A.z); prints every polygon of the
cell's tiles that is a floor candidate, whether it passes, and the winner
Integer arithmetic as in groove.exe (20.12 fixed point, sine table read from re/groove.exe, C division).
See notes/geometry.md "Floor test: inclusion rule and the floating-next-to-the-log case".
"""
import os
import struct
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import geomlib as G # noqa: E402
_EXE = os.path.join(os.path.dirname(HERE), 're', 'groove.exe')
SIN = struct.unpack_from('<4096i', open(_EXE, 'rb').read(), 0x574318 - 0x400000) # DAT_00574318
PROBE = 0x400 # DAT_0057dd98 (0.25) unless strat flag 0x200000
def _c8(b):
return b - 256 if b > 127 else b
def _cdiv(a, b):
q = abs(a) // abs(b)
return q if (a >= 0) == (b > 0) else -q
def to_local(px, pz, tile_b):
"""World (fixed) -> tile-local x/z exactly like 0x4037b3..0x4037f1 (rotation by -ry)."""
lx, lz = px - tile_b[3], pz - tile_b[5]
u = (-tile_b[1]) & 0xffffffff
if u:
s, c = SIN[u & 0xfff], SIN[(u + 0x400) & 0xfff]
lx, lz = (s * lz + c * lx) >> 12, (c * lz - s * lx) >> 12
return lx, lz
def test_poly(r, lx, ly, lz):
"""One 32-byte record. ly = probe height (feet - tile y + 0.25). Returns None (skipped) or
(dist, plane_y_local, passed, edge_values, tolerance); dist = probe height above the plane."""
ny32 = _c8(r[3]) * 32
if ny32 <= 0: # only up-facing planes (0x200000 strats also take down-facing ones)
return None
d0 = struct.unpack_from('<h', r, 6)[0]
i6 = _cdiv(d0 * 4096 - _c8(r[4]) * 32 * lz - _c8(r[2]) * 32 * lx - ny32 * ly, ny32)
dist = -i6
y = ly + i6 # the probe dropped vertically onto the plane
tol = -0x40 - ((0x1000 - ny32) >> 6)
vals = []
for k in range(4 if r[0] & 1 else 3):
nx, ny, nz, _, d = struct.unpack_from('<bbbBh', r, 8 + 6 * k)
vals.append(((ny * 32 * y + nz * 32 * lz + nx * 32 * lx) >> 12) - d)
return dist, y, all(v >= tol for v in vals), vals, tol
def floor(wad, x, y, z, wfpc_8000000=False):
ch, wf, trak, mp = G.load_level(wad)
px, py, pz = (int(round(v * 4096)) for v in (x, y, z))
skip_mat = 0x12 if wfpc_8000000 else 0x11
best = None
rows = []
for t in range(mp['N']):
b = mp['B'][t]
if (b[3] >> 12, b[5] >> 12) != (px >> 12, pz >> 12):
continue
lx, lz = to_local(px, pz, b)
ly = py - b[4] + PROBE
recs = [r for g in trak[mp['midx'][t]].coll for r in g]
for i, r in enumerate(recs):
if r[1] == skip_mat:
continue
res = test_poly(r, lx, ly, lz)
if res is None or res[0] < -0x80:
continue # plane more than 1/32 above the probe: ceiling branch, not a floor
dist, yl, ok, vals, tol = res
rows.append((t, i, r[1], dist / 4096, (b[4] + yl) / 4096, ok, vals, tol))
if ok and (best is None or dist < best[0]):
best = (dist, t, i, (b[4] + yl) / 4096)
return rows, best
if __name__ == '__main__':
if len(sys.argv) != 5:
sys.exit(__doc__)
rows, best = floor(sys.argv[1], *map(float, sys.argv[2:5]))
for t, i, mat, dist, h, ok, vals, tol in rows:
print(f'tile {t} poly {i:2d} mat {mat:3d} probe-plane {dist:+.4f} plane y {h:.4f} '
f'{"PASS" if ok else "fail"} edges {vals} (tolerance {tol})')
print('floor:', f'tile {best[1]} poly {best[2]} height {best[3]:.4f}' if best else 'none in this cell')