Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06241ea1ff | ||
|
|
87a1998dd4 | ||
|
|
165134c0d8 | ||
|
|
526059c70a | ||
|
|
ec2876d9c8 | ||
|
|
e36ff2d2c2 | ||
|
|
8b3c38fe56 | ||
|
|
db08cf8bf3 | ||
|
|
b5b6e22957 | ||
|
|
15ad050005 | ||
|
|
74f8fec341 | ||
|
|
8baed2e87c | ||
|
|
9e8e7f9a10 | ||
|
|
c39d58bdac | ||
|
|
0c2920dfe4 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -149,6 +149,40 @@ CutSceneTrigger camera moves only as points).
|
||||
(≤ 2000 extra vertices per tile model, on average ~1–2× as many triangles as the original).
|
||||
* Test level T1L9 (test world) has no debug version (the testlevel mod takes precedence).
|
||||
|
||||
## Allowance areas (LlamaMod option `debug-wireframe.allowance`, 2026-11-08)
|
||||
|
||||
Choice `off` (default) / `tips` ("Floating spots") / `all` ("All edges"). Shows the floor test's edge
|
||||
allowance: the engine accepts a floor polygon up to `tol = −0x40 − ((0x1000 − ny·32) >> 6)` (≈0.016–0.023)
|
||||
outside each edge plane; at sharp corners this becomes a sliver up to tol / sin(θ/2) long
|
||||
(geometry.md "Floor test: inclusion rule …"). Colour **pink 255,60,200**.
|
||||
|
||||
* Code: `mods/debug/FloorTest` = bit-exact port of FUN_004036d0's floor branch (same as `tools/floortest.py`;
|
||||
the sine table is `rint(sin(2πi/4096)·4096)`, identical to DAT_00574318), `mods/debug/AllowanceAreas`.
|
||||
All geometry uses the engine's plane scale (n·p = d/32 with |n| = 127), not `Collision.decode`'s d/4096,
|
||||
so the bands sit ~0.8 % further from the model origin than the green wireframe.
|
||||
* **tips**: for every placed tile and every up-facing collision polygon (not material 17 = skipped for the
|
||||
llama, not 30 = no floor) and every corner whose tolerance corner lies ≥ `MIN_REACH` (0.05) beyond the
|
||||
true corner, the area beyond the corner (tolerance region ∩ half-plane past the corner) is sampled in up to
|
||||
40 rows × 7 points. A sample counts when the exact engine test, with the feet on that polygon's plane,
|
||||
picks this polygon, the point is outside the polygon without tolerance, and no other passing floor of the
|
||||
cell is less than `MIN_GAP` (0.05) below (material-30 floors count as none). Samples outside the tile's
|
||||
cell are dropped (the engine only tests the cell's tiles). Kept when the winning samples reach ≥ 0.05
|
||||
outside the polygon: translucent strip (alpha 150) through the winning part of each row + an opaque
|
||||
centre line (4 mm), at the plane height + 0.012, in the tile's marker cell model (no collision).
|
||||
Strat object collision (crates as strats, platforms) is not considered.
|
||||
* **all**: per TRAK model, every up-facing floor polygon (same material filter) gets an opaque ring from
|
||||
its true corners to its tolerance corners (2n vertices, 2n triangles), appended to the model overlay like
|
||||
the wireframe (tile transform, alt models free). Tolerance corners further than 1.5 out are clamped.
|
||||
* Budget: if TEXT+TRAK+MAP+STPC+LGPC would exceed 12 MB (16 MB level arena) the level is rebuilt without
|
||||
allowance geometry and the apply log says "allowance areas left out: level too large". Not reached in
|
||||
any original level; a model whose overlay would pass 7900 vertices keeps its wireframe without bands.
|
||||
* Numbers (all 33 levels): tips 0–1454 slivers per level (river t1l3m00x most, t1l4m002 394),
|
||||
WAD +0–2.0 MB, ≤ 2 s per level; all: +0.02–2.9 MB, arena max 11.1 MB (t1l3m003).
|
||||
t1l4m002 crate (tile 443): poly 15 corner 2 (reach 0.18, contains Kuzco's spot 57.204/43.733),
|
||||
poly 11 corner 2 (0.175), polys 10/14 (0.11/0.13) on the other end, 12/13 barely (0.05).
|
||||
* Tests: `AllowanceAreasTest` (exact port vs. 44 floortest.py points, sine table vs. groove.exe,
|
||||
slivers of t1l4m002, no slivers on the flat start ground, band counts, process in each mode).
|
||||
|
||||
## Statistics per level (build of 2026-11-06)
|
||||
|
||||
Marker categories are the internal keys of `debugviz.py`: ausgang = exit, geheimnis = secret,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Browser feasibility: Boxedwine (2026-11-07)
|
||||
|
||||
Question: can *The Emperor's New Groove* (groove.exe, D3D7/DirectDraw, DirectInput, DirectSound) run in a
|
||||
web browser through [Boxedwine](https://github.com/danoon2/Boxedwine) (Wine + x86 emulator, also built to WebAssembly)?
|
||||
|
||||
## Verdict
|
||||
|
||||
**Not with today's Boxedwine web build: Direct3D is missing in the browser.**
|
||||
- **Web (Emscripten) build 26R1:** the game starts and shows Wine dialogs, then stops with the game's own
|
||||
message *"Direct Draw kann nicht gestartet werden."* ("Direct Draw cannot be started"). The web build has no
|
||||
OpenGL bridge: the Emscripten makefile defines none of the `BOXEDWINE_OPENGL_*` switches, the stripped file
|
||||
system has no `libGL.so.1` (Wine logs `Failed to load libGL … OpenGL support is disabled`), and the release
|
||||
readme says *"OpenGL and Direct3D are not supported in the Web/Emscripten build because they can not currently be
|
||||
converted to WebGL."* Without OpenGL, wined3d has no D3D device, so groove.exe finds no 3D device.
|
||||
- **Native Linux build (same emulator, x86 JIT), Wine 11 file system:** **works.** After three small Boxedwine
|
||||
fixes (see below) it reaches the intro, the main menu, the save slot screen, the map and in-game
|
||||
Village chapter 1 at ~30 fps (llvmpipe on Xvfb, load average ~15). Keyboard input works. One visible problem:
|
||||
in-game the camera seems to sit at Kuzco's position (the llama is not visible, the view pitches up/down
|
||||
with Up/Down); in the attract-mode demo Kuzco is drawn normally. Not investigated.
|
||||
With the Wine 6 file system it hangs on the loading screen (emulator livelock, see below).
|
||||
- **Software renderer (plan B) does not help:** no own rasterizer found in groove.exe. `FUN_0040ffd0`
|
||||
chooses a DirectDraw device + Direct3D device by name (`DisplayDevice`/`D3DDevice`). The "software" flag
|
||||
(`SoftwareScreenBPP/W/H`, device record +0x10) is derived from the enumerated **D3D** device description
|
||||
(`FUN_00412a10`: `(desc+0xf4 >> 3) & 1`), most likely D3D's own software device (RGB/MMX emulation) – in any case a device the game gets from Direct3D enumeration, not an own rasterizer (no other draw path was found by grep). In Wine that
|
||||
device is wined3d too, which needs OpenGL (or Vulkan). Wine has no GL-free D3D rasterizer, so the software
|
||||
path also needs a GL bridge in the browser.
|
||||
- **Path in Boxedwine:** yes, D3D7 → Wine `ddraw` → `wined3d` → OpenGL. Natively, Boxedwine passes the
|
||||
emulated GL calls to the host GL through SDL (`source/opengl/sdl`). In the browser that bridge would have
|
||||
to become WebGL (GL ES 2/3), which Boxedwine does not do. The source has an unused ES path
|
||||
(`source/opengl/es`: glshim/esopengl) that is not part of the Emscripten build.
|
||||
|
||||
### What it would take to ship
|
||||
1. A GL → WebGL bridge for the Emscripten build (upstream work in Boxedwine: wined3d needs desktop GL 2.1+
|
||||
features; a translation layer like gl4es/glshim or a WebGL2 backend for the marshalled GL calls). This is
|
||||
the blocker, weeks of emulator work at least, not something LlamaMod can patch around.
|
||||
2. Speed: the web build has no JIT (wasm interpreter, `TOTAL_MEMORY` 512 MB). Native JIT gives ~30 fps with
|
||||
1.2 cores; the interpreter is typically several times slower, so even with WebGL the 3D game would likely
|
||||
be far below playable on average hardware. Unmeasured.
|
||||
3. Distribution page: a static page where the user drops their own ISO/folder; JS builds the app zip in
|
||||
memory (game folder + CD data, ~400 MB without movies) plus a root zip with the registry values, never
|
||||
uploaded anywhere. Never host game data. Needs COOP/COEP headers for the multi-threaded build.
|
||||
4. Sound and movies: DirectSound opened 17 SDL audio streams natively (silent dummy driver here, audibility
|
||||
not checked). Intro movies (`movies/*.dat`) do not play in Wine 11 under Boxedwine (quartz: `Failed to
|
||||
create filter … hr 0x8007000e`); the game skips them.
|
||||
|
||||
Alternatives that do not need a GL bridge are out of scope here (e.g. a native port, or a WebGL renderer
|
||||
re-implementation of the engine's D3D7 calls).
|
||||
|
||||
## Versions
|
||||
|
||||
| Item | Version / source |
|
||||
|---|---|
|
||||
| Boxedwine | 26R1.0 (2026-04-22). GitHub releases have Windows, Mac and Web builds, **no Linux build** → built from the source tarball |
|
||||
| Linux build | GCC 11.4 (Mint 21.3). Official requirement is GCC 12; one fix needed (below). `libminizip-dev` missing → linked against the installed `libminizip.so.1` via a local symlink (no system packages installed) |
|
||||
| File systems | from Boxedwine's own list `http://www.boxedwine.org/v2/26R1/filesV2.xml`: `TinyCore15Wine6.0.zip`, `TinyCore15Wine11.0.zip`; web: `boxedwine.zip` (stripped Wine 6) from `Boxedwine26R1Web.zip` |
|
||||
| Browser | Chromium (`/usr/bin/chromium`, own profile) on Xvfb :26, page served by a local Python server with COOP/COEP headers on 127.0.0.1, MultiThreaded web build |
|
||||
| Game | German CD via `tools/apptest.py setup boxedwine`; LlamaMod's patched `game/groove.exe` (HKCU registry) |
|
||||
|
||||
## Procedure (native)
|
||||
|
||||
```
|
||||
Xvfb :26 -screen 0 1920x1080x24 -nolisten tcp +extension GLX
|
||||
# prefix: copy user.reg out of the file-system zip into <root>/home/username/.wine/, append
|
||||
# [Software\\Disney Interactive\\Emperors New Groove\\1.0] CDPath="Z:\\mnt\\lama\\cd-active\\" HDPath=".\\"
|
||||
# ScreenW=dword:280 ScreenH=dword:1e0
|
||||
# and write "disable" into .update-timestamp (otherwise wineboot updates the prefix: >15 min and it hung)
|
||||
SDL_AUDIODRIVER=dummy DISPLAY=:26 boxedwine -root <root> -zip TinyCore15Wine11.0.zip \
|
||||
-env WINEDLLOVERRIDES=mscoree,mshtml= -mount <apptest>/data /mnt/lama -w /mnt/lama/game /bin/wine groove.exe
|
||||
```
|
||||
Keys via `xdotool keydown/keyup` with the pointer over the window (X focus follows the pointer, no WM).
|
||||
The game renders 640x480 into the top-left of the 800x600 Boxedwine window.
|
||||
|
||||
Boxedwine fixes needed (patch: `$CLAUDE_JOB_DIR/tmp/boxedwine/results/boxedwine-26R1-linux-xvfb.patch`, not upstreamed):
|
||||
1. `readIcons.cpp`: `std::make_shared<U8[]>` → `std::shared_ptr<U8[]>(new U8[n]())` (GCC 11).
|
||||
2. `platform/linux/platformOpenGL.cpp`: skip GLX fbconfigs with `GLX_SLOW_CONFIG` caveat and without
|
||||
`GLX_WINDOW_BIT`. Under Xvfb/llvmpipe, Wine otherwise picks an accum-buffer config (SDL refuses it as
|
||||
"accelerated") or a non-window config (`set_pixel_format … not compatible for window rendering`) and wined3d
|
||||
reports `Failed to get a GL context`. Probably Xvfb-specific; real GPUs may not need it.
|
||||
3. `KMemory::mapNativeMemory`: when no free pages are left above 0xD0000000, reserve in the user area
|
||||
instead. Without it the first `glMapBufferRange` of wined3d (Wine 6) fails → `Failed to map bo` → page fault.
|
||||
Not checked whether Wine 11 still needs it.
|
||||
|
||||
Wine 6 file system with these fixes: loading screen drawn through D3D7, ~10–25 presents/s for ~30 s, then
|
||||
every emulated thread waits for the JIT memory mutex (`JitCodeGen::doJIT`) while one thread spins — the game
|
||||
never leaves the loading screen. Not investigated further; Wine 11 does not show it.
|
||||
|
||||
## Procedure (web)
|
||||
|
||||
Web page = release `MultiThreaded/` folder plus `lamaroot.zip` (= `boxedwine.zip` with the user.reg above, CDPath
|
||||
`C:\\files\\lama\\cd\\`) and `lama.zip` (`lama/` = game folder + `lama/cd/` = CD data without movies, stored,
|
||||
415 MB). URL: `boxedwine.html?root=lamaroot&app=lama&p=groove.exe&sound=false`.
|
||||
The `overlay=` parameter did **not** override `user.reg` of the root zip (game asked for the CD), a modified
|
||||
root zip did. Loading 415 MB into the page took a few seconds locally.
|
||||
|
||||
## Screenshots
|
||||
|
||||
- `../img/dev/boxedwine-native-menu.jpg` – main menu (native Boxedwine, Wine 11)
|
||||
- `../img/dev/boxedwine-native-demo.jpg` – attract-mode demo in 3D (the right/bottom strips are a stale frame
|
||||
outside the 640x480 game area)
|
||||
- `../img/dev/boxedwine-native-village.jpg` – Village chapter 1 in game (Kuzco not visible, see above)
|
||||
- `../img/dev/boxedwine-web-ddraw-error.jpg` – web build in Chromium: "Direct Draw kann nicht gestartet werden."
|
||||
|
||||
## Running it locally
|
||||
|
||||
A durable native setup lives in `~/opt/boxedwine-26R1/` (rebuilt 2026-11-07 from the procedure above):
|
||||
|
||||
| Path | Content |
|
||||
|---|---|
|
||||
| `bin/boxedwine` | Boxedwine 26R1.0 built from `dl-src/` (GitHub tag tarball) with `boxedwine-26R1-linux-xvfb.patch`, GCC 11.4. The sources are CRLF: convert the five patched files to LF (`sed -i 's/\r$//'`) before `patch -p1`; `project/linux/linux_build/lib/libminizip.so` → system `libminizip.so.1` |
|
||||
| `dl-fs/TinyCore15Wine11.0.zip` | Wine 11 file system (+ `filesV2.xml`) |
|
||||
| `root/home/username/.wine/` | `user.reg` from the zip + the game's HKCU key, `.update-timestamp` = `disable` |
|
||||
| `apptest/lama/data/` | `APPTEST_ROOT=~/opt/boxedwine-26R1/apptest tools/apptest.py setup lama`: `game/` (patched groove.exe), `cd-active/`; mounted as `/mnt/lama` |
|
||||
|
||||
Start: `lama-boxedwine` (`~/.local/bin`, bash) on the current `$DISPLAY`, normal sound
|
||||
(`SDL_AUDIODRIVER=dummy lama-boxedwine` for silence). Log: `~/opt/boxedwine-26R1/last-run.log`;
|
||||
PID file `~/opt/boxedwine-26R1/boxedwine.pid` blocks a second instance. Known issues: in-game camera at
|
||||
Kuzco's position, no intro movies (see above).
|
||||
|
||||
**Real screen (AMD RX 480, Mesa radeonsi):** the window stayed black and the game hung after the movie attempts; with `LIBGL_ALWAYS_SOFTWARE=1` (llvmpipe) it shows the title and runs. `lama-boxedwine` therefore defaults to the software renderer (`LAMA_BW_GPU=1` to try the GPU).
|
||||
@@ -0,0 +1,44 @@
|
||||
package mtn3;
|
||||
|
||||
import com.eliasmueller.llamamod.exe.ExeImage;
|
||||
import com.eliasmueller.llamamod.mods.Mod;
|
||||
import com.eliasmueller.llamamod.mods.ModContext;
|
||||
import com.eliasmueller.llamamod.mods.ModOption;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
/** Throwaway diagnostics: stale parent-variable access log + local heap consistency check. */
|
||||
public final class Mtn3DiagMod implements Mod {
|
||||
static final Path DIR = Path.of("/home/elias/.claude/jobs/90c6eed7/tmp/m3/diag");
|
||||
|
||||
public String id() { return "mtn3-diag"; }
|
||||
public String name() { return "Mountain 3 diagnostics"; }
|
||||
public String description() { return "Logs stale parent writes and checks the local heap (test only)."; }
|
||||
public boolean modifiesExecutable() { return true; }
|
||||
public List<ModOption> options() {
|
||||
return List.of(ModOption.bool("fix", "Fix", "Redirect stale parent writes to a dummy", false),
|
||||
ModOption.string("rev", "Rev", "cache buster", "1"));
|
||||
}
|
||||
|
||||
public void apply(ModContext ctx) throws IOException {
|
||||
ExeImage exe = ctx.exe();
|
||||
long data = exe.allocateData(8192, "mtn3 diag data");
|
||||
long cave = exe.nextAllocation();
|
||||
String fix = ctx.optionBool("fix") ? "1" : "0";
|
||||
Path bin = DIR.resolve(String.format("cave_%s_%x_%x.bin", fix, cave, data));
|
||||
Files.writeString(DIR.resolve("addresses.txt"), String.format("CAVE=0x%x DATA=0x%x FIX=%s%n", cave, data, fix));
|
||||
if (!Files.exists(bin)) {
|
||||
throw new IOException("assemble " + bin);
|
||||
}
|
||||
long at = exe.place(Files.readAllBytes(bin), "mtn3 diag code");
|
||||
if (at != cave) {
|
||||
throw new IOException("cave moved " + Long.toHexString(at));
|
||||
}
|
||||
exe.hookJmp(0x553770, "8B 44 24 04 8B", cave, "pushParentVarAddr");
|
||||
exe.hookJmp(0x553a40, "56 8B 74 24 08", cave + 5, "popParentVar");
|
||||
exe.hookJmp(0x41ada0, "8B 44 24 04 56", cave + 10, "FreeLocalMem");
|
||||
exe.hookJmp(0x41ae20, "8B 0D 9C 49 58 00", cave + 15, "AllocLocalMem");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
; Mountain 3 crash diagnostics (German groove.exe). nasm -f bin -DCAVE=.. -DDATA=.. -DFIX=0/1
|
||||
bits 32
|
||||
org CAVE
|
||||
%define D(o) dword [DATA+o]
|
||||
; DATA layout: 0 magic, 16 cnt_parent_ok, 20 cnt_stale_addr, 24 cnt_stale_pop, 28 cnt_free, 32 cnt_anom,
|
||||
; 36 cnt_alloc, 40 stale ring idx, 44 all ring idx, 48 dummy, 52 last free ptr, 56 last alloc size, 60 tmp idx,
|
||||
; 64 stale ring 32x32, 1088 anomaly record 64 bytes, 1152 dump 256 bytes (hdr-128..+128), 1408 all-ring 64x32 (2048)
|
||||
; 3456 free history 64 x 16
|
||||
entry_addr: jmp near push_addr
|
||||
entry_pop: jmp near pop_var
|
||||
entry_free: jmp near free_hook
|
||||
entry_alloc: jmp near alloc_hook
|
||||
|
||||
push_addr:
|
||||
mov eax,[esp+4]
|
||||
mov ecx,[eax+0xc]
|
||||
mov edx,[esp+8]
|
||||
mov [DATA+60],edx
|
||||
mov edx,0x11
|
||||
call record_all
|
||||
test ecx,ecx
|
||||
jz .stale
|
||||
cmp dword [ecx+0xec],0
|
||||
jz .stale
|
||||
inc D(16)
|
||||
.ok:
|
||||
mov edx,[ecx+0xf4]
|
||||
mov ecx,[esp+8]
|
||||
lea edx,[edx+ecx*4]
|
||||
.push:
|
||||
push edx
|
||||
push eax
|
||||
call 0x54bbd0
|
||||
add esp,8
|
||||
ret
|
||||
.stale:
|
||||
inc D(20)
|
||||
mov edx,1
|
||||
call record
|
||||
call stale_info
|
||||
call check_heap
|
||||
%if FIX
|
||||
mov edx,DATA+48
|
||||
jmp .push
|
||||
%else
|
||||
test ecx,ecx
|
||||
jnz .ok
|
||||
mov edx,DATA+48
|
||||
jmp .push
|
||||
%endif
|
||||
|
||||
pop_var:
|
||||
push esi
|
||||
mov esi,[esp+8]
|
||||
push esi
|
||||
call 0x54bc00
|
||||
add esp,4
|
||||
mov ecx,[esi+0xc]
|
||||
mov edx,[esp+0xc]
|
||||
mov [DATA+60],edx
|
||||
push eax
|
||||
mov eax,esi
|
||||
mov edx,0x12
|
||||
call record_all
|
||||
pop eax
|
||||
test ecx,ecx
|
||||
jz .stale
|
||||
cmp dword [ecx+0xec],0
|
||||
jz .stale
|
||||
inc D(16)
|
||||
.ok:
|
||||
mov edx,[ecx+0xf4]
|
||||
mov ecx,[esp+0xc]
|
||||
lea edx,[edx+ecx*4]
|
||||
.store:
|
||||
pop esi
|
||||
mov [edx],eax
|
||||
ret
|
||||
.stale:
|
||||
inc D(24)
|
||||
push eax
|
||||
mov eax,esi
|
||||
mov edx,2
|
||||
call record
|
||||
call stale_info
|
||||
call check_heap
|
||||
pop eax
|
||||
%if FIX
|
||||
mov edx,DATA+48
|
||||
jmp .store
|
||||
%else
|
||||
test ecx,ecx
|
||||
jnz .ok
|
||||
mov edx,DATA+48
|
||||
jmp .store
|
||||
%endif
|
||||
|
||||
; record: eax strat, ecx parent, edx kind, [DATA+60] idx. Preserves eax, ecx.
|
||||
record:
|
||||
push esi
|
||||
push edi
|
||||
push ebx
|
||||
mov esi,[DATA+40]
|
||||
and esi,31
|
||||
shl esi,5
|
||||
lea edi,[DATA+64+esi]
|
||||
jmp rec_common
|
||||
record_all:
|
||||
push esi
|
||||
push edi
|
||||
push ebx
|
||||
mov esi,[DATA+44]
|
||||
and esi,63
|
||||
shl esi,5
|
||||
lea edi,[DATA+1408+esi]
|
||||
inc D(44)
|
||||
dec D(40) ; compensated below
|
||||
rec_common:
|
||||
inc D(40)
|
||||
mov [edi],eax
|
||||
mov [edi+4],ecx
|
||||
mov [edi+8],edx
|
||||
mov ebx,[DATA+60]
|
||||
mov [edi+12],ebx
|
||||
mov ebx,[eax+0x18]
|
||||
mov [edi+16],ebx
|
||||
mov dword [edi+20],0
|
||||
mov dword [edi+24],0
|
||||
mov dword [edi+28],0
|
||||
test ecx,ecx
|
||||
jz .np
|
||||
mov ebx,[ecx+0x18]
|
||||
mov [edi+20],ebx
|
||||
mov ebx,[ecx+0xf4]
|
||||
mov [edi+24],ebx
|
||||
mov ebx,[ecx+0xec]
|
||||
mov [edi+28],ebx
|
||||
.np:
|
||||
pop ebx
|
||||
pop edi
|
||||
pop esi
|
||||
ret
|
||||
|
||||
free_hook:
|
||||
pushad
|
||||
inc D(28)
|
||||
mov eax,[esp+36]
|
||||
mov [DATA+52],eax
|
||||
mov esi,[DATA+28]
|
||||
and esi,63
|
||||
shl esi,4
|
||||
mov [DATA+3456+esi],eax
|
||||
mov ebx,[esp+32] ; caller
|
||||
mov [DATA+3456+esi+4],ebx
|
||||
mov ebx,[eax-4] ; size|used
|
||||
mov [DATA+3456+esi+8],ebx
|
||||
mov ebx,[eax-12] ; next
|
||||
mov [DATA+3456+esi+12],ebx
|
||||
call check_heap
|
||||
popad
|
||||
mov eax,[esp+4]
|
||||
push esi
|
||||
jmp 0x41ada5
|
||||
|
||||
alloc_hook:
|
||||
pushad
|
||||
inc D(36)
|
||||
mov eax,[esp+36]
|
||||
mov [DATA+56],eax
|
||||
call check_heap
|
||||
popad
|
||||
mov ecx,[0x58499c]
|
||||
jmp 0x41ae26
|
||||
|
||||
; walks the local heap; on the first inconsistency records it (code in anomaly+0)
|
||||
check_heap:
|
||||
pushad
|
||||
mov esi,[0x58499c]
|
||||
test esi,esi
|
||||
jz .done
|
||||
xor edi,edi
|
||||
mov ecx,70000
|
||||
.loop:
|
||||
mov edx,1
|
||||
cmp [esi+4],edi
|
||||
jne .bad
|
||||
mov edx,2
|
||||
movzx eax,word [esi+10]
|
||||
cmp eax,1
|
||||
ja .bad
|
||||
mov ebx,[esi]
|
||||
test ebx,ebx
|
||||
jz .done
|
||||
mov edx,3
|
||||
movzx eax,word [esi+8]
|
||||
lea eax,[esi+eax*4+12]
|
||||
cmp ebx,eax
|
||||
jne .bad
|
||||
mov edi,esi
|
||||
mov esi,ebx
|
||||
dec ecx
|
||||
jnz .loop
|
||||
.done:
|
||||
popad
|
||||
ret
|
||||
.bad:
|
||||
inc D(32)
|
||||
cmp dword [DATA+32],1
|
||||
jne .done
|
||||
mov [DATA+1088],edx
|
||||
mov [DATA+1092],esi
|
||||
mov [DATA+1096],edi
|
||||
mov eax,[esi]
|
||||
mov [DATA+1100],eax
|
||||
mov eax,[esi+4]
|
||||
mov [DATA+1104],eax
|
||||
mov eax,[esi+8]
|
||||
mov [DATA+1108],eax
|
||||
mov eax,[DATA+28]
|
||||
mov [DATA+1112],eax
|
||||
mov eax,[DATA+36]
|
||||
mov [DATA+1116],eax
|
||||
mov eax,[DATA+40]
|
||||
mov [DATA+1120],eax
|
||||
mov eax,[esp+32] ; return address of check_heap caller
|
||||
mov [DATA+1124],eax
|
||||
mov eax,[DATA+52]
|
||||
mov [DATA+1128],eax
|
||||
mov eax,[DATA+56]
|
||||
mov [DATA+1132],eax
|
||||
; dump 256 bytes from edi (previous header) if set, else esi-128
|
||||
test edi,edi
|
||||
jnz .src
|
||||
lea edi,[esi-128]
|
||||
.src:
|
||||
mov esi,edi
|
||||
lea edi,[DATA+1152]
|
||||
mov ecx,64
|
||||
rep movsd
|
||||
jmp .done
|
||||
|
||||
; stale_info: ecx parent, [DATA+60] idx -> DATA+4480 + (n&31)*16: target, block hdr, size|used, value before
|
||||
stale_info:
|
||||
pushad
|
||||
test ecx,ecx
|
||||
jz .out
|
||||
mov edi,[ecx+0xf4]
|
||||
mov eax,[DATA+60]
|
||||
lea edi,[edi+eax*4]
|
||||
mov ebx,[DATA+40]
|
||||
dec ebx
|
||||
and ebx,31
|
||||
shl ebx,4
|
||||
lea ebx,[DATA+4480+ebx]
|
||||
mov [ebx],edi
|
||||
mov eax,[edi]
|
||||
mov [ebx+12],eax
|
||||
mov dword [ebx+4],0
|
||||
mov dword [ebx+8],0
|
||||
mov esi,[0x58499c]
|
||||
mov ecx,70000
|
||||
.l:
|
||||
test esi,esi
|
||||
jz .out
|
||||
mov edx,[esi]
|
||||
test edx,edx
|
||||
jnz .h
|
||||
movzx eax,word [esi+8]
|
||||
lea edx,[esi+eax*4+12]
|
||||
.h:
|
||||
cmp edi,esi
|
||||
jb .next
|
||||
cmp edi,edx
|
||||
jae .next
|
||||
mov [ebx+4],esi
|
||||
mov eax,[esi+8]
|
||||
mov [ebx+8],eax
|
||||
jmp .out
|
||||
.next:
|
||||
mov esi,[esi]
|
||||
dec ecx
|
||||
jnz .l
|
||||
.out:
|
||||
popad
|
||||
ret
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""rd.py SESSION : attach briefly with winedbg, dump diag data, print summary"""
|
||||
import os, re, subprocess, sys, struct
|
||||
S = sys.argv[1]; DATA = 0x70f010; N = 4992
|
||||
env = dict(os.environ, WINEPREFIX='/home/elias/.claude/jobs/90c6eed7/tmp/%s/data/wine' % S, WINEDEBUG='-all')
|
||||
def dbg(cmds):
|
||||
out = subprocess.run(['winedbg'], input='\n'.join(cmds) + '\ndetach\nquit\n', env=env, capture_output=True, text=True, timeout=60).stdout
|
||||
return out
|
||||
pl = dbg(['info proc'])
|
||||
m = re.search(r'([0-9a-f]{8})\s+\d+\s+\\_ \'groove.exe\'', pl)
|
||||
if not m: print('no groove.exe'); sys.exit(1)
|
||||
pid = m.group(1)
|
||||
out = dbg(['attach 0x' + pid, 'x/%dx 0x%x' % (N // 4, DATA), 'x/16x 0x58499c'])
|
||||
words = {}
|
||||
for l in out.splitlines():
|
||||
mm = re.match(r'.*?0x([0-9a-f]{8}):\s+((?:[0-9a-f]{8}\s*)+)$', l)
|
||||
if mm:
|
||||
a = int(mm.group(1), 16)
|
||||
for i, w in enumerate(mm.group(2).split()): words[a + 4 * i] = int(w, 16)
|
||||
def W(o): return words.get(DATA + o, 0)
|
||||
names = set()
|
||||
for base, cnt in ((64, 32), (1408, 64)):
|
||||
for i in range(cnt):
|
||||
e = DATA + base + 32 * i
|
||||
for k in (16, 20):
|
||||
if words.get(e + k): names.add(words[e + k])
|
||||
nm = {}
|
||||
if names:
|
||||
o2 = dbg(['attach 0x' + pid] + ['x/s 0x%x' % a for a in sorted(names)])
|
||||
ss = [l for l in o2.splitlines() if l.startswith('Wine-dbg>') and '"' not in l or True]
|
||||
segs = o2.split('Wine-dbg>')
|
||||
for i, a in enumerate(sorted(names)):
|
||||
nm[a] = segs[2 + i].strip() if 2 + i < len(segs) else hex(a)
|
||||
print('parent_ok=%d stale_addr=%d stale_pop=%d free=%d alloc=%d anomalies=%d staleidx=%d allidx=%d' % (W(16), W(20), W(24), W(28), W(36), W(32), W(40), W(44)))
|
||||
def ring(base, cnt, idx, label, last=12):
|
||||
print('--', label)
|
||||
n = min(idx, cnt)
|
||||
for j in range(max(0, idx - last), idx):
|
||||
e = base + 32 * (j % cnt)
|
||||
s, p, k, vi, sn, pn, pv, pec = (W(e + 4 * q) for q in range(8))
|
||||
print(' #%d kind=%x strat=%x(%s) parent=%x(%s) v%d parentvars=%x parent_ec=%x target=%x' % (j, k, s, nm.get(sn, '-'), p, nm.get(pn, '-'), vi, pv, pec, pv + 4 * vi))
|
||||
ring(64, 32, W(40), 'stale', 32)
|
||||
for j in range(max(0, W(40) - 32), W(40)):
|
||||
e = 4480 + 16 * (j % 32); t, h, su, v = (W(e + 4 * q) for q in range(4))
|
||||
print(' stale#%d target=%x block=%x off=+0x%x %s size=%d value_before=%x%s' % (j, t, h, t - h if h else 0, 'USED' if su >> 16 else 'FREE', su & 0xffff, v, ' <<< HEADER HIT' if h and t - h < 12 else ''))
|
||||
ring(1408, 64, W(44), 'all parent accesses (last 16)', 16)
|
||||
if W(32):
|
||||
print('ANOMALY code=%d hdr=%x prevhdr=%x hdr.next=%x hdr.prev=%x sizeused=%x frees=%d allocs=%d staleidx=%d caller=%x lastfree=%x lastalloc=%d' % tuple(W(1088 + 4 * q) for q in range(12)))
|
||||
d = [W(1152 + 4 * q) for q in range(64)]
|
||||
for q in range(0, 64, 8): print(' ' + ' '.join('%08x' % x for x in d[q:q + 8]))
|
||||
fh = [(W(3456 + 16 * q), W(3456 + 16 * q + 4), W(3456 + 16 * q + 8), W(3456 + 16 * q + 12)) for q in range(64)]
|
||||
print('heapbase', hex(words.get(0x58499c, 0)))
|
||||
if '-f' in sys.argv:
|
||||
for q in range(64): print(' free ptr=%x caller=%x sizeused=%x next=%x' % fh[q])
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""wadfix.py IN OUT: RollingBoulderMountain never ends (its final 'wait until v13 == 0' compares with 0.5)."""
|
||||
import struct, sys
|
||||
d = bytearray(open(sys.argv[1], 'rb').read())
|
||||
pat = struct.pack('<6I', 0x1002f, 0xd0004, 0x44, 0x4e, 0xfffc0012, 0xb4) # freezeOffCamera 1; v13; pushShort 0; eq; jzYield -4; end
|
||||
i = d.find(pat)
|
||||
assert i >= 0 and d.find(pat, i + 1) < 0, 'pattern not unique'
|
||||
struct.pack_into('<I', d, i + 8, 0x08000044) # pushShort 0.5
|
||||
open(sys.argv[2], 'wb').write(d)
|
||||
print('patched at 0x%x' % (i + 8))
|
||||
@@ -88,7 +88,97 @@ and are set on load.
|
||||
* normals ×127 (code multiplies by 32 → 4096 scale), d ×4096 (fixed point) → local coordinates only ±8 units!
|
||||
* type bit0 = quad (4 edges), otherwise triangle (3 edges, 5th plane = 0). Bit2 ≈ normal points down (ny<0; ceilings/overhangs) [likely].
|
||||
* material: ground type (0 = normal; 0x11/0x12 are ignored depending on WFPC 0x8000000, 0x1e = water/death plane [likely]; the value ends up in the player's ground state).
|
||||
* Collision queries: `FUN_004036d0` (floor/ceiling), `FUN_00403ad0` (walls), `FUN_004042f0`, `FUN_004046e0`. Only **the tiles of the grid cell the player stands in** are tested (cell list from MAP), point relative to the tile's B position, rotated by −rot.
|
||||
* Collision queries: `FUN_004036d0` (floor/ceiling against the map tiles), `FUN_00403ad0` (the same floor test against strat objects with a collision model, not walls – see the floor test section below), `FUN_004042f0`, `FUN_004046e0`. Only **the tiles of the grid cell the player stands in** are tested (cell list from MAP), point relative to the tile's B position, rotated by −rot.
|
||||
|
||||
### Floor test: inclusion rule and the floating-next-to-the-log case
|
||||
|
||||

|
||||
|
||||
The crate has two such needles, one per long side: poly 15 (z 43.68–43.73, measured live) and poly 11 (z 43.29–43.38, same shape, where a user got stuck). Drawn with `tools/floorre/explain_t1l4m002.py` (builds on the floor-test agent's plot).
|
||||
(2026-11-08, from the decompile + objdump of 0x4036d0–0x403ad0, a bit-exact Python re-implementation
|
||||
`tools/floortest.py`, and live reads in game. [certain] unless marked.)
|
||||
|
||||
**Who calls what.** Per strat and frame `FUN_00403020(strat, models=DAT_005846ec, world=DAT_00584648)`:
|
||||
`FUN_004036d0` = floor test against the **map tiles of the strat's cell**; `FUN_00403ad0` = the same test
|
||||
against **strat objects with a collision model** (strat+0xec bit 0x20, within the box radius strat+0x13c;
|
||||
result index = (strat − DAT_006d9dc0)/0x154), skipped when strat+0xe8 bit 0x400. Neither is a wall test.
|
||||
The map result wins unless the object floor is higher. Result → floor record *(strat+0xc0):
|
||||
+0 height, +4 material (record byte 1), +6 flags (bit0 map floor, bit1 object floor), +0xc tile, +0xe polygon.
|
||||
Material 0x1e → treated as "no floor" (height 0xffff0000).
|
||||
|
||||
**FUN_004036d0, per tile of the cell, per collision record** (all ints, 20.12):
|
||||
1. Probe point, tile-local: `lx,lz` = (strat x/z − tile B x/z) rotated by −ry with the static sine table
|
||||
`DAT_00574318` (4096 entries, cos = sin[i+0x400]), `ly = strat y − tile y + DAT_0057dd98`;
|
||||
DAT_0057dd98 = **0x400 (0.25)**, or floor-record+0x18 / 2 for strats with flag 0x200000.
|
||||
2. Skip material 0x11 (0x12 instead when strat+0xe8 bit 0x8000000). Skip ny = 0; skip ny < 0 unless flag 0x200000.
|
||||
3. Height of the probe above the surface plane, measured **vertically**:
|
||||
`i6 = (d0·4096 − nz0·32·lz − nx0·32·lx − ny0·32·ly) / (ny0·32)` (C division), `dist = −i6`.
|
||||
The plane point under/over the probe is `Y = ly + i6` (plane equation solved for y at (lx,lz)).
|
||||
4. `dist < −0x80` (plane more than 1/32 above the probe) → ceiling/"surface above" branch (below).
|
||||
Else, if `dist < best` (strictly; initial 1000000) and ny > 0 → edge test.
|
||||
5. **Edge test** (3 edges, 4 if type bit0), on the point (lx, Y, lz) *on the plane*:
|
||||
`e_k = ((nyk·32·Y + nzk·32·lz + nxk·32·lx) >> 12) − dk`; **fail if e_k < tol** with
|
||||
`tol = −0x40 − ((0x1000 − ny0·32) >> 6)` — i.e. 64 + (4096−4064·ny)/64 units of 1/4064:
|
||||
**≈ 0.016 for flat floors, 0.023 at ny 0.5** (perpendicular distance outside the edge plane).
|
||||
No radius, no vertex test, no other use of the record (bytes 5, 11, … are padding; for triangles the
|
||||
5th plane is never read). The point must be inside (within tol) **every** edge plane.
|
||||
6. All edges pass → `best = dist`, height = tile y + Y, tile, polygon index (counted over groups A+B+C).
|
||||
**Selection rule: the passing plane with the smallest dist, i.e. the highest surface not more than
|
||||
0.25 + 1/32 above the feet.** Consequence: a passing surface up to 0.28 above the feet wins over the
|
||||
ground under the llama, and the llama is put onto it (verified, step 3 below).
|
||||
7. Ceiling branch (dist < −0x80): passing up-facing planes are kept as "lowest surface above"
|
||||
(local_70/local_5c). `FUN_00403020` uses that as the floor when no floor below was found
|
||||
(unless strat+0xe8 & 0x40200) – from the decompile only, not observed.
|
||||
|
||||
**Why polygon edges reach out: the tolerance turns sharp corners into long spikes.**
|
||||
Every edge is moved outwards by `tol`. At a corner with interior angle θ the accepted area therefore
|
||||
sticks out by ≈ tol / sin(θ/2): 0.023 at a right angle (every normal log/crate edge, all quads of the
|
||||
path), but ~0.19 at a 13.5° corner.
|
||||
|
||||
**The case (t1l4m002, world 4 chapter 2).** Cell 57,43 contains only tile 443 = model 129 (the
|
||||
wooden crate/log with its piece of path) at B (57.5, 2.0, 43.5), ry 3072. The transform was checked
|
||||
three ways: render faces (with z mirrored, see §2) and collision of model 129 line up (crate box on the
|
||||
crate box); the bit-exact engine re-implementation gives exactly the live floor record; the debug-wireframe
|
||||
screenshot shows the crate's cut end right next to the llama. Collision polygon 15 (type 0 = triangle, mat 0,
|
||||
plane (108,65,10)/127 → ny 0.51, a steep bevel strip along the crate's side) is a **needle triangle**:
|
||||
corners (world x/y/z) (57.725, 2.553, 43.734), (57.750, 2.651, 43.677), (57.315, 2.497, 43.729); the corner at
|
||||
57.315 has **13.5°** and points away from the crate, exactly along the crate's side face (x ≈ 57.31).
|
||||
Its tolerance zone is a 0.01–0.02 wide sliver that continues ~0.19 beyond the tip, over the path
|
||||
(pink in `img/floor_needle_t1l4m002.png`). Kuzco's position 57.204 / 43.733:
|
||||
* local (0.2329, ·, 0.2959); poly 15 edges e = (−37, 2005, −72) ≥ tol −95 → **passes**; Y = 2.4744 (the
|
||||
steep plane extended 0.11 beyond its tip), dist 0.2498.
|
||||
* poly 2 (path, ny 0.99) also passes, e = (525, 304, 850, 1101), height 2.270, dist 0.454 → loses
|
||||
because 2.474 is higher and still within 0.28 of the feet.
|
||||
* The sliver at x = 57.204 is only z 43.7300–43.7402 wide; x reaches down to ≈57.12.
|
||||
* Why exactly x = 57.204 [verified, mechanism not decoded]: putting the llama into the crate
|
||||
(x 57.25, z 43.60 / 43.70 / 43.733) the game pushes it back to x = 57.202–57.204 (wall push-out from the
|
||||
crate side at ~0.11 distance). So walking along/against the crate's side face puts the llama exactly on
|
||||
the line the needle's tolerance sliver extends to; only the ~1 cm band at the corner's z catches it.
|
||||
|
||||
**In game** (apptest :25, boot-to-level start "57.204, 2.474, 43.733, 0 @ 4/2", winedbg --gdb read/write of
|
||||
strat +0x30/+0x50 and the floor record):
|
||||
|
||||
| written pos (x y z) | game afterwards: pos / floor h / poly | re-implementation |
|
||||
|---|---|---|
|
||||
| boot start 57.204 2.474 43.733 | 57.204/2.474/43.733, h 2.474, mat 0, tile 443 **poly 15** | poly 15, 2.4744 |
|
||||
| 57.204 **2.27** 43.733 (on the path) | snapped **up** to y 2.474, poly 15 | poly 15, 2.4744 |
|
||||
| 57.204 2.27 43.736 | y 2.469, poly 15 | poly 15, 2.4690 |
|
||||
| 57.204 2.27 43.745 | y 2.270, poly 2 | poly 2, 2.2700 |
|
||||
| 57.15 2.27 43.733 | y 2.266, poly 2 | poly 2, 2.2661 |
|
||||
| 57.25 2.3 43.70 | pushed to x 57.204, y 2.270, poly 2 | – |
|
||||
|
||||
Screenshots: `img/floor_float_t1l4m002.jpg` (left: boot at the floating spot, poly 15; right: 0.012 further
|
||||
in z on the path, poly 2; debug wireframe on), `img/floor_float_t1l4m002_full.jpg`.
|
||||
After teleporting onto poly 15 with the debugger the llama sometimes slid off within seconds (steep
|
||||
plane, ny 0.51); started there by boot-to-level it stays [observed, not analysed].
|
||||
|
||||
**How common.** Scan of all 31 t1 level WADs (sample points 0.3/0.6/0.9 of the way from each corner to its
|
||||
tolerance tip, exact engine test over all tiles of that cell, counted when this polygon wins, the point
|
||||
is ≥ 0.08 outside the real polygon and the next lower floor is > 0.05 below): 2–526 polygons per level,
|
||||
t1l4m002: 110 (of 6468 floor-polygon instances), poly 443/15 among them (predicted spot 57.199/2.469/43.735).
|
||||
Most are sharp triangle tips of terrain/rock meshes at ledges; normal right-angled log/crate edges
|
||||
reach only 0.023 and never show it. [Counts are a heuristic scan; reachability of the spots not checked.]
|
||||
Scripts: `tools/floortest.py` (single position); scan code: `$CLAUDE_JOB_DIR/tmp/floorre/exposed.py` + `reach.py` (scratch, not in the repo).
|
||||
|
||||
### Face flags (u16)
|
||||
| Bit | Meaning | Confidence |
|
||||
@@ -217,3 +307,6 @@ Implemented in `tools/geom_import.py` (OBJ → TRAK + MAP tile part, rest of the
|
||||
* `geom_import.py BASE.wad MESH.obj NEW.wad [--coll-obj C.obj] [--keep-tiles] [--zones flat|keep] [--default-tex N] [--export-space]`
|
||||
* `geom_demo.py BASE.wad demo.obj [--plaza]` – example geometry made of texture-tile quads.
|
||||
* `geomlib.py` – parser/builder (TRAK, STPC models, MAP tile part, TEXT table/pages, collision).
|
||||
* `floortest.py WAD X Y Z` – bit-exact re-implementation of the engine's map floor test (FUN_004036d0) at a world
|
||||
position: every candidate polygon of the cell, its edge values/tolerance, and the chosen floor. Note: the engine's
|
||||
plane scale is n/127 with d/4064 (normal×32 vs d×4096), `geomlib.coll_decode` uses d/4096 (0.8 % off).
|
||||
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 92 KiB |
@@ -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.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Developer mode (`devmode`, CHEATS)
|
||||
|
||||
`mods/devmode/DevModeMod` + `DevModePatch` – two groove.exe patches, no data changes. Background and
|
||||
the full list of what the switch unlocks: `mod-cheats.md`, "Developer switch (property 258)".
|
||||
|
||||
## Options
|
||||
|
||||
| Key | Default | padAux bit | Effect |
|
||||
|---|---|---|---|
|
||||
| `flyKey` | F1 | 0x800 held | Llama (also from standing), LlamaChase, bunny rise while held; frog after a jump |
|
||||
| `heartKey` | F2 | 0x400 (pressed edge) | +1 heart, +5 purple fruit |
|
||||
| `idolKey` | F3 | 0x200 held | pink idols +1 per frame, max 99 |
|
||||
| `coinKey` | F11 | 0x100 held | coins +1 per frame |
|
||||
| `flyToggle` | off | – | fly key toggles bit 0x800 instead of hold |
|
||||
|
||||
Keys are `Hotkeys.option` choices (cross-mod conflict check); defaults avoid practice F5/F8, freecam
|
||||
F7/F9 and the game's F4/F6/F10. The developer switch itself has no option (always on with the mod).
|
||||
Two equal keys → apply fails with a message.
|
||||
|
||||
## Patches (German / English addresses, all via `Site`)
|
||||
|
||||
1. **Property 258 = 1.0.** `PROP_GET` (0x550E60 / 0x550B80) is the strat property getter; its
|
||||
`jmp [eax*4 + table]` has the table address at +21 (0x552684 / 0x552390, in .text). Entry
|
||||
0x102 (0x552A8C / 0x552798) pointed at the shared `push 0` handler (0x55231B / 0x55198F; the
|
||||
patch checks its bytes) and now points at a cave: `mov eax,[esp+14h]; push 0x1000; push eax;
|
||||
call <push result>; add esp,8; add esp,10h; ret`. The other ids of the shared handler
|
||||
(de: 0x75, 0x9E, 0xC1, 0xEE; en: 0xC1, 0xEE) stay 0. The table entry has a .reloc entry; the
|
||||
exe always loads at its image base, so the absolute cave address is fine.
|
||||
2. **padAux from keys.** `PAD_POLL_END` (0x425B88 / 0x425A08, tail of the pad poll FUN_004256c0:
|
||||
`mov eax,[padCount]; pop ebx; cmp eax,1; jbe; mov [padAux],0; add esp,8; ret`, 25 bytes) →
|
||||
`jmp` cave + NOPs. Cave: `pop ebx`, ecx = OR of the bits whose DIK slot in `KEY_STATE`
|
||||
(0x58226C / 0x5818FC, s32[256] frames held, all 0 while Alt is held) is > 0, 0 while `DEMO`;
|
||||
`mov [PAD_AUX],ecx` (0x5FCF58 / 0x5FC5E8); `mov eax,[padCount]` (keeps the old return value);
|
||||
`add esp,8; ret`. The edge detection padAuxPressed (FUN_00425c00, start of each tick) is
|
||||
untouched and works as before. Toggle mode: 8 data bytes (previous key state, on flag).
|
||||
|
||||
Hotkey names → DirectInput scan codes in `DevModePatch.DIK` (test: covers every `Hotkeys.VK` key).
|
||||
|
||||
## Verified in game (apptest `devmode-a`, :25, German exe, village 1-1, defaults)
|
||||
|
||||
* A+S three times: three different warp points (`img/mods/devmode_teleport.jpg`).
|
||||
* F2 twice: fruit 0 → 5 → 10, energy bar refilled.
|
||||
* F1 held 2.5 s: the llama rises above the village, falls back after release.
|
||||
* F3 held 1.5 s: idols 46; F11 held 1 s: coins 1 → 32 (`img/mods/devmode_keys.jpg`).
|
||||
* No crash; game starts normally with the mod (one crash in a Wine DLL at 68E9306B on the very
|
||||
first start of a fresh session prefix, not reproducible in 3 later starts with the mod).
|
||||
|
||||
Not checked in game: English exe (unit tests only), toggle mode, river log Space, 8-6 Space+Z,
|
||||
frog/bunny, LlamaChase.
|
||||
|
||||
## Limits
|
||||
|
||||
* Level-skip/music debug menu needs property 0 ≠ 0 as well (also hard-wired 0, used by 131 strat
|
||||
reads incl. cameras) – deliberately not touched.
|
||||
* Toggle state survives level changes (flying continues in the next level until pressed again).
|
||||
* With the switch on, CheatTeleport stays alive in 22 levels; A+S (camera left+right together) is
|
||||
the warp combo, so pressing both camera keys at once now teleports.
|
||||
@@ -0,0 +1,150 @@
|
||||
# "Mountain 3 crash" (speedrun community) – investigation 2026-11-08
|
||||
|
||||
Short version: **Mountain 3 = `t1l5m003`** (world 5, chapter 3). Its boulder spawner
|
||||
(`RollingBoulderMountain`) can end while a boulder it spawned is still alive. When that boulder
|
||||
finishes its path, it decrements `parent.v13` of the dead spawner. That write
|
||||
(`-= 1.0`, i.e. `-0x1000`) goes into freed or reused memory (use after free). If it hits a header of
|
||||
the game's local heap, a later `FreeLocalMem` crashes. That matches the community crash dump.
|
||||
The stale write is **verified in game**. A full crash was **not** reproduced. A one-word level-data
|
||||
patch removes the stale write (verified in game).
|
||||
|
||||
## Evidence from the community
|
||||
|
||||
* DebugDiag dump (English exe, 2000-10-17): access violation **writing** `0x0000340d` at
|
||||
`groove+0x1ad52` = VA `0x41AD52`, thread 0, Argonaut name `FreeLocalMem`.
|
||||
* English `0x41AD10..0x41AD89` is byte-identical to German **`FUN_0041ada0`** (only the rel32 of
|
||||
the "ITS NOT USED! %08x" debug printf differs). Crash instruction = German `0x41ADE2`
|
||||
`mov [eax+4], esi`.
|
||||
* The local heap (`FUN_0041af00` init, `FUN_0041ae20` alloc, `FUN_0041ada0` free) holds blocks with a
|
||||
12-byte header `{next, prev, u16 size (dwords), u16 used}`. The blocks are contiguous
|
||||
(`next == hdr + (size+3)*4`). On free, the routine merges with the **next** block if
|
||||
`next.used == 0`, then does `next->next->prev = hdr`. The crash means the header after the
|
||||
freed block had `used == 0` and `next == 0x3409`. 0x3409 is not 4-aligned, so it can't be a real
|
||||
block pointer. **The heap metadata was corrupted earlier, and the free only shows it.**
|
||||
* Clips: index.mp4 is in a snowy canyon with a pushable boulder (red X), wall shards
|
||||
(`SmashWallShrapnel`), a ruby-idol pickup (HUD), and a crash at the cave about 6 s later.
|
||||
index2.mp4 is the Kronk ice race (`KronkOnIceDoor`/`KronkOnIce`, same WAD): black screen about
|
||||
2 s after the race starts. Both are in `t1l5m003`. Our boot to (74.6, 14.8, 6.3) shows the same canyon
|
||||
and boulder as clip 1, so clip 1 is most likely the `#47` push-boulder section (hypothesis,
|
||||
based on what the scenes look like).
|
||||
* Side note: the heap is `operator_new(0x200000)` but is initialised with size `0x800000` into a u16
|
||||
field, so it is really 0xFFFD dwords (≈256 KB). Mountain 3 uses about 88 KB of it, so the heap
|
||||
is not exhausted. No leak was seen: block count stayed stable over minutes.
|
||||
|
||||
## The bug (strat code of unit 1 of t1l5m003, verified by disassembly)
|
||||
|
||||
`RollingBoulderMountain` (6 MAP instances, params v0..v9). `v13` is set to 0 and never
|
||||
incremented anywhere. The only other access is the boulder's `parent.v13 -= 1.0`
|
||||
(`pushParentVarAddr v13; dec`) in `L_0574`, which runs when the boulder ends its path or falls:
|
||||
|
||||
```
|
||||
spawner: ... spawn loop (L_0c24: spawn<7,31,4,0x14c000,0>(…RollingBoulderForMountain)) ...
|
||||
freezeOffCamera 1
|
||||
L_0d90: pushVar v13 / pushShort 0 / eq / jzYield L_0d90 / end ; ends at once when v13 == 0
|
||||
boulder: L_0574: parent.v13 -= 1.0 ; ... ; remove
|
||||
```
|
||||
|
||||
* With `v1 == 0` (instances #17 and #35) or `v4 != 0` (#22 and #47, the pushable "red X" boulders,
|
||||
which wait to be charged), the spawner spawns **one** boulder and **ends in the same frame**
|
||||
(v13 is still 0).
|
||||
* Instances #1 and #27 (`v1` = 8.3 / 8.75) loop: a boulder about every 8–10 s, until the player
|
||||
is near the path start. They end the same way if no boulder has died yet when the loop exits.
|
||||
* Strat removal (strat update loop `FUN_0054c4a0`) frees the var block
|
||||
(`+0xF4`) and the collision block (`+0xC0`) and puts the struct on the free list (LIFO). It does
|
||||
**not** clear the children's `+0xC` parent pointers. The parent opcodes (`0x553750`/`0x553770`/
|
||||
`0x553a40`) have no check.
|
||||
* So the boulder's write goes to `deadSpawner.vars + 0x34`: either memory of the freed var block
|
||||
(now part of a free block or of another allocation, possibly a **block header**), or, if the
|
||||
struct was already reused, `v13` of another strat. For a boulder, `v13` is its sound address
|
||||
(`v13 = sound:4`, used by `playSoundChannel`).
|
||||
* "Predictable intervals" (runner's guess) fits: the stale writes happen a fixed time after you
|
||||
pass a spawner, or after you push a boulder, when that boulder reaches the end of its path.
|
||||
Whether a write hurts depends on the heap layout at that moment, so it depends on timing.
|
||||
It is not deterministic across runs.
|
||||
|
||||
## In-game verification (apptest, Xvfb :26, sessions `mtn3-*`)
|
||||
|
||||
Tooling (throwaway, see `notes/dev/mountain3-diag/`): a test-only mod `Mtn3DiagMod` (loaded through
|
||||
`APPTEST_CP` + `-Dllamamod.extraMods`). It installs a code cave (`cave.asm`, nasm) that replaces the
|
||||
`pushParentVarAddr` and `popParentVar` handlers. They log every access, flag **stale** ones
|
||||
(parent `+0xEC == 0`, i.e. removed) together with the block that the target address falls in, and
|
||||
walk the whole heap on every alloc and free (`next/prev/used` consistency).
|
||||
`rd.py` reads the log by attaching `winedbg` for a moment (stdin script `attach`/`x`/`detach`;
|
||||
works under ptrace_scope 1). `apptest.py` in the agent copy got an env switch
|
||||
`APPTEST_IGNORE_WINEDBG` so the short attach is not taken for a crash (not copied back).
|
||||
A gdb-over-winedbg monitor with breakpoints was too slow: the game froze.
|
||||
|
||||
Boot-to-level `start=29.5, 14, 23.2, 180 @ 5/3` (next to spawners #1 and #17), no input, 70–85 s:
|
||||
|
||||
| | runs | runs with a stale write | heap anomalies | crash |
|
||||
|---|---|---|---|---|
|
||||
| original WAD | 6 (1 manual + 5) | 5 | 0 | 0 |
|
||||
| level fix (below) | 3 | 0 | 0 | 0 |
|
||||
|
||||
Every stale record was the same and deterministic for this start (logged by the cave):
|
||||
`RollingBoulderForMountain` → parent `RollingBoulderMountain` (struct `+0xEC == 0`, removed), `v13`,
|
||||
target = `freed var block + 0x34`. At the time of the write this address was inside a **used
|
||||
16-dword block** (a `0x40000` collision block, most likely the next boulder's), at offset +0x40 from its
|
||||
header, which is 12 bytes before the next block header. In this layout it corrupts collision data
|
||||
(u16 fields `+0x34/+0x36` become 0xF000/0xFFFF), not the heap. Any other layout (other timing,
|
||||
other route, other spawns in between, e.g. an 11-dword allocation such as a `0x28000` pickup's
|
||||
collision block puts a remainder header's size/used word exactly there) turns the same write into
|
||||
header corruption. **Not verified:** a crash caused by this write. No natural run crashed, and the
|
||||
real runner route (from the chapter start at full speed, pushing #22/#47, then the race) could not
|
||||
be driven reliably by key scripts (the push needs speed and alignment; several attempts missed).
|
||||
Also seen in game: strat structs are recycled LIFO (the dead spawner's struct was later reused by a
|
||||
boulder).
|
||||
|
||||
Other checks in this WAD: every `collisionOn` user is spawned or placed with collision type flags
|
||||
(no `typeFlags 0 + collisionOn` case like in the arena), and all `extra` operands are 0. Other
|
||||
parent-variable writers (`KronkOnIcePickup` → `parent.v7`, `CheckPointHead`, `RubyIdolStatue`,
|
||||
`AxeGuardAxe`) have parents that do not end while they live, as far as the code shows. That was
|
||||
checked by reading the code only; the cave saw no stale access from them.
|
||||
|
||||
Side finding (not part of the crash): boot-to-level `start=28.5, 10.5, 22.5, 0 @ 5/3` passes the
|
||||
floor check, but the game crashes while loading (`page fault read 0x00000002 at 0x405527`, floor/
|
||||
tile lookup in `FUN_004054c0`). Out of scope here.
|
||||
|
||||
## Fix / avoidance
|
||||
|
||||
* **Level data (tested):** in `RollingBoulderMountain`, change `pushShort 0` before `eq` to
|
||||
`pushShort 0.5` (word `0x00000044` → `0x08000044`, the only match of the 6-word pattern
|
||||
`0x1002f 0xd0004 0x44 0x4e 0xfffc0012 0xb4`, file offset 0x4a0a27 in the German
|
||||
`t1l5m003.wad`). `v13` only takes whole numbers (0, -1, -2 …), so the spawner never ends. It
|
||||
stays frozen off camera and costs nothing, and every boulder writes into a live spawner. The
|
||||
boulders still roll (in-game: 6–7 normal parent writes per run, 0 stale). `notes/dev/mountain3-diag/wadfix.py IN OUT`.
|
||||
The English CD's WAD is not checked; the patch looks for the pattern and refuses if it isn't unique.
|
||||
* **Exe (general, untested):** let removal (`+0xE8 & 4` path) clear `+0xC` of all strats whose
|
||||
parent is the removed one (pool `DAT_006d9dc0`, stride 0x154), and let the parent opcodes
|
||||
redirect a NULL or removed parent to a dummy dword. This also covers similar bugs in other levels.
|
||||
* **Runners (hypothesis):** there's no reliable avoidance. The write happens when the boulders of
|
||||
#17 and #35 (spawned when you pass their trigger spots) and the pushed boulders #22 and #47 reach
|
||||
the end of their path. Not pushing #22/#47 removes two of the four guaranteed stale writes, if
|
||||
the route allows that.
|
||||
|
||||
Diag tooling notes: the files in `notes/dev/mountain3-diag/` are as used, with absolute paths
|
||||
of the agent job dir. The mod writes `addresses.txt` and expects `cave_<fix>_<CAVE>_<DATA>.bin`
|
||||
(`nasm -f bin -DCAVE=0x6ef0e0 -DDATA=0x70f010 -DFIX=0 cave.asm`; these addresses are valid with
|
||||
boot-to-level + mtn3-diag on the German exe). This is only for the German exe; nothing is registered in the app.
|
||||
|
||||
## Discord answer (draft)
|
||||
|
||||
> We looked into the Mountain 3 crash. Your dump crashes in the game's own memory manager
|
||||
> (`FreeLocalMem`, writing to 0x340d). That is the symptom: its bookkeeping was overwritten
|
||||
> earlier. **Verified:** in Mountain 3 the rolling-boulder spawner can delete itself while
|
||||
> its boulder is still rolling. When that boulder reaches the end of its path (or falls off), it
|
||||
> writes into the spawner's memory, which has already been freed and reused. We saw this
|
||||
> write in the game with a debug build, and a one-value level patch makes it go away.
|
||||
> **Not verified yet:** that this exact write causes your crash. We never got the game to
|
||||
> crash. Where the write lands depends on what else is in memory at that moment, so it
|
||||
> breaks things only sometimes, and the crash can come seconds later somewhere else
|
||||
> (e.g. in the Kronk race). That would explain why it's Mountain 3 only, at different spots,
|
||||
> without an error message, and "every so often" around boulders. The likely triggers are the
|
||||
> one-shot boulders near the start/top of the slope and the two red-X push boulders. We'll
|
||||
> probably ship the fix in LlamaMod.
|
||||
|
||||
## In LlamaMod
|
||||
|
||||
The fix is the mod `mountain3-fix` (`mods/fixes/Mountain3FixMod`, Gameplay): it finds the unique pattern in
|
||||
`t1l5m003.wad` and replaces `pushShort 0` with `pushShort 0.5`; if the pattern is missing (other game
|
||||
version, file already changed) it leaves the file unchanged and says so in the apply log.
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import sys,struct,numpy as np; sys.path.insert(0,'.'); from floor import *
|
||||
t=443; x,y,z,rot,mi,_=mp['A'][t]; recs=[r for g in trak[mi].coll for r in g]
|
||||
def planes(r):
|
||||
return [struct.unpack_from('<bbbBh',r,2+6*k) for k in range(5)]
|
||||
def corners(r):
|
||||
P=planes(r); ne=4 if r[0]&1 else 3; E=P[1:1+ne]; S=P[0]; pts=[]
|
||||
for i in range(ne):
|
||||
a,b=E[i],E[(i+1)%ne]
|
||||
A=np.array([S[:3],a[:3],b[:3]],float); B=np.array([S[4],a[4],b[4]],float)/32
|
||||
pts.append(np.linalg.solve(A,B)) # n127*32*p = d -> n127.p = d/32 (p in units)...
|
||||
return pts
|
||||
# local (engine) -> world: local=(lz_w', -lx_w') for rot 3072 => dx=-lz, dz=lx
|
||||
a=-rot/4096*2*math.pi
|
||||
def toworld(p):
|
||||
lx,ly,lz=p
|
||||
# invert lx=c*dx+s*dz, lz=c*dz-s*dx with s=sin(a'),c=cos(a'), a'=(-rot)*2pi/4096
|
||||
s,c=math.sin(a),math.cos(a)
|
||||
dx=c*lx-s*lz; dz=s*lx+c*lz
|
||||
return (x+dx,y+ly,-z+dz)
|
||||
for i in [int(v) for v in sys.argv[1:]]:
|
||||
r=recs[i]; P=planes(r)
|
||||
print('poly',i,'type',r[0],'surface n',[v/127 for v in P[0][:3]])
|
||||
for p in corners(r):
|
||||
|
||||
print(' local',np.round(p,4),' world',np.round(toworld(p),4))
|
||||
@@ -0,0 +1,119 @@
|
||||
# Usage: python3 tools/floorre/explain_t1l4m002.py tools/floorre OUT.png (WAD via env WAD, default t1l4m002 path in floor.py; needs Pillow, numpy)
|
||||
# Explainer: Kuzco floating next to the crate in t1l4m002 (builds on floorre/plot.py)
|
||||
import sys, math
|
||||
sys.path.insert(0, sys.argv[1]) # floorre dir (floor.py, corners.py)
|
||||
OUT = sys.argv[2]
|
||||
sys.argv = sys.argv[:1]
|
||||
from floor import * # mp, trak, floor_test
|
||||
from corners import corners
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
SS = 2 # supersampling
|
||||
F = lambda n, b=False: ImageFont.truetype('DejaVuSans-Bold.ttf' if b else 'DejaVuSans.ttf', n * SS)
|
||||
K = (57.204, 2.474, 43.733)
|
||||
T = 443
|
||||
TX, TY, TZ, ROT, MI, _ = mp['A'][T]
|
||||
recs = [r for g in trak[MI].coll for r in g]
|
||||
|
||||
def xf(B):
|
||||
rot = B[1]; tx, ty, tz = B[3] / 4096, B[4] / 4096, B[5] / 4096
|
||||
a = -rot / 4096 * 2 * math.pi; s, c = math.sin(a), math.cos(a)
|
||||
return lambda p: (tx + c * p[0] - s * p[2], ty + p[1], tz + s * p[0] + c * p[2])
|
||||
|
||||
RED, BLUE, MAG, PINK = (215, 25, 25), (20, 90, 230), (210, 0, 210), (255, 70, 150)
|
||||
|
||||
def panel(X0, Z0, span, size, detail):
|
||||
S = size / span
|
||||
img = Image.new('RGB', (size * SS, size * SS), 'white'); d = ImageDraw.Draw(img, 'RGBA')
|
||||
P = lambda x, z: ((x - X0) * S * SS, (size - (z - Z0) * S) * SS)
|
||||
# rendered terrain coloured by height (like the agent's plot)
|
||||
for t in range(mp['N']):
|
||||
B = mp['B'][t]
|
||||
if not (X0 - 2 <= B[3] / 4096 <= X0 + span + 2 and Z0 - 2 <= B[5] / 4096 <= Z0 + span + 2): continue
|
||||
m = trak[mp['midx'][t]]; f = xf(B)
|
||||
for fc in sorted(m.faces, key=lambda fc: sum(m.verts[v][1] for v in fc['v'])):
|
||||
Q = [f((m.verts[v][0], m.verts[v][1], -m.verts[v][2])) for v in fc['v']]
|
||||
h = sum(p[1] for p in Q) / len(Q); g = int(max(0, min(255, (h - 1.5) / 2 * 255)))
|
||||
d.polygon([P(p[0], p[2]) for p in Q], fill=(g, 200 - g // 2, 255 - g, 255),
|
||||
outline=(130, 130, 130) if not detail else None)
|
||||
# cell grid
|
||||
for k in range(int(X0) - 1, int(X0 + span) + 2):
|
||||
d.line([P(k, Z0 - 1), P(k, Z0 + span + 1)], fill=(0, 140, 0), width=SS * (2 if detail else 1))
|
||||
for k in range(int(Z0) - 1, int(Z0 + span) + 2):
|
||||
d.line([P(X0 - 1, k), P(X0 + span + 1, k)], fill=(0, 140, 0), width=SS * (2 if detail else 1))
|
||||
return img, d, P, S
|
||||
|
||||
def collision(d, P, labels, sliver=None, fsz=12):
|
||||
f = xf(mp['B'][T])
|
||||
if sliver:
|
||||
for (x, z, w, i) in sliver:
|
||||
a, b = P(x, z), P(x + w, z - w)
|
||||
d.rectangle([a[0], a[1], b[0], b[1]], fill=PINK + (170,))
|
||||
for i, r in enumerate(recs):
|
||||
C = [f(p) for p in corners(r)]
|
||||
col = RED if i in (11, 15) else BLUE if i == 2 else (20, 20, 20)
|
||||
wid = 3 if i in (2, 11, 15) else 1
|
||||
pts = [P(p[0], p[2]) for p in C]
|
||||
if i in (11, 15): d.polygon(pts, fill=RED + (70,))
|
||||
d.line(pts + pts[:1], fill=col, width=wid * SS)
|
||||
if labels:
|
||||
cx = sum(p[0] for p in pts) / len(pts); cy = sum(p[1] for p in pts) / len(pts)
|
||||
lim = d.im.size
|
||||
if not (10 * SS < cx < lim[0] - 10 * SS and 10 * SS < cy < lim[1] - (80 if labels == 'zoom' else 10) * SS):
|
||||
continue
|
||||
txt = f'poly {i} · mat {r[1]}' if labels == 'zoom' and i in (2, 11, 15) else str(i)
|
||||
d.text((cx, cy), txt, fill=col, font=F(fsz, i in (2, 11, 15)), anchor='mm',
|
||||
stroke_width=SS * 2, stroke_fill='white')
|
||||
|
||||
def sliver_cells(x0, z0, x1, z1, step):
|
||||
out = []; tx, ty, tz = int(round(TX * 4096)), int(round(TY * 4096)), int(round(-TZ * 4096))
|
||||
z = z0
|
||||
while z < z1:
|
||||
x = x0
|
||||
while x < x1:
|
||||
if int(x) == 57 and int(z) == 43:
|
||||
_, res = floor_test(int(round(x * 4096)), int(round(2.27 * 4096)), int(round(z * 4096)), tx, ty, tz, ROT, recs)
|
||||
best = None
|
||||
for i, i7, ok, vals, tol, Y in res:
|
||||
if ok and i7 >= -0x80 and (best is None or i7 < best[0]): best = (i7, i)
|
||||
if best and best[1] in (11, 15): out.append((x, z, step, best[1]))
|
||||
x += step
|
||||
z += step
|
||||
return out
|
||||
|
||||
W = 1500
|
||||
# --- left: overview (3 x 3 cells) ---
|
||||
ov, d, P, S = panel(56.0, 42.0, 3.0, 640, False)
|
||||
collision(d, P, 'all', fsz=11)
|
||||
for t in range(mp['N']):
|
||||
B = mp['B'][t]; x, z = B[3] / 4096, B[5] / 4096
|
||||
if 56 <= x <= 59 and 42 <= z <= 45:
|
||||
d.text(P(x - 0.45, z - 0.38), f'tile {t}', fill=(110, 0, 140), font=F(12, True), stroke_width=SS * 2, stroke_fill='white')
|
||||
k = P(K[0], K[2]); d.ellipse([k[0] - 7 * SS, k[1] - 7 * SS, k[0] + 7 * SS, k[1] + 7 * SS], fill=MAG, outline='black', width=SS)
|
||||
ZX0, ZZ0, ZSP = 57.06, 43.16, 0.76
|
||||
a, b = P(ZX0, ZZ0 + ZSP), P(ZX0 + ZSP, ZZ0)
|
||||
d.rectangle([a, b], outline=(255, 140, 0), width=3 * SS)
|
||||
ov = ov.resize((640, 640), Image.LANCZOS)
|
||||
|
||||
# --- right: zoom on the crate's side with both needles ---
|
||||
zm, d, P, S = panel(ZX0, ZZ0, ZSP, 820, True)
|
||||
collision(d, P, 'zoom', sliver=sliver_cells(ZX0, ZZ0, ZX0 + ZSP, ZZ0 + ZSP, 0.0012), fsz=15)
|
||||
k = P(K[0], K[2]); r = 9 * SS
|
||||
d.ellipse([k[0] - r, k[1] - r, k[0] + r, k[1] + r], fill=MAG, outline='black', width=2 * SS)
|
||||
d.text((k[0], k[1] + 30 * SS), 'Kuzco\n57.204 / 43.733\n(measured live)', fill=MAG, font=F(15, True), anchor='ma',
|
||||
align='center', stroke_width=SS * 2, stroke_fill='white')
|
||||
tip = P(57.315, 43.729)
|
||||
y = tip[1] - 34 * SS
|
||||
d.line([(k[0], y), (tip[0], y)], fill='black', width=2 * SS)
|
||||
for xq in (k[0], tip[0]): d.line([(xq, y - 8 * SS), (xq, y + 8 * SS)], fill='black', width=2 * SS)
|
||||
d.text(((k[0] + tip[0]) / 2, y - 6 * SS), '0.11 outside poly 15', fill='black', font=F(14, True), anchor='md',
|
||||
stroke_width=SS * 2, stroke_fill='white')
|
||||
d.text((P(57.38, 43.80)), 'needle tips 13.5°', fill=RED, font=F(14, True), stroke_width=SS * 2, stroke_fill='white')
|
||||
d.text((P(ZX0 + 0.01, ZZ0 + 0.035)), 'pink = allowance: where the engine still takes the needle as floor\n(edge tolerance ≈ 0.02 → ≈ 0.19 long, 0.01 wide at a 13.5° tip)',
|
||||
fill=(190, 20, 110), font=F(14, True), stroke_width=SS * 2, stroke_fill='white')
|
||||
zm = zm.resize((820, 820), Image.LANCZOS)
|
||||
|
||||
out = Image.new('RGB', (20 + 640 + 20 + 820 + 20, 820 + 40), 'white')
|
||||
d = ImageDraw.Draw(out)
|
||||
out.paste(ov, (20, 20 + 90)); out.paste(zm, (680, 20))
|
||||
d.text((20, 20 + 60), 'Overview (3 × 3 cells), orange box = zoom →', fill='black', font=ImageFont.truetype('DejaVuSans-Bold.ttf', 17))
|
||||
out.save(OUT); print('saved', OUT, out.size)
|
||||
@@ -0,0 +1,40 @@
|
||||
import sys,struct,math; sys.path.insert(0,'/home/elias/projects/llamamod/tools'); import geomlib as G
|
||||
ch,wf,trak,mp=G.load_level(__import__('os').environ.get('WAD','/home/elias/.claude/jobs/90c6eed7/tmp/exitre/t1l4m002.wad'))
|
||||
_exe=open('/home/elias/projects/llamamod/re/groove.exe','rb').read()
|
||||
SIN=list(struct.unpack_from('<4096i',_exe,0x174318))
|
||||
def c8(b): return b-256 if b>127 else b
|
||||
def floor_test(px,py,pz,tx,ty,tz,rot,recs,dbg=False,probe_add=0x400):
|
||||
"""exact re-implementation of FUN_004036d0 floor branch (flags 0x200000 off). ints 20.12"""
|
||||
lx=px-tx; lz=pz-tz; ly=py-ty+probe_add
|
||||
u=(-rot)&0xffffffff
|
||||
if u:
|
||||
s=SIN[u&0xfff]; c=SIN[(u+0x400)&0xfff]
|
||||
lx,lz=(s*lz+c*lx)>>12,(c*lz-s*lx)>>12
|
||||
out=[]
|
||||
for i,r in enumerate(recs):
|
||||
if r[1]==0x11: continue
|
||||
ny=c8(r[3]); n14=ny*32
|
||||
if n14<=0: continue
|
||||
h=(((struct.unpack_from('<h',r,6)[0]*4096 - c8(r[4])*32*lz - c8(r[2])*32*lx) - n14*ly))
|
||||
q=abs(h)//abs(n14); i6= q if (h>=0)==(n14>=0) else -q # C truncating division
|
||||
i7=-i6
|
||||
Y=ly+i6
|
||||
tol=-0x40-((ny*-0x20+0x1000)>>6)
|
||||
ne=4 if r[0]&1 else 3
|
||||
vals=[]
|
||||
ok=True
|
||||
for k in range(ne):
|
||||
nx,nyy,nz,_,d=struct.unpack_from('<bbbBh',r,8+6*k)
|
||||
v=((nyy*32*Y+nz*32*lz+nx*32*lx)>>12)-d
|
||||
vals.append(v)
|
||||
if v<tol: ok=False
|
||||
out.append((i,i7,ok,vals,tol,ty+Y))
|
||||
return (lx,ly,lz),out
|
||||
if __name__=='__main__':
|
||||
t=443; x,y,z,rot,mi,_=mp['A'][t]
|
||||
recs=[r for g in trak[mi].coll for r in g]
|
||||
P=[float(a) for a in sys.argv[1:4]]
|
||||
loc,out=floor_test(*[int(round(v*4096)) for v in P],int(round(x*4096)),int(round(y*4096)),int(round(-z*4096)),rot,recs)
|
||||
print('local',[v/4096 for v in loc])
|
||||
for i,i7,ok,vals,tol,Y in out:
|
||||
if i7>=-0x80: print(f'poly {i:2d} dist {i7/4096:+.4f} planeY {Y/4096:.4f} pass={ok} edges {vals} tol {tol}')
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exact re-implementation of the engine's map floor test (FUN_004036d0, floor branch) on raw collision records.
|
||||
|
||||
floortest.py LEVEL.wad X Y Z world position in cells (game space, z = -A.z); prints every polygon of the
|
||||
cell's tiles that is a floor candidate, whether it passes, and the winner
|
||||
|
||||
Integer arithmetic as in groove.exe (20.12 fixed point, sine table read from re/groove.exe, C division).
|
||||
See notes/geometry.md "Floor test: inclusion rule and the floating-next-to-the-log case".
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
import geomlib as G # noqa: E402
|
||||
|
||||
_EXE = os.path.join(os.path.dirname(HERE), 're', 'groove.exe')
|
||||
SIN = struct.unpack_from('<4096i', open(_EXE, 'rb').read(), 0x574318 - 0x400000) # DAT_00574318
|
||||
PROBE = 0x400 # DAT_0057dd98 (0.25) unless strat flag 0x200000
|
||||
|
||||
|
||||
def _c8(b):
|
||||
return b - 256 if b > 127 else b
|
||||
|
||||
|
||||
def _cdiv(a, b):
|
||||
q = abs(a) // abs(b)
|
||||
return q if (a >= 0) == (b > 0) else -q
|
||||
|
||||
|
||||
def to_local(px, pz, tile_b):
|
||||
"""World (fixed) -> tile-local x/z exactly like 0x4037b3..0x4037f1 (rotation by -ry)."""
|
||||
lx, lz = px - tile_b[3], pz - tile_b[5]
|
||||
u = (-tile_b[1]) & 0xffffffff
|
||||
if u:
|
||||
s, c = SIN[u & 0xfff], SIN[(u + 0x400) & 0xfff]
|
||||
lx, lz = (s * lz + c * lx) >> 12, (c * lz - s * lx) >> 12
|
||||
return lx, lz
|
||||
|
||||
|
||||
def test_poly(r, lx, ly, lz):
|
||||
"""One 32-byte record. ly = probe height (feet - tile y + 0.25). Returns None (skipped) or
|
||||
(dist, plane_y_local, passed, edge_values, tolerance); dist = probe height above the plane."""
|
||||
ny32 = _c8(r[3]) * 32
|
||||
if ny32 <= 0: # only up-facing planes (0x200000 strats also take down-facing ones)
|
||||
return None
|
||||
d0 = struct.unpack_from('<h', r, 6)[0]
|
||||
i6 = _cdiv(d0 * 4096 - _c8(r[4]) * 32 * lz - _c8(r[2]) * 32 * lx - ny32 * ly, ny32)
|
||||
dist = -i6
|
||||
y = ly + i6 # the probe dropped vertically onto the plane
|
||||
tol = -0x40 - ((0x1000 - ny32) >> 6)
|
||||
vals = []
|
||||
for k in range(4 if r[0] & 1 else 3):
|
||||
nx, ny, nz, _, d = struct.unpack_from('<bbbBh', r, 8 + 6 * k)
|
||||
vals.append(((ny * 32 * y + nz * 32 * lz + nx * 32 * lx) >> 12) - d)
|
||||
return dist, y, all(v >= tol for v in vals), vals, tol
|
||||
|
||||
|
||||
def floor(wad, x, y, z, wfpc_8000000=False):
|
||||
ch, wf, trak, mp = G.load_level(wad)
|
||||
px, py, pz = (int(round(v * 4096)) for v in (x, y, z))
|
||||
skip_mat = 0x12 if wfpc_8000000 else 0x11
|
||||
best = None
|
||||
rows = []
|
||||
for t in range(mp['N']):
|
||||
b = mp['B'][t]
|
||||
if (b[3] >> 12, b[5] >> 12) != (px >> 12, pz >> 12):
|
||||
continue
|
||||
lx, lz = to_local(px, pz, b)
|
||||
ly = py - b[4] + PROBE
|
||||
recs = [r for g in trak[mp['midx'][t]].coll for r in g]
|
||||
for i, r in enumerate(recs):
|
||||
if r[1] == skip_mat:
|
||||
continue
|
||||
res = test_poly(r, lx, ly, lz)
|
||||
if res is None or res[0] < -0x80:
|
||||
continue # plane more than 1/32 above the probe: ceiling branch, not a floor
|
||||
dist, yl, ok, vals, tol = res
|
||||
rows.append((t, i, r[1], dist / 4096, (b[4] + yl) / 4096, ok, vals, tol))
|
||||
if ok and (best is None or dist < best[0]):
|
||||
best = (dist, t, i, (b[4] + yl) / 4096)
|
||||
return rows, best
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) != 5:
|
||||
sys.exit(__doc__)
|
||||
rows, best = floor(sys.argv[1], *map(float, sys.argv[2:5]))
|
||||
for t, i, mat, dist, h, ok, vals, tol in rows:
|
||||
print(f'tile {t} poly {i:2d} mat {mat:3d} probe-plane {dist:+.4f} plane y {h:.4f} '
|
||||
f'{"PASS" if ok else "fail"} edges {vals} (tolerance {tol})')
|
||||
print('floor:', f'tile {best[1]} poly {best[2]} height {best[3]:.4f}' if best else 'none in this cell')
|
||||