15 Commits
Author SHA1 Message Date
MineTec 06241ea1ff 0.2.5
build / build (push) Successful in 49s
release / release (push) Successful in 1m21s
2026-11-08 18:38:45 +01:00
MineTec 87a1998dd4 devmode test: load the exe outside assertThrows so it skips cleanly without game files (ci) 2026-11-08 18:36:59 +01:00
MineTec 165134c0d8 0.2.4
build / build (push) Failing after 1m9s
release / release (push) Failing after 51s
2026-11-08 18:28:02 +01:00
MineTec 526059c70a mountain 3 crash fix mod (keeps the boulder spawners alive, one word of t1l5m003), object palette test skips without a display 2026-11-08 18:28:02 +01:00
MineTec ec2876d9c8 developer mode mod: game's developer switch on (warp with a+s, river log cheat), keys for the hidden aux inputs (fly, heart and fruit, idols, coins) on both exe versions 2026-11-08 18:09:03 +01:00
MineTec e36ff2d2c2 mountain 3 crash: rollingbouldermountain spawners end while their boulders still write parent.v13 (use after free), seen in game, one-word level fix tested; diag tooling in notes/dev 2026-11-08 17:41:19 +01:00
MineTec 8b3c38fe56 debug wireframe: allowance areas option (off, floating spots, all edges) from an exact port of the engine floor test, capped by level memory 2026-11-08 17:41:19 +01:00
MineTec db08cf8bf3 developer switch (strat property 258): 101 reads, a+s dev warp points, aux bits for hearts, fruit, coins, idols and flying, river log invincible noclip, flipped and tested in game 2026-11-08 17:33:05 +01:00
MineTec b5b6e22957 frog moonjump: kuscofrog's mid-air code rises while padaux bit 0x800 is set, nothing on pc ever sets it (verified in game by poking the bit) 2026-11-08 16:44:54 +01:00
MineTec 15ad050005 floor explainer: no side view or header, pink labelled allowance, all polygon numbers in the zoom 2026-11-08 16:29:59 +01:00
MineTec 74f8fec341 nicer floor explainer: terrain overview with grid and polygon numbers, zoom on both crate needles with the accepted slivers, side view 2026-11-08 16:26:16 +01:00
MineTec 8baed2e87c floor test explainer image (needle triangle, accepted sliver, kuzco, heights), poly 11 is the same needle on the crate's other side 2026-11-08 16:21:37 +01:00
MineTec 9e8e7f9a10 floor test inclusion rule from the code (edge planes with a small tolerance, highest plane wins): explains kuzco floating next to the crate in jungle day 2 (needle triangle tip), tools/floortest.py re-implements the engine test 2026-11-08 16:15:28 +01:00
MineTec c39d58bdac boxedwine notes: real amd gpu shows a black window, software gl works 2026-11-07 23:01:02 +01:00
MineTec 0c2920dfe4 browser feasibility notes: boxedwine runs the game natively into the 3d level, its web build has no opengl/webgl bridge so direct3d fails 2026-11-07 22:44:04 +01:00
47 changed files with 2713 additions and 24 deletions

No files matched your search

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

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+98 -1
View File
@@ -63,7 +63,7 @@ diagnostics) → `jmp 0x41C050`. The cave writes only game globals, so it needs
| F4 in game | requests state 0x0C (world map) – the normal "back to map" | no |
| F7 in game | toggles 0x584708 → start row of the strat debug print (0x6D9DB8 = 0x20 / 0x48); nothing visible in retail strats | no |
| F6 | screenshot `capture%d.tga` | no |
| `CheatTeleport`, `CheatCharge` strats | present in most level WADs; strat-only logic, not decoded | no |
| `CheatTeleport`, `CheatCharge` strats | present in most level WADs; gated by property 258, see "Developer switch (property 258)" | no |
Level select / unlock-all is the save editor's job (`--unlock`), not duplicated here.
@@ -76,3 +76,100 @@ Level select / unlock-all is the save editor's job (`--unlock`), not duplicated
loss (`img/mods/cheats_fall.jpg`).
* `rewards` (bit 3) decoded only, not played through to a chapter end.
* Diagnostics: file written, game runs normally.
## Frog moonjump (PS second controller)
Question from speedrunners: is the frog moonjump (PS: needs a controller in port 2) reachable on PC?
Answer: **the logic is in the PC data, but its input can never be set on PC without patching.**
Strat side (all 34 level WADs disassembled, 2026-11-08). Property 88 `padAux` (DAT_005FCF58) is read
only by Llama (23 WADs), LlamaChase (t1l2m003), KuscoBunny (t1l7m004, t1l8m001, t1l8m002), KuscoFrog
(t1l4m003, t1l8m003) and CheatTeleport (26 WADs, also `padAuxPressed`). Everything except the frog's
air handler is additionally gated by property 258 (0x102), which the exe getter hard-wires to 0
(jump table 0x552684[0x102] → 0x55231B `push 0`); CheatTeleport removes itself when it is 0.
KuscoFrog (t1l4m003 unit 44), airborne handler L_136c – **no prop-258 check**:
```
if (self.padAux & 0.5) { ; bit 0x800
g22 = 0.055 ; vertical speed upward, every frame while the bit is set
v31 = 1.0
}
self.y = max(self.y + g22, v44)
```
Ground handler L_1d6c (take-off): `(padPressed & 4.0 /*jump*/) | ((padAux & 0.5) && prop258)` → the
aux bit alone cannot start a jump, it only keeps the frog rising once it is in the air (= moonjump:
jump normally, then hold the aux button). Llama/Bunny/LlamaChase have the same code (Llama: g22 = 0.0698)
but behind `prop258`, so they never moon-jump.
Exe side: 0x5FCF58 is referenced exactly 3× in groove.exe (all 4-byte immediates scanned): 0x425B93
`mov dword [0x5FCF58], 0` (end of the pad poll FUN_004256C0, only if pad count 0x5FCF6C > 1, i.e. a
DirectInput joystick is present), 0x425C1E (edge detect → 0x5FCF60 `padAuxPressed`), 0x551403 (strat
getter, prop 0x58). It lives in .bss → always 0. No keyboard key, joystick (1st or 2nd), debug key or
demo (.dem) playback writes it. Under Wine without a joystick: pad count 1, padAux 0 (read in game).
Verified in game (apptest :25, t1l4m003 Jungle day ch. 3, teleported onto PotionPadFrog → FrogPotion
→ frog): `set {int}0x5FCF58 = 0x800` via the winedbg gdb proxy. Frog on the ground: nothing happens.
Frog jumps (X): it keeps rising – y 13460 → 41617 (~2 s) → 59842 (~4 s), far above the level; clearing
the bit makes it fall back. Same write as the llama: normal jump, no effect (prop 258 = 0).
`img/mods/frog_moonjump.jpg` (top: normal frog jump, bottom: bit 0x800 set).
To enable on PC a patch is required, e.g. make the pad poll map a key to padAux bit 0x800. Which PS
button of pad 2 feeds bit 0x800 is not known (no PS binary analysed) – hypothesis only.
## Developer switch (property 258)
Strat property 258 (0x102) is a hard-wired developer flag: getter FUN_00550e60, jump table
0x552684[0x102] → 0x55231B `mov eax,[esp+14h]; push 0; push eax; call 0x54BBD0` (the `push 0` byte is
at **0x552320**; the same handler also serves the unread ids 0x75, 0x9E, 0xC1, 0xEE). It is read
101× in the 34 level WADs (all units disassembled; `pushProp` only, no player/parent/... variants).
Every use is a logical test (`and`, `not`, `jz`), so any non-zero value enables it.
Keys (in-game `padHeld` 0x5FCF20, measured with gdb while holding the key): A 0x400, S 0x800
(camera left/right), Return 0x1000, Z 0x2000 (action), X 0x4000 (jump), Space 0x8000 (power),
P 0x08 (pause). No keyboard key gives 0x100/0x200. `padAux` (prop 88, 0x5FCF58) and
`padAuxPressed` (prop 90, 0x5FCF60 = edge of padAux, computed every frame at 0x425C1E) are never
set on PC (see above). Strat constants are 20.12: `0.25` = 0x400, `0.5` = 0x800, `0.75` = 0xC00,
`10.0` = 0xA000, `256r` = raw 0x100.
| Reader strat | Levels | Gate | Bits / keys | Effect | Status |
|---|---|---|---|---|---|
| CheatTeleport (+CheatCharge, same unit) | placed in 22 levels: 1-1..1-4, 2-1, 2-2, 4-1..4-3, 5-1..5-3, 6-1..6-3, 7-2, 7-4, 8-1..8-5 (code also in 3-1..3-4, not placed) | entry `if (!prop258) remove` at level start | hold **A+S** (padHeld 0xC00) | teleports the llama to the next node of the strat's own path (hand-placed warp list, 1–18 nodes per level, often next to checkpoints, doors, puzzles; wraps around); sets `g0 = 14` | verified 1-1 (4 nodes, cycled twice) |
| CheatTeleport | same | prop258 | padAuxPressed 0x400 | +1 heart (if alive), +5 purple fruit, hudDigit = charge | verified (fruit 0 → 5, HUD shows it) |
| CheatTeleport | same | prop258 (+ prop 0 == 0) | padAux 0x100 held | +1 coin per frame (prop 190, coins of this level) | verified (HUD 307 coins after 0.5 s) |
| CheatTeleport | same | prop258 (+ prop 0 == 0) | padAux 0x200 held | +1 pink idol per frame, max 99 (prop 250) | verified (0 → 65 → 99) |
| CheatTeleport | same | prop258 | hold Space+Z (0xA000) | only `print` debug lists (player/camera state dump: "gFreezePlayer", "gCamAng", "teleported to location (" …) that the PC VM skips → nothing visible | static |
| CheatTeleport | same | prop258 **and prop 0 ≠ 0** | padAux 0x100 held = debug menu; then padPressed Space/Z = previous/next music track (spawns unit 48 = track display, `musicPlay`), X = lose heart, Return = +1 idol, A = +1 coin (+10 charge every 5), S = full charge, 0x100 = **`endLevel(g101,g102,g103)`** (finish level), 0x200 = +1 counterC; combo becomes 0xF00 | unreachable even with prop258 = 1 (prop 0 is also hard-wired 0, handler 0x552192) | static |
| Llama | 23 levels (all with a llama) | prop258 | padAux 0x800 held | moonjump/fly: starts a jump on the ground without pressing X (no cooldown check) and sets vertical speed 0.0698/frame every frame in the air → rises until released | verified 1-1: y 8.25 → 20.6 in ~2 s without touching a key |
| LlamaChase | 2-3 (night chase) | prop258 | padAux 0x800 | same moonjump; also allows jumping anywhere (normally only on floor material 11) | static (chase intro did not start under load) |
| KuscoBunny | 7-4, 8-1, 8-2 (potion) | prop258 | padAux 0x800 | rabbit jumps/rises with speed 0.045 while held | static |
| KuscoFrog | 4-3, 8-3 (potion) | prop258 only for take-off | padAux 0x800 | air rise has **no** gate (moonjump agent, verified); with prop258 the bit also starts the jump | static |
| KuscoAndPachaTogetherOnATree (river log) | 3-1..3-4 | prop258 | hold **Space** (0x8000) | damage handling skipped (invincible), +1 heart every frame, collision flag 0x40000 off (passes through banks), horizontal movement ×8, no current/steering term | verified 3-1: with Space the log shot through the river bank out of the level |
| RiverWhirlpool | 3-2 (36×) | prop258 | hold Space | whirlpool does not pull the log | static |
| CondorManDoor | 5-2 | prop258 | padHeld 0x100 (no PC key) | a hit always takes the "straight hit" branch (`v16 = 1`, later `otherPiece()` on impact; hypothesis: the door breaks) instead of needing the angle within 96 | static |
| EndGameTriggerBox | 8-6 (finale, 15×) | prop258 | hold Space+Z during the box's wait time | `endLevel(1,1,1)` at once (hypothesis: skips the rest of the finale) | static |
How it was verified (apptest `devflag-a`, :25, German exe): `set {char}0x552320 = 1` via the winedbg
gdb proxy at the main menu (CheatTeleport removes itself at level start, so the patch must be in
before the level loads), then `set {int}0x5FCF58 = 0x100/0x200/0x400/0x800` one at a time.
`img/mods/devflag_teleport.jpg` (1-1 start → three A+S warps), `devflag_aux_moonjump.jpg` (before /
after padAux 0x400+0x100: fruit 5 and 307 coins on the HUD / bit 0x800: llama flying above the
village), `devflag_river.jpg` (3-1 log normal / after Space boost in the bank / outside the level).
Note: the exe run by LlamaMod relocates the per-level array 0x584F1C to 0x6DB940 (seen at 0x550BC2),
so `levelRecord`/coins are not at the address of `re/groove.exe`.
Property 0 is a second hard-wired 0 read by strats (131×: Camera, CameraSide, CheatTeleport, birds,
butterflies, ladybirds, moths, Bunny, Panther, Yzma…). Ambient critters with a parameter set remove
themselves when it is non-zero, the camera uses alternative heights/distances when it is 0, and
CheatTeleport switches its combo to 0xF00 – **hypothesis**: a platform/hardware flag (PS button
layout or low-memory mode), not a debug flag. Other constant getter cases: 0x5E/0x60 = 84,
0x5F/0x61 = 123, 0x82/0x83 = 42 (stick thresholds), and 0, 0x13, 0x15, 0x47, 0x75, 0x76, 0x77,
0x98, 0x9E, 0xC1, 0xD8, 0xED, 0xEE, 0x102 = 0; of the zero ids only 0 and 0x102 are read by any strat.
No exe code writes padAux, and no other dev key handler was found beyond the table above (F4, F6, F7).
Implemented as the separate mod `devmode` (see `mod-devmode.md`); original suggestion: patch byte 0x552320 to 1
(or point jump-table entry 0x552A8C at a cave that pushes 0x1000, leaving the shared handler alone),
and map keys to padAux in the pad poll: at the end of FUN_004256C0 (0x425B88, where padAux is only
cleared when >1 pad) jump to a cave that builds padAux from the DIK state array 0x58226C + 4·DIK,
e.g. F1 → 0x800 (moonjump), F2 → 0x400 (heart + fruit), F3 → 0x200 (idols), F5 → 0x100 (coins).
A+S teleport and Space (river) work with the normal keys once prop 258 is 1. Note that F4/F6/F7
are taken; the frog's ungated moonjump would also react to the 0x800 key.
+57
View File
@@ -0,0 +1,57 @@
# Developer mode (`devmode`, CHEATS)
`mods/devmode/DevModeMod` + `DevModePatch` – two groove.exe patches, no data changes. Background and
the full list of what the switch unlocks: `mod-cheats.md`, "Developer switch (property 258)".
## Options
| Key | Default | padAux bit | Effect |
|---|---|---|---|
| `flyKey` | F1 | 0x800 held | Llama (also from standing), LlamaChase, bunny rise while held; frog after a jump |
| `heartKey` | F2 | 0x400 (pressed edge) | +1 heart, +5 purple fruit |
| `idolKey` | F3 | 0x200 held | pink idols +1 per frame, max 99 |
| `coinKey` | F11 | 0x100 held | coins +1 per frame |
| `flyToggle` | off | – | fly key toggles bit 0x800 instead of hold |
Keys are `Hotkeys.option` choices (cross-mod conflict check); defaults avoid practice F5/F8, freecam
F7/F9 and the game's F4/F6/F10. The developer switch itself has no option (always on with the mod).
Two equal keys → apply fails with a message.
## Patches (German / English addresses, all via `Site`)
1. **Property 258 = 1.0.** `PROP_GET` (0x550E60 / 0x550B80) is the strat property getter; its
`jmp [eax*4 + table]` has the table address at +21 (0x552684 / 0x552390, in .text). Entry
0x102 (0x552A8C / 0x552798) pointed at the shared `push 0` handler (0x55231B / 0x55198F; the
patch checks its bytes) and now points at a cave: `mov eax,[esp+14h]; push 0x1000; push eax;
call <push result>; add esp,8; add esp,10h; ret`. The other ids of the shared handler
(de: 0x75, 0x9E, 0xC1, 0xEE; en: 0xC1, 0xEE) stay 0. The table entry has a .reloc entry; the
exe always loads at its image base, so the absolute cave address is fine.
2. **padAux from keys.** `PAD_POLL_END` (0x425B88 / 0x425A08, tail of the pad poll FUN_004256c0:
`mov eax,[padCount]; pop ebx; cmp eax,1; jbe; mov [padAux],0; add esp,8; ret`, 25 bytes) →
`jmp` cave + NOPs. Cave: `pop ebx`, ecx = OR of the bits whose DIK slot in `KEY_STATE`
(0x58226C / 0x5818FC, s32[256] frames held, all 0 while Alt is held) is > 0, 0 while `DEMO`;
`mov [PAD_AUX],ecx` (0x5FCF58 / 0x5FC5E8); `mov eax,[padCount]` (keeps the old return value);
`add esp,8; ret`. The edge detection padAuxPressed (FUN_00425c00, start of each tick) is
untouched and works as before. Toggle mode: 8 data bytes (previous key state, on flag).
Hotkey names → DirectInput scan codes in `DevModePatch.DIK` (test: covers every `Hotkeys.VK` key).
## Verified in game (apptest `devmode-a`, :25, German exe, village 1-1, defaults)
* A+S three times: three different warp points (`img/mods/devmode_teleport.jpg`).
* F2 twice: fruit 0 → 5 → 10, energy bar refilled.
* F1 held 2.5 s: the llama rises above the village, falls back after release.
* F3 held 1.5 s: idols 46; F11 held 1 s: coins 1 → 32 (`img/mods/devmode_keys.jpg`).
* No crash; game starts normally with the mod (one crash in a Wine DLL at 68E9306B on the very
first start of a fresh session prefix, not reproducible in 3 later starts with the mod).
Not checked in game: English exe (unit tests only), toggle mode, river log Space, 8-6 Space+Z,
frog/bunny, LlamaChase.
## Limits
* Level-skip/music debug menu needs property 0 ≠ 0 as well (also hard-wired 0, used by 131 strat
reads incl. cameras) – deliberately not touched.
* Toggle state survives level changes (flying continues in the next level until pressed again).
* With the switch on, CheatTeleport stays alive in 22 levels; A+S (camera left+right together) is
the warp combo, so pressing both camera keys at once now teleports.
+150
View File
@@ -0,0 +1,150 @@
# "Mountain 3 crash" (speedrun community) – investigation 2026-11-08
Short version: **Mountain 3 = `t1l5m003`** (world 5, chapter 3). Its boulder spawner
(`RollingBoulderMountain`) can end while a boulder it spawned is still alive. When that boulder
finishes its path, it decrements `parent.v13` of the dead spawner. That write
(`-= 1.0`, i.e. `-0x1000`) goes into freed or reused memory (use after free). If it hits a header of
the game's local heap, a later `FreeLocalMem` crashes. That matches the community crash dump.
The stale write is **verified in game**. A full crash was **not** reproduced. A one-word level-data
patch removes the stale write (verified in game).
## Evidence from the community
* DebugDiag dump (English exe, 2000-10-17): access violation **writing** `0x0000340d` at
`groove+0x1ad52` = VA `0x41AD52`, thread 0, Argonaut name `FreeLocalMem`.
* English `0x41AD10..0x41AD89` is byte-identical to German **`FUN_0041ada0`** (only the rel32 of
the "ITS NOT USED! %08x" debug printf differs). Crash instruction = German `0x41ADE2`
`mov [eax+4], esi`.
* The local heap (`FUN_0041af00` init, `FUN_0041ae20` alloc, `FUN_0041ada0` free) holds blocks with a
12-byte header `{next, prev, u16 size (dwords), u16 used}`. The blocks are contiguous
(`next == hdr + (size+3)*4`). On free, the routine merges with the **next** block if
`next.used == 0`, then does `next->next->prev = hdr`. The crash means the header after the
freed block had `used == 0` and `next == 0x3409`. 0x3409 is not 4-aligned, so it can't be a real
block pointer. **The heap metadata was corrupted earlier, and the free only shows it.**
* Clips: index.mp4 is in a snowy canyon with a pushable boulder (red X), wall shards
(`SmashWallShrapnel`), a ruby-idol pickup (HUD), and a crash at the cave about 6 s later.
index2.mp4 is the Kronk ice race (`KronkOnIceDoor`/`KronkOnIce`, same WAD): black screen about
2 s after the race starts. Both are in `t1l5m003`. Our boot to (74.6, 14.8, 6.3) shows the same canyon
and boulder as clip 1, so clip 1 is most likely the `#47` push-boulder section (hypothesis,
based on what the scenes look like).
* Side note: the heap is `operator_new(0x200000)` but is initialised with size `0x800000` into a u16
field, so it is really 0xFFFD dwords (≈256 KB). Mountain 3 uses about 88 KB of it, so the heap
is not exhausted. No leak was seen: block count stayed stable over minutes.
## The bug (strat code of unit 1 of t1l5m003, verified by disassembly)
`RollingBoulderMountain` (6 MAP instances, params v0..v9). `v13` is set to 0 and never
incremented anywhere. The only other access is the boulder's `parent.v13 -= 1.0`
(`pushParentVarAddr v13; dec`) in `L_0574`, which runs when the boulder ends its path or falls:
```
spawner: ... spawn loop (L_0c24: spawn<7,31,4,0x14c000,0>(…RollingBoulderForMountain)) ...
freezeOffCamera 1
L_0d90: pushVar v13 / pushShort 0 / eq / jzYield L_0d90 / end ; ends at once when v13 == 0
boulder: L_0574: parent.v13 -= 1.0 ; ... ; remove
```
* With `v1 == 0` (instances #17 and #35) or `v4 != 0` (#22 and #47, the pushable "red X" boulders,
which wait to be charged), the spawner spawns **one** boulder and **ends in the same frame**
(v13 is still 0).
* Instances #1 and #27 (`v1` = 8.3 / 8.75) loop: a boulder about every 8–10 s, until the player
is near the path start. They end the same way if no boulder has died yet when the loop exits.
* Strat removal (strat update loop `FUN_0054c4a0`) frees the var block
(`+0xF4`) and the collision block (`+0xC0`) and puts the struct on the free list (LIFO). It does
**not** clear the children's `+0xC` parent pointers. The parent opcodes (`0x553750`/`0x553770`/
`0x553a40`) have no check.
* So the boulder's write goes to `deadSpawner.vars + 0x34`: either memory of the freed var block
(now part of a free block or of another allocation, possibly a **block header**), or, if the
struct was already reused, `v13` of another strat. For a boulder, `v13` is its sound address
(`v13 = sound:4`, used by `playSoundChannel`).
* "Predictable intervals" (runner's guess) fits: the stale writes happen a fixed time after you
pass a spawner, or after you push a boulder, when that boulder reaches the end of its path.
Whether a write hurts depends on the heap layout at that moment, so it depends on timing.
It is not deterministic across runs.
## In-game verification (apptest, Xvfb :26, sessions `mtn3-*`)
Tooling (throwaway, see `notes/dev/mountain3-diag/`): a test-only mod `Mtn3DiagMod` (loaded through
`APPTEST_CP` + `-Dllamamod.extraMods`). It installs a code cave (`cave.asm`, nasm) that replaces the
`pushParentVarAddr` and `popParentVar` handlers. They log every access, flag **stale** ones
(parent `+0xEC == 0`, i.e. removed) together with the block that the target address falls in, and
walk the whole heap on every alloc and free (`next/prev/used` consistency).
`rd.py` reads the log by attaching `winedbg` for a moment (stdin script `attach`/`x`/`detach`;
works under ptrace_scope 1). `apptest.py` in the agent copy got an env switch
`APPTEST_IGNORE_WINEDBG` so the short attach is not taken for a crash (not copied back).
A gdb-over-winedbg monitor with breakpoints was too slow: the game froze.
Boot-to-level `start=29.5, 14, 23.2, 180 @ 5/3` (next to spawners #1 and #17), no input, 70–85 s:
| | runs | runs with a stale write | heap anomalies | crash |
|---|---|---|---|---|
| original WAD | 6 (1 manual + 5) | 5 | 0 | 0 |
| level fix (below) | 3 | 0 | 0 | 0 |
Every stale record was the same and deterministic for this start (logged by the cave):
`RollingBoulderForMountain` → parent `RollingBoulderMountain` (struct `+0xEC == 0`, removed), `v13`,
target = `freed var block + 0x34`. At the time of the write this address was inside a **used
16-dword block** (a `0x40000` collision block, most likely the next boulder's), at offset +0x40 from its
header, which is 12 bytes before the next block header. In this layout it corrupts collision data
(u16 fields `+0x34/+0x36` become 0xF000/0xFFFF), not the heap. Any other layout (other timing,
other route, other spawns in between, e.g. an 11-dword allocation such as a `0x28000` pickup's
collision block puts a remainder header's size/used word exactly there) turns the same write into
header corruption. **Not verified:** a crash caused by this write. No natural run crashed, and the
real runner route (from the chapter start at full speed, pushing #22/#47, then the race) could not
be driven reliably by key scripts (the push needs speed and alignment; several attempts missed).
Also seen in game: strat structs are recycled LIFO (the dead spawner's struct was later reused by a
boulder).
Other checks in this WAD: every `collisionOn` user is spawned or placed with collision type flags
(no `typeFlags 0 + collisionOn` case like in the arena), and all `extra` operands are 0. Other
parent-variable writers (`KronkOnIcePickup` → `parent.v7`, `CheckPointHead`, `RubyIdolStatue`,
`AxeGuardAxe`) have parents that do not end while they live, as far as the code shows. That was
checked by reading the code only; the cave saw no stale access from them.
Side finding (not part of the crash): boot-to-level `start=28.5, 10.5, 22.5, 0 @ 5/3` passes the
floor check, but the game crashes while loading (`page fault read 0x00000002 at 0x405527`, floor/
tile lookup in `FUN_004054c0`). Out of scope here.
## Fix / avoidance
* **Level data (tested):** in `RollingBoulderMountain`, change `pushShort 0` before `eq` to
`pushShort 0.5` (word `0x00000044` → `0x08000044`, the only match of the 6-word pattern
`0x1002f 0xd0004 0x44 0x4e 0xfffc0012 0xb4`, file offset 0x4a0a27 in the German
`t1l5m003.wad`). `v13` only takes whole numbers (0, -1, -2 …), so the spawner never ends. It
stays frozen off camera and costs nothing, and every boulder writes into a live spawner. The
boulders still roll (in-game: 6–7 normal parent writes per run, 0 stale). `notes/dev/mountain3-diag/wadfix.py IN OUT`.
The English CD's WAD is not checked; the patch looks for the pattern and refuses if it isn't unique.
* **Exe (general, untested):** let removal (`+0xE8 & 4` path) clear `+0xC` of all strats whose
parent is the removed one (pool `DAT_006d9dc0`, stride 0x154), and let the parent opcodes
redirect a NULL or removed parent to a dummy dword. This also covers similar bugs in other levels.
* **Runners (hypothesis):** there's no reliable avoidance. The write happens when the boulders of
#17 and #35 (spawned when you pass their trigger spots) and the pushed boulders #22 and #47 reach
the end of their path. Not pushing #22/#47 removes two of the four guaranteed stale writes, if
the route allows that.
Diag tooling notes: the files in `notes/dev/mountain3-diag/` are as used, with absolute paths
of the agent job dir. The mod writes `addresses.txt` and expects `cave_<fix>_<CAVE>_<DATA>.bin`
(`nasm -f bin -DCAVE=0x6ef0e0 -DDATA=0x70f010 -DFIX=0 cave.asm`; these addresses are valid with
boot-to-level + mtn3-diag on the German exe). This is only for the German exe; nothing is registered in the app.
## Discord answer (draft)
> We looked into the Mountain 3 crash. Your dump crashes in the game's own memory manager
> (`FreeLocalMem`, writing to 0x340d). That is the symptom: its bookkeeping was overwritten
> earlier. **Verified:** in Mountain 3 the rolling-boulder spawner can delete itself while
> its boulder is still rolling. When that boulder reaches the end of its path (or falls off), it
> writes into the spawner's memory, which has already been freed and reused. We saw this
> write in the game with a debug build, and a one-value level patch makes it go away.
> **Not verified yet:** that this exact write causes your crash. We never got the game to
> crash. Where the write lands depends on what else is in memory at that moment, so it
> breaks things only sometimes, and the crash can come seconds later somewhere else
> (e.g. in the Kronk race). That would explain why it's Mountain 3 only, at different spots,
> without an error message, and "every so often" around boulders. The likely triggers are the
> one-shot boulders near the start/top of the slope and the two red-X push boulders. We'll
> probably ship the fix in LlamaMod.
## In LlamaMod
The fix is the mod `mountain3-fix` (`mods/fixes/Mountain3FixMod`, Gameplay): it finds the unique pattern in
`t1l5m003.wad` and replaces `pushShort 0` with `pushShort 0.5`; if the pattern is missing (other game
version, file already changed) it leaves the file unchanged and says so in the apply log.
@@ -204,6 +204,23 @@ public enum Site {
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),
@@ -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). */
@@ -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;
}
}
@@ -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<>();
@@ -50,7 +50,9 @@ class GameExeTest {
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.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));
@@ -23,7 +23,7 @@ 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), for the German and the English exe.
*/
@@ -31,14 +31,15 @@ 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"));
/** {@code version}: "de" or "en" (the English exe runs against the German CD data here). */
private AppPaths install(String version) throws IOException {
@@ -39,7 +39,8 @@ class ExeVersionsTest {
"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"));
"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(
@@ -56,7 +57,11 @@ class ExeVersionsTest {
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"));
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");
@@ -70,6 +75,11 @@ class ExeVersionsTest {
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;
}
@@ -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)");
}
}
+25
View File
@@ -0,0 +1,25 @@
import sys,struct,numpy as np; sys.path.insert(0,'.'); from floor import *
t=443; x,y,z,rot,mi,_=mp['A'][t]; recs=[r for g in trak[mi].coll for r in g]
def planes(r):
return [struct.unpack_from('<bbbBh',r,2+6*k) for k in range(5)]
def corners(r):
P=planes(r); ne=4 if r[0]&1 else 3; E=P[1:1+ne]; S=P[0]; pts=[]
for i in range(ne):
a,b=E[i],E[(i+1)%ne]
A=np.array([S[:3],a[:3],b[:3]],float); B=np.array([S[4],a[4],b[4]],float)/32
pts.append(np.linalg.solve(A,B)) # n127*32*p = d -> n127.p = d/32 (p in units)...
return pts
# local (engine) -> world: local=(lz_w', -lx_w') for rot 3072 => dx=-lz, dz=lx
a=-rot/4096*2*math.pi
def toworld(p):
lx,ly,lz=p
# invert lx=c*dx+s*dz, lz=c*dz-s*dx with s=sin(a'),c=cos(a'), a'=(-rot)*2pi/4096
s,c=math.sin(a),math.cos(a)
dx=c*lx-s*lz; dz=s*lx+c*lz
return (x+dx,y+ly,-z+dz)
for i in [int(v) for v in sys.argv[1:]]:
r=recs[i]; P=planes(r)
print('poly',i,'type',r[0],'surface n',[v/127 for v in P[0][:3]])
for p in corners(r):
print(' local',np.round(p,4),' world',np.round(toworld(p),4))
+119
View File
@@ -0,0 +1,119 @@
# Usage: python3 tools/floorre/explain_t1l4m002.py tools/floorre OUT.png (WAD via env WAD, default t1l4m002 path in floor.py; needs Pillow, numpy)
# Explainer: Kuzco floating next to the crate in t1l4m002 (builds on floorre/plot.py)
import sys, math
sys.path.insert(0, sys.argv[1]) # floorre dir (floor.py, corners.py)
OUT = sys.argv[2]
sys.argv = sys.argv[:1]
from floor import * # mp, trak, floor_test
from corners import corners
from PIL import Image, ImageDraw, ImageFont
SS = 2 # supersampling
F = lambda n, b=False: ImageFont.truetype('DejaVuSans-Bold.ttf' if b else 'DejaVuSans.ttf', n * SS)
K = (57.204, 2.474, 43.733)
T = 443
TX, TY, TZ, ROT, MI, _ = mp['A'][T]
recs = [r for g in trak[MI].coll for r in g]
def xf(B):
rot = B[1]; tx, ty, tz = B[3] / 4096, B[4] / 4096, B[5] / 4096
a = -rot / 4096 * 2 * math.pi; s, c = math.sin(a), math.cos(a)
return lambda p: (tx + c * p[0] - s * p[2], ty + p[1], tz + s * p[0] + c * p[2])
RED, BLUE, MAG, PINK = (215, 25, 25), (20, 90, 230), (210, 0, 210), (255, 70, 150)
def panel(X0, Z0, span, size, detail):
S = size / span
img = Image.new('RGB', (size * SS, size * SS), 'white'); d = ImageDraw.Draw(img, 'RGBA')
P = lambda x, z: ((x - X0) * S * SS, (size - (z - Z0) * S) * SS)
# rendered terrain coloured by height (like the agent's plot)
for t in range(mp['N']):
B = mp['B'][t]
if not (X0 - 2 <= B[3] / 4096 <= X0 + span + 2 and Z0 - 2 <= B[5] / 4096 <= Z0 + span + 2): continue
m = trak[mp['midx'][t]]; f = xf(B)
for fc in sorted(m.faces, key=lambda fc: sum(m.verts[v][1] for v in fc['v'])):
Q = [f((m.verts[v][0], m.verts[v][1], -m.verts[v][2])) for v in fc['v']]
h = sum(p[1] for p in Q) / len(Q); g = int(max(0, min(255, (h - 1.5) / 2 * 255)))
d.polygon([P(p[0], p[2]) for p in Q], fill=(g, 200 - g // 2, 255 - g, 255),
outline=(130, 130, 130) if not detail else None)
# cell grid
for k in range(int(X0) - 1, int(X0 + span) + 2):
d.line([P(k, Z0 - 1), P(k, Z0 + span + 1)], fill=(0, 140, 0), width=SS * (2 if detail else 1))
for k in range(int(Z0) - 1, int(Z0 + span) + 2):
d.line([P(X0 - 1, k), P(X0 + span + 1, k)], fill=(0, 140, 0), width=SS * (2 if detail else 1))
return img, d, P, S
def collision(d, P, labels, sliver=None, fsz=12):
f = xf(mp['B'][T])
if sliver:
for (x, z, w, i) in sliver:
a, b = P(x, z), P(x + w, z - w)
d.rectangle([a[0], a[1], b[0], b[1]], fill=PINK + (170,))
for i, r in enumerate(recs):
C = [f(p) for p in corners(r)]
col = RED if i in (11, 15) else BLUE if i == 2 else (20, 20, 20)
wid = 3 if i in (2, 11, 15) else 1
pts = [P(p[0], p[2]) for p in C]
if i in (11, 15): d.polygon(pts, fill=RED + (70,))
d.line(pts + pts[:1], fill=col, width=wid * SS)
if labels:
cx = sum(p[0] for p in pts) / len(pts); cy = sum(p[1] for p in pts) / len(pts)
lim = d.im.size
if not (10 * SS < cx < lim[0] - 10 * SS and 10 * SS < cy < lim[1] - (80 if labels == 'zoom' else 10) * SS):
continue
txt = f'poly {i} · mat {r[1]}' if labels == 'zoom' and i in (2, 11, 15) else str(i)
d.text((cx, cy), txt, fill=col, font=F(fsz, i in (2, 11, 15)), anchor='mm',
stroke_width=SS * 2, stroke_fill='white')
def sliver_cells(x0, z0, x1, z1, step):
out = []; tx, ty, tz = int(round(TX * 4096)), int(round(TY * 4096)), int(round(-TZ * 4096))
z = z0
while z < z1:
x = x0
while x < x1:
if int(x) == 57 and int(z) == 43:
_, res = floor_test(int(round(x * 4096)), int(round(2.27 * 4096)), int(round(z * 4096)), tx, ty, tz, ROT, recs)
best = None
for i, i7, ok, vals, tol, Y in res:
if ok and i7 >= -0x80 and (best is None or i7 < best[0]): best = (i7, i)
if best and best[1] in (11, 15): out.append((x, z, step, best[1]))
x += step
z += step
return out
W = 1500
# --- left: overview (3 x 3 cells) ---
ov, d, P, S = panel(56.0, 42.0, 3.0, 640, False)
collision(d, P, 'all', fsz=11)
for t in range(mp['N']):
B = mp['B'][t]; x, z = B[3] / 4096, B[5] / 4096
if 56 <= x <= 59 and 42 <= z <= 45:
d.text(P(x - 0.45, z - 0.38), f'tile {t}', fill=(110, 0, 140), font=F(12, True), stroke_width=SS * 2, stroke_fill='white')
k = P(K[0], K[2]); d.ellipse([k[0] - 7 * SS, k[1] - 7 * SS, k[0] + 7 * SS, k[1] + 7 * SS], fill=MAG, outline='black', width=SS)
ZX0, ZZ0, ZSP = 57.06, 43.16, 0.76
a, b = P(ZX0, ZZ0 + ZSP), P(ZX0 + ZSP, ZZ0)
d.rectangle([a, b], outline=(255, 140, 0), width=3 * SS)
ov = ov.resize((640, 640), Image.LANCZOS)
# --- right: zoom on the crate's side with both needles ---
zm, d, P, S = panel(ZX0, ZZ0, ZSP, 820, True)
collision(d, P, 'zoom', sliver=sliver_cells(ZX0, ZZ0, ZX0 + ZSP, ZZ0 + ZSP, 0.0012), fsz=15)
k = P(K[0], K[2]); r = 9 * SS
d.ellipse([k[0] - r, k[1] - r, k[0] + r, k[1] + r], fill=MAG, outline='black', width=2 * SS)
d.text((k[0], k[1] + 30 * SS), 'Kuzco\n57.204 / 43.733\n(measured live)', fill=MAG, font=F(15, True), anchor='ma',
align='center', stroke_width=SS * 2, stroke_fill='white')
tip = P(57.315, 43.729)
y = tip[1] - 34 * SS
d.line([(k[0], y), (tip[0], y)], fill='black', width=2 * SS)
for xq in (k[0], tip[0]): d.line([(xq, y - 8 * SS), (xq, y + 8 * SS)], fill='black', width=2 * SS)
d.text(((k[0] + tip[0]) / 2, y - 6 * SS), '0.11 outside poly 15', fill='black', font=F(14, True), anchor='md',
stroke_width=SS * 2, stroke_fill='white')
d.text((P(57.38, 43.80)), 'needle tips 13.5°', fill=RED, font=F(14, True), stroke_width=SS * 2, stroke_fill='white')
d.text((P(ZX0 + 0.01, ZZ0 + 0.035)), 'pink = allowance: where the engine still takes the needle as floor\n(edge tolerance ≈ 0.02 → ≈ 0.19 long, 0.01 wide at a 13.5° tip)',
fill=(190, 20, 110), font=F(14, True), stroke_width=SS * 2, stroke_fill='white')
zm = zm.resize((820, 820), Image.LANCZOS)
out = Image.new('RGB', (20 + 640 + 20 + 820 + 20, 820 + 40), 'white')
d = ImageDraw.Draw(out)
out.paste(ov, (20, 20 + 90)); out.paste(zm, (680, 20))
d.text((20, 20 + 60), 'Overview (3 × 3 cells), orange box = zoom →', fill='black', font=ImageFont.truetype('DejaVuSans-Bold.ttf', 17))
out.save(OUT); print('saved', OUT, out.size)
+40
View File
@@ -0,0 +1,40 @@
import sys,struct,math; sys.path.insert(0,'/home/elias/projects/llamamod/tools'); import geomlib as G
ch,wf,trak,mp=G.load_level(__import__('os').environ.get('WAD','/home/elias/.claude/jobs/90c6eed7/tmp/exitre/t1l4m002.wad'))
_exe=open('/home/elias/projects/llamamod/re/groove.exe','rb').read()
SIN=list(struct.unpack_from('<4096i',_exe,0x174318))
def c8(b): return b-256 if b>127 else b
def floor_test(px,py,pz,tx,ty,tz,rot,recs,dbg=False,probe_add=0x400):
"""exact re-implementation of FUN_004036d0 floor branch (flags 0x200000 off). ints 20.12"""
lx=px-tx; lz=pz-tz; ly=py-ty+probe_add
u=(-rot)&0xffffffff
if u:
s=SIN[u&0xfff]; c=SIN[(u+0x400)&0xfff]
lx,lz=(s*lz+c*lx)>>12,(c*lz-s*lx)>>12
out=[]
for i,r in enumerate(recs):
if r[1]==0x11: continue
ny=c8(r[3]); n14=ny*32
if n14<=0: continue
h=(((struct.unpack_from('<h',r,6)[0]*4096 - c8(r[4])*32*lz - c8(r[2])*32*lx) - n14*ly))
q=abs(h)//abs(n14); i6= q if (h>=0)==(n14>=0) else -q # C truncating division
i7=-i6
Y=ly+i6
tol=-0x40-((ny*-0x20+0x1000)>>6)
ne=4 if r[0]&1 else 3
vals=[]
ok=True
for k in range(ne):
nx,nyy,nz,_,d=struct.unpack_from('<bbbBh',r,8+6*k)
v=((nyy*32*Y+nz*32*lz+nx*32*lx)>>12)-d
vals.append(v)
if v<tol: ok=False
out.append((i,i7,ok,vals,tol,ty+Y))
return (lx,ly,lz),out
if __name__=='__main__':
t=443; x,y,z,rot,mi,_=mp['A'][t]
recs=[r for g in trak[mi].coll for r in g]
P=[float(a) for a in sys.argv[1:4]]
loc,out=floor_test(*[int(round(v*4096)) for v in P],int(round(x*4096)),int(round(y*4096)),int(round(-z*4096)),rot,recs)
print('local',[v/4096 for v in loc])
for i,i7,ok,vals,tol,Y in out:
if i7>=-0x80: print(f'poly {i:2d} dist {i7/4096:+.4f} planeY {Y/4096:.4f} pass={ok} edges {vals} tol {tol}')
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Exact re-implementation of the engine's map floor test (FUN_004036d0, floor branch) on raw collision records.
floortest.py LEVEL.wad X Y Z world position in cells (game space, z = -A.z); prints every polygon of the
cell's tiles that is a floor candidate, whether it passes, and the winner
Integer arithmetic as in groove.exe (20.12 fixed point, sine table read from re/groove.exe, C division).
See notes/geometry.md "Floor test: inclusion rule and the floating-next-to-the-log case".
"""
import os
import struct
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import geomlib as G # noqa: E402
_EXE = os.path.join(os.path.dirname(HERE), 're', 'groove.exe')
SIN = struct.unpack_from('<4096i', open(_EXE, 'rb').read(), 0x574318 - 0x400000) # DAT_00574318
PROBE = 0x400 # DAT_0057dd98 (0.25) unless strat flag 0x200000
def _c8(b):
return b - 256 if b > 127 else b
def _cdiv(a, b):
q = abs(a) // abs(b)
return q if (a >= 0) == (b > 0) else -q
def to_local(px, pz, tile_b):
"""World (fixed) -> tile-local x/z exactly like 0x4037b3..0x4037f1 (rotation by -ry)."""
lx, lz = px - tile_b[3], pz - tile_b[5]
u = (-tile_b[1]) & 0xffffffff
if u:
s, c = SIN[u & 0xfff], SIN[(u + 0x400) & 0xfff]
lx, lz = (s * lz + c * lx) >> 12, (c * lz - s * lx) >> 12
return lx, lz
def test_poly(r, lx, ly, lz):
"""One 32-byte record. ly = probe height (feet - tile y + 0.25). Returns None (skipped) or
(dist, plane_y_local, passed, edge_values, tolerance); dist = probe height above the plane."""
ny32 = _c8(r[3]) * 32
if ny32 <= 0: # only up-facing planes (0x200000 strats also take down-facing ones)
return None
d0 = struct.unpack_from('<h', r, 6)[0]
i6 = _cdiv(d0 * 4096 - _c8(r[4]) * 32 * lz - _c8(r[2]) * 32 * lx - ny32 * ly, ny32)
dist = -i6
y = ly + i6 # the probe dropped vertically onto the plane
tol = -0x40 - ((0x1000 - ny32) >> 6)
vals = []
for k in range(4 if r[0] & 1 else 3):
nx, ny, nz, _, d = struct.unpack_from('<bbbBh', r, 8 + 6 * k)
vals.append(((ny * 32 * y + nz * 32 * lz + nx * 32 * lx) >> 12) - d)
return dist, y, all(v >= tol for v in vals), vals, tol
def floor(wad, x, y, z, wfpc_8000000=False):
ch, wf, trak, mp = G.load_level(wad)
px, py, pz = (int(round(v * 4096)) for v in (x, y, z))
skip_mat = 0x12 if wfpc_8000000 else 0x11
best = None
rows = []
for t in range(mp['N']):
b = mp['B'][t]
if (b[3] >> 12, b[5] >> 12) != (px >> 12, pz >> 12):
continue
lx, lz = to_local(px, pz, b)
ly = py - b[4] + PROBE
recs = [r for g in trak[mp['midx'][t]].coll for r in g]
for i, r in enumerate(recs):
if r[1] == skip_mat:
continue
res = test_poly(r, lx, ly, lz)
if res is None or res[0] < -0x80:
continue # plane more than 1/32 above the probe: ceiling branch, not a floor
dist, yl, ok, vals, tol = res
rows.append((t, i, r[1], dist / 4096, (b[4] + yl) / 4096, ok, vals, tol))
if ok and (best is None or dist < best[0]):
best = (dist, t, i, (b[4] + yl) / 4096)
return rows, best
if __name__ == '__main__':
if len(sys.argv) != 5:
sys.exit(__doc__)
rows, best = floor(sys.argv[1], *map(float, sys.argv[2:5]))
for t, i, mat, dist, h, ok, vals, tol in rows:
print(f'tile {t} poly {i:2d} mat {mat:3d} probe-plane {dist:+.4f} plane y {h:.4f} '
f'{"PASS" if ok else "fail"} edges {vals} (tolerance {tol})')
print('floor:', f'tile {best[1]} poly {best[2]} height {best[3]:.4f}' if best else 'none in this cell')