Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06241ea1ff | ||
|
|
87a1998dd4 | ||
|
|
165134c0d8 | ||
|
|
526059c70a | ||
|
|
ec2876d9c8 | ||
|
|
e36ff2d2c2 | ||
|
|
8b3c38fe56 | ||
|
|
db08cf8bf3 | ||
|
|
b5b6e22957 | ||
|
|
15ad050005 | ||
|
|
74f8fec341 | ||
|
|
8baed2e87c | ||
|
|
9e8e7f9a10 | ||
|
|
c39d58bdac | ||
|
|
0c2920dfe4 | ||
|
|
d32d197b54 | ||
|
|
0568ae7f24 | ||
|
|
64259ea15b | ||
|
|
322e36c0f3 | ||
|
|
52c70e817d | ||
|
|
1cc59bfb78 | ||
|
|
932ccf5fc8 | ||
|
|
ff6673af66 | ||
|
|
ca22c75fac | ||
|
|
f984019199 |
No files matched your search
@@ -1,5 +1,6 @@
|
||||
# game data (copyrighted) - never commit
|
||||
re/groove.exe
|
||||
re/groove_en.exe
|
||||
re/groove_decomp.c
|
||||
re/proj/
|
||||
re/ghidra.log
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.5 – 2026-11-08
|
||||
|
||||
(0.2.4 was tagged but its release build failed on a test; 0.2.5 contains the same changes plus that test fix.)
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.5'
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,6 @@ test harnesses
|
||||
## Status and honesty
|
||||
|
||||
Everything here was found by reverse engineering and tested against the game; where something is
|
||||
a guess, the page says so (*likely*, *unknown*). Development used the German CD; other language
|
||||
versions are expected to work but were not tested. The project is not affiliated with Disney or
|
||||
a guess, the page says so (*likely*, *unknown*). Development used the German CD; the English
|
||||
first release is supported too (see [game versions](reference/game-files.md#grooveexe-versions)). The project is not affiliated with Disney or
|
||||
Argonaut and contains no game data.
|
||||
@@ -98,7 +98,7 @@ and press **Play** to play the level – or press **F5** in the editor. See
|
||||
|
||||
## What you need
|
||||
|
||||
* The PC game (CD or ISO image; developed with the German version).
|
||||
* The PC game (CD or ISO image): the German or the English version (see [FAQ](20-troubleshooting.md#faq)).
|
||||
* LlamaMod – see [Installing LlamaMod](02-installing.md).
|
||||
* A graphics card with **OpenGL 3.3** for the editor's 3D view.
|
||||
* On Linux, Wine to run the game (LlamaMod sets it up).
|
||||
@@ -1,7 +1,7 @@
|
||||
# Installing LlamaMod and the first start
|
||||
|
||||
LlamaMod runs on **Windows** and **Linux**. It needs your own copy of the game – the CD or an ISO
|
||||
image of it. The game itself does not have to be installed: LlamaMod copies what it needs into
|
||||
image of it, German or English version. The game itself does not have to be installed: LlamaMod copies what it needs into
|
||||
its own data folder and never changes the original.
|
||||
|
||||
## Download and start
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
| "Not enough free disk space" | The game copy needs about 900 MB. Free up space or choose another **Data folder**. |
|
||||
| The game hangs or its window is gone | Click **■ Quit game** in LlamaMod (the Play button while the game runs). |
|
||||
| "The game crashed" | Switch off the mods you turned on last, or click **Restore original**, and play again. If the original game crashes too, report it with `game.log` (**Settings → Logs**). |
|
||||
| All monitors flash black for half a minute or more when the game starts | The **Built-in cheats** option *Troubleshooting: write display diagnostics* is on. It makes the game try every screen mode of every monitor before it starts. Switch it off; use it only when you are asked for a display report. |
|
||||
| "groove.exe was removed or changed" | Click **Rebuild**; LlamaMod writes it again and then starts the game. On Windows, see [Antivirus warnings](#antivirus-warnings). |
|
||||
|
||||
## The editor
|
||||
@@ -101,9 +102,13 @@ after an update, **Settings → Clear cache** throws it away; active mods are re
|
||||
|
||||
## FAQ
|
||||
|
||||
**Do I need the German version of the game?**
|
||||
The tools were developed with the German CD. Other language versions use the same engine and
|
||||
should work; if one does not, please report it.
|
||||
**Which versions of the game work?**
|
||||
The German CD (`groove.exe` of 2001-02-12) and the English first release (2000-10-17) are
|
||||
supported, including every mod. With another version LlamaMod shows a warning at start and before
|
||||
the first Apply (nothing is blocked); the level mods still work, but the mods
|
||||
marked *modifies groove.exe* stop with "unknown program version" – the first lines of
|
||||
`logs/apply.log` name the version LlamaMod found. Please report it together with that
|
||||
`groove.exe`, then it can be added.
|
||||
|
||||
**Can I share levels with people who do not have the game?**
|
||||
No – levels are built from the receiver's own game copy. That is also what makes sharing legal:
|
||||
|
||||
@@ -40,8 +40,9 @@ Every change can be undone with **Ctrl+Z**.
|
||||
|
||||
## Step 4: A coin trail
|
||||
|
||||
1. Open the **Objects** panel (left) and click **Coin line** under the quick setups – or press
|
||||
**O** for the **Coin line / arc** tool.
|
||||
1. Open the **Objects** tab in the left dock – the ⊙ icon right next to *Outliner* (inactive tabs show only
|
||||
their icon) and click **Coin line** under **Quick setups** – or press **O** for the
|
||||
**Coin line / arc** tool.
|
||||
2. Click once at the start of the trail and once at its end. Coins appear every *spacing* cells
|
||||
on the ground (also up the ramp).
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ Every drag is one undo step – **Ctrl+Z** takes it back.
|
||||
|
||||
## Step 3: Add a checkpoint
|
||||
|
||||
1. Open the **Objects** panel (left dock) and click the quick setup **Checkpoint**.
|
||||
1. Open the **Objects** tab in the left dock – the ⊙ icon right next to *Outliner* (inactive tabs show only
|
||||
their icon) and click the quick setup **Checkpoint**.
|
||||
2. The Place tool (**P**) is now active. Click on the ground where the checkpoint should stand.
|
||||
|
||||
The editor places a checkpoint post together with its respawn point a little in front of it.
|
||||
|
||||
@@ -69,7 +69,8 @@ plane shows as material 30. Look for:
|
||||
|
||||
The llama should not have to start over after every fall.
|
||||
|
||||
1. Open the **Objects** panel (left dock) and click **Checkpoint** in **Quick setups**.
|
||||
1. Open the **Objects** tab in the left dock – the ⊙ icon right next to *Outliner* (inactive tabs show only
|
||||
their icon) and click **Checkpoint** in **Quick setups**.
|
||||
2. Click on a platform in the middle of the course.
|
||||
|
||||
You get a checkpoint post with its respawn point just in front of it. Falling after passing it
|
||||
|
||||
@@ -20,8 +20,19 @@ Registry key (all values): `HKEY_LOCAL_MACHINE\Software\Disney Interactive\Emper
|
||||
with `CDPath`, `HDPath`, screen size, volumes and devices. LlamaMod's base patch of `groove.exe`
|
||||
redirects it to `HKEY_CURRENT_USER`, so no admin rights are needed.
|
||||
|
||||
Development used the **German CD**. Other language versions are expected to use the same formats
|
||||
but were not tested.
|
||||
Development used the **German CD**. The English first release is supported as well: all exe
|
||||
patches find their places in both builds (table below). Its own CD data has not been tested yet –
|
||||
the formats are expected to be the same.
|
||||
|
||||
### groove.exe versions
|
||||
|
||||
| Build | PE date | Size | Notes |
|
||||
|---|---|---|---|
|
||||
| German | 2001-02-12 | 1,642,496 bytes | reference for all addresses in these pages |
|
||||
| English (first release, menu "Start Game") | 2000-10-17 | 1,634,304 bytes | same code, shifted (e.g. main menu frame handler 0x549990 instead of 0x549C50); .data globals laid out differently (game state 0x583D74 instead of 0x5846E4, player strat 0x6D94A4 instead of 0x6D9E1C); .data 0xD000 bytes in the file instead of 0xE000 |
|
||||
|
||||
LlamaMod supports both (`exe/Site` lists every address it uses in both builds). Addresses in the
|
||||
reference pages are those of the German build.
|
||||
|
||||
## Level naming scheme
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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])
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
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).
|
||||
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 92 KiB |
@@ -173,6 +173,11 @@ stairs, platforms register as shadow casters (`solids`). 1.0 = texture colour
|
||||
|
||||
## Exit (LevelDoor) – how it works
|
||||
|
||||
> **Corrected 2026-11-07 (in game, Jungle Day 2):** the listing below was the first reading. The
|
||||
> LevelDoor only sets g101–g105 and checks global 0x21 *once*; the level is ended by the **Llama's
|
||||
> script** (material 16 + exit cell + at most ~0.6 above the floor). See "Exit via stale floor
|
||||
> material" further down and docs/reference/objects.md.
|
||||
|
||||
Strat bytecode (village, STPC 0x430332, 3 parameters, invisible, no model), decoded via the
|
||||
VM `FUN_0054d180` (opcode < 0x45: table 0x5796bc with 16-bit operand, otherwise 0x578ff4+op·4):
|
||||
```
|
||||
@@ -199,6 +204,46 @@ loop: if g21 == 3 ; game state "running"
|
||||
(zone teleport). t1l3m003: 2 of the 4 doors (cells 71,48/71,49) have no pad at all.
|
||||
* Second path (strat code at 0x4252aa, player logic [likely]): floor material 16 and cell =
|
||||
g104/g105 (set by the LevelDoor at start, Llama sets them to −1) → level change(g101..g103).
|
||||
|
||||
### Exit via stale floor material (hover + position jump, t1l4m002) – checked 2026-11-07
|
||||
|
||||
Speedrun video (position/hover hack): Kuzco hovers above a house-door stump, is moved to the exit and
|
||||
the level ends. Reproduced in game (apptest, gdb through `winedbg --gdb`, position written by gdb):
|
||||
* **Jungle Day chapter 2 = t1l4m002** (LNFO 95 coins / 2 secrets; tally "Dschungeltag Kapitel 2,
|
||||
0/95, 0/2"). LevelDoor at 44.5/25.5 (cell 44,25), params (1,4,3); RubyIdolDoor in the same
|
||||
cell. Cell 44,25 (tile 255) has material 16 **only in swap model 95** (pad x 44.67–45.0,
|
||||
z 25.25–25.75, y 2.25); main model 94 has none → with the door closed the exit cannot fire.
|
||||
House-door pads (material 16, main models) in cells 38,20 / 51,0 / 52,5 / 41,24 / 44,27 /
|
||||
50,27 / 41,29 / 57,55 / 41,38 / 41,44 plus swap pads in 22,21 / 26,21 / 23,23 / 50,1 / 43,21 /
|
||||
46,30 / 58,49 / 31,50; the zones pair them (e.g. 44,27 → 50.5/27.5, 50,27 → 44.5/27.5).
|
||||
* **Floor test (FUN_004036d0):** only tiles of the player's own cell; per upward polygon the plane
|
||||
height at (x,z) is compared with a probe point (y + 0.25, or y + (floor record +0x18)/2 with
|
||||
+0xe8 flag 0x200000 as for the llama);
|
||||
the **nearest floor below the probe wins, with no distance limit**. Material 17 is skipped for
|
||||
the llama (+0xe8 = 0x24882a, no 0x8000000). If nothing is below: for the llama (+0xe8 & 0x40000)
|
||||
material/height are **not** updated (stale) and x/z go back to the previous position.
|
||||
In game: llama held at 44.9/**6.0**/27.5 (3.7 above the pad of the stump at 44,27) → floor
|
||||
record h 2.30, **material 16**, tile 282 poly 15, every frame.
|
||||
* **Order per tick (FUN_0054c4a0):** all strat scripts (VM FUN_0054d180) first, then the
|
||||
collision pass (FUN_00403020 for every strat with +0xe8 & 0x8000). A position written after the
|
||||
collision pass is seen by the scripts of the next tick with the **old floor record**.
|
||||
In game: after hovering above the 44,27 pad, llama set to 44.5/2.3/25.5 right after its floor
|
||||
test → next event endLevel `FUN_0054d620` (called from the VM, floor record still 16), before
|
||||
any new floor test → tally (twice with screenshot, once traced). Control: same jump from normal
|
||||
ground (material 0) → no exit; jump written just *before* the floor test → record becomes 0, no exit.
|
||||
* **Who ends the level here: the Llama, not the LevelDoor.** LevelDoor bytecode: wait 5 ticks,
|
||||
g101..g105 = params + cell, then **once** `if global 0x21 != 3 → end` (so "g21 == 3" above is a
|
||||
one-time check, and global 0x21 is not the game state 0x5846e4), then the per-tick loop. In
|
||||
t1l4m002 the LevelDoor was gone from the strat list (scan of 0x6d9e38) while g101..g105 =
|
||||
1,4,3,44,25 and global 0x21 = 0. The Llama block at STPC 0x39ff4c–0x3a0064: if floorMaterial
|
||||
== 16 and health > 0 and (y < floorHeight + 0.6 and global 0x21 != 2, or y < floorHeight + 0.025
|
||||
and (g1 == 0 or global 0x23)): cell == (g104, g105) → level change(g101..g103), else fade-out +
|
||||
`getDoorPos` (op 0x100, zone teleport = house door) + `forceCollision`. The 0.6 limit is why
|
||||
hovering high above a house pad does not trigger the house teleport (bytecode; not separately
|
||||
tested), while landing at floor height in the exit cell with the stale record does.
|
||||
* Not explained: in two sessions with a moved player start (boot-to-level `start` 43.3/25.5) the
|
||||
same jump did not end the level (Llama script ran with record 16 in cell 44,25, no endLevel).
|
||||
* Images: `img/debug/exit-hover-trick.jpg` (top: video; bottom: our pool/door platform, tally).
|
||||
* Parameters in the originals: (1, world, chapter+1) = next chapter; in the last chapter of a
|
||||
world **(7,7,7)** = "world completed" → back to the selection map, unlock the next world.
|
||||
* `FUN_0054d620`: remembers the current level (0x585008..14), sets the next (0x584eb4/b8/bc), shows the
|
||||
|
||||
@@ -123,8 +123,8 @@ on Windows. German CD (`groove.exe` MD5 697d67a31c20079838c662d8187dbd86, 1,642,
|
||||
file offsets of the changed bytes: **0x579B, 0x57D0, 0x5810, 0x5859, 0x5880** (original 02,
|
||||
new 01; MD5 with only this part 6199a42eb7582b2a70b1ac6e29f00f43; the base patch also relocates the
|
||||
world-9 level records, see "groove.exe patches" below). The patcher searches the pattern
|
||||
(push of the subkey string VA + push HKLM) instead of fixed offsets, so other language builds work
|
||||
if their code is the same.
|
||||
(push of the subkey string VA + push HKLM) instead of fixed offsets; the English build has the
|
||||
changed bytes at the same file offsets. Supported builds: see "groove.exe versions" below.
|
||||
|
||||
File lookup (FUN_00415690): tries `HDPath\dir\file`, then `CDPath\dir\file` (checked with
|
||||
`_access`), and only then loops over drive letters with `GetDriveTypeA == DRIVE_CDROM`. A plain
|
||||
@@ -225,8 +225,9 @@ reads a CD folder, collects writes in `ctx.written`, resolves options (defaults
|
||||
(`platform/ExePatcher`, owner "base", always first) → exe changes of the enabled mods in apply
|
||||
order. Without exe mods the result is byte-identical to `ExePatcher.patch` (installer output);
|
||||
"Restore original" goes back to it. The base patch (REVISION 2) = HKCU + `relocateLevelRecords`
|
||||
(world-9 coin/flag arrays moved into .data's page slack, 4-byte address operands only, see
|
||||
testlevel-chapters.md); `ExePatcher.changes` lists both as byte runs. A mod gets the shared image with `ctx.exe()`; all changes are
|
||||
(world-9 coin/flag arrays moved into .data's page slack – English build: .rdata's page slack, and
|
||||
.rdata's section flags get the write bit, because its .data slack is only 43 bytes; 4-byte address
|
||||
operands only, see testlevel-chapters.md); `ExePatcher.changes` lists both as byte runs. A mod gets the shared image with `ctx.exe()`; all changes are
|
||||
attributed to the mod id, journaled into the mod's cache manifest (`"exe": [...]`) and replayed
|
||||
(with full verification) on a cache hit. Every patch/allocation is logged in apply.log
|
||||
(`exe <mod>: patch 0x… n bytes – label`).
|
||||
@@ -270,6 +271,58 @@ in cave code are fine). Tests: `exe/ExeImageTest` (real exe, re-parse + `objdump
|
||||
`mods/ModOptionsAndExeTest`; in-game check: `mods/ExeSmokeMod` (test classes, see below; its call
|
||||
counter is an `allocateData` block).
|
||||
|
||||
### groove.exe versions (`exe/GameExe`, `exe/Site`)
|
||||
|
||||
Two builds are supported; their code is the same but shifted, and the .data globals are laid
|
||||
out differently, so **no patch uses a fixed address**:
|
||||
|
||||
| Build | PE date | Size | SHA-256 |
|
||||
|---|---|---|---|
|
||||
| German (reference, all RE notes) | 2001-02-12 | 1,642,496 | `7832eda4ee47a717e94441f21b75a9f30d11445c9f2408c6135a8fc01812e95b` |
|
||||
| English first release ("Start Game", "Chapter") | 2000-10-17 | 1,634,304 | `bf0eb8829ed3e371c4e951ece0a7753acf2c28d07001b842e079b098576403b0` |
|
||||
|
||||
`exe.game()` (a `GameExe`, cached per `ExeImage`) identifies the build by SHA-256 (`build()`,
|
||||
`describe()` – logged in apply.log as `groove.exe: English version of 2000-10-17`) and resolves
|
||||
each named `Site` on the file **as parsed** (before any patch): `va(site)`, `original(site, n)` =
|
||||
the original bytes there (expected bytes for `patch`/`hookCall`, and the stolen bytes a detour
|
||||
re-emits, e.g. the frame handlers' `mov eax,[global]`). Rules in `Site`: `code(sig)` = unique
|
||||
match in .text, `??` masking absolute addresses and rel32 operands; `operand(sig, off)` /
|
||||
`operandAt(site, off)` = the absolute address an instruction uses (must lie in .data – globals
|
||||
are always read from code, never guessed); `callTarget(site)` = target of a call; `.calling(s)`
|
||||
also checks that the call at the site goes to `s`. Each site lists its German and English VA:
|
||||
`exe/GameExeTest` checks that both builds resolve to them and that every site disassembles
|
||||
(objdump) to the expected instruction; `mods/ExeVersionsTest` checks that the German output of
|
||||
every exe mod and option set is **byte-identical** to the output before the version layer (SHA-256
|
||||
golden values) and that every mod makes the same patches in the English exe;
|
||||
`ExeModCombinationsTest` runs all combinations on both. The English exe is read from
|
||||
`-Dllamamod.exeEn` (default `re/groove_en.exe`, gitignored – never commit it or add it to test
|
||||
resources); tests skip without it.
|
||||
|
||||
An unknown build fails on the first site that does not match exactly once, e.g.
|
||||
`groove.exe (unknown version (PE date …, … bytes)): MENU_FRAME (state 2 (main menu) frame handler …)
|
||||
not found – unknown program version. LlamaMod supports: German version of 2001-02-12, English
|
||||
version of 2000-10-17.` The base patch stays lenient (HKCU by pattern, relocation skipped).
|
||||
The mod cache key includes the exe hash for every build except the German one (whose keys did
|
||||
not change).
|
||||
|
||||
**In game** (apptest :25, 2026-11-07, English exe in `original/hd` with the **German CD data** –
|
||||
the English exe runs with it; menus are English because the strings are in the exe): main menu
|
||||
without logos after 17 s and no attract demo after 100 s (intro skip); boot to level (Village 2,
|
||||
TestLevel 9); practice F5/F8 restores the position; free camera F7 flies away from the llama;
|
||||
cheats: winedbg showed cheat flags 0x583DA0 = the chosen bits, energy 0x584500 stayed 0x5000
|
||||
after falls into water with `health`, dropped to 0x4000 without it; TestLevel chapter 9 → "Quit to
|
||||
Map Select" gives a sane map (Village / Chapter 1 / 55) instead of the broken progress.
|
||||

|
||||
|
||||
**Adding a build:** copy it to `re/` (gitignored), add a `GameExe.Build` (language, PE date, size,
|
||||
SHA-256), run `GameExeTest`: every failing site needs a longer/other signature (keep it unique in
|
||||
all builds; mask only addresses and rel32). For the new column, read each address off the
|
||||
resolved sites (or with a script that pairs relocation entries of matched instructions, as done
|
||||
for the English build: an operand is accepted only if most references to the German global agree),
|
||||
then add the column to `Site` and a case to `Build.va`. Check the base patch's free space
|
||||
(`ExePatcher.SLACK_SECTIONS`) and run `ExeVersionsTest` / `ExeModCombinationsTest`; then test in
|
||||
game.
|
||||
|
||||
### Antivirus false positives
|
||||
|
||||
A changed groove.exe with extra code may be flagged by antivirus programs (Windows Defender).
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,162 @@
|
||||
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() throws Exception {
|
||||
ExeImage img = exe("de"); // outside assertThrows: skips the test when the exe is not available
|
||||
img.setOwner("devmode");
|
||||
DevModeMod mod = new DevModeMod();
|
||||
TestModContext ctx = new TestModContext(tmp, mod, Map.of("idolKey", "F1"), img);
|
||||
assertThrows(IllegalArgumentException.class, () -> mod.apply(ctx));
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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}')
|
||||
@@ -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')
|
||||