Compare commits

10 Commits
Author SHA1 Message Date
MineTec abe97b6977 changelog: alligator arena, documentation and known limits for 0.2.1; current launcher and editor screenshots everywhere, coaster in the readme pictures
build / build (push) Successful in 57s
release / release (push) Successful in 1m16s
2026-11-07 16:51:40 +01:00
MineTec bc8408d963 save path of the relocated coin and flag arrays verified in game: saves after finishing jungle 2 and village 1 byte-identical to the old exe, testlevel coins never reach the save 2026-11-07 16:51:16 +01:00
MineTec a64df88c77 release automation: gitea actions build every push and publish tagged releases (version from the tag, packages, sha256sums, changelog section as text), simple 5-step install guide in the readme 2026-11-07 16:47:42 +01:00
MineTec f5fd411592 llama coaster: ride the catacombs coaster cart on a new track (loop or grand circuit with drops, climbs, boosts, spin pads and jumps), 1-3 laps, three speeds, coins; offline cart simulation checks every jump and climb; roller coaster chapter in the strats docs 2026-11-07 16:45:54 +01:00
MineTec 6d449257a7 tool paths configurable by environment (LLAMAMOD_CD, LAMA_WINEPREFIX, LAMA_MOD, APPTEST_ROOT, APPTEST_ISO; tools/lamapaths.py), music tracks without a file on the cd marked in the editor and the tutorial uses kronk, levelbuild.py accepts the english texture names 2026-11-07 16:34:17 +01:00
MineTec a0d961357e readme: large current screenshots of the launcher and of the editor with an open level, outside the picture table 2026-11-07 16:19:26 +01:00
MineTec 624718b893 documentation for level builders: docs index, step-by-step guide, file format reference (wad, geometry, objects, textures, audio, testlevel chapters, engine limits), strat vm and script language with the arena as worked example, tools overview, developer notes; readme rewritten as project front page with screenshots; review notes moved to notes/dev 2026-11-07 16:14:40 +01:00
MineTec 00b2880d67 base exe patch moves the per-chapter coin and flag arrays into free .data space (128 entries), so every testlevel chapter is safe (revision 2, caches rebuilt once); new projects start on a free chapter from 32, manual shows number ranges, texture swatch caches follow texture edits, stray levelbau.md.orig removed 2026-11-07 15:52:00 +01:00
MineTec d6b852a1b6 testlevel chapters 2-31 overwrite game memory (coin counter and flag rows end after world 8: progress, language, options): level projects, play-tests and the arena use chapters 1 and 32-49, old settings migrate; level tally counts the level's own coins and secrets 2026-11-07 15:27:08 +01:00
MineTec 987b0104fa levelbuild texture names mean the same picture on every base level (matched by pixels, missing village textures copied over in the java builder, clear error otherwise), editor texture choices follow the base 2026-11-07 15:27:08 +01:00
141 changed files with 6034 additions and 550 deletions

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
# Builds and tests every push and pull request (tests that need the game CD skip themselves).
name: build
on:
push:
branches: ['**']
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: |
25
21
- name: Build and test
run: |
./gradlew --no-daemon \
-Porg.gradle.java.installations.fromEnv=JAVA_HOME_25_X64 \
-Porg.gradle.java.installations.auto-download=false \
build
+61
View File
@@ -0,0 +1,61 @@
# Builds and publishes a release when a version tag is pushed:
# git tag v0.2.1 && git push origin v0.2.1
# The release gets the four packages (full/launcher x Windows/Linux), SHA256SUMS.txt and the
# matching CHANGELOG.md section as its text. Tests that need the game CD skip themselves here.
#
# Needs: Gitea Actions enabled and a runner with the label "ubuntu-latest" (act_runner).
# The built-in token is used; if it may not create releases on your instance, add a repository
# secret RELEASE_TOKEN (a token with write access to the repository).
name: release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: JDK 25 (toolchain) and 21 (runs Gradle)
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: |
25
21
- name: Version from the tag
run: |
V="${GITHUB_REF_NAME#v}"
echo "VERSION=$V" >> "$GITHUB_ENV"
python3 tools/release/changelog_section.py "$V" > release-notes.md
echo "Release $V"; head -5 release-notes.md
- name: Build, test and package
run: |
./gradlew --no-daemon \
-Porg.gradle.java.installations.fromEnv=JAVA_HOME_25_X64 \
-Porg.gradle.java.installations.auto-download=false \
-PreleaseVersion="$VERSION" \
build packageAll
- name: Checksums
run: |
cd build/dist
ls -la
sha256sum LlamaMod-"$VERSION"-*.zip LlamaMod-"$VERSION"-*.tar.gz > SHA256SUMS.txt
cat SHA256SUMS.txt
- name: Publish
env:
GITEA_URL: ${{ github.server_url }}
GITEA_REPO: ${{ github.repository }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || github.token }}
run: |
python3 tools/release/publish_gitea.py \
--tag "$GITHUB_REF_NAME" --title "LlamaMod $VERSION" --notes release-notes.md \
build/dist/LlamaMod-"$VERSION"-*.zip build/dist/LlamaMod-"$VERSION"-*.tar.gz \
build/dist/SHA256SUMS.txt
+99
View File
@@ -0,0 +1,99 @@
# Changelog
## 0.2.1 – changes since 0.1.0
The first test release was 0.1.0 (launcher edition). Everything below is new for testers.
There are two downloads: **full** (launcher + level editor) and **launcher** (mods only).
### Launcher
- **Clearer texts everywhere.** Mod descriptions and options are explained in plain words,
directly under each option instead of only in tooltips.
- **Setup wizard**
- Wrong files, truncated ISO downloads and too little disk space are caught when you
pick the source, not after you click Set up.
- Copy progress shows megabytes ("123 of 568 MB").
- Cancel tells you what happened.
- **Play**
- While the game runs, Play becomes **Quit game**, so a hung game can be stopped from LlamaMod.
- A crash now says "The game crashed" with advice, instead of only "The game has ended".
- Wine: a too-old Wine version gives a warning, and a wrong Wine command is explained.
- **Safer applying**
- Two LlamaMod windows (or the command line) can no longer break each other's apply.
- If LlamaMod is closed in the middle of applying mods, it finishes or rolls back the
apply on the next start and tells you.
- Settings changed in one window show up in the other.
- Old cached mod results are rebuilt after an update.
- **Mods**
- **Mirror mode:** pick the worlds with check boxes.
- **Practice and Free camera:** if two hotkeys use the same key, Apply refuses and says
which ones clash.
- **Boot to level:** a custom start position belongs to its level and no longer breaks
other levels.
- **Intro skip and Boot to level** can now be used together.
- **Test World:** a new gallery with all 253 characters, enemies, animals and objects
of the game, standing still on pedestals (the animal pen is gone).
- **New levels and the Alligator Arena only use TestLevel chapters 1 and 32–49.** On other
chapters the game stored the level's coins over your progress and other game data (chapter 9
broke the world map). Levels and settings on old chapters move to a free one automatically.
The game itself is fixed too: picking an empty TestLevel chapter on the world map (or in
Boot to level) no longer damages your progress.
- The level tally shows the real coin and secret totals of new levels, the Test World and the
arena instead of the Village's "55".
- **New: Alligator Arena** – a minigame on a TestLevel chapter (default 49): alligators come in
waves into a walled pit; every hit costs one of their lives and coins pop out, their bite
costs your energy; beat every wave and the exit opens. Lives, waves, energy and coins per hit
can be set.
- **New: Llama Coaster** – a roller-coaster ride on a TestLevel chapter (default 48), with the
cart from the Catacombs: drops, climbs, boost arrows, a jump and a spin pad. Two tracks, 1–3
laps, three speeds.
- **Save games**
- Backups can no longer overwrite each other.
- Import… opens in the backups folder.
- The table fits small (1280×720) screens.
- **Layout:** works on small and laptop screens; the dark theme is easier to read.
### Level editor (full edition)
- **Play-test**
- Shows "Game running" and a **Stop game** button.
- Shift+F5 shows a crosshair where the llama will start.
- Play-test copies are removed when the game ends, and your mod selection comes back.
- **Readable names.** "Coin 3", "Floor 2", "Waypoint 4" instead of internal ids, in the
outliner, inspector, status bar and messages; the outliner filter finds them.
- **Error messages.** Damaged files give one plain sentence with optional details.
- **Geometry**
- Painting or erasing tiles in one drag is one undo step.
- New tile option **No collision** (walk-through decoration).
- **Meshes and export**
- **Export level as glTF/GLB** (textures, vertex colours; optionally only the selection,
or collision as a separate mesh).
- Imported and exported models are **no longer mirrored** compared to Blender. Meshes in
older projects keep their look; switch them to "right" handedness in the inspector to
fix them.
- **Objects**
- **Guards** and **alligators** need a waypoint box around them, or the game removes
them. The editor now sets this up automatically and warns when it is wrong. It also
says which objects can trigger a guard's event bit.
- The start camera no longer starts inside the llama's head on some base levels. Moving
the player start moves its camera and intro fly-by too.
- Snap to floor follows the game's own rule.
- **Object scripts**
- Custom object types can be edited and deleted (with undo).
- **Script language with compiler:** if/else, loops, switch, events, named parameters.
- The script view is much easier to read.
- Opcodes have checked descriptions.
- Narrow docks, small windows and long labels wrap instead of being cut off.
### Documentation
- New guide for building your own levels, a reference of the game's file formats and engine
limits, and a chapter on object scripts (strats) with the arena and the coaster as worked
examples: start at [docs/README.md](docs/README.md).
### Known limits
- Tested on Linux (Wine) and in a Windows VM. Real Windows hardware is not tested yet.
- The game draws objects only within about 12 units of the llama.
- Four music tracks of the game have no file on the CD (GAMEOVER, TALLY, PUZZLE2, PLATFORM); the
editor marks them.
+130 -232
View File
@@ -1,268 +1,166 @@
# levelbau – level modding kit for *The Emperor's New Groove* (PC, 2000)
# LlamaMod – mods and a level editor for *The Emperor's New Groove* (PC, 2000)
German title: *Ein Königreich für ein Lama*. PC port by Argonaut, built on the Croc 2 engine.
This kit came out of an AI-assisted reverse-engineering project. It contains:
**LlamaMod** is a one-click mod manager and a full 3D **level editor** for the PC game, for
**Windows and Linux**. It installs the game from your own CD or ISO into its own folder, applies
mods without ever touching the original files, and lets you build new levels, edit the original
ones and share them as small files. Everything is based on a complete reverse engineering of the
game's level formats and its object scripts ("strats").
* **Format documentation** for the level files (`*.wad`): geometry, collision, object
placement, textures, waypoints, visibility zones, and what makes the level exit work.
* **Python tools** that read, export and rebuild those files byte-exactly.
* **A level builder** (`tools/levelbuild.py`). You describe a level as a short Python script
(boxes, ramps, stairs, fences, trees, sea, objects) and it writes a complete, playable WAD.
* **An example level, "Testwelt"** (`levels/testwelt.py`). It goes into the hidden, empty
*TestLevel* slot (world 9, chapter 1) and has its own geometry, a gallery with every
object type on pedestals, an animal pen and a small parkour with a working exit.
* **A debug patch generator** (`tools/debugviz.py`). It makes collision visible in every
level, including invisible walls, along with invisible trigger and camera objects.
* **An automated in-game tester** (`tools/lamatest.py`). On Linux it runs the game under
Wine on a virtual display, loads the test level, takes screenshots and detects crashes.
![The LlamaMod launcher: tick mods, set their options, Apply and Play](docs/img/readme-launcher.jpg)
Nothing from the game itself is included: no game files, no executable, no decompiled
code, no built WADs. Every tool works on **your own copy** of the game.
*The launcher: tick the mods you want, set their options, click Apply and Play.*
> The notes in `notes/` are detailed and technical. Handing them to an AI assistant is the
> recommended way to use this kit (see the last section).
![The LlamaMod level editor with Village chapter 1 open](docs/img/readme-editor.jpg)
---
*The level editor with Village chapter 1 open: outliner (left), 3D view, inspector (right), assets and scripts (bottom).*
## Requirements
More pictures:
* Your own copy of the game. Development used the **German CD version**. Other language
versions should use the same format, but this hasn't been tested.
* Python 3.8+ with `numpy` and `Pillow`. Optional: `capstone` (disassembler helper).
* For the automated in-game tests only: Linux, Wine, Xvfb, xdotool, ImageMagick, tesseract
with German language data, and PulseAudio (`pactl`).
* Optional: Blender (preview renders), and Ghidra (to regenerate the decompile, see below).
| | |
|---|---|
| ![Test World gallery](docs/img/testworld-gallery.jpg) | ![Alligator Arena](docs/manual/img/mod_arena.jpg) |
| **Test World**: all 253 figures and objects of the game in one level | **Alligator Arena**: a minigame with new, scripted enemies |
| ![Llama Coaster](docs/manual/img/mod_coaster.jpg) | ![Debug view](docs/img/debug-view.jpg) |
| **Llama Coaster**: a new roller coaster track for the catacombs cart | **Debug view**: collision and invisible objects in the running game |
| ![glTF in Blender](docs/img/gltf-blender.jpg) | |
| **glTF/OBJ** export and import for Blender | |
The builder and the export tools are plain Python, so they should also work on Windows.
The test harness is Linux/Wine-only.
## Features
### Paths – adjust these
* **Mods** – tick, *Apply*, *Play*: Test World, Alligator Arena, Llama Coaster, your own levels, Boot to level,
Intro skip, Mirror mode, Practice tools (save/restore position, extra checkpoints), Randomizer,
Draw distance, Built-in cheats, Debug wireframe, Free camera & noclip.
* **Level editor** – new levels from templates or editing the original levels; brushes, the base
level's tiles, OBJ/glTF meshes; textures (import, paint, animate); objects from **every world**;
waypoints, cameras, triggers; fog, music, lights, visibility regions; strat scripts (read,
change numbers, write new object types); checks for problems; **F5** starts the game directly in
the level.
* **Sharing** – a level is a small `.llamalevel` file with only your changes and no game data; the
receiver's LlamaMod builds it from their own copy. New levels live in the hidden *TestLevel*
world.
* **Save games** – unlock worlds and chapters, coins, collectibles; backups before every change.
* **Safe** – the original CD/ISO and files are never changed; *Restore original* switches all mods
off.
The tools were written for one Linux/Wine setup and some paths are hard-coded at the top of
the scripts:
## Get it
| Script | Variable | Meaning |
|---|---|---|
| `tools/levelbuild.py` | `WADS` | folder with the original level WADs (the `wads` folder of the game CD) |
| `tools/levelbuild.py` | `MODWAD` | where `--install` puts the built level (`T1L9M001.wad`) |
| `tools/debugviz.py` | `CD_WADS` | same as `WADS` |
| `tools/lamatest.py` | `PREFIX`, `LAMA_MOD` | Wine prefix and the overlay script |
You need **your own copy of the game** (the CD or an ISO image of it). Nothing else – LlamaMod
brings everything it needs (also Java). Development used the German CD; other language versions
should work but are untested.
The game reads its level data from the CD drive, in the `wads` folder of the CD. To mod it,
make a copy of the CD data (or an overlay of symlinks, see `tools/lama-mod`), point the CD
drive or `CDPath` at that copy, and put modified WADs into its `wads` folder.
### Install in 5 steps
---
1. **Download** from the **Releases** page of this repository (top of the page):
* Windows: `LlamaMod-<version>-full-windows-x64.zip`
* Linux: `LlamaMod-<version>-full-linux-x64.tar.gz`
## Folder layout
(*full* = with the level editor. The *launcher* packages are the same without the editor.)
2. **Unpack** it into any folder you like (for example *Documents* or your home folder).
There is no installer and you need no admin rights.
3. **Start** LlamaMod:
* Windows: double-click **`LlamaMod.exe`**. If Windows says *"Windows protected your PC"*,
click **More info → Run anyway** (the program is not signed, that's all).
* Linux: install Wine once (`sudo apt install wine` on Ubuntu/Mint/Debian), then double-click
**`LlamaMod`** or run `./LlamaMod`. `./install-desktop-entry.sh` adds it to your app menu.
4. **Set up the game** – on the first start a short wizard asks for your game: put the CD in or
pick the ISO file, click **Set up** and wait about a minute. Your CD/ISO is only read, never
changed.
5. **Play** – on the **Mods** tab tick the mods you want, click **Apply**, then **Play**.
*Restore original* switches all mods off again.
```
README.md this file
notes/ format documentation and findings
geometry.md TRAK/MAP geometry, collision polygons, coordinate spaces, zones, limits
objects.md MAP chunk layout, strat (object) entries, waypoints, player start, exit
strat_types_table.md every object type in every original level, with counts
textures.md TEXT chunk: texture pages, RLE16, texture table, animations
levelbau.md how to use levelbuild.py, measured player physics, exit logic
testharness.md the automated Wine/Xvfb tester
debugviz.md debug patch: colour legend, what is shown, limits
img/ screenshots and preview renders
tools/
geomlib.py core library: WAD, TRAK, STPC models, MAP geometry, TEXT, collision
geom_export.py WAD -> OBJ/MTL (+ textures, vertex colours, collision.obj, objects.obj)
geom_import.py OBJ -> new geometry inside a WAD copy
mapchunk.py MAP chunk parser/writer (byte-exact on all original levels)
strats.py, strat_table.py, stpcwalk.py, map_dump.py object types and strat code helpers
textchunk.py, text_export.py, texsheet.py textures: export/import PNG, contact sheets
levelbuild.py the level builder (see notes/levelbau.md)
debugviz.py debug patch generator (collision/trigger visualisation)
lamatest.py automated in-game test on a virtual display (Linux/Wine)
lama-mod shell script: builds a CD overlay with modded WADs (Wine setup)
vorschau/ Blender preview render scripts
disx86.py small disassembler helper for groove.exe (needs capstone)
wadtool.py minimal WAD chunk lister/extractor/replacer (used by several tools)
cor2png.py, png2cor.py loading/concept pictures (*.COR, LZ-compressed 640x480) <-> PNG
asf2wav.py music (*.ASF, 4-bit ADPCM) -> WAV
levels/
testwelt.py the example test world
testwelt_parkour.seq scripted run through the parkour (used with lamatest.py)
sprunglabor.py "jump lab": test lanes used to measure jump height/width
re/ghidra_scripts/
DumpAll.java Ghidra headless script: decompiles every function to one C file
New levels (Test World, Alligator Arena, Llama Coaster, your own) are in the hidden world
**TestLevel**: open the **Save games** tab once and click **Unlock TestLevel**, then pick it on the
world map. If your antivirus complains about `groove.exe`: mods that change the game program are
marked *modifies groove.exe*; LlamaMod tells you what to do. More help:
[Troubleshooting](docs/manual/20-troubleshooting.md). What changed: [CHANGELOG.md](CHANGELOG.md).
### Build from source
Needs a JDK 25 (toolchain) and Gradle running on Java 21 (for Spotless):
```bash
./gradlew spotlessApply build fatJar # tests + build/libs/llamamod-<ver>-all.jar
java -jar build/libs/llamamod-*-all.jar # LlamaMod
java -jar build/libs/llamamod-*-all.jar --editor # the level editor alone
./gradlew packageAll # Linux + Windows packages, full and launcher edition
```
---
Details: [For developers](docs/developers.md).
## Quick start
## Documentation
**[docs/](docs/README.md)** – start there.
* **[Build your own level](docs/guide/README.md)** – a step-by-step guide for newcomers.
* **[Level editor manual](docs/manual/index.md)** – every tool and dialog, with tutorials (also
built into the editor: **F1**).
* **[Reference](docs/reference/README.md)** – file formats (WAD, geometry, collision, objects,
textures, music), TestLevel, engine limits.
* **[Strats](docs/strats/README.md)** – how the game's object scripts work, the script language,
custom object types, and the [Alligator Arena walkthrough](docs/strats/arena.md).
* **[Tools](docs/tools.md)** – the app's command line, the Python kit, in-game test harnesses.
* **[Lab notes](notes/README.md)** – the detailed engineering notebook.
## The Python kit
The project started as a Python reverse-engineering kit, which is still in [`tools/`](tools/):
byte-exact parsers and writers for every level chunk, OBJ export/import, a script-driven level
builder ([`tools/levelbuild.py`](tools/levelbuild.py), example [`levels/testwelt.py`](levels/testwelt.py)),
texture and picture converters, a music decoder, the debug-view generator and an automated
in-game tester for Linux/Wine. Python 3.8+ with `numpy` and `Pillow`.
```bash
# export a level to OBJ (+ collision and object markers)
python3 tools/geom_export.py /path/to/cd/wads/t1l1m001.wad out_village --collision --objects
# -> level.obj/.mtl, texture pages, collision.obj, objects.obj, preview images
# build the example test world
python3 tools/levelbuild.py levels/testwelt.py testwelt.wad
# build the original test world from its script
python3 tools/levelbuild.py levels/testwelt.py testwelt.wad --base /path/to/cd/wads/t1l1m001.wad
python3 tools/levelbuild.py --types # object types you can place
python3 tools/levelbuild.py --textures tex.png # texture sheet with the names you can use
# build the debug patch for all levels
python3 tools/debugviz.py --help
# music to WAV, loading picture to PNG
python3 tools/asf2wav.py /path/to/cd/music/Village.asf village.wav
python3 tools/cor2png.py /path/to/cd/t111.cor t111.png
```
### Playing the test level
Where the tools look for the game files (or pass `--base`/`--src`):
The game has a hidden world 9, *TestLevel*, with 49 chapter slots, but the retail game ships
no data for it. Put your built WAD into the CD's `wads` folder as `T1L9M001.wad`. Also add a
loading picture `T191.COR`, for example a copy of another `T1xx.COR`.
| Variable | Meaning | Default |
|---|---|---|
| `LLAMAMOD_CD` | the CD folder (`wads/`, `music/`, …), also for the Gradle tests | `~/.wine-lama/iso-dk/Setup/Eng/Cd` |
| `LAMA_WINEPREFIX` | Wine prefix of a hand-made game setup (`lamatest.py`, `levelbuild.py --install`) | `~/.wine-lama` |
| `LAMA_MOD` | the `lama-mod` script used by `lamatest.py` | `~/.local/bin/lama-mod`, else `tools/lama-mod` |
| `APPTEST_ROOT`, `APPTEST_ISO` | session folders and ISO of `apptest.py` | `~/.cache/llamamod-apptest` (`$CLAUDE_JOB_DIR/tmp` if set), `~/Downloads/GROOVE_DK.iso` |
| `LLAMAMOD_DATA` | data folder of the app | `~/.local/share/LlamaMod` (Windows `%LOCALAPPDATA%\LlamaMod`) |
To reach world 9, edit a save file (`king0.ksv`…`king2.ksv`, 256 bytes):
All tools and variables: [docs/tools.md](docs/tools.md#paths).
* `+0x04/+0x05` = map cursor (world, chapter)
* `+0x3f/+0x40` = current world/chapter
* `+0x42/+0x43` = highest unlocked world/chapter
### Key facts in one minute
Set them to world 9, chapter 1.
* **The world is made of tiles.** TRAK holds 100–650 small models (render triangles + collision
polygons); MAP places them on a W × H grid with height and rotation, plus baked vertex colours
and a 32-region visibility table. No BSP, no single world mesh.
* **Objects are "strats"**: compiled scripts in STPC. An object's type is its byte offset into
STPC, so a level can only use the types its own STPC contains (or types merged in from other
levels).
* **Scale**: y up, one unit per grid cell; the llama is 0.6 tall, walks 1.1 units/s, steps up
nothing and jumps ~1.3 far.
* **The exit** fires only when the llama stands in the LevelDoor's grid cell **on floor with
collision material 16**.
* **Textures** are PlayStation-style 15-bit pixels on 256 × 256 pages; there are no free UVs.
When you leave through the exit, the game unlocks the next chapter. `levelbuild.py --install`
therefore links chapters 2–49 to the same file, so that picking one on the map doesn't crash.
More: [docs/reference](docs/reference/README.md).
### Debug patch: visible collision and invisible walls
### Building on this with an AI assistant
```bash
python3 tools/debugviz.py --all debug_wads --src /path/to/cd/wads # all 33 level WADs
python3 tools/debugviz.py t1l1m001.wad t1l1m001_debug.wad # a single level
python3 tools/debugviz_check.py debug_wads /path/to/cd/wads # offline sanity check
```
This whole project was made by an AI coding agent (Claude Code). To continue it, give an assistant
the [lab notes](notes/README.md) first – they contain offsets, field meanings, confidence levels and
pitfalls – and let it regenerate the decompile of `groove.exe` with Ghidra for questions about game
logic (headless `DumpAll` script, see [Tools](docs/tools.md#reverse-engineering)). Always test in the
game (`tools/apptest.py`) and change one thing at a time: the engine crashes on inconsistent data.
Put the resulting WADs into the CD's `wads` folder in place of the originals, keeping the
same file names. On the Wine setup, `lama-mod debug an|aus` toggles them. The patch only
adds render geometry. Collision, objects, waypoints and zones stay byte-identical, so
gameplay is unchanged.
## Legal
Legend (full version in `notes/debugviz.md`):
| Colour | Meaning |
|---|---|
| green / yellow-green wireframe | walkable floor / slope |
| blue | wall |
| orange | ceiling / overhang |
| **red, semi-transparent fill** | **invisible collision**: invisible walls and floors |
| magenta lines | death plane ("no floor") |
| violet fill | level exit floor (material 16) |
| other colours | special collision materials |
| Marker | Meaning |
|---|---|
| white arrow | player start |
| violet cube | exit |
| turquoise cube | checkpoint |
| cyan cube | camera |
| yellow cube | trigger / cutscene |
| orange cube | force or special zone |
| gold cube | secret zone |
| grey cube | other invisible helper object |
| pink wire box | ambience / music / fog zone |
Cubes sit on a stick, and a horizontal line shows the object's facing. Wire boxes show
trigger areas.
Status: 19 of 33 levels were loaded in the game without problems. The rest
(catacombs/roller coaster world 7, lab world 8, two others) passed the offline check only.
Collision of moving objects (platforms, doors) is not shown.
---
## LlamaMod and the level editor
Besides the Python kit, this repository contains **LlamaMod** (Java 25, Swing; `src/`), a
one-click mod manager for the game on **Windows and Linux**, and a full **3D level editor**
built into it. Nothing from the game is included: the wizard installs the game from your own
CD/ISO into LlamaMod's data folder, and all mods are built from that copy.
* **Mods** – tick mods, click *Apply*/*Play*: debug wireframe, Test World, randomizer, fog,
mirror mode, intro skip, cheats, practice tools, free camera, *boot to level* … Mods may change
level files and `groove.exe` (code caves); the original files are never touched.
Developer notes: `notes/llamamod-app.md`, one `notes/mod-*.md` per mod.
* **Level editor** (*Level Editor* button, or `LlamaMod --editor`): create new levels from
templates, brushes and the base level's tile models and objects, or edit the original
levels; import OBJ/glTF meshes and PNG/JPG textures (texture manager, paint-on-faces,
animations); waypoints, zones, fog, lights, visibility regions; **Play (F5)** builds the
level and starts the game directly in it (Escape returns to the editor).
* **Sharing**: a level is a small `.llamalevel` file with only your changes. Others import it in
LlamaMod (*Mods → Levels → Import level*) and it is built from their own game copy; new levels
appear in the hidden *TestLevel* world (chapters 1–49).
User guide (English): **`notes/editor-guide.md`**. In the editor: *Help → Getting started*
and *Help → Shortcut sheet*. Technical notes: `notes/editor-design.md`, `editor-api.md`,
`editor-format.md`, `editor-render.md`, `editor-ui.md`, `editor-tools-*.md`,
`editor-playtest.md`.
```bash
./gradlew build fatJar # tests + build/libs/llamamod-<ver>-all.jar
./gradlew packageLinux # build/dist/LlamaMod-<ver>-full-linux-x64.tar.gz (own Java runtime)
./gradlew packageWindows # build/dist/LlamaMod-<ver>-full-windows-x64.zip (LlamaMod.exe + runtime)
./gradlew packageAll # both systems, full and launcher (no editor) edition
java -jar build/libs/llamamod-*-all.jar # LlamaMod
java -jar build/libs/llamamod-*-all.jar --editor # the level editor alone
```
![The level editor](notes/img/editor/ui_select_move.jpg)
---
## Key facts
* **The world is made of tiles.** TRAK holds about 100–650 small models: render triangles
plus collision polygons. MAP places them on a W×H grid (96×96 in the village) with height
and rotation. MAP also stores baked vertex colours and a 32-zone visibility table. There is
no BSP and no single world mesh.
* **Objects are "strats".** These are compiled scripts in the STPC chunk. In MAP, an object's
type is simply its **byte offset into STPC**. A level can therefore only use the object
types its own STPC contains. The builder uses the village (`t1l1m001`) as its base.
* **Coordinates and scale.** y points up. Fixed point 20.12 for objects, 4096 = 360° for
angles, and render space vs. game space have z mirrored. The llama is about 0.6 units
tall, runs about 1.1 units/s, can step up at most about 0.35 and jumps about 1.3 units
far with a run-up.
* **The level exit (LevelDoor)** is an invisible trigger. It fires only when the llama
stands in its grid cell **on floor with collision material 16**. Its parameters are
(tribe, world, chapter) of the next level.
* **Texture pixels** are PS1-style 15-bit BGR, RLE16-compressed, on 256×256 pages. The
value 0x0000 means transparent.
The parsers rebuild every original WAD byte-for-byte, so they can be trusted as format
specs.
---
## Building on this with an AI assistant
This whole kit was made by an AI coding agent (Claude Code) working through the steps
below. Any capable assistant that can run code should be able to continue.
1. **Give it the notes first.** Ask it to read `notes/*.md`. They contain offsets, field meanings, confidence levels and pitfalls.
2. **Regenerate the decompile** for questions about game logic. Install Ghidra and a full
JDK (version 25 for Ghidra 12.x), then run
`analyzeHeadless <projdir> groove -import groove.exe -scriptPath re/ghidra_scripts -postScript DumpAll.java groove_decomp.c`.
This writes all functions as C (about 2 MB) for the assistant to search with grep.
Loader functions worth knowing: MAP `0x42ac50`, TRAK `0x42aac0`, STPC `0x42ab50`, strat
interpreter `0x54d180`.
3. **Always test in the game.** Have the assistant load and screenshot every build. On
Linux, `tools/lamatest.py run <wad> <outdir>` does this unattended in about 75 s. On
Windows, test by hand or adapt the script.
4. **Change one thing at a time** and keep the original WADs untouched. The game crashes on
inconsistent data: tiles outside the grid, a vertex-colour block that doesn't match the
vertex counts, or missing chapters.
### Ideas for what to try next
* Use object types from other levels. You would have to merge and relocate their STPC
code and their textures.
* ~~Import custom textures~~ – done in the level editor (texture manager, import, painting).
* ~~Make a real visual editor~~ – done: the LlamaMod level editor (see above).
* Add custom music or sounds (SMPC/AMPC/SRPC are sound banks).
---
This is a non-commercial fan project, not affiliated with Disney or Argonaut. Please share
only tools and your own content, not the game's files.
This is a non-commercial fan project, not affiliated with Disney Interactive or Argonaut Games.
**No game files are included** – no executable, no level data, no music, no decompiled code. You
need your own copy of the game; every tool works on that copy. Please share only tools and your own
content (`.llamalevel` projects contain only your changes), not the game's files.
+5 -3
View File
@@ -4,7 +4,8 @@ plugins {
}
group = 'com.eliasmueller.llamamod'
version = '0.2.0'
// Release builds pass -PreleaseVersion=<tag without v> (see .gitea/workflows/release.yml).
version = findProperty('releaseVersion') ?: '0.2.1'
repositories {
mavenCentral()
@@ -92,8 +93,9 @@ processResources {
test {
useJUnitPlatform()
// golden tests: -Dllamamod.cd=<cd dir> -Dllamamod.ref=<python reference dir>
systemProperty 'llamamod.cd', System.getProperty('llamamod.cd', "${System.getProperty('user.home')}/.wine-lama/iso-dk/Setup/Eng/Cd")
// golden tests: -Dllamamod.cd=<cd dir> (or env LLAMAMOD_CD) -Dllamamod.ref=<python reference dir>;
// tests that need the CD are skipped when the folder does not exist
systemProperty 'llamamod.cd', System.getProperty('llamamod.cd', System.getenv('LLAMAMOD_CD') ?: "${System.getProperty('user.home')}/.wine-lama/iso-dk/Setup/Eng/Cd")
systemProperty 'llamamod.ref', System.getProperty('llamamod.ref', '')
// level builder references: python3 tools/levelbuild.py levels/testwelt.py <lbref>/testwelt.wad etc.
systemProperty 'llamamod.lbref', System.getProperty('llamamod.lbref', '')
+77
View File
@@ -0,0 +1,77 @@
# LlamaMod documentation
Documentation for modding *The Emperor's New Groove* (PC, 2000; German: *Ein Königreich für ein
Lama*) with LlamaMod: building levels, the game's file formats, and how the game's object scripts
("strats") work.
![The Test World: every figure of the game in one level](img/testworld-gallery.jpg)
## Start here
| If you want to … | read |
|---|---|
| build your own level | **[Guide: Build your own level](guide/README.md)** |
| look something up in the editor | [Level editor manual](manual/index.md) (also in the editor: **F1**) |
| know the numbers and rules of the engine | [Design rules](guide/design-rules.md) |
| build levels from code | [Levels from scripts](guide/levels-in-code.md) |
| understand the game's data | [Reference](reference/README.md) |
| change or write object behaviour | [Strats](strats/README.md) |
| find a tool | [Tools](tools.md) |
| work on LlamaMod itself | [For developers](developers.md) |
## Contents
**[Guide](guide/README.md)** – for level builders
* [Build your own level](guide/README.md): setup, editor basics, ground, start and exit, objects,
look and feel, play-test, sharing
* [Design rules](guide/design-rules.md): sizes, jump distances, the rules that keep a level working
* [Levels from scripts](guide/levels-in-code.md): Python and Java level builders, the editor's
command line
**[Level editor manual](manual/index.md)** – every tool, panel and dialog of the editor, with
tutorials (first level, parkour, editing an original level, Blender import)
**[Reference](reference/README.md)** – the game's data
* [Game files](reference/game-files.md): folders, naming scheme, worlds, COR pictures, movies,
save games
* [WAD container](reference/wad.md): the chunks of a level file
* [Geometry and collision](reference/geometry.md): tiles, grid, coordinate spaces, collision
materials, visibility regions
* [Objects, waypoints, zones](reference/objects.md): strat records, parameters, the exit,
environment zones, lights
* [Textures](reference/textures.md): pages, pixels, texture rectangles, animations
* [Music and sound](reference/audio.md): ASF music, sound banks, speech – known and unknown
* [TestLevel (world 9)](reference/testlevel.md): where custom levels live, safe chapters
* [Engine facts and limits](reference/engine-limits.md): scale, draw distance, memory, crashes
**[Strats](strats/README.md)** – object behaviour
* [How the strat VM works](strats/README.md): units, frames, variables, events, spawning,
collision
* [Reading scripts](strats/reading-scripts.md): the Script view and the assembly
* [The script language](strats/script-language.md): writing new behaviour
* [Customising strats](strats/custom-types.md): constants, imports, clones, custom types, mods
* [Example: the Alligator Arena](strats/arena.md): a complete minigame explained step by step
* [Example: the roller coaster](strats/roller-coaster.md): a new ride for the game's own coaster cart – track data and waypoints, no new strat code
**[Tools](tools.md)** – LlamaMod's launcher, editor and command line, the Python kit, the in-game
test harnesses
**[For developers](developers.md)** – build, code map, writing a mod, maintaining the manual
**[Lab notes](../notes/README.md)** – the detailed engineering notebook behind all of this
## Pictures
| | |
|---|---|
| ![LlamaMod launcher](img/launcher.jpg) | ![Level editor](img/editor.jpg) |
| The launcher: mods with options, save games, settings | The level editor with Village 1 |
| ![Alligator Arena](manual/img/mod_arena.jpg) | ![Debug view](img/debug-view.jpg) |
| The Alligator Arena minigame | Debug view: collision and invisible objects in the game |
## Status and honesty
Everything here was found by reverse engineering and tested against the game; where something is
a guess, the page says so (*likely*, *unknown*). Development used the German CD; other language
versions are expected to work but were not tested. The project is not affiliated with Disney or
Argonaut and contains no game data.
+108
View File
@@ -0,0 +1,108 @@
# For developers
How the code is organised, how to build and test it, and where the engineering notes are.
## Build
Requirements: a **JDK 25** installed (the Gradle toolchain compiles with it; there is no
auto-download). Gradle itself and Spotless currently need to run on **Java 21**, e.g.
`JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 ./gradlew …` on Debian/Ubuntu.
```bash
./gradlew spotlessApply build # compile, format, run the tests
./gradlew fatJar # build/libs/llamamod-<ver>-all.jar
java -jar build/libs/llamamod-*-all.jar # launcher
java -jar build/libs/llamamod-*-all.jar --editor # the editor alone
./gradlew packageLinux # tar.gz with its own Java runtime
./gradlew packageWindows # zip with LlamaMod.exe + runtime (built on Linux)
./gradlew packageAll # both systems, full and launcher (no editor) edition
```
Details (jlink, launch4j, editions, version and build id): [notes/llamamod-app.md](../notes/llamamod-app.md#build).
**Tests that need the game.** Golden and in-game-data tests read the CD folder given by
`-Dllamamod.cd=<…/Setup/<lang>/Cd>` or the environment variable `LLAMAMOD_CD` (default:
`~/.wine-lama/iso-dk/Setup/Eng/Cd`); if that folder does not exist they are skipped. Byte-exact round trips over all level WADs (WAD, TRAK, MAP, TEXT, strat code) are the
safety net for every format change.
## Code map
Package root [`src/main/java/com/eliasmueller/llamamod/`](../src/main/java/com/eliasmueller/llamamod/):
| Package | Content | Notes |
|---|---|---|
| `format` | WAD, TRAK, STPC, MAP, TEXT, collision, COR | [Reference](reference/README.md) |
| `format.strat` | strat disassembler, assembler, script view, compiler | [Strats](strats/README.md), [notes/strat-language.md](../notes/strat-language.md) |
| `format.stpcmerge` | importing object types between levels (code, models, textures, sounds, texts) | [notes/editor-cross-import.md](../notes/editor-cross-import.md) |
| `levelbuild` | Java level builder (`LevelBuilder`, `LevelScript`) | [Levels from scripts](guide/levels-in-code.md) |
| `mods` | mod framework (`Mod`, `ModContext`, `ModManager`, options) and the built-in mods | [notes/llamamod-app.md](../notes/llamamod-app.md#mods), `notes/mod-*.md` |
| `exe` | patching `groove.exe`: PE image, code caves, a tiny x86 emitter | [notes/llamamod-app.md](../notes/llamamod-app.md#grooveexe-patches-and-code-caves-exeexeimage-exex86) |
| `platform` | install from ISO/CD, data folder, config, registry, Wine/Windows launch | [notes/llamamod-app.md](../notes/llamamod-app.md) |
| `save` | KSV save games, TestLevel chapters | [Game files](reference/game-files.md#save-games-ksv) |
| `ui` | launcher window | |
| `editor.model`, `editor.io` | scene model, commands/undo, project format, WAD import/build | [notes/editor-api.md](../notes/editor-api.md), [notes/editor-format.md](../notes/editor-format.md) |
| `editor.render` | OpenGL viewport | [notes/editor-render.md](../notes/editor-render.md) |
| `editor.ui` | editor shell and plugin API | [notes/editor-ui.md](../notes/editor-ui.md) |
| `editor.tools.*` | editor plugins: geometry, objects, environment, textures, catalog (cross-world import), strats, playtest, gltf, help | `notes/editor-tools-*.md`, [notes/editor-playtest.md](../notes/editor-playtest.md) |
## Writing a mod
A mod implements [`Mod`](../src/main/java/com/eliasmueller/llamamod/mods/Mod.java): `id`, `name`,
`description`, `category` (LEVELS, GAMEPLAY, VISUAL, CHEATS), `options()` and `apply(ModContext)`.
`apply` reads files of the game as earlier mods left them (`ctx.read`) and writes replacements
(`ctx.write`); `ctx.exe()` gives access to `groove.exe` for patches. Mods are registered in
[`ModRegistry`](../src/main/java/com/eliasmueller/llamamod/mods/ModRegistry.java). LlamaMod caches
mod outputs and never touches the original files.
Good examples: [`mods/testworld/`](../src/main/java/com/eliasmueller/llamamod/mods/testworld/) (a
level built in code), [`mods/arena/`](../src/main/java/com/eliasmueller/llamamod/mods/arena/) (level
+ new strat types, walkthrough: [Arena](strats/arena.md)),
[`mods/fog/`](../src/main/java/com/eliasmueller/llamamod/mods/fog/) (data-only change of all levels),
[`mods/practice/`](../src/main/java/com/eliasmueller/llamamod/mods/practice/) (exe code caves with
hotkeys). Full API: [notes/llamamod-app.md](../notes/llamamod-app.md#writing-a-mod).
Every mod should be checked in the running game; [`tools/apptest.py`](../tools/apptest.py) does
this unattended on Linux ([Tools](tools.md#testing-in-the-game)).
## The editor manual
[`docs/manual/`](manual/index.md) is the editor's user manual **and** its built-in help: the build
bundles the folder into the jar (`processResources` in `build.gradle`), the Help window renders it
with its own small Markdown converter, and F1 maps editor topics to chapter anchors
(`editor/tools/help/context-help.txt`). Rules for that folder:
* Only the Markdown subset described at the top of `index.md`; every chapter must be linked from
`index.md`; links must stay **inside** `docs/manual` (the help window cannot open other files).
* Blocks between `generated:NAME` comments (shortcuts, menus, mod reference) are rewritten from the
live program by `ManualTest` – do not edit them by hand.
* `./gradlew test --tests '*ManualTest*'` checks links, anchors and images.
Maintenance notes: [notes/editor-manual.md](../notes/editor-manual.md).
## Engineering notes
[`notes/`](../notes/README.md) is the project's lab notebook: one file per format, editor area and
mod, with addresses in `groove.exe`, confidence levels, what was verified in the game and known
limits. Process material (agent briefs, review reports) is in [`notes/dev/`](../notes/dev/).
## Releasing
Releases are built by Gitea Actions ([.gitea/workflows/release.yml](../.gitea/workflows/release.yml)):
1. Add a `## <version> – …` section at the top of [CHANGELOG.md](../CHANGELOG.md) (it becomes the
release text; the workflow fails if it is missing).
2. `git tag v<version> && git push origin v<version>`
The workflow builds and tests (tests that need the game CD skip themselves on the runner), runs
`packageAll` with `-PreleaseVersion=<version>` (the tag decides the version; without it
`build.gradle`'s default is used), writes `SHA256SUMS.txt` and creates the release with the four
packages via [tools/release/publish_gitea.py](../tools/release/publish_gitea.py) (standard library
only; re-running replaces files of the same name). [.gitea/workflows/build.yml](../.gitea/workflows/build.yml)
builds every push.
Requirements on the Gitea side: Actions enabled (`[actions] ENABLED = true`), an `act_runner` with
the label `ubuntu-latest` (an image with `python3`, `curl`/network access – the build downloads the
Temurin JDK/jmods and launch4j), and an attachment size limit above ~60 MB (`[attachment] MAX_SIZE`
in `app.ini`). If the built-in token may not create releases, add a repository secret
`RELEASE_TOKEN` with write access. Locally the same packages come from
`./gradlew -PreleaseVersion=<version> packageAll` (output in `build/dist`).
+126
View File
@@ -0,0 +1,126 @@
# Build your own level
A step-by-step path from "I have the game" to "my friends are playing my level". Each step is short
and links to the chapter of the [editor manual](../manual/index.md) that has the details.
The manual is also built into the editor: press **F1** over any panel, tool or dialog.
| Step | |
|---|---|
| 1 | [Set up LlamaMod](#1-set-up-llamamod) |
| 2 | [Learn the editor in ten minutes](#2-learn-the-editor-in-ten-minutes) |
| 3 | [Plan the level](#3-plan-the-level) |
| 4 | [Build the ground](#4-build-the-ground) |
| 5 | [Start, checkpoints and the exit](#5-start-checkpoints-and-the-exit) |
| 6 | [Objects, enemies and cameras](#6-objects-enemies-and-cameras) |
| 7 | [Look and feel: textures, fog, music, light](#7-look-and-feel-textures-fog-music-light) |
| 8 | [Play-test](#8-play-test) |
| 9 | [Share it](#9-share-it) |
| 10 | [Go further](#10-go-further) |
Before you start, skim [Design rules](design-rules.md): the numbers (jump distances, draw
distance) and the handful of rules that keep the game from crashing.
![The level editor](../img/editor.jpg)
## 1. Set up LlamaMod
1. Get **your own copy** of the game: the CD or an ISO image of it (*The Emperor's New Groove* PC,
2000; German: *Ein Königreich für ein Lama*). LlamaMod contains no game files.
2. Download the **full** package for Windows or Linux, unpack it and start `LlamaMod.exe` /
`./LlamaMod` (Linux needs Wine to run the game). Or build it from source – see the
[main README](../../README.md#build-from-source).
3. The first-start wizard copies the game into LlamaMod's data folder. The original CD or ISO is
never changed.
4. Click **Play** once to check that the unmodded game runs.
Manual: [Installing LlamaMod and the first start](../manual/02-installing.md).
## 2. Learn the editor in ten minutes
Click **Level Editor** in LlamaMod and follow
[Your first level in 10 minutes](../manual/tutorial-first-level.md): a new level from the
*Island* template, a ramp, a coin trail, play it. Then read
[Introduction and concepts](../manual/01-introduction.md) – tiles, brushes, objects, zones, base
levels, chapters – and [Navigating the viewport](../manual/04-navigation.md).
## 3. Plan the level
* **New or edited?** A *new level* starts empty on a **base level** (default: Village 1) that
provides the object types, textures, sky, fog and music. *Editing an original level* changes the
real Village, River … in place ([tutorial](../manual/tutorial-original-level.md)).
* **Choose the base by its objects.** Only the base's object types are available directly; more
can be imported from any world ([Importing objects](../manual/09-objects.md#importing-objects-from-other-worlds)).
* **Size.** The village grid is 96 × 96 cells; the llama is 0.6 tall and jumps about 1.3 cells.
A small, dense level plays better than a big empty one – the draw distance is only 8–16 cells.
* **Where it goes.** New levels live in the hidden world 9, *TestLevel*, on chapter 1 or 32–49
([why](../reference/testlevel.md)). The editor picks a free chapter.
## 4. Build the ground
* **Brushes** (B) draw boxes, ramps, stairs, cylinders and more; they are cut into tiles when the
level is built ([Building geometry](../manual/06-geometry.md)).
* **Tiles** (T) place the base level's own tile models for an original look.
* **Meshes** from Blender or any 3D program come in as OBJ or glTF
([Importing meshes](../manual/07-meshes.md), [Blender tutorial](../manual/tutorial-blender.md)).
* **Collision** (K) decides what is solid: walls, floors, invisible walls, death floors, the exit
floor. The llama cannot step up even small edges – use slopes, or walls without collision.
![A level exported as glTF and opened in Blender](../img/gltf-blender.jpg)
## 5. Start, checkpoints and the exit
Use the one-click setups in the Objects panel ([Objects](../manual/09-objects.md#one-click-setups)):
* **Player start** – the llama plus its start camera node behind it. Keep the space behind the
llama free, or the camera jumps aside.
* **Checkpoint** – where the llama respawns after a fall.
* **Exit** – a LevelDoor *and* a floor with collision material 16 in the same grid cell. Without
that floor the exit does nothing ([why](../reference/objects.md#the-exit-leveldoor--certain)).
Its target is the next level; in TestLevel never "world finished" (7, 7, 7).
* **Death plane** – a death floor below everything, so falling off respawns instead of falling
forever.
## 6. Objects, enemies and cameras
* Place coins with the **Coin tool** (O), anything else from **Assets → Objects**
([Objects](../manual/09-objects.md)).
* Enemies and moving things often need a **waypoint chain** – some guards even need a pair of
waypoint boxes and an event bit, or the game removes them. The Inspector and *Check for problems*
tell you ([Waypoints](../manual/10-waypoints.md)).
* **Cameras**: camera zones, cut scenes and the start camera
([Cameras and triggers](../manual/11-cameras-triggers.md)).
* Change how an object behaves – a number in its script or a whole new type: see
[Strats](../strats/README.md) and [manual chapter 22](../manual/22-strats.md).
## 7. Look and feel: textures, fog, music, light
* **Textures**: the base's textures, imported PNG/JPG pictures, painting onto faces, flip-book
animations ([Textures](../manual/08-textures.md)).
* **Environment zones** set fog (= draw distance), sky colour and **music**; teleport zones move
the player between areas ([Environment](../manual/12-environment.md),
[music ids](../reference/audio.md#music)).
* **Light**: tiles get baked vertex colours (*Bake lighting*), lights only light objects.
* **Visibility regions** hide areas the camera cannot see – needed for big levels
([Visibility](../manual/13-visibility.md)).
## 8. Play-test
**F5** builds the level and starts the game directly in it; **Shift+F5** starts at the camera
position. *Check for problems* (also run on every build) lists errors and warnings – double-click
one to select the culprit. **Escape** in the game quits straight back to the editor.
Manual: [Building, problems and play-testing](../manual/15-build-play.md).
## 9. Share it
**File → Save** writes a small `.llamalevel` project: only your changes, no game data. Send that
file; the receiver copies it into LlamaMod's `levels` folder (or opens it in the editor and uses
*Level → Add to LlamaMod…*), ticks it in the *Levels* category and clicks *Apply*. It is built from
their own game copy. Manual: [Sharing levels](../manual/17-sharing.md).
## 10. Go further
* Build levels from code instead of the mouse: [Levels from scripts](levels-in-code.md).
* Understand the data: [Reference](../reference/README.md).
* Write object behaviour: [Strats](../strats/README.md) and the
[Alligator Arena walkthrough](../strats/arena.md).
* All tools at a glance: [Tools](../tools.md).
+48
View File
@@ -0,0 +1,48 @@
# Design rules
The numbers and rules that matter when you design a level for this engine. Background and sources:
[Engine facts and limits](../reference/engine-limits.md).
## Sizes to design with
| | |
|---|---|
| one grid cell | 1 unit |
| llama | 0.6 tall, walks 1.1 units/s |
| jump up | ≤ 0.35 (0.5 fails) |
| jump across, with a run-up | ~1.3 (from standing much less) |
| easy parkour | gaps 0.3 with +0.2 height, platforms ≥ 1.5 long |
| steps without jumping | none – use slopes or walls without collision |
| draw distance | the fog end of the zone: 8–16 cells in the originals |
| objects visible | within ~12.5 units of the llama |
![The Test World's gallery: every figure of the game on a pedestal](../img/testworld-gallery.jpg)
## Rules that keep the level working
1. **One player start**, with nothing solid right behind the llama (start camera).
2. **Exit = LevelDoor + floor with collision material 16 in the same cell.** Target (1, 9,
chapter) in TestLevel – never (7, 7, 7).
3. **A death floor or death plane** under every place the llama can fall to.
4. **A checkpoint** before every hard part.
5. **TestLevel chapter 1 or 32–49** for new levels.
6. **Guards and box walkers need their waypoint boxes** (and guards an event bit someone sets),
or the game removes them.
7. **Every area must be visible from where the camera is**: with visibility regions, check the
masks (*Check for problems* reports regions nothing can see).
8. **Stay within the budget**: 16 MB level memory, 127 texture pages, 128 sounds – the import
preview and *Check for problems* show where you are.
The editor and both level builders enforce the data-level rules (tiles inside the grid, vertex
colours, type fields, collision cells) automatically. *Check for problems* reports the rest.
## Good habits
* **Play-test often** (F5). The engine is unforgiving and a small change can stop the exit or
hide an area.
* **Look at the originals.** Open an original level in the editor and see how it builds doors,
cameras and enemy paths; the [debug view mod](../tools.md#debug-view) shows collision and
invisible objects in the running game.
* **Keep levels compact.** Short sight lines hide the draw distance and keep the frame rate up.
* **Copy numbers** (speeds, parameters, waypoint fields) from original objects that already do
what you want.
+97
View File
@@ -0,0 +1,97 @@
# Levels from scripts
Besides the editor, a level can be described as a short script that a **level builder** turns
into a complete WAD: geometry made of boxes, ramps, stairs, trees and sea, objects placed by type
name, lighting baked automatically. There are two builders with the same ideas:
| Builder | Language | Used by |
|---|---|---|
| [`tools/levelbuild.py`](../../tools/levelbuild.py) | Python 3 (numpy, Pillow) | the original test world [`levels/testwelt.py`](../../levels/testwelt.py), the jump lab [`levels/sprunglabor.py`](../../levels/sprunglabor.py) |
| [`levelbuild/LevelBuilder.java`](../../src/main/java/com/eliasmueller/llamamod/levelbuild/LevelBuilder.java) | Java (`LevelScript`) | the Test World mod ([`mods/testworld/`](../../src/main/java/com/eliasmueller/llamamod/mods/testworld/)), the [Alligator Arena](../strats/arena.md), the editor's brushes |
Both start from a **base level** (default Village 1): its object code, textures and sound are
kept; geometry, tile grid, vertex colours, objects and waypoints are generated from scratch. Full
documentation of the Python builder, the measured physics and the test world:
[notes/levelbau.md](../../notes/levelbau.md).
## A Python level
```python
LEVEL_ARGS = dict(name='MyLevel', seed=7)
def build(L):
L.sea(0, 0, 60, 60, y=-2.0, kill=(6, 4, 54, 56)) # sea + death plane
L.box(14, 12, 26, 24, -0.4, 0, top='stein', side='stein') # a stone plaza
L.stairs(26, 18, 29, 22, 0, 1.5, 3, '+x') # stairs up to the east
L.box(29, 17, 33, 23, 0, 1.5, top='gras', side='erde')
L.player(20, 14.5, rot=0) # facing +z
L.checkpoint(17, 18.5)
L.coins([(27, 20), (28, 20)])
L.strat('Bunny', 31, 20)
L.box(31, 21, 32, 22, 1.5, 1.56, top='platte', side='stein', mat=16) # exit floor
L.exit(31.5, 21.5) # LevelDoor in the same cell
```
```bash
python3 tools/levelbuild.py my_level.py out/my_level.wad # build
python3 tools/levelbuild.py --types # object types of the base
python3 tools/levelbuild.py --textures tex.png # texture names and a contact sheet
```
Coordinates are game units: x east, y up, z = grid row ("south"); angles in degrees, 0° faces +z.
Building blocks: `floor`, `box`, `platform`, `pillar`, `ramp`, `stairs`, `fence`, `tree`, `sea`,
`quad`, `collider`, `obj` (an OBJ file), `height_at`. The exit needs a floor with collision
material 16 in its cell – the test world uses `box(..., mat=16)` under the door.
**Texture names**: the Python builder's own names are German (`gras`, `stein`, `erde`, `platte`,
`meer` …, list: `--textures`); the English names of the Java builder (`grass`, `stone`, `earth`,
`slab`, `sea` …) work as well and give the same result.
The base level is `t1l1m001.wad` in the CD folder `$LLAMAMOD_CD/wads` (default
`~/.wine-lama/iso-dk/Setup/Eng/Cd`), or `--base <file>`. `--install` copies the WAD into the TestLevel
mod of a hand-made Wine setup (`$LAMA_WINEPREFIX/mods/testlevel`, default `~/.wine-lama`) – with
LlamaMod, use a Java level or the editor instead. All variables: [Tools: Paths](../tools.md#paths).
## A Java level
Implement [`LevelScript`](../../src/main/java/com/eliasmueller/llamamod/levelbuild/LevelScript.java):
```java
public final class MyLevel implements LevelScript {
@Override public String levelName() { return "My Level"; }
@Override public void build(LevelBuilder l) {
l.sea(0, 0, 40, 40, -1.5, new double[] {7, 7, 33, 33});
l.floor(10, 10, 30, 30, 0, "grass");
l.box(18, 24, 22, 25, 0, 0.06, o().top("pattern_purple").mat(16)); // exit floor
l.player(20, 12, 0);
l.checkpoint(12, 12);
l.exit(20.5, 24.5, null, null, new int[] {1, 9, 32});
l.coins(List.of(new double[] {20, 16}, new double[] {20, 18}));
}
}
```
`buildWad(baseWad, wads, opts)` returns the WAD. To ship it, write a small
[mod](../developers.md#writing-a-mod) whose `apply` writes `wads/T1L9M0nn.wad` (chapter 1 or
32–49) and a loading picture – [`ArenaMod`](../../src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaMod.java)
is a complete example. Texture names are English here (`grass`, `stone`, `sand`, `inca`, …) and are
matched by picture on the chosen base.
## The editor from the command line
The editor's project format can be scripted too:
```bash
llamamod --editor-new my.llamalevel t1l1m001 --name "My level" # new project on a base
llamamod --editor-import t1l2m001 jungle.llamalevel # an original level as a project
llamamod --editor-build my.llamalevel out.wad # build the WAD
llamamod --import-level my.llamalevel # install as a level mod
```
## Seeing the result without the game
* [`tools/geom_export.py`](../../tools/geom_export.py) `level.wad outdir --collision --objects`
writes OBJ files, texture pages and two preview images.
* [`tools/vorschau/`](../../tools/vorschau/) renders labelled overviews with Blender (Eevee).
* In the game: the test harness, see [Tools](../tools.md#testing-in-the-game).
Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

+1 -1
View File
@@ -76,7 +76,7 @@ created. Object types of other levels can be added later with **Assets → All w
The game has eight worlds – **Village, Jungle at Night, River, Jungle by Day, Mountain, City,
Catacombs, Lab** – each with several chapters. A ninth, hidden world called **TestLevel** has 49
empty chapter slots. New levels go into one of these TestLevel chapters, so they never replace an
empty chapter slots; 19 of them (1 and 32–49) are safe to use. New levels go into one of these TestLevel chapters, so they never replace an
original level: each project gets its own chapter (shown in the New level dialog and in
[Level settings](14-level-settings.md)). In the game they appear on the world-9 map as
"TestLevel – chapter *n*".
+1 -1
View File
@@ -29,7 +29,7 @@ list of recent projects.
| **Level name** | The name LlamaMod and the loading picture show. |
| **Author** | Your name (remembered for the next level). |
| **Base level** | The original level that provides object types, textures, sky, fog and music, shown with its loading picture. Default: Village – Chapter 1. See [Base levels](01-introduction.md#base-levels). |
| **TestLevel chapter** | The chapter of world 9 (TestLevel) the level replaces when it is enabled. The next free chapter is suggested. |
| **TestLevel chapter** | The chapter of world 9 (TestLevel, 32–49 or 1) the level replaces when it is enabled. The next free chapter is suggested. |
| **Start with** | **Empty** (nothing but the base level's sky, fog and music), **Flat floor** (a 16×14 grass floor, player start and an exit) or **Island** (a grass island with trees in the sea, player start and an exit). |
Every new level starts clean: of the base level it keeps only the **start zone** (its fog, colours
+1 -1
View File
@@ -87,7 +87,7 @@ toggle snapping for this drag. **Shift+click** selects an existing brush without
The options section in the Inspector changes with the primitive. Textures are chosen with a
swatch button: click it for a searchable grid of all textures of the level (names are the
village texture names; `#n` is texture number *n* of the base level, see
village texture names the base level has; `#n` is texture number *n* of the base level, see
[Textures](08-textures.md)). **none** leaves that part out.
| Option | Primitives | Meaning |
+4 -2
View File
@@ -10,8 +10,10 @@ paint them onto faces.
* A level has up to **127 pages**. Each page has one *blend class* (opaque, cut-out,
semi-transparent …); every texture on it should have the same class.
* Textures are numbered: `#221` is texture 221 of the level. In brush texture fields you can type
`#221` or a name. The names (`grass`, `stone`, `bark` …) are those of the village textures;
other base levels mostly use numbers.
`#221` or a name. The names (`grass`, `stone`, `bark` …) are those of the village textures.
On another base level a name means the same picture wherever that level has it (Village 2 and 3
have most of them); names the base level does not have are left out of the texture grid, and a
brush that uses one is skipped with a warning.
* Brushes can also use flat colours (colour names, or `rgbRRGGBB`).
* The game shows textures in **15-bit colour** (5 bits per channel) with 1 bit of transparency,
so gradients get coarser and soft edges become hard.
+1 -1
View File
@@ -113,7 +113,7 @@ Colour pickers update the viewport live while you pick; the whole pick is one un
| Field | Meaning |
|---|---|
| **Music** | Track started when the zone becomes active (`music/<name>.ASF`): VILLAGE, JUNGLE1, RIVER, CITY, CATACOMB, LAB, BALLOON, KRONK, VICTORY … *0 – (none)* stops the music. |
| **Music** | Track started when the zone becomes active (`music/<name>.ASF`): VILLAGE, JUNGLE1, RIVER, CITY, CATACOMB, LAB, BALLOON, KRONK, VICTORY … *0 – (none)* stops the music. Tracks marked *(no file on the CD)* have no music file (on the German CD) – pick another one. |
| **Sky model** | The object model drawn as the sky dome (one of the sky models used in this level), or none. |
| **Sky yaw** | Turns the sky dome. |
+1 -1
View File
@@ -25,7 +25,7 @@ nothing.
| **Description** | A few sentences for the mod list. |
| **Project** | *new level* or *changes to an original level* (read only). |
| **Base level** | The original level the project is built on – it provides the object code, textures, sounds, sky and music (read only). |
| **TestLevel chapter** | New levels only: the chapter of world 9 (TestLevel, 1–49) the level replaces when its mod is on. |
| **TestLevel chapter** | New levels only: the chapter of world 9 (TestLevel: 32–49, or 1) the level replaces when its mod is on. Chapters 2–31 are not offered: the game would store this level's coins over other game data. |
Below the chapter field the dialog lists which chapters other level projects in your `levels`
folder already use, so you can pick a free one. Each level needs its own chapter; chapter 1 is
+7 -4
View File
@@ -39,14 +39,17 @@ Then tick the level in LlamaMod, click **Apply** and **▶ Play**. To get rid of
## Chapters and conflicts
Each new level occupies one of the 49 TestLevel chapters. The editor picks a free chapter for
Each new level occupies one of the TestLevel chapters **32–49** (or chapter 1). The editor picks a free chapter for
new projects, but levels from different people may use the same one. If two active level mods
use the same chapter – or two mods change the same original level – LlamaMod stops applying and
names the files involved. Fix it in the mod's options: every new-level mod has a
**TestLevel chapter** option (1–49).
**TestLevel chapter** option (1, 32–49).
> **Note:** Chapter 1 of the TestLevel is also used by the built-in **Test World** mod. Prefer
> chapters 2 and above for your own levels.
> **Note:** Chapter 1 of the TestLevel is also used by the built-in **Test World** mod. The game
> keeps coins and flags for chapters 2–31 of world 9 in memory that belongs to other things (your
> progress, the text language …), so LlamaMod does not use them: levels saved with such a chapter
> by an older version move to a free chapter from 32 on. With more than 18 new levels, some share a
> chapter – only one of them can be on at a time.
## Updating a shared level
+32 -3
View File
@@ -38,7 +38,7 @@ option:
| Option | Values | Meaning |
|---|---|---|
| TestLevel chapter | 1 – 49 | the chapter of world 9 the level occupies; each level needs its own (chapter 1 is also used by the Test World mod) |
| TestLevel chapter | 1, 32 – 49 | the chapter of world 9 the level occupies; each level needs its own (chapter 1 is also used by the Test World mod). Other chapters would overwrite game data such as your progress, so older levels on chapters 2–31 move to a free one of 32–49 |
Edited original levels have no options – they replace their chapter. See
[Sharing levels](17-sharing.md).
@@ -61,7 +61,7 @@ not want with **Remove…**.
## The Alligator Arena minigame
The **Alligator Arena** mod (Levels) puts a small minigame on a TestLevel chapter (default 8):
The **Alligator Arena** mod (Levels) puts a small minigame on a TestLevel chapter (32–49, default 49):
a walled sand pit where alligators come in waves (wave 1: one, then two at a time). Face an
alligator and **kick** (Action) or **jump and press Action in the air** (the llama dives onto
it). Every hit costs it one life: it blinks, backs off and coins pop out – they count on the
@@ -74,6 +74,21 @@ another mod on the same chapter stops the apply with a message – pick another
![Alligator Arena: two alligators, coins popping out](img/mod_arena.jpg)
## The Llama Coaster ride
The **Llama Coaster** mod (Levels) puts a roller-coaster ride on a TestLevel chapter (32–49,
default 48). You ride the cart from the Catacombs coaster levels: it leaves the station by itself,
races down drops and up climbs, speeds up on the boost arrows, jumps over a gap and turns round on
the spin pad. Press **Left** and **Right** to move the cart across the track and collect the
coins. After the last lap the cart stops at the station, fireworks go off and the tally appears;
**Enter** rides again, and *Beenden und zur Auswahlkarte wechseln* in the Esc menu goes back to the
map. The options pick the track (*Loop* or the longer *Grand circuit*), the laps (1–3), the speed
and whether there are coins. Unlock TestLevel on the **Save games** tab and pick the chapter on the
world-9 map. A level project or another mod on the same chapter stops the apply with a message –
pick another chapter then.
![Llama Coaster: boost arrows on the Grand circuit](img/mod_coaster.jpg)
## Command line
LlamaMod can be scripted:
@@ -107,7 +122,21 @@ A minigame on a TestLevel chapter: alligators come in waves into a walled pit. E
| Waves | 1 – 5 | 3 | Wave n brings n alligators (at most 2 at once). |
| Llama energy | 1 – 5 | 4 | Energy cap of the llama in the arena (5 = normal). |
| Coins per hit | 0 – 5 | 1 | Coins that pop out per hit (5 more at the end). |
| TestLevel chapter | 2 – 8 | 8 | Chapter of world 9 the arena replaces. |
| TestLevel chapter | 32 – 49 | 49 | Chapter of world 9 the arena replaces (32–49; chapter 1 is the Test World's). |
#### Llama Coaster
A roller coaster ride on a TestLevel chapter, with the cart from the Catacombs coaster levels: drops, climbs, boost arrows, a spin pad that turns the cart backwards and a jump over a gap. Steer left and right to stay on the track and collect coins; the ride ends at the station. Unlock TestLevel on the Save games tab.
*Id* `coaster`
| Option | Values | Default | Description |
|---|---|---|---|
| Track | Loop, Grand circuit | Loop | Short loop (about 100 units) or the grand circuit (about 150 units, two jumps, two spins). |
| Laps | 1 – 3 | 2 | Laps before the ride stops at the station. |
| Speed | Slow, Normal (as in the game), Fast | Normal (as in the game) | Cruise speed and boost strength of the cart. |
| Coins | on / off | on | Coins along the track. |
| TestLevel chapter | 32 – 49 | 48 | Chapter of world 9 the coaster replaces (32–49; chapter 1 is the Test World's, 49 the arena's). |
#### Test World
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+1 -1
View File
@@ -50,7 +50,7 @@ The start of the level uses the **start zone**:
4. In the Inspector, click **Fog / sky colour** and choose a new colour – for example a soft
orange for an evening mood. The viewport follows while you pick.
5. Lower **End (Max)** a little for a denser fog.
6. Under **Music & sky**, choose another **Music** track, for example `PUZZLE2`.
6. Under **Music & sky**, choose another **Music** track, for example `22 – KRONK`.
The village houses have their own zones (one per door), so the inside of a house keeps its
original fog. See [Environment](12-environment.md) for every field.
+56
View File
@@ -0,0 +1,56 @@
# Reference: game files and engine
Everything we know about the data of *The Emperor's New Groove* (PC, 2000; German: *Ein Königreich
für ein Lama*), found by reverse engineering `groove.exe` and checking every claim against the
level files on the CD and in the running game. The game runs on Argonaut's **Croc 2 engine** (a PC
port of a PlayStation design), which explains most of its quirks.
Confidence is marked where it matters: **certain** = verified in code and data (byte-exact round
trip or seen in game), **likely** = consistent with the data, **unknown** = open question.
| Page | What it covers |
|---|---|
| [Game files](game-files.md) | CD and HD folders, level naming scheme, worlds and chapters, loading pictures (COR), movies, demos, save games (KSV) |
| [WAD container](wad.md) | the chunk list of a level file and which chunk does what |
| [Geometry and collision](geometry.md) | TRAK tile models, the MAP tile grid, coordinate spaces, collision polygons and materials, vertex colours, visibility regions |
| [Objects, waypoints, zones](objects.md) | MAP strat records, parameters, waypoints, environment zones (fog, music, teleports), lights, the player start, checkpoints and the exit |
| [Textures](textures.md) | TEXT chunk: pages, pixel format, texture rectangles, texture animations |
| [Music and sound](audio.md) | ASF music, the level sound bank, speech, what is still unknown |
| [TestLevel (world 9)](testlevel.md) | the hidden world all custom levels live in, and which chapters are safe |
| [Engine facts and limits](engine-limits.md) | scale, jump distances, draw distance, memory budget, things that crash the game |
How objects *behave* (the strat bytecode) has its own section: [Strats](../strats/README.md).
## Where the details live
These pages are the readable summary. The full lab notes – with every address in `groove.exe`,
open questions and the history of each finding – are in [`notes/`](../../notes/README.md):
| Topic | Lab notes |
|---|---|
| geometry, collision, coordinate spaces | [notes/geometry.md](../../notes/geometry.md) |
| MAP layout, strat records, other chunks | [notes/objects.md](../../notes/objects.md) |
| textures | [notes/textures.md](../../notes/textures.md) |
| every object type in every level | [notes/strat_types_table.md](../../notes/strat_types_table.md) |
| object parameters | [notes/editor-object-schemas.md](../../notes/editor-object-schemas.md) |
| world 9 chapters | [notes/testlevel-chapters.md](../../notes/testlevel-chapters.md) |
| level building, physics measurements, the exit | [notes/levelbau.md](../../notes/levelbau.md) |
| fog, lights, regions | [notes/editor-tools-environment.md](../../notes/editor-tools-environment.md), [notes/mod-fog.md](../../notes/mod-fog.md) |
| importing objects across levels | [notes/editor-cross-import.md](../../notes/editor-cross-import.md) |
## Code that reads and writes these formats
Every parser rebuilds all original files byte for byte, so the code is a reliable executable
specification.
| Format | Java (LlamaMod) | Python (kit) |
|---|---|---|
| WAD container | [`format/Wad.java`](../../src/main/java/com/eliasmueller/llamamod/format/Wad.java) | [`tools/wadtool.py`](../../tools/wadtool.py) |
| TRAK / STPC models, collision | [`format/Trak.java`](../../src/main/java/com/eliasmueller/llamamod/format/Trak.java), [`Model.java`](../../src/main/java/com/eliasmueller/llamamod/format/Model.java), [`Collision.java`](../../src/main/java/com/eliasmueller/llamamod/format/Collision.java), [`Stpc.java`](../../src/main/java/com/eliasmueller/llamamod/format/Stpc.java) | [`tools/geomlib.py`](../../tools/geomlib.py) |
| MAP | [`format/MapChunk.java`](../../src/main/java/com/eliasmueller/llamamod/format/MapChunk.java) | [`tools/mapchunk.py`](../../tools/mapchunk.py) |
| TEXT | [`format/TextChunk.java`](../../src/main/java/com/eliasmueller/llamamod/format/TextChunk.java) | [`tools/textchunk.py`](../../tools/textchunk.py) |
| whole level | [`format/Level.java`](../../src/main/java/com/eliasmueller/llamamod/format/Level.java) | – |
| strat code | [`format/strat/`](../../src/main/java/com/eliasmueller/llamamod/format/strat/), [`format/stpcmerge/`](../../src/main/java/com/eliasmueller/llamamod/format/stpcmerge/) | [`tools/strats.py`](../../tools/strats.py), [`tools/stpcwalk.py`](../../tools/stpcwalk.py) |
| COR pictures | [`format/CorImage.java`](../../src/main/java/com/eliasmueller/llamamod/format/CorImage.java), [`CorEncoder.java`](../../src/main/java/com/eliasmueller/llamamod/format/CorEncoder.java) | [`tools/cor2png.py`](../../tools/cor2png.py), [`tools/png2cor.py`](../../tools/png2cor.py) |
| KSV saves | [`save/Ksv.java`](../../src/main/java/com/eliasmueller/llamamod/save/Ksv.java) | – |
| ASF music | – | [`tools/asf2wav.py`](../../tools/asf2wav.py) (decode only) |
+73
View File
@@ -0,0 +1,73 @@
# Music and sound
Sound is the least explored part of the game. This page separates what is verified from what is
only a guess. Custom music or sound effects are **not supported yet** by any LlamaMod tool.
## Music
| Fact | Status |
|---|---|
| Music tracks are files `music/<NAME>.asf` on the CD (33 files) | certain |
| A level does not reference music files directly: **environment zones** carry a music id, and the start zone's id plays when the level begins | certain |
| Music ids map to names through a table in `groove.exe` at 0x578960 (20 bytes per entry, name pointer at +8), played by `FUN_00546b70`; id 0 stops the music | certain |
| ASF here is Argonaut's own format (not Microsoft ASF): 4-bit ADPCM, 44.1 kHz stereo, 17-byte blocks, decoded by `asfcodec.adl` in the game folder | certain (decoder reimplemented) |
| A second zone field (u16 at +0x56) is probably the ambience setting | likely |
The id table, as used by the editor's zone inspector
([`EnvMath.MUSIC`](../../src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvMath.java)):
| Id | Track | Id | Track | Id | Track | Id | Track |
|---|---|---|---|---|---|---|---|
| 0 | (stop) | 10 | GAMEOVER | 20 | SNKJUNG | 30 | TUNE1 |
| 1 | VILLAGE | 11 | TALLY | 21 | FROG | 31 | CHACHA |
| 2 | JUNGLE1 | 12 | TURRACE | 22 | KRONK | 32 | JJUNGLE2 |
| 3 | RIVER | 13 | PACHASNK | 23 | KIDLLAMA | 33 | TUNE9 |
| 4 | JUNGLE2 | 14 | MOUNT2 | 24 | TUNE8 | 34 | TUNE6 |
| 5 | MOUNTAIN | 15 | BALLOON | 25 | ROLLER | 35 | VICTORY |
| 6 | CITY | 16 | JUNGSPID | 26 | BUNNY | 36 | PUZZLE2 |
| 7 | CATACOMB | 17 | JAGCHASE | 27 | BLOCK | 37 | PLATFORM |
| 8 | LAB | 18 | RIVER | 28 | TUNE5 | 38 | (none) |
| 9 | TITLE | 19 | RIVRACE | 29 | CITYPUZZ | 39 | ENDGAME |
Four names (GAMEOVER, TALLY, PUZZLE2, PLATFORM) have no `.asf` file on the German CD; what the
game plays for them (probably nothing) was not checked. The editor marks them *(no file on the CD)*.
**Changing the music of a level** works today: pick another id for the start zone (or any zone)
in the editor's *Environment* panel ([manual: Environment](../manual/12-environment.md),
[tutorial: Edit an original level](../manual/tutorial-original-level.md)).
**Listening to the tracks:** [`tools/asf2wav.py`](../../tools/asf2wav.py) `in.asf out.wav`
decodes a track exactly like the game's codec (filter 0: `s = s1 + (n << shift)`, filter 1:
`s = 2·s1 − s2 + (n << shift)`, 16-bit wrap-around instead of clipping – FFmpeg's decoder clips and
drifts).
**New music – open:** there is no encoder yet. Writing one (the inverse of `asf2wav.py`) and
replacing a track file through a mod would be the natural next step; the header fields after the
block count (`rate`, `flags` with bit 1 = stereo) are read by the decoder, the others are *unknown*.
## Sound effects
| Fact | Status |
|---|---|
| Each level has its own sample bank, chunk **SMPC**: `u32 n`, n × `{u32 "cvg " tag, u32 length, data}` | certain ([`SoundMerge.java`](../../src/main/java/com/eliasmueller/llamamod/format/stpcmerge/SoundMerge.java)) |
| Strats play samples by index (`soundPlay(sound:N)`, opcodes 0xd5 / 0x99 / 0x131 play, stop, is-playing); at most **128** samples per level, ids with bit 0x80 are the front-end sounds | certain |
| The samples are PlayStation-style VAG ADPCM, decoded by `Vagcodec.adl` | likely (tag and codec name; the sample header was not decoded) |
| **AMPC** holds ambient sound data (`ambi` / `VABp` blocks) | likely, not decoded |
| `FESOUND.WAD` is the front-end sound bank (not a chunk file) | likely |
Samples can be **moved between levels**: the editor's cross-world import and the arena copy the
samples an imported object needs into the target level and renumber the `sound:N` operands
([notes/editor-cross-import.md](../../notes/editor-cross-import.md)). Changing which sample an
object plays is a constant edit in its script ([Strats](../strats/custom-types.md)). Exporting or
replacing a sample's audio is not possible yet.
## Speech and dialogue
* **LGPC** holds the dialogue texts of a level in all languages plus a speech slot per line.
* A speech slot `#<hex>` is an index into **SRPC**, whose entries are offset/size pairs into the
global speech file `music/<language>.cvs` in the game folder (the German version ships
`english.cvs`).
* Strats show texts with opcodes 0xf0 / 0xfa (`topSay text:N`). Imported objects bring their
texts and speech references along.
The CVS file format itself was not analysed.
+67
View File
@@ -0,0 +1,67 @@
# Engine facts and limits
Numbers to design levels with, and the things that crash the game. Measured in the game with the
test harness unless noted.
## Scale and movement
One unit = one grid cell. Measured with the jump lab ([`levels/sprunglabor.py`](../../levels/sprunglabor.py))
and a coin measuring track ([notes/levelbau.md](../../notes/levelbau.md#findings-from-the-game-tests-lamatest-xvfb)):
| Quantity | Value |
|---|---|
| llama height | about 0.6 |
| walking speed | about 1.1 units/s (≈ 1 s to accelerate) |
| highest edge a jump clears | 0.35 (0.5 is too high) |
| long jump with a run-up | about 1.3 units (from standing: much shorter, a 0.5 gap fails) |
| comfortable parkour | gaps of 0.3 with +0.2 height, platforms ≥ 1.5 long |
| step-up without jumping | none – any edge with wall collision blocks; use slopes or walls without collision |
| frame rate of the logic | 30 frames per second (script speeds are per frame) |
Controls are **camera-relative**; the start camera sits behind the llama and moves aside if
something solid is right behind it – keep the area behind start points clear.
## Drawing
| Fact | Value |
|---|---|
| draw distance | = **fog end** of the active environment zone; originals 7–16 cells at detail *Maximum* (roller coaster up to 23), about half at *Minimum* |
| objects | drawn only within about **12.5 units of the llama** (they keep running); their activity radius also follows the fog end |
| hidden areas | visibility regions: a cell is drawn only if the camera region's mask contains its region |
| texture coordinates | none per vertex – every triangle shows half of a texture rectangle |
| lights | light only object models; tiles use baked vertex colours |
The *Draw distance* mod scales all fog values of all levels
([notes/mod-fog.md](../../notes/mod-fog.md)).
## Memory and counts
| Limit | Value | Source |
|---|---|---|
| level memory | **16 MB** for STPC, TEXT, TRAK, MAP, LGPC and the strat pool; originals use up to ~7 MB | `FUN_0041eea0` |
| strat heap | 65533 words (≈ 256 KB) for strat slots (0x154 bytes each), collision blocks and variables | `FUN_0041ae20` |
| strat slots | `poolExtra + 30` – must cover placed **and** spawned objects | MAP |
| vertices per tile model | 8000 (transform buffer) | |
| collision polygon | within ±8 units of its tile's origin | i16 fixed point |
| texture pages | 127 | signed page byte |
| texture entries | 65535 | u16 face field |
| sound samples | 128 per level | signed char id |
| file sizes that worked | TRAK up to 1 MB, 5800 tiles, WAD up to ~7 MB (originals); the test world: 628 models, 2474 tiles, 6.3 MB | measured |
## Things that crash the game
| Cause | Symptom |
|---|---|
| a tile outside the W × H grid | memory corruption, crash on load or later |
| vertex-colour block not exactly one entry per vertex | objects read shifted, crash |
| a strat type with collision but type flags 0 (or too few collision-bone words) | page fault in the heap allocator `FUN_0041ae20` |
| exit target (7, 7, 7) in world 9 | crash after saving (world 10 has no name) |
| a selectable chapter without a WAD | "Couldnt load wad" on the world map |
| a waypoint-dependent type without its boxes | not a crash: the game removes the object ("removing strat") |
| world-9 chapters 2–31 (unpatched exe) | progress, language or options overwritten ([TestLevel](testlevel.md)) |
## Coordinates, numbers, angles
* Game space: x east, y up, z = grid row; strats use 20.12 fixed point (4096 = 1.0).
* MAP angles: 4096 = 360°; strat angles: 0x1000000 = 360°.
* Objects on a flat original floor tile stand at `tile.y + 0.25`.
+99
View File
@@ -0,0 +1,99 @@
# Game files
What is on the CD and on the hard disk, how files are named, and the small formats around the
levels. The level files themselves are described in [WAD container](wad.md).
## Two folders: HD and CD
The installer copies a small **HD** part to the hard disk; everything big stays on the **CD**. The
game looks up each file in `HDPath\dir\file`, then in `CDPath\dir\file`, then on every CD drive
(`FUN_00415690`). A plain folder in `CDPath` is enough – no real CD drive is needed. LlamaMod relies
on this: it points `CDPath` at its own `cd-active` folder, where mod outputs replace original files
([notes/llamamod-app.md](../../notes/llamamod-app.md#registry-cd-path-and-the-exe-patch)).
| Folder | Contents |
|---|---|
| HD (`Setup/<lang>/Hd` on the CD) | `groove.exe`, `aal.dll`, `aleax2.3dl`, the audio codecs `asfcodec.adl` and `Vagcodec.adl`, `music/english.cvs` (speech), save games `king0.ksv`…`king2.ksv` |
| CD (`Setup/<lang>/Cd`) | `wads/` level files, `music/*.asf` music, `movies/*.dat` videos, `*.cor` pictures, `test9.tle`, empty folders `gallery/`, `in/`, `out/` |
Registry key (all values): `HKEY_LOCAL_MACHINE\Software\Disney Interactive\Emperors New Groove\1.0`
with `CDPath`, `HDPath`, screen size, volumes and devices. LlamaMod's base patch of `groove.exe`
redirects it to `HKEY_CURRENT_USER`, so no admin rights are needed.
Development used the **German CD**. Other language versions are expected to use the same formats
but were not tested.
## Level naming scheme
Levels are named **`t<tribe>l<world>m<chapter>`** (tribe is always 1 in the game):
| World | Name | Files | Notes |
|---|---|---|---|
| 1 | Village | t1l1m001–004 | `t1l1m001` is the default base level for new levels |
| 2 | Jungle at Night | t1l2m001–003 | t1l2m003 is the llama chase (player strat `LlamaChase`) |
| 3 | River | t1l3m001–004 | player strat `KuscoAndPachaTogetherOnATree` |
| 4 | Jungle by Day | t1l4m001–003 | t1l4m002 has the Alligator (used by the arena) |
| 5 | Mountain | t1l5m001–003 | |
| 6 | City | t1l6m001–003 | |
| 7 | Catacombs | t1l7m001–005 | m001/3/5 are roller-coaster rides (player strat `RollerCoaster`) |
| 8 | Lab | t1l8m001–006 | |
| 9 | TestLevel | none on the CD | 49 chapter slots, see [TestLevel](testlevel.md) |
Other WADs: `t0i0m000` (front end), `t0i0m998` / `t0i0m999` (outro and game-over backdrops),
`t6l6m001/002` (memory-card/options screens – *likely*), `FESOUND.WAD` (front-end sound bank, not
a chunk file). The chapter counts the world map uses are a table in `groove.exe` (0x572420):
4, 3, 4, 3, 3, 3, 5, 5, 49 – world 8 has one more file than its table entry (*unknown* why).
## Pictures (COR)
| File | Use |
|---|---|
| `T1<w><c>.COR` | loading picture of world *w*, chapter *c* (`T191.COR` … `T1949.COR` for TestLevel) |
| `<w><c><n>.cor` | concept art shown with a chapter (*likely*) |
| `tally<n>.cor`, `gameover.cor`, `loading.cor`, logos | screens; `gameovertest.cor` is unused |
Format (*certain*, [`tools/cor2png.py`](../../tools/cor2png.py)): 8-byte signature
`17 98 AF 89 FE 42 D1 12`, u32 version 1, u32 unpacked size, u32 packed size, then LZ data:
a byte `n < 0x80` = *n* literal bytes follow; `1LLLoooo oooooooo` = copy L+3 bytes from *o* bytes
back. The result is 640×480 RGB (a few greyscale ones). [`tools/png2cor.py`](../../tools/png2cor.py)
and [`CorEncoder.java`](../../src/main/java/com/eliasmueller/llamamod/format/CorEncoder.java) write
it; LlamaMod uses that for the loading pictures of new levels.
## Movies and demos
* `movies/<w><c><n>intro.dat` / `…outro.dat` and the logo movies: MPEG-1 video 320×240, 29.97 fps,
MP2 audio, played through DirectShow. The Intro skip mod patches the calls that start them
([notes/mod-introskip.md](../../notes/mod-introskip.md)).
* `wads/*.DEM`: recorded attract-mode demos, u32 frame count + 8 bytes of input per frame
(*likely*; not used by LlamaMod).
## Save games (KSV)
`king0.ksv`…`king2.ksv` in the game folder, 256 bytes each. Decoded from the load/save functions
`FUN_0041c640` / `FUN_0041c820`; code: [`save/Ksv.java`](../../src/main/java/com/eliasmueller/llamamod/save/Ksv.java).
| Offset | Meaning |
|---|---|
| 0x00 | used flag (0 = empty) |
| 0x01 / 0x02 | lives / energy |
| 0x03–0x06 | world-map cursor: tribe, world, chapter, map |
| 0x07 | number of gold coins |
| 0x08–0x3D | u8 [9][6] chapter flags (bit 0 collectible found, bit 1 all coins = gold coin) |
| 0x3E–0x41 | last played tribe, world, chapter, map |
| 0x42 / 0x43 | highest unlocked world / chapter |
| 0x44–0xAF | u16 [9][6] coins per chapter |
| 0xB0–0xCF | unknown |
| 0xD0–0xD9 | options (effects and music volume, speaker mode, …) |
To start a TestLevel chapter without a mod, set the cursor, the last played and the highest
unlocked world/chapter to 9 and the chapter. LlamaMod does this with `llamamod --unlock <slot> 9 <chapter> --cursor`
and with the *Save games* tab.
## Hidden debug features
Strings and code in `groove.exe` point to leftovers of the developers' tools. They were seen in the
binary but **not** verified or used by LlamaMod: F6 writes a screenshot `capture<n>.tga`, the
command-line switch `-display_diagnostics` writes a `<PC name>.dispdiag` file, a log
`c:\kingdom_dbg.txt`, and an unused slide-show player that reads the empty `in`/`out` folders.
The *Built-in cheats* mod uses the game's own cheat codes
([notes/mod-cheats.md](../../notes/mod-cheats.md)).
+155
View File
@@ -0,0 +1,155 @@
# Geometry and collision
The world is **not** one big mesh and there is no BSP. It is a **grid of tiles**, inherited from
Croc: TRAK holds a set of small models (the level's construction kit), and MAP places them on a
W × H grid, one cell = one unit. Every byte described here is reproduced exactly by
[`format/Trak.java`](../../src/main/java/com/eliasmueller/llamamod/format/Trak.java),
[`format/MapChunk.java`](../../src/main/java/com/eliasmueller/llamamod/format/MapChunk.java) and
[`tools/geomlib.py`](../../tools/geomlib.py) for all original levels.
Full detail with all loader addresses: [notes/geometry.md](../../notes/geometry.md).
![The test world from above in the editor: every square is one grid cell](../img/testworld-top.jpg)
## Coordinate spaces
| Space | Used by | Axes |
|---|---|---|
| **Game space** | strats, collision, MAP fixed-point tile positions, lights | x east, **y up**, z = grid row (positive); 20.12 fixed point (4096 = 1.0); right-handed as the game shows it |
| **Export space** | TRAK/STPC vertices, MAP float tile positions | same, but **z negated** (grid at z ∈ (−H, 0]); drawn by a Direct3D (left-handed) camera |
| **File space** | OBJ/glTF of every LlamaMod and kit tool | = game space (y up, right-handed): Blender shows a level the same way round as the game |
Converting export ↔ file space negates z and reverses the triangle corner order. Angles:
4096 = 360° in MAP records; the strat VM uses 0x1000000 = 360°. A tile is turned only about y
(0, 1024, 2048, 3072 in practice). Rotation 0 faces +z, 90° faces +x.
## TRAK: tile models (*certain*)
`u32 n`, then **all n headers** (0x84 bytes each), then the data of every model in order.
Typical levels have 100–650 models with up to 422 vertices / 798 faces each.
| Header field | Meaning |
|---|---|
| 0x00 | bounding-box centre (3 × f32) |
| 0x0c | 8 box corners (used for frustum culling) |
| 0x6c / 0x6e | vertex count / face count |
| 0x78 / 0x7a / 0x7c | collision polygon counts in three groups (the engine always uses the sum; meaning of the groups *unknown*) |
| pointers | 0 in the file, set on load |
Data per model:
* **Vertex**, 24 bytes: `f32 x, y, z, nx, ny, nz` (lighting comes from the MAP vertex colours, not
from the normals).
* **Face**, 28 bytes, triangles only: `u16 flags, u16 v0, v1, v2, u16 texture, u16 0, f32 plane
nx, ny, nz, d`.
* **Collision polygon**, 32 bytes: `u8 type, u8 material`, then 5 planes `{i8 nx, ny, nz, u8 0,
i16 d}`. Plane 0 is the surface, planes 1–4 the edges (normals point inwards). Type bit 0 =
quad, otherwise triangle. Normals are ×127, d is 20.12 fixed point – so a collision polygon
must lie within **±8 units of its tile's origin**.
### Face flags
| Bit | Meaning |
|---|---|
| 0x0008 | start of a new render batch (texture page or blend mode changes) – must be set on the first face of each batch, at the latest every 256 faces |
| 0x0010, 0x0020, 0x0800 | which corners of the texture rectangle the triangle uses (below) |
| 0x0400 | double-sided (no backface culling) |
| 0x4000 | ignore vertex colours |
| 0x0001, 0x0002 | special sorting/transparency modes (*likely*) |
| others | present in the data, not read by the PC renderer (*unknown*, probably PlayStation leftovers) |
### No free texture coordinates
The engine has **no per-vertex UVs**. Every triangle shows one half of a texture rectangle from the
TEXT table: with flag 0x800 the face is the first or (with 0x10) second half of a quad, without it
a triangle covering the lower-left half; 0x20 mirrors horizontally. 96 % of world faces are quad
halves. This is why the level builder and editor split surfaces into 1 × 1 pieces and why a narrow
wall shows a squashed texture. See [Textures](textures.md).
## MAP: the tile grid (*certain*)
MAP starts with the grid and the tiles; objects follow (see [Objects](objects.md)). The blocks that
concern geometry:
| Block | Format |
|---|---|
| header | `u32 nTiles, u32 W, u32 H` |
| tiles (floats) | nTiles × `{f32 x, y, z, u32 rotY, u32 model, u32 hasAlt}` in export space; x, z = cell centre |
| regions | 32 × `{u32 visibility mask, i32 value}`, then W·H × u32 region number per cell |
| tiles (fixed point) | nTiles × `{rx, ry, rz, x, y, z}` 20.12 in game space – used for drawing and collision; must match the float copy exactly |
| model index | nTiles × u32 (= the float block's `model`) |
| tile swaps (WFPC 0x10000) | one `{cellX, y, cellZ, modelA, modelB}` per tile with `hasAlt ≠ 0`: doors and bridges that change their model (strat opcodes 0x11a / 0x11b) |
| vertex colours | per tile: one u32 RGBA per vertex of its model (0x80 = 1.0, the game doubles and clamps); more sets for animated colours; then the same for the swap model |
Rules that crash the game or corrupt the level when broken:
* every tile must lie inside the grid (`0 ≤ x < W`, `0 ≤ row < H`) – outside, the cell list
writes into foreign memory;
* the vertex-colour block must have **exactly** one entry per vertex of the tile's model – else
everything after it (the objects) is read shifted;
* the float and fixed-point copies and the model index must agree;
* tile swaps: one entry per tile with `hasAlt ≠ 0`, in tile order.
The cell of a tile is `floor(x) + floor(−z) · W` (export-space floats).
## Collision (*certain*)
* Only **the tiles listed in the cell the player stands in** are tested (`FUN_004036d0` floors and
ceilings, `FUN_00403ad0` walls). A wall polygon that sticks into the neighbouring cell has no
effect there – builders enter a polygon into every cell it covers, as a collision-only tile if
needed.
* Collision is in game space, relative to the tile's fixed-point position.
* There is **no step-up logic**: a 0.12-unit edge with a wall polygon already blocks the llama.
Low plinths need walls without collision; stairs need an invisible slope.
### Collision materials
The material byte ends up in the player's ground state and is read by strats (`floorMaterial`).
| Material | Meaning |
|---|---|
| 0 | normal ground |
| **16** | **exit floor**: the LevelDoor ends the level only when the llama stands on material 16 in the door's cell; also used under the village house doors (zone teleports) |
| 17, 18 | ignored when WFPC flag 0x8000000 is set (*likely*: special surfaces) |
| **30** (0x1e) | death plane / water (*likely*): falling onto it empties the energy and the llama respawns at the last checkpoint or the start (Model 0 of many levels is such an invisible plane at y = −2.75) |
| others | special surfaces, meaning *unknown* – the debug view colours them |
![Debug view of Village 3: collision frames, invisible walls in red, objects as markers](../img/debug-view.jpg)
## Visibility regions (no BSP)
Drawing (`FUN_0042bf40`) walks the grid cells within the draw distance around the camera and draws
a cell's tiles **only if the cell's region bit is set in the mask of the camera's region**. Up to 32
regions; mask 0 or a camera outside the grid means "everything". New geometry must therefore get a
region that the surrounding regions can see, or it stays invisible – the builders put everything
into region 0 with mask `0xffffffff`. Objects in hidden cells are skipped too (unless strat flag
0x800). The editor's *Visibility regions* tool edits this
([manual chapter 13](../manual/13-visibility.md)).
## STPC: object models and code
STPC starts with the **object models** (same model format as TRAK, header 0x8c bytes, optionally
skinned with a bone table), then the **animation sets** (per-frame bone matrices and compressed
vertex deltas – the compression is only partly understood), then the **strat code units**
([Strats](../strats/README.md)). The engine never parses the code; MAP records and bytecode refer
into STPC by absolute byte offsets, so STPC can only be extended at the end or merged with
relocation ([notes/editor-cross-import.md](../../notes/editor-cross-import.md)).
## WFPC flags
WFPC is a u32 of feature flags. Some change the data layout and must stay as they are: 0x10,
0x100, 0x10000 (tile swaps), 0x100000, 0x200000, 0x4000000, 0x10000000. Others: 0x800 renderer
fog flag, 0x80000 read by a strat opcode, 0x8000000 makes collision materials 17/18 ignored.
## Writing new geometry – checklist
1. TRAK: correct counts, bounding box and face planes recomputed, texture index below the
number of texture entries, batch bit 0x8, ≤ 8000 vertices per model.
2. Collision polygons within ±8 units of the tile origin, entered into every cell they cover.
3. MAP: tiles inside the grid, float/fixed/model blocks consistent, vertex colours exactly per
vertex, tile-swap block matching `hasAlt`, a visible region.
4. Rewrite the WAD's total length.
The editor and both level builders do all of this; the details of the builder's approach
(splitting into tiles per cell and height band, merging identical tiles, baking sun, sky and
ambient occlusion into vertex colours) are in [notes/levelbau.md](../../notes/levelbau.md).
+141
View File
@@ -0,0 +1,141 @@
# Objects, waypoints, zones and lights
Everything that is not static geometry lives in the second half of the MAP chunk: object
instances ("strats"), their parameters, waypoint chains and environment zones. Lights are in their
own chunk, LGHT. Full byte layouts: [notes/objects.md](../../notes/objects.md).
## Strats: objects are programs
An object – coin, enemy, door, camera, the player – is an instance of a **strat**: a script in the
level's STPC code. A MAP record says *where* and *which code*; the code does the rest. There is no
type id: **the type is the code's byte offset in STPC**. Type names come from the code itself
(a `debugName "Coin"` instruction at the entry point). How the code works: [Strats](../strats/README.md).
About 300 types are placed in the original levels, about 200 more are only spawned by other
strats (splinters, effects, heads). The full list of which type appears in which level, with
instance counts, is [notes/strat_types_table.md](../../notes/strat_types_table.md)
(`tools/strat_table.py`).
## The MAP object blocks
After the geometry blocks ([Geometry](geometry.md#map-the-tile-grid-certain)):
| Block | Format |
|---|---|
| parameter pool | `u32 n, n × u32` – all object parameters of the level, referenced by index |
| environment zones | `u32 n, n × 90 bytes` (below) |
| waypoints | `u32 n, n × 34 bytes {u32 next, u32, u16 rx, ry, rz, s32 x, y, z, u32, u32}`; `next ≠ 0` = the following entry belongs to the same chain |
| strats | `u32 nStrats, u32 poolExtra`, nStrats × 58-byte records |
| optional tail | paths (WFPC 0x200000, unused by the CD levels), a buffer size (WFPC 0x10), a u16 |
### Strat record (58 bytes, *certain* unless noted)
| Offset | Field |
|---|---|
| 0 | rotation x, y, z (u16 each, 4096 = 360°; x/z almost always 0) |
| 6 | position x, y, z (s32, 20.12 fixed point, game space) |
| 18 | **code offset in STPC = the type** |
| 22 / 26 | parameter count / index into the parameter pool (−1 if none) |
| 30 | number of strat variables (fixed per type) |
| 34 | 16-byte slots for event handlers (fixed per type, *likely*) |
| 38 | type flags: collision and physics setup (fixed per type) |
| 42 | extra memory words (collision bones; fixed per type) |
| 46 | first waypoint index or −1 |
| 54 | flags; bit 1 = not created at level start |
`poolExtra` is the number of strat slots beyond 30: the engine allocates `poolExtra + 30` slots,
which must cover all placed objects **plus everything spawned at run time**.
The type fields (variables, slots, flags, extra) come from the compiler and must be copied from an
original instance of the same type; a type that uses collision but has type flags 0 crashes the
game. Builders and the editor do this automatically.
### Parameters
The *n* parameters of an instance are pushed onto the new strat's stack and become its
**variables v0 … v(n−1)**. Values are 20.12 fixed point by convention (0x1000 = 1.0), booleans
0 / 0x1000, counts and world/chapter numbers `n << 12`. The editor knows the meaning of the
parameters of many types (schemas in
[`editor/tools/objects/Schemas.java`](../../src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Schemas.java),
findings in [notes/editor-object-schemas.md](../../notes/editor-object-schemas.md)); the others are
shown raw with the values used in the original levels.
### Waypoints
A waypoint chain is a list of positions used for patrol paths, moving platforms, camera paths –
and for some types (guards, box walkers) as a **pair of boxes** the object must stand in, or the
game removes it ("removing strat"). Guards also wait for an **event bit** (global variable 95) that
another object sets ([notes/editor-object-schemas.md](../../notes/editor-object-schemas.md#event-bits-global-95)).
The **player's waypoint chain is the start camera**: the camera takes the first node within 2.5
cells of the llama. A good start has one node about 2 cells behind the llama and 0.5 above it, and
nothing solid right behind the llama (the camera moves aside).
## Special objects
| Role | Type | Notes |
|---|---|---|
| player start | `Llama` | exactly one per level (`RollerCoaster`, `KuscoAndPachaTogetherOnATree`, `LlamaChase` in special levels) |
| checkpoint | `CheckPointPost` (spawns `CheckPointHead`), `CheckPointBox` | the respawn point after a fall |
| exit | `LevelDoor` | invisible trigger, see below; variants `VillageEndDoor`, `EndGameDoor` |
| cameras | `CameraSide`, `CutSceneCameraDirectional`, `CutSceneTrigger` | camera zones and cut scenes |
| always present | `CheatTeleport`, `Wompy`, `CutSceneTrigger` | |
### The exit (LevelDoor) – *certain*
The LevelDoor has no model. Every frame it checks:
```
game state is "running"
and the floor polygon under the llama has collision material 16
and floor(llama.x) == floor(door.x) and floor(llama.z) == floor(door.z) ; same grid cell
-> endLevel(tribe, world, chapter) ; its three parameters
```
So an exit needs **a floor with collision material 16 in the door's grid cell** – height does not
matter. In several original levels the pad only appears when a tile swap opens the door (Village 1:
the RubyIdolDoor swaps the tile to a model that has the material-16 pad). The parameters are the
next level; the originals use (1, world, chapter + 1), and **(7, 7, 7)** in the last chapter of a
world means "world finished" – in TestLevel that crashes, so custom levels use (1, 9, chapter).
`endLevel` shows the tally (coins x / LNFO total, secrets) and unlocks the next chapter.
A second path in the Llama's own code ends the level when it stands on material 16 in the cell
stored in globals g104/g105 with the target in g101–g103 – the arena uses this
([Arena](../strats/arena.md)).
## Environment zones (90 bytes, *certain* for the fields below)
A zone sets fog, colours, music and sky, and optionally teleports the player.
| Field | Meaning |
|---|---|
| cell x, y, z | the trigger cell |
| target x, y, z, angle | teleport target and facing |
| flags (+0x20) | flag 8 = **start zone** (exactly one per level: the level begins with its settings); flag 4 = do not move the player |
| sky model | STPC offset of a sky model (0 = none) |
| fog mode | 0 none, 1 darkness with a light around the player, 2 table fog, 3 vertex fog |
| fog start / end | two pairs (detail Minimum and Maximum); **fog end = far clip = draw distance** |
| colours | ambient colour for object lighting, fog/backdrop colour |
| music id | 1–39 (see [Music](audio.md#music)), 0 stops the music |
Teleports are triggered from strat code (opcode 0x100 looks up the zone of the calling object's
cell). In the original levels the trigger cells hold exit floors (material 16) or door objects –
this is how the village house doors work. The editor edits zones in the
*Environment* panel ([manual chapter 12](../manual/12-environment.md)).
## Lights
LGHT: `u32 n`, then per light a type, a colour (×2/255) and direction or position (game space).
Lights only light **object models** (ambient + all directional lights + the nearest point lights,
at most 6). Tiles use baked vertex colours, so changing a light does not change the ground – bake
the vertex colours again. Point lights are full up to radius *a* and fade to 0 at *b*; one byte
makes them flicker.
## Tools
| Task | Tool |
|---|---|
| list types, instances, parameters of a level | [`tools/strats.py`](../../tools/strats.py) `level.wad [--all]`, [`tools/map_dump.py`](../../tools/map_dump.py) `--strats --tiles --lights` |
| type × level table | [`tools/strat_table.py`](../../tools/strat_table.py) |
| place objects from a script | [`tools/levelbuild.py`](../../tools/levelbuild.py) `L.strat(...)`, Java [`LevelBuilder`](../../src/main/java/com/eliasmueller/llamamod/levelbuild/LevelBuilder.java) |
| place, edit, check objects visually | the level editor ([manual chapter 9](../manual/09-objects.md), [10](../manual/10-waypoints.md), [11](../manual/11-cameras-triggers.md)) |
+53
View File
@@ -0,0 +1,53 @@
# TestLevel (world 9)
The game has a hidden ninth world, **TestLevel**, with 49 chapter slots – but the retail CD ships
no data for it. It is the natural home for custom levels: originals stay untouched, and the world
map can reach it once a save game unlocks it. Every LlamaMod level mod, the Test World and the
Alligator Arena are installed as `wads/T1L9M0nn.wad` with a loading picture `T19n.COR`.
Code: [`save/TestLevelChapters.java`](../../src/main/java/com/eliasmueller/llamamod/save/TestLevelChapters.java),
lab notes: [notes/testlevel-chapters.md](../../notes/testlevel-chapters.md).
## Use chapters 1 and 32–49
The game keeps per-chapter coin counters and flag bytes in arrays that only have rows for worlds
0–8 (index `world · 6 + chapter`). For world 9, chapters 2–31 land on **other game variables**:
| Chapter | What its coin counter / flag byte overwrites |
|---|---|
| 1 | nothing (free) |
| 5–8 | the last played tribe/world/chapter/map |
| 6–9 (flags) | the **language index** |
| 9 | the **furthest progress** – the world map breaks |
| 10–31 | options, HUD state and tables used by the original levels |
| **32–49** | tables of tribes 2 and 3, which the game never uses – free |
So custom levels use **chapter 1 or 32–49**. The editor only offers these, new projects take the
first free chapter from 32, and old projects on other chapters are moved automatically.
LlamaMod additionally relocates both arrays to a 128-entry buffer in unused space of `groove.exe`
(base exe patch, revision 2), so even picking an empty chapter on the world map is harmless with
LlamaMod's executable. Mods still keep to 1 and 32–49 so they also work with an unpatched exe.
Save games are unaffected: a save after finishing a normal or TestLevel chapter is byte-identical
with and without the relocation, and only the 54 entries of worlds 0–8 are saved.
## Making world 9 reachable
* **Empty chapters must exist.** Finishing chapter *n* unlocks chapter *n* + 1, and the world map
crashes ("Couldnt load wad") when a selectable chapter has no file. LlamaMod links every missing
`T1L9M002…049.wad` to an installed world-9 WAD and `T192…T1949.COR` to `T191.COR`.
* **Never exit to (7, 7, 7).** "World finished" in world 9 unlocks a world 10 that has no name;
the game crashes after saving. Exits in TestLevel point to (1, 9, chapter).
* **Get there:** set a save game's map cursor to world 9 (`llamamod --unlock <slot> 9 <chapter>
--cursor`, the launcher's *Save games* tab), or use the *Boot to level* mod / the editor's
play-test, which start the game directly in the level.
## Tally totals
The tally screen after the exit shows "coins collected *x* / *total*". *Total* is not counted by
the game – it is the **LNFO** chunk (`u32 coins, u32 secrets`). A new level built on the village
would show the village's 55. LlamaMod recounts it for new levels (Coin = 1, Coin5 = 5, Secret
objects; the arena adds its dynamic coins).
Cosmetic leftovers: the world map shows no background for world 9 and a garbage coin target, and
the loading screen says "TestLevel Kapitel *n*".
+53
View File
@@ -0,0 +1,53 @@
# Textures (TEXT chunk)
Each level carries its own textures. Parse and rebuild are byte-identical for all level WADs
(including the RLE encoder). Code: [`format/TextChunk.java`](../../src/main/java/com/eliasmueller/llamamod/format/TextChunk.java),
[`tools/textchunk.py`](../../tools/textchunk.py). Lab notes: [notes/textures.md](../../notes/textures.md),
editor side: [notes/editor-tools-textures.md](../../notes/editor-tools-textures.md).
## Layout
```
u32 nPal 0 in all WADs (8-bit palettes are unused)
u32 nPages 13–22 per level, all 256×256
nPages × { u32 flags, u32 w, u32 h, data }
flags & 0x80 : u32 length + RLE16 (all original pages)
other ≠ 0 : w·h raw u16
u32 nTex ~180–1260 texture entries
nTex × { u16 flags, u8 page, u8 clut (0xff), u8 x0, x1, y0, y1 } (edges inclusive)
u32 nAnim texture animations
nAnim × { u16 texIndex, u8 type, payload } type 8 = flip book: list of texture indices
```
RLE16: a sequence of s16 *n*; *n* > 0 → *n* literal pixels follow, *n* < 0 → the next pixel
repeated −*n* times.
## Pixels
16 bit in **PlayStation order**: bits 0–4 red, 5–9 green, 10–14 blue, bit 15 = STP
(semi-transparency). `0x0000` is fully transparent. Export to PNG uses alpha 0 = `0x0000`,
alpha 128 = STP 0, alpha 255 = STP 1. The page flags (low 7 bits) select the blend class:
1/6 opaque, 4/7 alpha blend, 5 another blend mode, 2/3 special converters (*likely* the
PlayStation semi-transparency modes; details open).
## Texture entries
A face never has its own UVs: it names a **texture entry**, a rectangle on a page, and shows half
of it ([Geometry](geometry.md#no-free-texture-coordinates)). Entry flag bit 0 = flat colour (the
rectangle bytes are R, G, B instead) – the level builder appends such entries for plain colours.
Other entry flag bits (blend mode, size class) are only partly understood.
Limits: at most **127 pages** per level (signed page byte) and 65535 entries (u16 face field).
## Changing textures
* **Replace pixels, keep the table.** Exporting pages to PNG and importing them back changes no
index anywhere: `tools/text_export.py export level.wad dir` / `import level.wad dir new.wad`.
* **Add pages and entries.** New pages and entries are appended; faces then use the new indices.
The editor's texture manager does this, including colour reduction, painting onto faces and
flip-book animations ([manual chapter 8](../manual/08-textures.md)).
* **Same name, same picture.** Texture *indices* differ between base levels. The builders resolve
texture names (`grass`, `stone`, …) by picture fingerprint on each base and copy missing
village pictures over ([notes/levelbau.md](../../notes/levelbau.md)).
![Contact sheet of the Village 1 textures with their indices, made by tools/texsheet.py](../img/village-textures.jpg)
+63
View File
@@ -0,0 +1,63 @@
# WAD container
A level is one file `wads/t1l<world>m<chapter>.wad`. It is a flat list of chunks:
```
u32 total length
repeat: u32 tag (four ASCII letters stored reversed: "OFNI" in the file = INFO)
u32 length
length bytes of chunk data
```
There are no checksums. When a chunk changes size, only the chunk length and the total length
must be rewritten ([`format/Wad.java`](../../src/main/java/com/eliasmueller/llamamod/format/Wad.java),
[`tools/wadtool.py`](../../tools/wadtool.py) – `wadtool.py list|extract|replace`).
## Chunks
In file order. "Loader" is the function in `groove.exe` that reads the chunk (dispatcher at
0x558960).
| Chunk | Contents | Documented in |
|---|---|---|
| INFO, VERS | u32 1 each, ignored | – |
| WFPC | u32 feature flags of the level; they switch optional blocks in MAP and STPC on and off – never change them | [Geometry](geometry.md#wfpc-flags) |
| TEXT | texture pages, texture rectangles, texture animations | [Textures](textures.md) |
| FONT | 256 × 4 u16 glyph rectangles | – |
| SPRT | sprite list (u32, more with WFPC 0x100000) | – |
| NAME | u32 + level name (not shown anywhere in the game) | – |
| SMPC | sound samples of the level (`cvg ` blocks) | [Music and sound](audio.md) |
| AMPC | ambient sound data (`ambi`/`VABp`) | [Music and sound](audio.md) |
| SRPC | references into the speech file `music/<lang>.cvs` | [Music and sound](audio.md) |
| **TRAK** | world geometry: the tile models (render triangles + collision polygons) | [Geometry](geometry.md) |
| **STPC** | object models, skeletal animations and the **strat code** (object behaviour) | [Geometry](geometry.md#stpc-object-models-and-code), [Strats](../strats/README.md) |
| **MAP** | the tile grid, vertex colours, visibility regions, objects, waypoints, environment zones | [Geometry](geometry.md), [Objects](objects.md) |
| LGHT | lights (directional and point lights for object models) | [Objects](objects.md#lights) |
| LNFO | u32 coins, u32 secrets: the totals shown on the tally screen | [TestLevel](testlevel.md#tally-totals) |
| LGPC | dialogue texts in all languages + speech slots | – |
| END | end marker | – |
Not every level has every chunk. The three chunks that matter for level building are **TRAK**,
**STPC** and **MAP**; a new level takes everything else (textures, sounds, sky, fog, music) from an
original **base level**.
## The base-level idea
An object type exists in a level only if its code is in that level's STPC. MAP refers to a type
by its **byte offset into STPC** – there is no global type id. So every custom level starts as a
copy of an original level (default: Village 1, `t1l1m001`) and replaces geometry and objects:
* Python [`tools/levelbuild.py`](../../tools/levelbuild.py) and Java
[`levelbuild/LevelBuilder.java`](../../src/main/java/com/eliasmueller/llamamod/levelbuild/LevelBuilder.java)
generate TRAK and MAP from a script and keep STPC, TEXT and sound of the base.
* The level editor stores only the difference to the base level and rebuilds the WAD from the
user's own CD ([notes/editor-format.md](../../notes/editor-format.md)).
* Object types of *other* levels can be merged into the base's STPC together with their models,
animations, textures and sounds – the editor's *All worlds* import and the arena use this
([notes/editor-cross-import.md](../../notes/editor-cross-import.md)).
## Level memory
The game loads STPC, TEXT (compressed pages + 20 bytes per texture entry), TRAK, MAP, LGPC and the
strat pool into one **16 MB** block (`FUN_0041eea0`); original levels use up to about 7 MB. See
[Engine facts and limits](engine-limits.md).
+120
View File
@@ -0,0 +1,120 @@
# Strats: how objects work
Every object in the game – a coin, a squirrel, a moving platform, a camera, the llama itself – is
driven by a small program called a **strat** (Argonaut's "strategy" scripts, inherited from Croc).
The programs are compiled bytecode for a virtual machine in `groove.exe` (`FUN_0054d180`). LlamaMod
can read every one of them, change their numbers, write new ones and move them between levels.
| Page | For |
|---|---|
| this page | how the VM works: units, instances, frames, variables, events, spawning, collision |
| [Reading scripts](reading-scripts.md) | the editor's Script view and the assembly listing, with a worked example |
| [The script language](script-language.md) | writing new behaviour in LlamaMod's structured language |
| [Customising strats](custom-types.md) | changing constants, cloning types, custom types in the editor and in mods |
| [Example: the Alligator Arena](arena.md) | a complete minigame written in the script language, explained step by step |
| [Example: the roller coaster](roller-coaster.md) | a new ride for the original coaster cart: how the ride works, track and waypoint data, speed patch |
Lab notes and tables: [notes/strat-opcodes.md](../../notes/strat-opcodes.md) (all 434 opcodes with
semantics, confidence and Argonaut names), [notes/strat-language.md](../../notes/strat-language.md)
(complete syntax), [notes/editor-object-schemas.md](../../notes/editor-object-schemas.md)
(parameters of individual types). Machine-readable tables, used by the editor and the compiler:
[`ops.tsv`](../../src/main/resources/com/eliasmueller/llamamod/format/strat/ops.tsv),
[`props.tsv`](../../src/main/resources/com/eliasmueller/llamamod/format/strat/props.tsv).
## Code units and types
The strat code of a level sits at the end of its STPC chunk as a list of **code units**:
`u32 nUnits`, then per unit `u32 nWords` and the words. A unit is one compiled source file and can
contain several strats plus their name strings and data (for example `Spider` + `RopeForSpider`).
The village has 53 units, Lab 1 has 65.
A **type** is just an entry point into this code: a MAP record or a `spawn` instruction names a
byte offset in STPC, and the code there starts with `debugName "Name"`. Code refers to models,
animation sets and other code by **absolute STPC offsets** – which is why STPC cannot be
rearranged without relocating every reference, and why each level carries its own copy of the
code of every type it uses.
LlamaMod's disassembler covers the code of all 34 level WADs (1461 units, 670 555 words) and
assembles every unit back **byte-identically**.
## One frame in the life of a strat
The VM is a **stack machine** that runs every live strat once per frame (30 frames per second):
1. The strat continues where it stopped last frame.
2. It executes instructions – push values, compute, read and write properties, play animations,
move – until it reaches a **yield** (`jmpYield`, `jzYield`, `callYield` …).
3. At the yield its turn ends; the next strat runs. Next frame it continues after the yield.
So a typical object is "set up, then `loop { …; yield }`", and anything that takes time is a loop
that counts frames. Speeds and turn rates in scripts are **per frame**.
`end` / `remove` deletes the strat; `return` returns from a `call`.
## Data a strat works with
| Kind | Syntax | What it is |
|---|---|---|
| **variables** | `v0`, `v1`, … (or an alias name) | the strat's own variables (count = the type's *nVars*). The first *n* hold the instance's **MAP parameters** |
| **properties** | `self.x`, `self.rotY`, `self.floorHeight`, `self.damageTaken` … | 194 named engine fields of the strat: position, rotation, floor under it, distance to the player, animation state, alpha, collision damage … |
| other strats' properties | `player.x`, `parent.v3`, `camera.…`, `target.…`, `boss.…`, `dialog.…` | the player, the strat that spawned this one (`parent`), the camera and a few registered special strats |
| **globals** | `g0` … | shared by all strats; the engine itself never reads most of them – their meaning comes from how scripts use them (e.g. g95 = event bits, g101–g105 = exit target and cell, g17–g20 = dive target) |
| **numbers** | `1.0`, `90deg`, `204r` | 20.12 fixed point (4096 = 1.0 = one cell); angles 0x1000000 = 360°; `r` marks a raw integer |
Binary operators pop *b* (top) then *a* and push `a op b`; comparisons push 0/1; `mul` = a·b >> 12.
## Events
A strat registers **event handlers** in its slots (`onEvent`, in the script language
`on touched { … }` / `on event(type, param) { … }`). When the event fires, the handler runs like a
subroutine. Known event types:
| Type | Fires when |
|---|---|
| 4 | **touched**: contact with another strat (pick-ups, enemies). Needs collision (`collisionOn` + `collRadius`) |
| 0x40 | timer |
| 0x80 / 0x100 | player distance |
## Spawning
`spawn<params, vars, slots, typeFlags, extra>(arguments…, unit:N+offset)` creates a new strat from
code: the five inline values are exactly the type fields of a MAP record
([Objects](../reference/objects.md#strat-record-58-bytes-certain-unless-noted)), the stack
arguments become the child's parameters, and the child's `parent` is the spawner. Facts found
the hard way (by the arena):
* The child appears at the spawner's **matrix position** of the last frame, not at its current
`x/y/z`: after `self.x = …` the spawner must **yield** before spawning.
* A type that uses collision needs collision type flags (e.g. 0x28000 like Coin) and enough
`extra` words for its collision bones – otherwise the game crashes in the heap allocator.
* The strat pool must have room for every spawned strat (`poolExtra`).
## Collision and damage
On contact, each side's `hitDamage` is copied into the other's `damageTaken`, and both get event 4.
The llama's attacks set bits in its `hitDamage`: **256** kick (Action on the ground), **512** dive
(Action in the air; it homes in on the target an enemy publishes in g17–g19), **1024** charge.
The llama counts a contact as *its* hit if the other's `hitDamage` shares a bit with its own,
otherwise `damageTaken & 127 == 1` costs it a heart and knocks it back. The original Alligator has
513 (= 512 | 1): only the dive hurts it, a kick gets you bitten.
## References to level data
Some operands are indices into the level's own data and differ between levels; the cross-level
import rewrites exactly these:
| Operand | Points to |
|---|---|
| `model:N`, `anim:N`, `unit:N[+off]` (`address`, opcode 0xb2) | a model, an animation set, code of a unit; negative = shared animations in `DEFANIM.WAD` |
| `sound:N` (opcode 0xd7) | a sample of the level's sound bank |
| `text:N` (opcodes 0xf0, 0xfa) | a dialogue line in LGPC |
| `tex:N` (pushed before texture opcodes) | a texture entry (shadows, sprites, particles) |
## How the names were found
All 434 opcodes (0x000–0x1b1) were named by decompiling every handler with Ghidra; 252 of them
match Argonaut's own names from the Croc 2 PlayStation demo's jump table and the Croc 1
decompilation (knowledge only, no code used – see the sources in
[notes/strat-opcodes.md](../../notes/strat-opcodes.md#sources)). Confidence: 353 high, 79 medium,
2 low; high-confidence opcodes cover 97 % of all instructions in the levels. The names of rarely
used opcodes and properties can still be wrong.
+240
View File
@@ -0,0 +1,240 @@
# Example: the Alligator Arena
The *Alligator Arena* mod is a complete minigame built from scratch: a walled pit on an island,
alligators coming in waves, coins popping out on every hit, an exit that opens after the last wave.
It uses every technique of this documentation – a scripted level, two new strat types written in
the [script language](script-language.md), and an object (the Alligator) imported from another
world. This page walks through the code.
![The arena: an alligator chasing the llama](../manual/img/mod_arena.jpg)
| File | Role |
|---|---|
| [`ArenaMod.java`](../../src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaMod.java) | the mod: options, writes the WAD and the loading picture |
| [`Arena.java`](../../src/main/java/com/eliasmueller/llamamod/mods/arena/Arena.java) | the level script: geometry and object placement |
| [`ArenaScripts.java`](../../src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaScripts.java) | the strat scripts `ArenaMaster`, `ArenaGator` (and a test probe) |
| [`ArenaImport.java`](../../src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaImport.java) | compiles the scripts and merges them into the base level |
| [`ArenaTest.java`](../../src/test/java/com/eliasmueller/llamamod/mods/arena/ArenaTest.java) | unit tests (scripts compile for all option corners, the built level decodes) |
| [notes/mod-arena.md](../../notes/mod-arena.md) | lab notes: engine facts found, what was verified in the game |
Options: alligator **lives** (1–10, default 5), **waves** (1–5, default 3; wave *n* brings
min(*n*, 2) alligators), llama **energy** cap (1–5, default 4), **coins** per hit (0–5, default 1),
TestLevel **chapter** (32–49, default 49).
## 1. The plan
* The **base level** is Village 1 (`t1l1m001`), like every new level: its code provides the
Llama, Coin, CheckPointPost and textures.
* The Alligator lives in **Jungle by Day 2** (`t1l4m002`). Its model, animations and sounds are
needed, so the new scripts are **compiled against that level** and then **imported** into the
village base together with everything they reference.
* Two new types: `ArenaMaster`, an invisible referee standing on the exit pad, and `ArenaGator`,
a new alligator spawned by the master.
* The level goes into a TestLevel chapter ([why 32–49](../reference/testlevel.md)).
## 2. The level (`Arena.build`)
The level is a [`LevelScript`](../../src/main/java/com/eliasmueller/llamamod/levelbuild/LevelScript.java)
for the Java [`LevelBuilder`](../../src/main/java/com/eliasmueller/llamamod/levelbuild/LevelBuilder.java),
the same builder the Test World uses. Coordinates are game units (one cell each):
```java
l.sea(0, 0, 40, 40, -1.5, new double[] {ix0 - 3, iz0 - 3, ix1 + 3, iz1 + 3}); // sea + death plane
l.floor(a.x0(), a.z0(), a.x1(), a.z1(), a.floor(), "sand"); // pit floor 16..24
l.box(a.x0() - t, a.z0() - t, a.x1() + t, a.z0(), 0, WALL,
o().top("stone").side("stone_moss")); // one of four walls
// exit pad: one cell with collision material 16 under a gate
l.box(ex - 0.5, ez - 0.5, ex + 0.5, ez + 0.5, a.floor(), a.floor() + 0.06,
o().top("pattern_purple").side("inca").rot(0).sideColl(false).mat(DOOR_MAT));
l.player(st[0], st[1], st[2], null, 1.6); // llama + start camera node 1.6 behind it
l.checkpoint(a.x0() + 0.8, a.z0() + 0.8);
l.strat(ArenaScripts.MASTER, ex, ez, a.floor(), 0.0, null, LevelBuilder.Waypoints.NONE, 0, false);
l.coins(List.of(new double[] {a.x0() + 0.6, 20}, new double[] {a.x1() - 0.6, 20}));
```
Points to note:
* The **exit pad** is a 1 × 1 box whose top has collision **material 16** – the exit rule from
[Objects](../reference/objects.md#the-exit-leveldoor--certain). There is no LevelDoor; the master
takes its role.
* The walls are 1.1 high – too high to jump (0.35), so the fight stays in the pit.
* Texture names (`sand`, `stone_moss`, `inca`) are resolved on the base level by picture.
## 3. Compiling and importing (`ArenaImport`)
`Arena.buildWad` runs before the geometry is built:
```java
merged = ArenaImport.merge(baseWad, src, "t1l4m002", units(probe, debug));
return LevelScript.super.buildWad(merged.wad(), wads, opts);
```
1. **`append`** compiles each unit with `StratCompiler` → `StratAssembler`, laid out as if it were
the last unit of **t1l4m002's** STPC, and appends it there. So `model:47` is the Alligator's model
and `unit:56+0xf0` is that level's `EnemyCoin`. A later unit can name an earlier one as
`unit:@ArenaGator`; `append` replaces that with `unit:N+offset`.
2. **`merge`** asks [`StpcMerge`](../../src/main/java/com/eliasmueller/llamamod/format/stpcmerge/StpcMerge.java)
– the editor's cross-world import engine – to import the two entry points into the village:
it follows every reference and copies the model, 7 animation sets, textures, 3 sound samples and
the spawned strats (`EnemyCoin` and `Sparkles` already exist in the village and are shared),
relocates every operand and verifies the result.
3. It adds one **unplaced prototype MAP record** per type with its type fields
(`nVars`, `slots`, `typeFlags`, `extra`), so the level builder can place the type by name.
The type fields are declared in `Arena.units`:
| Type | vars | slots | type flags | extra | why |
|---|---|---|---|---|---|
| ArenaGator | 16 | 5 | 0xC000 | 3 | collision like the Alligator; 3 collision bones |
| ArenaMaster | 12 | 0 | 0 | 0 | invisible logic, no collision, no handlers |
## 4. The referee: `ArenaMaster`
`ArenaScripts.master()` generates the script; the waves are unrolled by Java. With 2 waves it
reads like this (simplified):
```
strat ArenaMaster {
alias wave = v1
alias alive = v3
alias kills = v4
alias t = v5
g104 = -1 // no exit cell yet (the llama's own exit check reads g104/g105)
g105 = -1
t = 60 // 2 seconds
while (t > 0) { t -= 1; call cap; yield }
wave = 1
self.x = 20.5 // move to a spawn spot …
self.z = 22.2
yield // … and let the engine update the matrix before spawning
spawn<0, 16, 5, 50176, 3>(unit:@ArenaGator)
alive += 1
self.x = 20.5 // back onto the exit pad
self.z = 23.5
while (alive > 0) { call cap; yield } // gators do parent.v3 -= 1 when they die
t = 45
while (t > 0) { t -= 1; call cap; yield }
wave = 2
… // two spawns this time
g101 = 4096r // exit target (1, 9, chapter) as raw 20.12 values
g102 = 36864r
g103 = 200704r
g104 = int(self.x) // the exit cell
g105 = int(self.z)
… // 5 bonus EnemyCoins
loop {
call cap
if (player.floorMaterial == 16 && int(self.x) == int(player.x)
&& int(self.z) == int(player.z)) {
endLevel(4096r, 36864r, 200704r) // the LevelDoor's rule
g1 += 1
remove
}
… // a Sparkles effect every 8 frames
yield
}
}
sub cap { // energy option: keep the llama's energy at the cap
if (player.health2 > 4) { player.health2 = 4 }
}
```
What it shows:
* **Variables as state** – `alive` is `v3`, which the gators change from outside with
`parent.v3 -= 1`: the parent/child link is the communication channel.
* **Waiting = looping with yield**: every wait is a `while … yield` that also calls `cap`, so the
energy cap holds every frame (also after a respawn).
* **The spawn rule**: move, `yield`, then `spawn` – without the yield the alligator would appear at
the master's old position (it copies the matrix of the last frame).
* **The exit** is the LevelDoor logic written out: material 16 under the llama in the same cell →
`endLevel(tribe, world, chapter)`. Setting g101–g105 additionally arms the Llama's built-in check
for the same cell.
## 5. The alligator: `ArenaGator`
`ArenaScripts.gator()` reuses the original Alligator's assets but has its own behaviour. Its parts:
**Setup** – model, size, collision and how it takes hits:
```
setModel(model:47)
scale(1.5)
self.hitDamage = 1793 // 1 | 256 | 512 | 1024
collisionOn<16384>()
collRadius(409r)
collisionBone(0, 409r) // three collision spheres along the body
collisionBone(6.0, 409r)
collisionBone(10.0, 409r)
on touched {
if (self.damageTaken & 1792) { // kick, dive or charge of the llama
if (cool <= 0 && lives > 0) { hit = 1 }
} else {
bit = 1 // contact without an attack: it bit the llama
}
}
```
`hitDamage` 1793 shares a bit with all three llama attacks, so each of them counts as the llama's
hit; bit 1 makes a plain contact hurt the llama ([Collision and damage](README.md#collision-and-damage)).
The handler only sets flags – the main loop reacts to them, which keeps the handler short.
**Fade-in** – `setAmbient` from 0 to 63 over 32 frames, playing the idle animation.
**Main loop** – a state machine in a `switch`, once per frame:
| State | Behaviour |
|---|---|
| 0 watch | turn towards the player (2° per frame); after a timer, if the llama is within 5 cells and on the same floor height (±0.4), play the snap animation and sound |
| 1 snap | wait for the animation, then start chasing |
| 2 chase | accelerate up to `0.03 + 0.006 · wave` per frame towards the llama; lunge when closer than 1.2, give up after 75 frames |
| 4, 5 lunge | fast forward move, then slow down (numbers from the original Alligator) |
| 3 back off | move backwards for a few frames, then rest and watch again |
Before the `switch`, the flags from the handler are handled:
* `hit`: lose a life, sound, three green particle puffs, *coins* `EnemyCoin`s, blink for 45
frames (alpha 0.25 and a red tint every 4 frames, no further hits meanwhile), back off.
* `bit`: back off and rest 90 frames, so the llama gets a breather.
After the `switch` the gator is clamped to the pit and to the floor height.
**Death** – when `lives` reaches 0 the loop `break`s: collision off, the original's death (shrink
over a few frames with puffs, five `EnemyCoin`s), then `parent.v3 -= 1` (one alive less for the
master), `parent.v4 += 1` (kills) and `remove`.
**`sub aim`** – ported from the original Alligator: each frame it publishes its position in
g17–g19 (and the distance in g20) if it is the best target below a jumping llama. That is what makes
the llama's **dive** home in on it.
## 6. The mod (`ArenaMod`)
`apply` refuses a chapter another mod already wrote, builds the WAD, rewrites the tally total
(`LevelTally.counted`: 2 placed coins + every coin the alligators can drop + 5 bonus), writes it
to `wads/T1L9M0nn.wad` and draws a loading picture with Java2D (`CorEncoder`). That is all a level
mod needs: LlamaMod's mod pipeline puts the files into the game's CD folder and links the empty
TestLevel chapters.
## 7. How it was tested
* `ArenaTest` compiles the scripts for every corner of the options, checks wave sizes, builds the
level from the CD and decodes the merged code (the master spawns the gator unit, the gator
spawns EnemyCoin).
* In the game ([`tools/apptest.py`](../../tools/apptest.py)): a probe type (`ArenaProbe`, option
`probe=true`) proved that compiled `on touched` handlers fire; then fights with default options,
a quick win with `lives=1, waves=1`, and a defeat (energy 0 → *Continue* restarts the arena).
![Defeated alligator: puffs and coins](../img/strats/arena_defeat.jpg)
![Win: bonus coins on the exit pad, then the tally](../img/strats/arena_win.jpg)
## What to take from it
1. Borrow assets by **compiling against the level that has them** and importing with `StpcMerge`.
2. Keep event handlers short; set flags and react in the main loop.
3. Communicate through `parent.vN` and globals; spawn only after a `yield`.
4. Copy numbers (speeds, animation indices, type fields) from the original type's script.
5. Write the exit yourself if you need a condition – material 16 + same cell + `endLevel`.
+101
View File
@@ -0,0 +1,101 @@
# Customising strats
From the smallest change to a completely new object type. Steps 1–5 need only the level editor;
step 6 is for programmers writing a LlamaMod mod.
| Step | What changes | Where it is stored |
|---|---|---|
| 1. Object parameters | one placed object | the project's object list |
| 2. Constants | a number in a type's code – every object of that type | `stratEdits.constants` |
| 3. Import a type from another level | the level's code gets a new type with its models, sounds, texts | the project's import list |
| 4. Clone a type | a new type as a copy of an existing one | `stratEdits.types` (source text) |
| 5. New type from a script | a new type written from scratch | `stratEdits.types` |
| 6. Script types in a mod | types compiled and merged by Java code | the mod's source |
A project never stores game code: constants are stored as *old → new value at an offset*, custom
types as source text, imports as *type X from level Y*. Everything is rebuilt from the player's own
CD when the project is opened ([notes/editor-format.md](../../notes/editor-format.md)).
## 1. Object parameters
Select an object; the Inspector's *Object parameters* section shows its MAP parameters (= the
variables v0, v1, … of its strat). For many common types the editor knows names, ranges and meanings
(schemas in [`Schemas.java`](../../src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Schemas.java));
the rest are raw values with the values used in the original levels as a hint.
User guide: [manual chapter 9](../manual/09-objects.md).
## 2. Constants
Double-click a line with a number in the Script panel (or *Edit value…*). The change applies to
every object of the type, is undoable and turns the line orange. On load the old value is checked
first, so a project made for another game version cannot corrupt the code silently. A
`pushShort` holds only −8.0 … 7.99; larger values need a source edit.
![Original and edited Coin constant in the game](../manual/img/strat_coin_constant_ab.jpg)
## 3. Importing types from other levels
*Assets → All worlds* lists every object of every level; dragging one into the viewport imports it.
The import copies the type's code **and everything it reaches**: spawned sub-strats (projectiles,
shards, heads), models, animation sets, textures, sound samples and dialogue lines, relocates all
references, shares what the level already has (Coin, EnemyCoin, Sparkles …) and verifies the
merged code. Limits: 16 MB level memory, 128 sounds, 127 texture pages.
Details: [notes/editor-cross-import.md](../../notes/editor-cross-import.md),
[manual chapter 9](../manual/09-objects.md#importing-objects-from-other-worlds).
## 4. Cloning a type
*Clone as new type…* in the Script panel copies a type's listing into the type editor, renamed.
Change what you need, *Check* (assemble and verify) and *Create type*. The new unit is appended at
the end of the level's code – nothing else moves – and the type appears in *Assets → Objects*.
* **Type fields / parameters from** decides which existing type's MAP fields (variables, slots,
collision flags, extra words) and parameter names new objects get. Pick the type you cloned,
unless you changed how many variables or collision bones the code uses.
* *Edit type…* and *Delete type…* work later on, with undo; placed objects follow their type.
* Custom types can refer to each other by name (`unit:OtherType`).
The manual's tutorial *Make the squirrel run in a circle* clones Bucky and inserts a circle loop
([manual chapter 22](../manual/22-strats.md#tutorial-make-the-squirrel-run-in-a-circle)).
![Two squirrels running in circles: waypoint chain and custom script](../manual/img/strat_bucky_circle.jpg)
## 5. A new type from a script
*Level → New object type from script…* opens the type editor with a template. Set *Language* to
*Script* and write the behaviour in the [script language](script-language.md). Models, animations
and sounds are those of the current level (`model:N`, `anim:N`, `sound:N` – look them up in the
Script panel of a type that uses them). Choose *Type fields from* a type with similar needs (a
pick-up: Coin; a walking enemy: one of the enemies).
## 6. Script types in a mod (Java)
A mod can do the same in code. The arena shows the pattern
([`mods/arena/`](../../src/main/java/com/eliasmueller/llamamod/mods/arena/)):
1. Write the types as script-language text (often with values filled in from options).
2. **Compile against a source level** whose models, animations and sounds you want to use – the
indices in the script are that level's:
`StratAssembler.assemble(StratCompiler.compile(src), StratListing.layout(stpcImage, base))`
and append the words as a new unit to the source level's STPC (`ArenaImport.append`).
3. **Merge into the base level** with `StpcMerge` (the same engine as the editor's import): it
copies the closure (models, animation sets, textures, sounds, spawned strats), relocates and
verifies. Add one unplaced prototype MAP record per type with its type fields.
4. **Build the level** with a `LevelScript` / `LevelBuilder`
([`levelbuild/`](../../src/main/java/com/eliasmueller/llamamod/levelbuild/)) and place the types by
name with `l.strat(...)`.
5. Write the WAD into a TestLevel chapter from the mod's `apply` and add a loading picture.
The [arena page](arena.md) walks through every part of this.
## Choosing type fields
| Field | What to put there |
|---|---|
| variables | at least the highest `vN` the code uses + 1 (parameters count as variables) |
| slots | number of event handler slots (`on …` blocks) – at least 1 if there is one |
| type flags | 0 for invisible logic; collision types need collision flags (Coin: 0x28000; Alligator: 0xC000 / 0xC400) |
| extra | collision-bone words: at least the number of `collisionBone` calls |
Wrong values do not always fail at once: too small collision areas corrupt the strat heap and
crash later (page fault in `FUN_0041ae20`).
+114
View File
@@ -0,0 +1,114 @@
# Reading scripts
LlamaMod shows every strat in two forms: the **Script view**, a readable reconstruction, and the
**Assembly** listing, the exact instructions. Both are in the level editor's *Script* panel
(**View → Panels → Script**, **Ctrl+Shift+K**); the user-level walkthrough is
[manual chapter 22](../manual/22-strats.md).
![The Script panel with Bucky's script](../manual/img/strat_script_panel.jpg)
Code: [`StratListing`](../../src/main/java/com/eliasmueller/llamamod/format/strat/StratListing.java)
(assembly), [`StratPseudo`](../../src/main/java/com/eliasmueller/llamamod/format/strat/StratPseudo.java)
(script view), [`OpTable`](../../src/main/java/com/eliasmueller/llamamod/format/strat/OpTable.java)
(names and help texts).
## Example: the Coin
Script view of the village coin:
```
strat Coin:
setModel(model:5)
collRadius(409r)
onEvent ev_0000, 0 // event type 4 → L_0084
if (v1) { collisionOn<32768>() ... }
if (v0) {
if (self.floorMaterial == -1.0) { skipArgs … } else {
self.y = self.floorHeight + 204r
self.rotY = rnd(4096.0)
}
}
loop { turnLeft(5.625deg); yield }
```
Line by line:
| Line | Meaning |
|---|---|
| `setModel(model:5)` | show model 5 of this level's STPC (the coin) |
| `collRadius(409r)` | collision radius 409/4096 ≈ 0.1 cell |
| `onEvent ev_0000, 0` | install the handler table entry `ev_0000` in slot 0: event type 4 (touched) jumps to `L_0084`, the pick-up code |
| `if (v1) …` | parameter 1 switches collision on |
| `if (v0) …` | parameter 0 (`snapToFloor` in the editor) puts the coin 204/4096 ≈ 0.05 above the floor under it and turns it randomly – unless there is no floor (`floorMaterial == -1`) |
| `loop { turnLeft(5.625deg); yield }` | spin 5.625° per frame, forever |
Changing `204r` to `1.5` makes every snapping coin float 1.5 cells high – that is a constant edit
([Customising strats](custom-types.md)).
## Script view conventions
| You see | It means |
|---|---|
| `self.x`, `player.y`, `parent.v3` | property or variable of this strat, the player, the spawner |
| `v0`, `snapToFloor` | variable 0; named when the editor knows the type's parameter |
| `g12` | global variable 12 |
| `1.0`, `5.625deg`, `204r` | fixed point (1.0 = one cell), an angle, a raw integer (204/4096) |
| `op<a, b>(x, y)` | instruction `op` with inline operands `a, b` and stack arguments `x, y` |
| `loop { … yield }` | runs once per frame |
| `while`, `do … while`, `switch`, `break`, `continue`, `break name` | rebuilt from the jumps |
| `a && b`, `a \|\| b` | short-circuit conditions (chains of conditional jumps) |
| `goto L_xxxx` | jumps that do not form nested blocks – 31 places in the whole game, all one pattern in the llama's script |
The script view is a **reading aid** and is not compiled back. Edits happen on the assembly (or
in the script language for new types).
## The assembly listing
One instruction per line, exactly as stored:
```
Coin:
debugName "Coin"
address model:5 ; model
setModel
onEvent ev_0000, 0 ; event type 4 → L_0084
pushVar snapToFloor ; v0 (alias from the object schema)
jz L_018c
...
L_0218:
push 5.625deg ; = 64.0
turnLeft
jmpYield L_0218
end
```
* Short opcodes (below 0x45) carry a signed 16-bit operand: `pushVar v3`, `pushShort 1.5`,
`jz label`. Long opcodes take inline words: `spawn 3, 6, 0, 0, 0`.
* Directives: `.unit`, `.alias`, `.word`, `.string`, `.event`, `.switch` / `.case`, `.at`.
* Disassemble → assemble is byte-identical for all 1461 units of the game; no instruction is
emitted as raw words.
Full syntax: [notes/strat-language.md](../../notes/strat-language.md). Every opcode with its stack
effect and meaning: [notes/strat-opcodes.md](../../notes/strat-opcodes.md).
## Finding things
* **Which script runs this object?** Select it with *Follow selection* on.
* **Who spawns this type?** The side panel lists the units that reference it.
* **What does this instruction do?** Select the line; the side panel shows the opcode's
semantics, stack effect, Argonaut name and confidence.
* **Where is this variable used?** *Uses* lists every line with the selected operand.
* **Outside the editor:** [`tools/strats.py`](../../tools/strats.py) `level.wad --all` lists every
named strat of a level with code offsets and type fields;
[notes/strat_types_table.md](../../notes/strat_types_table.md) shows which level has which type.
## Typical patterns
| Pattern | Meaning |
|---|---|
| `v1 * 30` | seconds → frames |
| `self.distToPlayerSqXZ < 25` | player within 5 cells (squared distance) |
| `turnTowardAngleY(self.angleToPlayer, 2deg)` | turn towards the player, 2° per frame |
| `if (!self.animPlaying) animPlay(anim:N)` | loop an animation |
| `wpFirst()`, `wpNext()`, `self.wpParam` | walk the waypoint chain; per-node parameter |
| `g95 = g95 \| (1 << (v - 1))` | set an event bit that guards wait for |
+192
View File
@@ -0,0 +1,192 @@
# Example: the roller coaster
The *Llama Coaster* mod builds a new roller-coaster ride: a circuit with drops, climbs, boost
arrows, a spin pad, a jump over a gap, a station where the ride starts and ends, and fireworks at
the finish. Unlike the [arena](arena.md) it writes no new strat code: it **reuses the game's own
coaster cart** (`RollerCoaster`) and only gives it new track data – new geometry and a new
waypoint chain. This page explains how the original ride works and how the mod feeds it.
![The ride: first drop, boost, jump, spin pad, climb, station](../img/strats/coaster_ride.jpg)
| File | Role |
|---|---|
| [`CoasterMod.java`](../../src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterMod.java) | the mod: options, chapter check, writes the WAD and the loading picture |
| [`CoasterTrack.java`](../../src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterTrack.java) | the two layouts; turns legs and pieces into segments, corners and the waypoint chain |
| [`CoasterLevel.java`](../../src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterLevel.java) | the level script: track bed, rails, pillars, station, ground, objects |
| [`CoasterSpeed.java`](../../src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterSpeed.java) | patches the cart's speed constants for the Speed option |
| [`CoasterSim.java`](../../src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterSim.java) | frame-by-frame model of the cart, to check a layout without the game |
| [`CoasterTest.java`](../../src/test/java/com/eliasmueller/llamamod/mods/coaster/CoasterTest.java) | unit tests |
| [notes/mod-coaster.md](../../notes/mod-coaster.md) | lab notes: engine facts, what was verified in the game |
Options: **track** (Loop ~100 units, Grand circuit ~150 units), **laps** (1–3, default 2),
**speed** (slow / normal / fast), **coins** (on/off), TestLevel **chapter** (32–49, default 48).
See [manual chapter 18](../manual/18-mods.md#the-llama-coaster-ride) for players.
## 1. How the original ride works
Catacombs chapters 1, 3 and 5 (`t1l7m001`, `t1l7m003`, `t1l7m005`) are roller-coaster rides. The
ride music is track 25, `ROLLER` ([Music](../reference/audio.md#music)); the levels have the
longest views of the game (fog end up to 23 cells, [Engine facts](../reference/engine-limits.md#drawing)).
* **The cart is the player.** These levels have no `Llama` strat. The `RollerCoaster` strat calls
`isPlayer()` and draws `model:0` – the cart *with* Kuzco the llama and Pacha in it – so the llama
is not attached at run time, it is part of the cart model and its animations (lean, cheer, look
back). See [Objects](../reference/objects.md#special-objects).
* **The camera** is the ordinary `Camera` strat, spawned by the cart like the Llama spawns it. It
follows the global `g29`; in a corner the cart sets `g29` to the heading *after* the turn, so the
camera swings round first.
* **Track following**: the cart drives straight along its heading and turns by exactly 90° at a
waypoint. Its height comes from the floor under it. So a ride is a chain of straight legs on the
grid with 90° corners; slopes, jumps and gaps are pure geometry.
* **Floor materials** of the track bed trigger the effects: 5 boost arrows, 11 jump, 13 spin pad
(the cart hops and turns round, riding backwards to the next corner), 16 the end, 30 the
invisible death plane below (fall = restart).
* **Start and end**: the first waypoints are *station* nodes – the cart crawls out with letterbox
borders. On material 16, when the current waypoint is the last one, the strat waits 30 frames and
calls `endLevel` with its own parameters 1–3 (tribe, world, chapter) → the tally screen.
* Helpers: `RollerDoor` (gates), `FireworksTrigger` (rockets at its waypoint spots when the cart
passes), `CoinRollerCoaster` (coins hovering over the track), `WompyRollerCoaster`.
The river levels (`t1l3m001…4`) look similar but are not rail rides: their player
`KuscoAndPachaTogetherOnATree` floats on water, is pushed by current zones, steers freely between
the banks and leaves through a normal `LevelDoor`. That is why the mod uses the coaster.
### The cart script step by step
Excerpts of `RollerCoaster` as the editor's script view shows it (`v19` = speed, `v22` = boost
excess, `v18` = sideways offset, `v21` = 0 or 2048 when riding backwards):
```
call L_0200() // prepare the waypoints:
// self.wpX = int(self.wpX * 2 + 0.5) / 2 x/z rounded to halves
// setWaypointParam2((self.wpRotY + 512) & 3072) heading rounded to a quarter turn
spawn<4, 41, 2, 0x248000, 0>(0.9, 1.0, 0, 0, unit:Camera)
isPlayer()
self.y = self.floorHeight
showLevelStartInfo<1>()
loop {
call L_0074() // the end check (below)
call L_012c() // left/right input → v18 (at most half the speed)
moveRightQ(v18)
moveForwardQ(v19)
if (v22 > 2r) { v19 -= 2r; v22 -= 2r } // a boost wears off …
else if (v19 > 0.15) { v19 = max(v19 - 2r, 0.15) } // … down to cruise 0.15 (614 raw)
if (self.floorMaterial == 11) { … g22 = 163r } // jump: vertical speed
else if (self.floorMaterial == 5) { v19 += 20r; v22 += 20r } // boost arrows
else if (self.floorMaterial == 13) { … g22 = 61r; v20 = 128 } // spin: hop + half turn
if (self.y - 204r - v19 * 1228r < self.floorHeight && self.floorMaterial != -1) {
self.y = self.floorHeight // stay on the floor (a climb > ~0.1 per frame is lost)
} else { … fall … }
// corner: within 0.75 of the waypoint, steer round it and take the next one
v15 = (self.x - self.wpX) * v4 + (self.z - self.wpZ) * v5
if (v15 < 0.75 | (self.wpParam && v15 < 2.0)) { … g29 = self.wpRotY … }
…
}
L_0074: // the end
if (v13 > 1) { v13 -= 1; if (v13 <= 1) { endLevel(v1, v2, v3) } }
else if (self.floorMaterial == 16 && (self.wpEndFlags & 3r)) { v13 = 30 }
```
## 2. The plan
* **Base level = Catacombs 1 (`t1l7m001`)**, not the village: it already contains the cart, its
camera, coins, gates, fireworks, textures, sounds and the ride music. Importing them into the
village ([like the arena](arena.md#3-compiling-and-importing-arenaimport)) would only copy the
same things over. The mod keeps the base level's code and replaces geometry and objects.
* The **track** follows the cart's rules: straight legs along the axes, 90° corners, heights
changing only on straights.
* The **cart's waypoint chain** is generated from the same layout, so geometry and path cannot
disagree.
* The level goes into a TestLevel chapter ([why 32–49](../reference/testlevel.md)).
## 3. The track (`CoasterTrack`)
A layout is a start corner and a list of legs; each leg is a heading and a list of pieces
(kind, length in units, height change):
```java
new CoasterTrack("loop", new double[] {10, 10, 24}, List.of(
stationLeg(90, 4, 8, 4, 2), // run-in, END, brake, STATION, first drop
leg(0, p(TRACK, 2), p(TRACK, 8, 2), p(TRACK, 2)), // climb 2
leg(270, p(TRACK, 2), p(SPIN, 1), p(TRACK, 1), p(TRACK, 4, -2)),
leg(0, p(BOOST, 2), p(JUMP, 1), p(GAP, 2, -1), p(TRACK, 9)), // jump over a gap
leg(270, p(TRACK, 3), p(TRACK, 5, -2), p(TRACK, 2)),
leg(180, p(BOOST, 1), p(TRACK, 2), p(TRACK, 25, 7)))); // lift hill back up
```
The constructor lays the pieces out and refuses a layout that does not close, turns by anything
but 90° or lacks exactly one STATION and one END on leg 0. Each kind maps to a floor material
(TRACK 0, BOOST 5, JUMP 11, SPIN 13, END 16).
![The two layouts in the editor's top view: Loop and Grand circuit](../img/strats/coaster_tracks.jpg)
**Waypoints** (`nodes(laps)`): node 0 one unit behind the cart and node 1 at the end of the
station, both with parameter 1.0 (the crawl); then one node per corner, 0.5 before its centre with
the heading after the turn, repeated for every lap; and a last node 9 units past the END pad. So
the END pad only ends the ride in the last lap – in earlier laps the cart rolls over it.
## 4. The level (`CoasterLevel.build`)
The level is a [`LevelScript`](../../src/main/java/com/eliasmueller/llamamod/levelbuild/LevelScript.java)
for the [`LevelBuilder`](../../src/main/java/com/eliasmueller/llamamod/levelbuild/LevelBuilder.java):
* **Track bed**: per unit two 1 × 1 quads with the piece's texture and material (boost arrows,
jump stripes, spin target), an underside, and **rails** 0.35 high on both sides – their inner
faces are collision walls, so steering cannot leave the track. Gaps get end faces.
* **Corners**: flat 2 × 2 squares with rails on the two outer sides.
* **Pillars** every 5 units and under each corner, a **boarding platform** with posts left of the
station, a **ground plane** at height 0 with material 30 (the death plane).
* **Objects**:
```java
l.strat("RollerCoaster", st[0], st[1], st[2], st[3],
new int[] {0, tribe * FX, world * FX, chapter * FX}, // endLevel target = this chapter
LevelBuilder.Waypoints.of(wps), 0, false);
l.strat("RollerDoor", …); // gate at the station exit
l.strat("FireworksTrigger", …, Waypoints.of(fw), …); // six rocket spots around the END pad
l.strat("CoinRollerCoaster", …); // every 1.5 units on long straights
```
## 5. Speed and checking a layout (`CoasterSpeed`, `CoasterSim`)
The **Speed** option scales two constants of the cart: the cruise speed `pushShort 614` (six
places: the floor after a boost and the cap in corners) and the boost `pushShort 20` (two places).
`CoasterSpeed.find` locates them in the unit's listing (the instruction before and after must
match) and rewrites only those 16-bit operands.
A faster cart flies farther, and a jump that lands in a corner's turn zone misses the turn.
`CoasterSim` replays the cart's speed and jump rules frame by frame on a layout and reports jumps
that do not clear their gap or land too late, and climbs steeper than the floor probe allows.
The tests run it for every track × speed × laps.
## 6. The mod (`CoasterMod`)
`apply` refuses a chapter whose WAD another mod (or a level project) already changed, builds the
level on `t1l7m001`, sets the tally total to the number of coins (`LevelTally.counted`) and draws
a loading picture in Java (the title sits in the band the tally text leaves free).
![The tally after the ride](../img/strats/coaster_tally.jpg)
## 7. How it was tested
* **Unit tests** (`CoasterTest`): both layouts close, contain every feature and do not overlap;
bad layouts are refused; the waypoint chain follows the cart's rounding rules; every jump lands
for all speeds and laps; the speed patch changes exactly the 8 operands; the built level has one
RollerCoaster (and no Llama), the gate, the fireworks, the coins, the right exit target, the
floor materials 0/5/11/13/16/30 and the tally total; a chapter another mod wrote is refused.
* **In the game** (`tools/apptest.py`): Loop and Grand circuit at all three speeds, 1 and 2 laps –
full rides from the station to the tally with a screenshot every 1.5 s, no crash. After the tally
Enter restarts the ride; in the ride Esc → *Beenden und zur Auswahlkarte wechseln* goes back to
the world map.
## What to take from it
1. **Reuse a ride type with new data** when its rules fit: a new geometry plus a new waypoint chain
is a new ride, without writing strat code.
2. **Read the original strat first** – the 90° corners, the half-unit rounding and the 0.1 climb
limit all come from its script, and the layout code enforces them.
3. **Generate path and geometry from one description**, so they always match.
4. **Model the physics offline** (`CoasterSim`) to catch bad jumps before starting the game.
5. **Constants can be tuned in place**: find them in the listing by their neighbours and patch the
operand, instead of recompiling the strat.
+87
View File
@@ -0,0 +1,87 @@
# The script language
LlamaMod's **script language** (version 2) is a small structured language in the style of the
Script view. [`StratCompiler`](../../src/main/java/com/eliasmueller/llamamod/format/strat/StratCompiler.java)
compiles it to strat assembly, and
[`StratAssembler`](../../src/main/java/com/eliasmueller/llamamod/format/strat/StratAssembler.java)
turns that into bytecode with the same checks as hand-written assembly. It is used by the editor's
type editor (*Language: Script*) and by mods such as the [arena](arena.md). Complete reference:
[notes/strat-language.md](../../notes/strat-language.md#script-language-stratcompiler--writing-new-behaviour);
tests: `StratCompilerTest`.
## A first strat
```
// bobs one cell up and down and turns
strat Bobber {
alias base = v0
alias phase = v1
setModel(model:5) // any opcode: name<inline operands>(stack arguments)
base = self.y + 1 // 1 = one cell
phase = 0
on touched { // event handler (type 4), runs as a subroutine
soundPlay(sound:6)
}
loop {
phase = phase + 4deg
self.y = base + sin(phase)
turnLeft(4deg)
yield // end of this frame
}
}
sub wobble { // called with "call wobble", ends with return
self.rotZ = sin(self.rotY) * 0.1
}
```
## Building blocks
| Construct | Notes |
|---|---|
| `strat Name { … }` | a type; its label `Name` is the entry point |
| `sub name { … }`, `call name`, `return` | subroutines |
| `alias name = v3` | a name for a variable; parameter names from the object schema are predefined |
| `x = e`, `x += e`, `-=`, `*=`, `/=` | assignment; `x += 1` compiles to `inc` |
| places | `vN`, alias, `gN`, `self.prop`, `player.prop`, `parent.prop`, `parent.vN`, `camera.` / `target.` / `boss.` / `dialog.prop` |
| `if (c) { } else if (c) { } else { }` | |
| `while (c) { }`, `loop { }`, `do { } while (c)` | a `yield` as the last statement of a loop becomes the loop's frame-ending back jump |
| `switch (e) { case 1: … break default: … }` | C rules (fall through without `break`); case values are constants |
| `break`, `continue`, `yield`, `end`, `remove` | |
| `on touched { … }`, `on event(type[, param]) [slot n] { … }` | event handlers; touched needs `collisionOn<…>()` and `collRadius(…)` |
| `op<a, b>(e1, e2)` | **any opcode** by name: inline operands in `<>` (raw words), stack arguments in `()`; the count must match the opcode |
### Numbers
* `10` and `10.0` are ten cells (fixed point); `1.5`, `90deg` as in assembly.
* **Raw** integers: `215r` (= 215/4096) or hex `0x10`. Values inside `<…>` are always raw.
* Operators over constants are folded at compile time with the VM's own fixed-point rules:
`2 * 3 + 0.5` → `6.5`.
* `use rawNumbers` switches to the version-1 rule (plain integers raw) – old scripts get it
automatically.
### Operators (low → high precedence)
`||` · `&&` · `|` · `&` · `==` `!=` · `<` `>` `<=` `>=` · `<<` `>>` · `+` `-` · `*` `/` · unary
`-` `!`. In conditions `&&` / `||` short-circuit; in a value both sides are evaluated.
### References
`model:N`, `anim:N`, `unit:N[+off]` or `unit:Name` (another custom type), `sound:N`, `tex:N` –
indices into the level the code is compiled against.
## What the compiler does not do (yet)
* no `for` loop, no typed variables (everything is a 32-bit word, fixed point by convention);
* `spawn` takes raw type fields (`spawn<0, 16, 5, 0xC400, 3>(unit:…)`) – you must know the
values the spawned type needs;
* the Script view does not show event handlers as `on … { }` blocks.
## Tips
* Look at an original type that does something similar first (Script panel) and copy its numbers
– speeds, animation indices, sounds and type fields are level-specific.
* Keep each frame's work small and always reach a `yield` in loops; a loop without a yield freezes
the game.
* Test in the game early: the compiler checks the code's form and references, not whether the
object behaves well.
+135
View File
@@ -0,0 +1,135 @@
# Tools
Everything in this repository that you can run, what it is for and where its code and notes are.
None of the tools contain game data; they all work on your own copy of the game.
## LlamaMod (Java app)
The main product: a mod manager with a launcher and a 3D level editor, for Windows and Linux.
Java 25 + Swing, sources in [`src/main/java/com/eliasmueller/llamamod/`](../src/main/java/com/eliasmueller/llamamod/).
![The LlamaMod launcher](img/launcher.jpg)
| Part | What it does | Docs |
|---|---|---|
| launcher | first-start wizard (copies the game from CD/ISO), mod list with options, save-game editor, starts the game (Wine on Linux) | [manual: Installing](manual/02-installing.md), [manual: Mods](manual/18-mods.md), [notes/llamamod-app.md](../notes/llamamod-app.md) |
| level editor | 3D editor for new and original levels: brushes, tiles, meshes, textures, objects, waypoints, cameras, zones, lights, visibility, strat scripts, play-test, glTF export | [editor manual](manual/index.md) |
| mods | Test World, Alligator Arena, level projects, Boot to level, Intro skip, Mirror mode, Practice tools, Randomizer, Draw distance, Built-in cheats, Debug wireframe, Free camera | [manual: LlamaMod mods](manual/18-mods.md), `notes/mod-*.md` |
### Command line
```text
llamamod start the GUI
llamamod --version show the version and build id
llamamod --install <iso|folder> set up the game data
llamamod --mods list available mods and their options
llamamod --apply [id,id,...] [--opt id.key=value ...]
apply mods (none = original); options are saved
llamamod --launch [--wait] start the game
llamamod --registry show the registry values
llamamod --saves show save games
llamamod --unlock <slot 1-3> <world> <chapter> [--cursor]
llamamod --editor [project.llamalevel | t1l1m001] level editor
llamamod --editor-build <project.llamalevel> <out.wad> [--cd <dir>]
llamamod --editor-import <level.wad|t1l1m001> <project.llamalevel> [--cd <dir>]
llamamod --editor-new <project.llamalevel> [base level] [--name <name>] [--cd <dir>]
llamamod --import-level <project.llamalevel> (into the level folder, shown as a mod)
llamamod --remove-level <id|file> move a level mod's file to backups/levels/
```
Data folder: `-Dllamamod.data=<folder>` or `LLAMAMOD_DATA` (default `~/.local/share/LlamaMod`,
Windows `%LOCALAPPDATA%\LlamaMod`). Source: [`Main.java`](../src/main/java/com/eliasmueller/llamamod/Main.java).
### Debug view
The *Debug wireframe* mod
([`mods/debug/`](../src/main/java/com/eliasmueller/llamamod/mods/debug/)) adds visible geometry to
every level: collision as coloured frames (invisible walls filled red, the exit floor violet, death
planes magenta) and markers for invisible objects (start, exit, checkpoints, cameras, triggers,
zones). Gameplay is unchanged. Its Python ancestor is `tools/debugviz.py`; colour legend and
details: [notes/debugviz.md](../notes/debugviz.md).
![Debug view in Village 3](img/debug-view.jpg)
## Python kit (`tools/`)
The reverse-engineering toolkit the project started with. Python 3.8+ with `numpy` and `Pillow`
(`capstone` for `disx86.py`). All parsers rebuild the original files byte for byte.
| Tool | Purpose |
|---|---|
| [`wadtool.py`](../tools/wadtool.py) | list, extract and replace WAD chunks |
| [`geomlib.py`](../tools/geomlib.py) | library: WAD, TRAK, STPC models, MAP tile part, TEXT, collision |
| [`geom_export.py`](../tools/geom_export.py) | level → OBJ/MTL with textures and vertex colours, `--collision` (collision.obj by material), `--objects`, preview renders |
| [`geom_import.py`](../tools/geom_import.py) | OBJ → new world geometry in a copy of a level |
| [`geom_demo.py`](../tools/geom_demo.py) | example geometry for `geom_import.py` |
| [`mapchunk.py`](../tools/mapchunk.py) | MAP parser/writer |
| [`map_dump.py`](../tools/map_dump.py) | print a level's tiles, strats, lights |
| [`strats.py`](../tools/strats.py) | object types of a level: names, instance counts, code offsets, type fields |
| [`strat_table.py`](../tools/strat_table.py) | type × level table → [notes/strat_types_table.md](../notes/strat_types_table.md) |
| [`stpcwalk.py`](../tools/stpcwalk.py) | rough walk through STPC (models, animations, code) |
| [`textchunk.py`](../tools/textchunk.py), [`text_export.py`](../tools/text_export.py) | TEXT chunk; export pages to PNG and import them back |
| [`texsheet.py`](../tools/texsheet.py) | contact sheet of a level's textures with indices |
| [`levelbuild.py`](../tools/levelbuild.py) | level builder from a Python script ([guide](guide/levels-in-code.md)) |
| [`make_testlevel.py`](../tools/make_testlevel.py) | early experiment: one object per type on a floor plate |
| [`debugviz.py`](../tools/debugviz.py), [`debugviz_check.py`](../tools/debugviz_check.py) | debug-view WADs for all levels and an offline check |
| [`cor2png.py`](../tools/cor2png.py), [`png2cor.py`](../tools/png2cor.py) | loading pictures (COR) ↔ PNG |
| [`asf2wav.py`](../tools/asf2wav.py) | music (ASF) → WAV, exact decoder |
| [`disx86.py`](../tools/disx86.py) | disassemble `groove.exe` at a virtual address |
| [`vorschau/`](../tools/vorschau/) | Blender (Eevee) preview renders of a level with labels |
| [`lama-mod`](../tools/lama-mod) | shell script: CD overlay of symlinks for mods in a hand-made Wine setup (pre-LlamaMod) |
Level scripts in [`levels/`](../levels/): `testwelt.py` (the original test world),
`sprunglabor.py` (jump lab used to measure the llama), `testwelt_parkour.seq` (scripted run).
### Paths
The tools find the original game files through these environment variables
([`tools/lamapaths.py`](../tools/lamapaths.py)); the defaults match a hand-made Wine setup.
Command-line options (`--base`, `--src`, `--root`, `--iso`) win over them.
| Variable | Used by | Default |
|---|---|---|
| `LLAMAMOD_CD` | `levelbuild.py`, `debugviz.py`, `debugviz_check.py`, Gradle tests (`-Dllamamod.cd`) | `$LAMA_WINEPREFIX/iso-dk/Setup/Eng/Cd` – the CD folder (`wads/`, `music/`, `*.cor`) |
| `LAMA_WINEPREFIX` | `lamatest.py`, `levelbuild.py --install` | `~/.wine-lama` |
| `LAMA_MOD` | `lamatest.py` | `~/.local/bin/lama-mod`, else `tools/lama-mod` |
| `APPTEST_ROOT` | `apptest.py` (session folders) | `$CLAUDE_JOB_DIR/tmp` if set, else `~/.cache/llamamod-apptest` |
| `APPTEST_ISO` | `apptest.py` (first install) | `~/Downloads/GROOVE_DK.iso` |
| `LAMATEST_DIR` | `lamatest.py` (state, logs) | `/tmp/lamatest-$USER` |
A missing folder stops the tool with a message naming the variable to set.
## Testing in the game
| Tool | What it does | Notes |
|---|---|---|
| [`tools/apptest.py`](../tools/apptest.py) | runs the game **through LlamaMod** in an isolated session: own data folder, Wine prefix, invisible X display (Xvfb), muted private PulseAudio; applies mods, unlocks a level, navigates the menus, presses keys, takes screenshots, detects crashes | [notes/llamamod-app.md](../notes/llamamod-app.md#testing-mods-in-game-toolsapptestpy) |
| [`tools/lamatest.py`](../tools/lamatest.py) | the older harness for a hand-made Wine setup: loads a WAD as TestLevel and runs a key sequence | [notes/testharness.md](../notes/testharness.md) |
Linux only; needs Wine, Xvfb, xdotool, ImageMagick, tesseract (German data, for reading menu
texts) and PulseAudio. Session folders and the ISO: `APPTEST_ROOT`, `APPTEST_ISO` (see
[Paths](#paths)).
```bash
./gradlew fatJar
tools/apptest.py run demo --display 7 --mods arena --level 9 49 \
--seq "Up:1.5 shot:walk z shot:kick" --out /tmp/demo
```
## Reverse engineering
* `re/ghidra_scripts/DumpAll.java` – headless Ghidra script that decompiles every function of
`groove.exe` into one C file. The exe, the decompile and (currently) the script itself are
excluded from the repository (`.gitignore`). Run
`analyzeHeadless <projdir> groove -import groove.exe -scriptPath re/ghidra_scripts -postScript DumpAll.java groove_decomp.c`
(Ghidra 12.x needs JDK 25).
* Key functions: MAP loader `0x42ac50`, TRAK `0x42aac0`, STPC `0x42ab50`, strat VM `0x54d180`,
chunk dispatcher `0x558960`.
## Other folders
| Folder | Content |
|---|---|
| [`examples/`](../examples/) | `testwelt.llamalevel`: the original test world as an editor project |
| [`packaging/`](../packaging/) | icons, Windows (launch4j) and Linux packaging files |
| [`deb-build/`](../deb-build/) | older script (German) that builds a Debian package of the game itself from your ISO, with the soundtrack converted to MP3 |
+65
View File
@@ -0,0 +1,65 @@
# Lab notes
The project's engineering notebook: what was found in `groove.exe` and the game data, how each part
of LlamaMod works, and what was verified in the game. The notes are detailed and technical (with
addresses, confidence levels and test logs). For a readable introduction start with the
[documentation](../docs/README.md) instead.
Images used by the notes are in [`img/`](img/).
## Formats and engine
| Note | Topic |
|---|---|
| [geometry.md](geometry.md) | TRAK / MAP / STPC models, coordinate spaces, collision, visibility, writing new geometry |
| [objects.md](objects.md) | MAP layout, strat records, waypoints, zones, special objects, the other chunks |
| [textures.md](textures.md) | TEXT chunk |
| [strat-opcodes.md](strat-opcodes.md) | all 434 strat VM opcodes, conventions, sources |
| [strat-language.md](strat-language.md) | LlamaMod's strat assembly, script view and script language |
| [strat_types_table.md](strat_types_table.md) | every object type in every level, with counts |
| [editor-object-schemas.md](editor-object-schemas.md) | parameters of object types, guards, event bits, start camera |
| [testlevel-chapters.md](testlevel-chapters.md) | world 9 chapters and the relocation patch |
| [levelbau.md](levelbau.md) | the Python level builder, measured physics, the exit, the test world |
| [debugviz.md](debugviz.md) | debug view: colour legend, how it is built |
| [testharness.md](testharness.md) | `tools/lamatest.py`, PC controls |
## LlamaMod app and mods
| Note | Topic |
|---|---|
| [llamamod-app.md](llamamod-app.md) | build, CLI, data folder, registry, mod framework, exe patches, save games, `tools/apptest.py` |
| [mod-arena.md](mod-arena.md) | Alligator Arena |
| [mod-coaster.md](mod-coaster.md) | Llama Coaster (roller-coaster ride; how the original rides work) |
| [mod-testworld.md](mod-testworld.md) | Test World and its gallery |
| [mod-levels.md](mod-levels.md) | level projects as mods |
| [mod-boottolevel.md](mod-boottolevel.md) | Boot to level |
| [mod-introskip.md](mod-introskip.md) | Intro skip |
| [mod-mirror.md](mod-mirror.md) | Mirror mode |
| [mod-practice.md](mod-practice.md) | Practice tools |
| [mod-randomizer.md](mod-randomizer.md) | Randomizer |
| [mod-fog.md](mod-fog.md) | Draw distance |
| [mod-cheats.md](mod-cheats.md) | Built-in cheats |
| [mod-freecam.md](mod-freecam.md) | Free camera & noclip |
## Level editor internals
| Note | Topic |
|---|---|
| [editor-design.md](editor-design.md) | design brief |
| [editor-api.md](editor-api.md) | scene model, commands, IO |
| [editor-format.md](editor-format.md) | the `.llamalevel` project format |
| [editor-render.md](editor-render.md) | OpenGL viewport, coordinate conventions, culling |
| [editor-ui.md](editor-ui.md) | application shell and plugin API |
| [editor-tools-geometry.md](editor-tools-geometry.md) | brushes, tiles, collision, mesh import, library |
| [editor-tools-objects.md](editor-tools-objects.md) | objects, paths, cameras |
| [editor-tools-environment.md](editor-tools-environment.md) | zones, fog, music, lights, visibility, baking |
| [editor-tools-textures.md](editor-tools-textures.md) | texture manager, import, painting, animations |
| [editor-cross-import.md](editor-cross-import.md) | importing object types from other levels, engine limits |
| [editor-playtest.md](editor-playtest.md) | play-test straight into the level |
| [editor-manual.md](editor-manual.md) | the manual window and context help |
| [editor-guide.md](editor-guide.md) | early short user guide (superseded by [docs/manual](../docs/manual/index.md)) |
## Process
[`dev/`](dev/) holds the briefs for the AI agents that worked on the project and the review reports
of the overnight UX and bug reviews (`bug-review-*.md`, `ux-review-*.md`).
File renamed without changes.
@@ -60,7 +60,7 @@ objdump, replay, restore).
After rebasing onto the merged main tree (simp-all + launcher UX changes): orig, all-v, boot-exe, mrf-r
re-run with the final jar: all OK (CDPath `L:\cd-active\`).
![matrix](img/launcher-matrix.jpg)
![matrix](../img/launcher-matrix.jpg)
Screens: all mods in Village (wireframe, mirrored) and the free camera; River with mirror+randomizer+fog;
Test World with all mods; boot-to-level + all exe mods (River loading card); practice restore.
@@ -14,20 +14,20 @@ position) started straight in the level, and Escape in the game returned to the
| Before | After |
|---|---|
| ![](img/editor/ux_llama_before.jpg) | ![](img/editor/ux_llama_after.jpg) |
| ![](../img/editor/ux_llama_before.jpg) | ![](../img/editor/ux_llama_after.jpg) |
| A 1920-wide window: the Inspector is 170 px wide and shows no values at all. Side docks have two rows of tabs. | The docks get their intended widths (290 / 340 px). Side-dock tabs are icons with the active one named. The engine fields of the object type are folded away. |
| ![](img/editor/ux_brush_before.jpg) | ![](img/editor/ux_brush_after.jpg) |
| ![](../img/editor/ux_brush_before.jpg) | ![](../img/editor/ux_brush_after.jpg) |
| The Brush tool's buttons read "Bl…", "Pl…", the texture names "gr…", the numbers show "0,5". | Full labels and a decimal point. |
| ![](img/editor/ux_coin_before.jpg) | ![](img/editor/ux_coin_after.jpg) |
| ![](../img/editor/ux_coin_before.jpg) | ![](../img/editor/ux_coin_after.jpg) |
| The Coin tool's options are cut off: only the top pixels of the fields are visible. | The options are on one line, or wrap to a second row in narrow windows. |
| ![](img/editor/ux_menu_before.jpg) | ![](img/editor/ux_menu_after.jpg) |
| ![](../img/editor/ux_menu_before.jpg) | ![](../img/editor/ux_menu_after.jpg) |
| *Help* comes before *Geometry*, and the shortcuts are German ("Strg", "Umschalt"). | The menus are File … Geometry, Tools, Help, and the keys are named "Ctrl", "Shift". |
| ![](img/editor/ux_orig_before.jpg) | ![](img/editor/ux_orig_after.jpg) |
| ![](../img/editor/ux_orig_before.jpg) | ![](../img/editor/ux_orig_after.jpg) |
| *Open original level*: the description is cut at the right edge, and the selected row's id is grey on orange. | The text wraps and the id is readable. |
| ![](img/editor/ux_small_before.jpg) | ![](img/editor/ux_small_after.jpg) |
| ![](../img/editor/ux_small_before.jpg) | ![](../img/editor/ux_small_after.jpg) |
| A 1280×720 window: the right dock is squeezed to 130 px. Also, Esc did not close the dialog, so it was still open and blocked every key. | The docks keep their sizes and the tool options wrap. Esc closes dialogs. |
![](img/editor/ux_quit_game.jpg)
![](../img/editor/ux_quit_game.jpg)
*New: Play while the game still runs offers to quit it.*
@@ -20,27 +20,27 @@ covered by code review), Windows (the antivirus notice is unchanged there).
| Before | After |
|---|---|
| ![](img/app/ux_start_before.jpg) | ![](img/app/ux_start_after.jpg) |
| ![](../img/app/ux_start_before.jpg) | ![](../img/app/ux_start_after.jpg) |
| The app opens with the mod list scrolled to the end (Visual, Cheats); Levels and Gameplay are out of sight. | The list opens at the top and keeps its place when it is rebuilt (Apply, theme). Short windows get a lower header. |
| ![](img/app/ux_mods_before.jpg) | ![](img/app/ux_mods_after.jpg) |
| ![](../img/app/ux_mods_before.jpg) | ![](../img/app/ux_mods_after.jpg) |
| Options have no explanation unless you hover (Worlds "all"? Checkpoint spacing "10" of what?). Descriptions talk about "far clip plane", "strafe", "rigid object models". | Every option is explained next to it. Mod descriptions say what happens in the game and which keys to press. |
| ![](img/app/ux_wizard_before.jpg) | ![](img/app/ux_wizard_after.jpg) |
| ![](../img/app/ux_wizard_before.jpg) | ![](../img/app/ux_wizard_after.jpg) |
| A text file is "shorter than expected (truncated?)", a wrong folder gets "expected Setup/…/Hd/groove.exe", a half-downloaded ISO is "Game found" and fails only after Set up. | Plain messages that say what to do; an incomplete ISO is caught at once, with both sizes. |
| ![](img/app/ux_exe_before.jpg) | ![](img/app/ux_exe_after.jpg) |
| ![](../img/app/ux_exe_before.jpg) | ![](../img/app/ux_exe_after.jpg) |
| On Linux a missing groove.exe is blamed on antivirus software and explained with Windows Defender menus. After Rebuild you have to click Play again. | Linux gets a short message; **Rebuild** writes the file and starts the game. |
| ![](img/app/ux_saves_before.jpg) | ![](img/app/ux_saves_after.jpg) |
| ![](../img/app/ux_saves_before.jpg) | ![](../img/app/ux_saves_after.jpg) |
| "lowest 4 bits of the coin count", "unknown item flag (bit 0)", "Cursor on", "Revert"; no hint how to restore a backup. | Plain wording, "Opens at", "Undo changes"; **Import…** starts in the backups folder and the hint says so. |
| ![](img/app/ux_settings_before.jpg) | ![](img/app/ux_settings_after.jpg) |
| ![](../img/app/ux_settings_before.jpg) | ![](../img/app/ux_settings_after.jpg) |
| "registry values ScreenW/ScreenH", "wine from PATH"; the mod cache shows 0.0 MB after applying 6 mods (stale). | "Automatic (1920x1052)" shows the real size, plain hints, a note on what *Set up again* keeps; the cache size is current. |
| ![](img/app/ux_dark_before.jpg) | ![](img/app/ux_dark_after.jpg) |
| ![](../img/app/ux_dark_before.jpg) | ![](../img/app/ux_dark_after.jpg) |
| Dark theme: the *modifies groove.exe* badge is dark orange on grey (barely readable). | A lighter badge text in the dark theme. |
| New: quitting a hidden or hung game | New: crash report |
|---|---|
| ![](img/app/ux_quit_after.jpg) | ![](img/app/ux_crash_after.jpg) |
| ![](../img/app/ux_quit_after.jpg) | ![](../img/app/ux_crash_after.jpg) |
| While the game runs, Play becomes **■ Quit game** (neutral colour) with a confirmation. | Shown as soon as Wine reports the crash (the game hides behind Wine's error window until that is closed), with Quit game / Open logs. |
![](img/app/ux_av_linux_before.jpg)
![](../img/app/ux_av_linux_before.jpg)
*Before: on Linux every first Apply of an exe mod showed this Windows Defender notice. Now it is
shown on Windows only.*
+4 -2
View File
@@ -114,7 +114,7 @@ Kinds FLOOR, BOX, PLATFORM, PILLAR, RAMP, STAIRS, FENCE, TREE, SEA, KILL_PLANE,
– the `levelbuild.LevelBuilder` primitives; parameters = props named as in `BrushKind.params()`
(`Param(name, label, type, default, role, choices)`; role X/Y/Z/XZ_LIST/XYZ_LIST/XZ_RECT marks
coordinates). `brush.bounds()` gives an approximate box. Texture parameters: village names
(`LevelBuilder.TEX`), `"#index"`, flat colour names (`LevelBuilder.FLAT`) or `"rgbRRGGBB"`.
(`LevelBuilder.TEX`, resolved per base by `BaseTextures`), `"#index"`, flat colour names (`LevelBuilder.FLAT`) or `"rgbRRGGBB"`.
For a preview of the generated geometry: `BrushCompiler.compile(scene, warnings)` → generated
`TileModel`s/`Tile`s/textures (same as the build).
@@ -151,7 +151,9 @@ vertex colours tint the lighting.
* Meaning of many raw fields is unknown (`unk*` props of zones/waypoints, texture entry flags,
collision groups B/C, region value) – they are editable but not interpreted.
* Tile swaps (WFPC 0x10000) can only exist in bases with that flag.
* Texture names exist only for the village (levelbuild names); other levels use `#index`.
* Texture names are the village's levelbuild names; on other bases they resolve to the same picture
(`levelbuild.BaseTextures`, pixel fingerprint); names the base lacks are not offered (GeoWidgets) and a
brush using one is skipped with a warning (the editor has no CD access to copy them over).
* Lights: types other than 1/2/4 carry no data and are ignored by the engine.
* Removing original tiles shifts tile indices used by strat scripts (see editor-format.md).
* The engine limits: ≤ 8000 vertices per tile model, collision within ±8 of the tile origin
+1 -1
View File
@@ -5,7 +5,7 @@ Code: `src/main/java/com/eliasmueller/llamamod/editor/tools/environment/`, tests
`src/test/.../editor/tools/environment/` (`EnvironmentToolsTest` needs `-Dllamamod.cd`; `EnvTour`
= scripted screenshots, `EnvScenarios` = the in-game test projects).
![zones](img/editor/env_zones.jpg)
![zones](img/editor/env_zone.jpg)
## What the user gets
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+28
View File
@@ -68,6 +68,31 @@ stein, stein_kante, stein_moos, platte, holz_dunkel, holz_maser, holzbalken, rin
inka, muster_lila, …`), a direct index (int) or a flat colour (`'meer'`, `'rot'`, … or
`(r,g,b)`; appended to the texture table as a flat-colour entry (flags 0x105, page −1)).
Contact sheet with indices: `notes/img/texturen_dorf.png`.
**Texture names on other bases** (2026-11-07). The `TEX` indices are village (t1l1m001) indices; on another
base the same index is a different picture (village 2: index 1 = dark red floor). Names are therefore
resolved per base by picture: `levelbuild.BaseTextures` holds a fingerprint (FNV-1a over page format,
size and pixels of the entry's rectangle) of every named village texture; a name maps to the village index
if the base shows that picture there (village 1: always → outputs unchanged), else to the first base entry
with the same picture (flags 0x104 preferred). Coverage of the 47 names: t1l1m002 38 (missing: the three
flower textures, `wood_grain*`, `wood_brown*`, `wood_rings`, `wood_edge`), t1l1m003 29, t1l1m004 28,
t1l2m001 9, t1l3m001/t1l5m001 0–2.
* Java builder (`LevelBuilder`, test world/arena/jump lab, `fromCd`): a missing picture is **copied from the
village** (CD file loader `wads/t1l1m001.wad`): packed onto a new 256×256 page (same page flags, 1 px
clamped border) and appended as an entry after the base table; flat colours and copies share the
appended range in order of first use. Without a loader (editor brushes: `BrushCompiler` passes none)
the name is rejected: "texture 'flowers' is a village texture this base level does not have …"
(the editor shows that as a brush warning and only offers the names the scene's base has).
`LevelBuilder.baseTextureNames()` lists the names a base has.
* Python `tools/levelbuild.py --base`: same matching (md5 of RGBA rectangle); missing names raise a
KeyError (no copying).
* In game (apptest, display :22, 2026-11-07): Java-built level on t1l1m002 (grass box, stone box, sand,
trees, plus copied `flowers`, `wood_rings`, `wood_grain`), `--editor-import` → level mod, Village
chapter 2: grass green, stone grey, trees bark + leaves, copied textures correct
(`notes/img/editor/village2_levelbuild_textures.jpg`, `…_textures2.jpg`). Before: grass = dark red
(`village2_levelbuild_start_camera.jpg`).
* Editor (display :22): on a t1l1m002 scene the brush swatches and the texture grid show the base's own
pictures for the names, without the names it lacks (`notes/img/editor/village2_texture_picker.jpg`).
**Every triangle shows a whole texture tile** (the engine has no free UVs) – so
everything is split into 1×1 pieces; narrow/flat walls show the texture squashed.
@@ -190,6 +215,9 @@ loop: if g21 == 3 ; game state "running"
49 chapter slots, table 0x572420) and calls `lama-mod an`. For this, `lama-mod` now also takes
symlinks from mod folders (`find … -type f -o -type l`). Checked: chapter 2 on the map is
selectable and loads the test world.
* **Only chapters 1 and 32–49 are safe** for world 9: the per-chapter coin counter and flag byte of
the other chapters overwrite game globals (chapter 9 destroys the furthest progress), see
[testlevel-chapters.md](testlevel-chapters.md).
* The selection map shows no background for world 9 (only text) and 772 as the coin target
(coin table 0x5722f4 only has worlds 1–8) – cosmetic.
+12 -7
View File
@@ -121,7 +121,8 @@ Each contains `68 90 E0 56 00` (push offset subkey string, VA 0x56E090) followed
byte `02` of each `push 80000002h` becomes `01` (HKEY_CURRENT_USER), so no admin rights are needed
on Windows. German CD (`groove.exe` MD5 697d67a31c20079838c662d8187dbd86, 1,642,496 bytes),
file offsets of the changed bytes: **0x579B, 0x57D0, 0x5810, 0x5859, 0x5880** (original 02,
new 01; patched MD5 6199a42eb7582b2a70b1ac6e29f00f43). The patcher searches the pattern
new 01; MD5 with only this part 6199a42eb7582b2a70b1ac6e29f00f43; the base patch also relocates the
world-9 level records, see "groove.exe patches" below). The patcher searches the pattern
(push of the subkey string VA + push HKLM) instead of fixed offsets, so other language builds work
if their code is the same.
@@ -222,8 +223,10 @@ reads a CD folder, collects writes in `ctx.written`, resolves options (defaults
`game/groove.exe` is rebuilt on every apply: `original/hd/groove.exe` → HKCU base patch
(`platform/ExePatcher`, owner "base", always first) → exe changes of the enabled mods in apply
order. Without exe mods the result is byte-identical to the old ExePatcher output; "Restore
original" goes back to it. A mod gets the shared image with `ctx.exe()`; all changes are
order. Without exe mods the result is byte-identical to `ExePatcher.patch` (installer output);
"Restore original" goes back to it. The base patch (REVISION 2) = HKCU + `relocateLevelRecords`
(world-9 coin/flag arrays moved into .data's page slack, 4-byte address operands only, see
testlevel-chapters.md); `ExePatcher.changes` lists both as byte runs. A mod gets the shared image with `ctx.exe()`; all changes are
attributed to the mod id, journaled into the mod's cache manifest (`"exe": [...]`) and replayed
(with full verification) on a cache hit. Every patch/allocation is logged in apply.log
(`exe <mod>: patch 0x… n bytes – label`).
@@ -313,7 +316,9 @@ Decoded from FUN_0041c640 (load) / FUN_0041c820 (save); details in `save/Ksv.jav
| 0xD0–0xD9 | options: effects volume, music volume, 0x80, speaker mode, 6 more option bytes |
Chapters per world (table 0x572420): 4, 3, 4, 3, 3, 3, 5, 5, 49. World 9 has no row in the
per-chapter arrays. The KSV files shipped on the CD are empty (0x00 then 0xCC). Selecting an empty
per-chapter arrays. Its coin counter/flag byte for chapters 2–31 land on other globals (chapter 9 =
furthest progress): mods use only chapters 1 and 32–49 (`save/TestLevelChapters`,
[testlevel-chapters.md](testlevel-chapters.md)). The KSV files shipped on the CD are empty (0x00 then 0xCC). Selecting an empty
slot and "new game" immediately writes a zeroed file.
## Tests done (2026-11-06)
@@ -354,9 +359,9 @@ On real hardware no wrapper should be needed (unverified).
Runs the game through the LlamaMod app (fat jar CLI), invisible and muted, one isolated *session*
per name: own data folder (`<root>/<name>/data`, = LLAMAMOD_DATA, own Wine prefix and save games),
own Xvfb display, own PulseAudio daemon with only a null sink (PulseEffects on the desktop steals
streams from null sinks on the main server). Root: `$APPTEST_ROOT`, default
`/home/elias/.claude/jobs/90c6eed7/tmp`. The original game data is installed once from
`~/Downloads/GROOVE_DK.iso` into `<root>/_llamamod-template` (file lock, so parallel agents wait)
streams from null sinks on the main server). Root: `$APPTEST_ROOT`, else `$CLAUDE_JOB_DIR/tmp`
(AI agent sessions), else `~/.cache/llamamod-apptest`. The original game data is installed once from
the ISO (`--iso`, `$APPTEST_ISO`, default `~/Downloads/GROOVE_DK.iso`) into `<root>/_llamamod-template` (file lock, so parallel agents wait)
and hard-linked into each session (~30 MB per session + ~200 MB Wine prefix on first start).
`~/.wine-lama` and the user's own game are never touched.
+10 -8
View File
@@ -11,11 +11,12 @@ Registered in `ModRegistry` (one line). Test-only: `ArenaTestMod` (src/test, scr
| `waves` | 3 (1–5) | wave n has min(n, 2) alligators (3 waves = 5 alligators) |
| `energy` | 4 (1–5) | llama energy cap (5 = game normal) |
| `coins` | 1 (0–5) | EnemyCoins per hit (+5 on defeat, +5 bonus at the exit) |
| `chapter` | 8 (2–8) | TestLevel chapter; a chapter already written by another mod → apply error |
| `chapter` | 49 (32–49) | TestLevel chapter; a chapter already written by another mod → apply error; a stored 2–8 falls back to 49 |
Chapter ≤ 8 on purpose: `levelRecord` (coin counter) lives at `0x584F1C[world·6 + chapter]`; for
world 9 chapter 9 that is 0x585018 = furthest progress (unverified risk for level projects on
chapters ≥ 9, see "Issues").
Only chapters 32–49: the coin counter `0x584F1C[world·6 + chapter]` and the flag byte
`0x584EC8[…]` of world-9 chapters 2–31 overwrite other globals (chapter 8 = last played map,
chapter 9 = furthest progress – verified, see [testlevel-chapters.md](testlevel-chapters.md)).
The tally total (LNFO) is 2 placed coins + alligators · ((lives − 1) · coins + 5) + 5 bonus.
Script options (`Arena`, tests/`ArenaTestMod`): `probe=true` (event probe 1.6 ahead of the llama),
`master=false` (no alligators), `start=x,z,rot`, `debug=true` (HUD coin counter shows
@@ -72,7 +73,9 @@ per type (nVars/slots/typeFlags/extra), then `LevelBuilder` builds the level.
* `spawn` copies the strat's matrix/translation block, not `x/y/z`: after `self.x = …` the child
appears at the old place unless the spawner yields first.
* Energy 0: the game's "Weiter / Beenden" screen; Weiter restarts the level (all strats fresh).
* The level tally after the exit shows "Münzen gesammelt 0 / 55" (not our coin counter).
* The level tally after the exit showed "Münzen gesammelt 0 / 55" on chapter 8: 0 = the coin slot
after endLevel wrote the last played map into it, 55 = LNFO of the Village base. Fixed: chapter
32–49 and recounted LNFO ("2 / 12" for 1 alligator with 1 life).
## Verified in game (apptest :24, Wine 6, load 3–9, 2026-11-07)
@@ -101,7 +104,6 @@ mod's chapter → IOException; probe option.
## Issues / limits
* Loading picture and tally use the TestLevel texts ("TestLevel Kapitel 8").
* Loading picture and tally use the TestLevel texts ("TestLevel Kapitel 49").
* The exit target is the arena itself (1, 9, chapter): it restarts after the tally.
* Level projects on TestLevel chapters ≥ 9 may write the coin counter over furthest progress
(0x585018) – not verified.
* Exiting chapter 49 unlocks "chapter 50" (progress 9/50); the map caps the cursor at 49 (checked).
+113
View File
@@ -0,0 +1,113 @@
# Llama Coaster (`coaster`, LEVELS)
Package `mods/coaster`: `CoasterMod` (options, chapter check, writes `wads/T1L9M0nn.wad` +
loading picture `T19nn.COR`), `CoasterLevel` (level script: track geometry + objects),
`CoasterTrack` (layouts → segments, corners, waypoint chain), `CoasterSpeed` (patches the
RollerCoaster strat's speed constants), `CoasterSim` (frame model of the strat's speed/jump logic,
used by the tests to check a layout). Tests: `CoasterTest`. Registered in `ModRegistry` (one line).
Docs: [docs/strats/roller-coaster.md](../docs/strats/roller-coaster.md), manual chapter 18.
| Option | Default | Meaning |
|---|---|---|
| `track` | `loop` | `loop` (~103 units/lap) or `grand` (~150 units, two jumps, two spin pads) |
| `laps` | 2 (1–3) | laps before the ride ends at the station |
| `speed` | `normal` | `slow` ×0.8, `normal` ×1, `fast` ×1.25 (cruise 614 raw and boost 20 raw/frame) |
| `coins` | on | CoinRollerCoaster along the straights (loop 27, grand 44; placed once, not per lap) |
| `chapter` | 48 (32–49) | TestLevel chapter; a chapter another mod already wrote → apply error |
## How the original rides work
**Roller coaster = Catacombs 1, 3, 5 (`t1l7m001/3/5`, world 7 – not world 8; Yzma's lab is
world 8 and has no ride).** Strat units of t1l7m001: `RollerCoaster` (unit 0, 1775 words),
`CameraFixedPointBox`, `RollerDoor`, `CoinRollerCoaster`, `WompyRollerCoaster`,
`FireworksTrigger`, `Camera`, `SpawnableRing`, `Sparkles`, `SpawnableNeedleBall`.
* **The cart is the player.** There is no `Llama` strat: `RollerCoaster` calls `isPlayer()` and
`lookAtMe()`, uses `model:0` (cart with Kuzco-llama and Pacha, scale 2) and its animations
(lean, cheer, look back, fall). The llama is part of the cart model, not attached at runtime.
* **Camera:** the strat spawns the shared `Camera` strat (`spawn<4,41,2,0x248000,0>(0.9, 1.0, …,
unit:Camera)`), the same chase camera as the Llama's; it follows `g29` = the cart's heading,
which the strat sets to the next waypoint's heading in corners (so the camera swings round
before the cart). `CameraFixedPointBox` (one per level) is a fixed-point camera zone.
* **Track following = waypoint chain + floor.** At start (`L_0200`) every waypoint x/z is rounded
to halves and its heading to a quarter turn (param2). The cart drives straight along its heading
at speed `v19` (min 0.15 = 614 raw), the floor height comes from the collision floor below it
(`noFloorPhysics`, `self.y = floorHeight`; a climb steeper than ~0.1 per frame loses the
floor). Within 0.75 in front of the current waypoint it turns by 90° on a small arc round the
waypoint (funnel: lateral offset clamped to 1.2·distance) and switches to the next one – so the
tracks are straight legs on the grid with 90° corners; only the geometry makes slopes.
Left/right input shifts the cart sideways (`v18`, ±half the speed); the rails are collision walls.
* **Floor materials** (collision material of the track bed): 5 boost (+20 raw speed and
"excess" per frame, decays back to cruise), 11 jump (vertical speed 163 raw, gravity 14 raw per
frame), 13 spin (small hop + half turn: the cart rides backwards until the next corner), 15 an
animation/sound pad, 16 the end (only when the current waypoint is the last one), 30 the
invisible death plane far below (fall → restart).
* **Start:** the first two waypoints have param 1.0 (station): the cart crawls (0.025 + nudges)
out of the station with letterbox borders and the start line, then picks up cruise speed.
* **End:** on material 16 with the last waypoint active the strat plays a sound and after 30
frames calls `endLevel(param1..3)` – the cart's own params 1–3 are the target (tribe, world,
chapter) → tally screen. `RollerDoor` are gates the cart passes, `FireworksTrigger` fires rockets
at its waypoint spots when the cart passes, `WompyRollerCoaster` is a spinning Wompy pick-up.
**River = Jungle river 1–4 (`t1l3m001…4`, world 3; world 2 is the jungle at night).** The player
strat is `KuscoAndPachaTogetherOnATree` (both on a log, also `isPlayer()` + the shared `Camera`).
It also rounds its waypoints to halves/quarter turns and turns at waypoints whose param is 1 or 2
(left/right), but it floats on water (`floorMaterial 5` = water there: rumble, splash particles)
and is pushed by current/force zones (`RiverCurrentBox`, many "kraft" zones); the player steers
freely between the banks, `CameraLift`/`RiverWaterfallCamera` give the scripted shots, and the
level ends at a normal `LevelDoor`. It is a steerable boat, not a rail ride, so the coaster mod
uses the RollerCoaster strat.
## Design
* **Base level = t1l7m001** (not the village + import): its STPC already has every unit the ride
needs and their textures, sounds and the ride music; importing `RollerCoaster` + `Camera` into
the village would only duplicate them. The original geometry and objects are replaced by
`LevelBuilder` output (`LevelScript.buildWad`), so `format.stpcmerge` is only used read-only
(`StpcImage`/`StratCode`) by `CoasterSpeed`.
* **Track:** straight legs along the axes, 2 wide (two 1-wide lanes, centre line on integers),
joined by flat 2 × 2 corner squares; heights change only on straights. Pieces: TRACK, BOOST (mat
5), JUMP (11), SPIN (13), END (16), STATION, GAP (no floor). Rails 0.35 high (inner face =
collision), underside, pillars every 5 units and under corners, a boarding platform with posts
left of the station, ground plane at y = 0 with material 30. Textures are t1l7m001 TEXT indices.
* **Leg 0** of every layout: run-in, END pad, brake run, STATION (5), first drop with a boost at
its top. The circuit must close (checked) and END must come before STATION on leg 0.
* **Waypoint chain** (`CoasterTrack.nodes`): node 0 one unit behind the cart and node 1 at the
station's end, both param 1.0 (crawl); then one node per corner, 0.5 before its centre, heading
after the turn, repeated per lap; last node 9 units past the END pad on leg 0, so the END pad is
live only in the last lap. RollerCoaster params = (0, 1, 9, chapter) ×4096 → the tally restarts
the ride, like the arena.
* **Speed** (`CoasterSpeed`): finds the six `pushShort 614` (cruise floor/cap) and two
`pushShort 20` (boost, after `pushVar v19/v22`) in the RollerCoaster unit with `StratListing`
and rewrites the 16-bit operands; nothing else changes.
* **Checking a layout offline** (`CoasterSim`, test `everyJumpLandsAndNoClimbLosesTheFloor`):
for every track × speed × laps every jump must clear its gap and land before the commit zone of
the next corner, no climb > 0.1 per frame, speed stays 0.02–0.45.
* Objects: `RollerCoaster` at the station start, a `RollerDoor` at the station exit, a
`FireworksTrigger` before the END pad with six rocket spots, CoinRollerCoaster every 1.5 units
on straights ≥ 4 long outside leg 0 (on a slight zig-zag). The tally total (LNFO) is the coin count.
* Loading/tally picture: drawn in Java (dark purple, a rail curve, title "Llama Coaster" in the
band y 265–335 that the game's tally text leaves free).
## Verified in game (apptest, :26, 2026-11-07)
* loop/normal 1 lap and 2 laps, loop/fast 1 lap, grand/fast 1 lap (chapter 40), grand/slow:
station crawl with letterbox and start line, drops, climbs, boost arrows, start hint "Kusco, bleib in der
Mitte der Kurve und wir werden uns automatisch umdrehen!", spin pad (cart turns backwards), jump over the gap and
landing, corners, coins counted (e.g. 14/27, 19/44), END pad → fireworks → tally with the
loading picture. No crash in any run.
* After the tally: **Enter** restarts the ride (same chapter); **Esc** asks "Möchtest du das
Spiel beenden?" (No → back to the tally). In the ride, Esc → *Beenden und zur Auswahlkarte
wechseln* → Yes → world-9 map on "TestLevel Kapitel 48". Same behaviour as the arena.
![ride](img/mods/coaster_ride.jpg)
![tracks (editor top view: loop, grand)](img/mods/coaster_tracks.jpg)
![tally](img/mods/coaster_tally.jpg)
## Limits
* Only 90° corners on the grid and straight slopes (the strat cannot follow curves or banking);
no loops/inversions (the cart has no roll).
* The tally coin count is not kept on the world map (it showed 0 after the ride) – same as the
other TestLevel mods; not investigated.
* The original levels' sky/fog/lighting of t1l7m001 stays (dark catacombs, fog end 23 cells).
+16 -6
View File
@@ -35,6 +35,16 @@ properties). Language/syntax: `notes/strat-language.md`. Disassembler/assembler:
getter FUN_00550e60 and setter FUN_005509f0 share the numbering; ids without a setter are read-only
(a write would go to address 0).
## Spawning and collision (engine facts, found by the arena, 2026-11-07)
* `spawn<params, vars, slots, typeFlags, extra>` (0xe0, FUN_0054bfc0) takes the same fields as a MAP
strat record: params = MAP parameter count, vars = local variables, slots, typeFlags = MAP type flags
(collision setup), `extra` = words of the collision-bone area.
* `spawn` copies the spawner's matrix/translation block (+0x20…), not its `x/y/z` properties: after
`self.x = …` the child still appears at the old place. Set the position, **yield**, then spawn.
* A MAP-placed type that uses `collisionOn` needs collision type flags in its MAP record (0x28000 like
Coin); with typeFlags 0 the game crashes (page fault in the heap allocator FUN_0041ae20).
## Sources
* groove.exe handler tables, decompiled with Ghidra 12.2 (headless, all 0x1b2 + 0x45 entries and
@@ -642,7 +652,7 @@ ops = operands (inline words for long ops, the 16-bit argument for short ops); p
| 143 | `camScale` | yes | yes | low | DAT_006D9D30, camera value initialised to 1.0 (zoom/fov factor?). |
| 144 | `levelHeaderC` | yes | no | low | Level header (DAT_00584648)+0x0C ushort << 12. |
| 145 | `slopeSteepness` | yes | no | medium | 1.0 - floor normal y (DAT_0057DDA4) << 12: 0 on flat ground (FUN_004054c0). |
| 147 | `levelTableByte` | yes | no | low | Byte table 0x585024[map + (chapter + tribe*10)*4] << 12 (per-level constant). |
| 147 | `levelTableByte` | yes | no | low | Byte table 0x585024[map + (world + tribe*10)*4] << 12 (FUN_0041af40(tribe, world, map)); bss, never written by the game (always 0) – but world-9 coin counters of TestLevel chapters 22–31 land in its tribe-1 part (see testlevel-chapters.md). |
| 149 | `flagEC_1` | yes | no | low | Flag +0xEC & 0x1. |
| 150 | `flagEC_8000000` | yes | no | low | Flag +0xEC & 0x8000000. |
| 151 | `cheat5` | yes | no | medium | Cheat toggle bits DAT_00584710 & 0x20 (button-sequence cheats, FUN_0041a0xx). |
@@ -660,10 +670,10 @@ ops = operands (inline words for long ops, the 16-bit argument for short ops); p
| 165 | `instanceData1` | yes | yes | low | Map-instance record (+0xF0)+0x28 -> word 1. |
| 166 | `floorCollisionOn` | yes | no | medium | Flag +0xEC & 0x8000 (floor collision, ops 0xa5/0xa6). |
| 169 | `lives` | yes | yes | low | DAT_00584E6C (fixed, starts at 5.0, saved per slot) - lives or max health. |
| 171 | `saveProgressA` | yes | no | low | DAT_00585008 << 12 (saved progress byte). |
| 172 | `progressWorld` | yes | no | low | DAT_0058500C << 12 (saved; world of the furthest/selected level, 8 = finale). |
| 173 | `progressChapter` | yes | no | low | DAT_00585010 << 12 (saved; chapter, 6 with world 8 = last level). |
| 174 | `saveProgressB` | yes | no | low | DAT_00585014 << 12 (saved progress byte). |
| 171 | `saveProgressA` | yes | no | low | DAT_00585008 << 12: last played tribe (KSV +0x3E, set by endLevel). |
| 172 | `progressWorld` | yes | no | low | DAT_0058500C << 12: last played world (KSV +0x3F, set by endLevel; 8 = finale). |
| 173 | `progressChapter` | yes | no | low | DAT_00585010 << 12: last played chapter (KSV +0x40; 6 with world 8 = last level). |
| 174 | `saveProgressB` | yes | no | low | DAT_00585014 << 12: last played map (KSV +0x41). |
| 175 | `unk_6da308` | yes | no | low | DAT_006DA308, no other reference. |
| 178 | `cheat4` | yes | no | medium | DAT_00584710 & 0x10 (cheat toggle). |
| 179 | `hudFlags` | yes | yes | medium | DAT_006DA2E0 HUD element enable mask (default 0x3F000; 0x2000 timer, 0x8000/0x10000/0x20000/0x40000 counters). |
@@ -676,7 +686,7 @@ ops = operands (inline words for long ops, the 16-bit argument for short ops); p
| 187 | `hitDamage` | yes | yes | medium | +0x140: damage dealt on contact (copied into the other strat's +0x144). |
| 188 | `damageTaken` | yes | no | medium | +0x144: damage received from the last contact (player cleared to 0 each frame). |
| 189 | `health2` | yes | yes | medium | DAT_00584E70 = health (same as 92, settable). |
| 190 | `levelRecord` | yes | yes | low | Per-level saved word DAT_00584F1C[chapter + world*6] (best/collected value for the current level; HUD shows it). |
| 190 | `levelRecord` | yes | yes | medium | Coin counter of the current level: u32 DAT_00584F1C[chapter + world*6] (coins << 12; HUD and tally show it, zeroed on level start, KSV keeps worlds 0–8 as u16). World 9 has no row: TestLevel chapter c hits 0x584FF4 + 4c (chapter 9 = furthest progress 0x585018) – only chapters 1 and 32–49 are free (testlevel-chapters.md). |
| 191 | `counterC` | yes | yes | low | DAT_00584FFC: persistent HUD counter (icon 0x150), saved. |
| 192 | `counterA` | yes | yes | low | DAT_00584FF4: persistent HUD counter (icon 0x184, displayed up to 999) - probably coins. |
| 201 | `distToPlayerSq` | yes | no | high | +0x148: cached squared distance to the player ((d>>6)^2 sum), updated each frame when tracking (op 0x30). |
+3 -2
View File
@@ -57,7 +57,8 @@ WAD path absolute!), `rs.sh NAME "SEQ"` (restart with installed WAD + `seq`, mon
**Caution**: no `pgrep -f groove.exe` in wait loops – it matches your own shell
(the command line contains "groove.exe") and lamatest takes it for a foreign instance.
State/PIDs/wine.log: `$LAMATEST_DIR` (default `/tmp/lamatest-$USER`).
State/PIDs/wine.log: `$LAMATEST_DIR` (default `/tmp/lamatest-$USER`). Game: `$LAMA_WINEPREFIX` (default `~/.wine-lama`),
`lama-mod`: `$LAMA_MOD` (see [docs/tools.md](../docs/tools.md#paths)).
`run` exit codes: 0 OK, 3 crash, 4 timeout/hang, 5 image frozen.
Output in `<outdir>`: `report.txt`, `wine.log` (stderr, truncated to 2 MB),
@@ -66,7 +67,7 @@ Output in `<outdir>`: `report.txt`, `wine.log` (stderr, truncated to 2 MB),
## Safety rules (implemented in the script)
- Aborts if a groove.exe with a different DISPLAY is running (Elias' instance).
- Aborts if a groove.exe with a different DISPLAY is running (the user's own instance).
- xdotool only with DISPLAY=:5.
- Sound: own null sink, the app is never muted; `stop` unloads the sink.
- Save games (`*.ksv` in the game folder) are backed up on `start` and restored
+149
View File
@@ -0,0 +1,149 @@
# TestLevel chapters: which ones are safe (2026-11-07)
World 9 ("TestLevel") has 49 chapter slots (table 0x572420), but the game's per-chapter arrays only
have rows for worlds 0–8. Indexing them with world 9 runs into other globals. Code:
`save/TestLevelChapters` (usable = chapter **1** and **32–49**).
## The arrays (index world·6 + chapter)
| Array | Where it is used |
|---|---|
| u32 coins 0x584F1C (coins << 12) | `levelRecord` (strat var 0xBE) get/set, HUD (FUN_00426bf0), tally (FUN_0040dd60), endLevel (FUN_0054d620: "all coins" check), **zeroed on every level start** (FUN_0041cba0), KSV keeps 54 entries as u16 |
| u8 flags 0x584EC8 (bit 0 collectible, bit 1 gold coin) | collectible picked up (HUD, strat var 0xFA rising) → \|=1; endLevel → \|=2 when coins == static total 0x5722F4[world·5 + chapter] (garbage for world 9, often 0) or cheat 0x584710&8 → \|=3; gold count FUN_0041cd70 (worlds 1–8 only) |
Coin total table 0x5722F4 and the selection map only know worlds 1–8 (map shows garbage targets
for world 9 – cosmetic).
## What world-9 chapter c hits
Coin slot 0x584FF4 + 4c, flag byte 0x584EFE + c (Ghidra references 0x584EF0–0x585240):
| c | coin slot | flag byte |
|---|---|---|
| 0 | 0x584FF4 counter var 0xC0 (HUD) | free |
| **1** | 0x584FF8 free (only cleared by new game) | free |
| 2 | 0x584FFC counter var 0xBF (HUD icon 0x150) | free |
| 3 | 0x585000 HUD flag (FUN_00427c40) | free |
| 4 | 0x585004 var 0x80 | free |
| 5–8 | 0x585008–14 last played tribe/world/chapter/map (strat vars 0xAB–0xAE, KSV +0x3E–41) | free |
| 6–9 | | 0x584F04 **language index** (u32; \|=2 → text table out of range) |
| 9 | 0x585018 **furthest progress** (world, chapter bytes; KSV +0x42/43) | |
| 10–13 | 0x58501C/20 free | 0x584F08 option (KSV +0xD6) |
| 12 | 0x585024 start of byte table (var 0x93, tribe 0) | |
| 14–21 | 0x585028–48 free | 0x584F0C / 0x584F10 (var 0x5B) |
| 22–25 | 0x58504C–58 table 0x585024, tribe 1 (read by var 0x93 in original levels) | free |
| 26–29 | table, tribe 1 | 0x584F18 option (KSV +0xD8) |
| 30–31 | table, tribe 1 (world 8/9) | coin slots of world 0 (never read) |
| **32–49** | table, tribes 2/3 (never used: tribe is 1, 4 only in the attract demo) | coin slots of world 0 |
## Verified in game (apptest :24, 2026-11-07)
* **Chapter 9** (arena test mod, 1 alligator, 2 coins collected): pause → "Beenden und zur
Auswahlkarte" → the selection map shows world "°p", "Kapitel 0", coin target 14384: furthest
progress 0x585018 was replaced by the coin counter (2 << 12 → world 0, chapter 32).
![chapter 9: broken map](img/mods/testlevel_ch9_map_broken.jpg)
* **Chapter 49** (same run): tally "Münzen gesammelt 2 / 12, Geheimnisse 0 / 0" (LNFO recounted),
save written with progress (9, 50) – the map caps the cursor at chapter 49, no crash; map fine.
![chapter 49 tally](img/mods/testlevel_ch49_tally.jpg)
* Before (arena on chapter 8, arena agent): tally "0 / 55": the collected value is the coin slot
after endLevel copied the level ids into 0x585008–14 (chapter 8 = last played map = 0), the total
is LNFO of the Village base.
## Tally totals (LNFO)
Chunk LNFO = u32 coins, u32 secrets → 0x5FF070 / 0x5FF060 (FUN_0042AB10; the tally shows the values
of the level just finished, kept in 0x5FF064 / 0x5FF06C). Originals: e.g. t1l3m004 40 = 20 Coin +
4 Coin5·5; levels with CoinLine/CoinCircle/enemies count those too. `mods/levels/LevelTally` recounts
Coin (1) + Coin5 (5) and Secret strats for new level projects and the Test World (20 coins, 0
secrets); the arena adds its dynamic coins (alligator hit/death coins + 5 exit bonus).
## Migration
* Level projects: stored chapter 1 or 32–49 kept (first file wins), others get the first free of
32–49; more than 18 projects share chapters (only one of them can be on – apply error otherwise).
Play-tests/exports of an old project: `migrate` (2..19 → 32..49, 20..31 → 32..43).
* The chapter options are choices (level: 1, 32–49; arena: 32–49, default 49); a stored old value is
invalid and falls back to the default (ModOptions).
* Editor: New level / Level settings spinners only offer usable chapters. The empty template
(`Templates.makeEmpty`) defaults to 32; New level and CLI `--editor-new` take the first chapter
from 32 no project in the levels folder uses (`LevelProjects.nextFreeChapter`).
![chapter option](img/mods/testlevel_chapter_option.jpg)
## Fix: base exe patch relocates both arrays (ExePatcher.REVISION 2, 2026-11-07)
`ExePatcher.relocateLevelRecords` (part of the always-applied base patch: `ExePatcher.patch` for
the installer, `ExePatcher.changes` replayed as owner "base" in `ModManager.baseExe`) moves both
arrays to a 128-entry buffer (index ≤ 9·6 + 49 = 103) in the slack after .data's VirtualSize:
.data ends at 0x6DB935, the page at 0x6DC000 (.idata follows), so coins u32[128] at **0x6DB940**,
flags u8[128] at **0x6DBB40** (zero-filled and RW, mapped with .data's last page; no new section,
no code). Every absolute address of the arrays in .text is rewritten (whole-file scan of
0x584EC8–0x584EFE / 0x584F1C–0x584FF4: no other references, none in .data):
| Old | New | Sites (operand VA) |
|---|---|---|
| 0x584F1C coins | 0x6DB940 | tally 0x40DE67, KSV load 0x41C72A / save 0x41C8FB, new game 0x41CA6B, level start 0x41CBCC, HUD 0x426AC2 / 0x426D43 / 0x426E08, endLevel 0x54D6BA, levelRecord ptr 0x550BC5, 0x55223E |
| 0x584FF4 coin loop end (`cmp eax, imm; jl`) | 0x6DBA18 | 0x41C74B, 0x41C91A (0x584FF4 itself is also counter var 0xC0 – those uses stay) |
| 0x584EC8 flags | 0x6DBB40 | tally 0x40DEA1 / 0x40DEC9, KSV 0x41C65C / 0x41C832, new game 0x41CA64 (rep stosd 54 bytes), collectible 0x42706B, endLevel 0x54D6E3 / 0x54D708 / 0x54D70F |
| 0x584ECE flags from world 1 | 0x6DBB46 | map icons 0x41B724 / 0x41B79C / 0x41B7EE / 0x54A75E, gold count 0x41CD7B |
| 0x584EFE flag loop end | 0x6DBB76 | KSV 0x41C6D6 / 0x41C8AF, gold count 0x41CD9B |
Worlds 0–8 behave as before (KSV keeps the first 54 entries; the rest is never saved). World-9
flags are not cleared by "new game" (only the 54 saved bytes are) – they only affect the map's
collectible/gold icons of TestLevel chapters within one game session. If the counts of these
references differ (other exe build) the relocation is skipped as a whole. Test: `ExePatcherTest`.
**Effect:** every world-9 chapter is harmless in LlamaMod's groove.exe, so the remaining holes are
closed without restricting the map: picking an empty (linked) chapter like 9 on the world map,
chapters 2–31 that unlock together with 32+, and Boot to level on chapters 2–31 (its option keeps
1–49). Not linking empty chapters is no option: a missing WAD crashes the map ("Couldnt load wad",
`ModManager.world9Fixup`). Mod levels still only use 1 and 32–49 (no migration churn; also safe
with an exe that has not been rebuilt yet after the update).
Verified in game (apptest :24, 2026-11-07):
* A/B, Test World on, map cursor world 9 chapter 9 (the linked empty chapter), start, pause →
quit to the map: old jar (base REVISION 1) → map "°p / Kapitel 0 / 14384" (progress zeroed by the
level start alone); new → "TestLevel / Kapitel 9 / 237" (237 = garbage static coin total, cosmetic).
![chapter 9 before/after](img/mods/testlevel_ch9_fixed.jpg)
* Arena (test mod) on chapter 9, 2 coins collected (HUD 2 / 12), quit to the map → map fine.
* Save with world-1 chapter 1 flags = 3 and gold count 2: slot menu "Goldmünzen 2", the map shows
the collectible and gold icons of Village chapter 1 (KSV load loop + map reads relocated).
* **KSV save loop after finishing a normal level, A/B byte comparison** (apptest :25, sessions
`savetest-a` = fat jar of the commit before 00b2880, base REVISION 1; `savetest-b` = HEAD,
REVISION 2; 2026-11-07). Slot 3 (`king2.ksv`) preloaded identically in both with a distinct
pattern for worlds 1–8, chapters 1–5: flags `(w + c) % 4`, coins `((5w + c) % 15 + 1) << 12`
(gold count 19/20 to match), so a save loop reading the old arrays in B would write zeros.
Mods: boot-to-level (slot 3) + cheats `rewards` (flags |= 3 at the chapter end). Runs:
1. **Jungle night 2** (world 2 ch. 2, exit pad reachable directly): start `29.45, 3.5, 60.3, 180
@ 2/2`, Up ~5.5 s → 3 coins → tally "Münzen gesammelt 3 / 70" → Return.
2. **Village 1** (world 1 ch. 1): t1l1m001 rebuilt by a throw-away tool (WadImporter → tile 420
set to its swap-alt model 49 with the exit pad, RubyIdolDoor removed, the two coins near the
door moved to x 42.5, `StartOverride` 42.5/3.5/51.8 yaw 180 → WadBuilder), written over the
session's `cd-active/wads/t1l1m001.wad` after apply; Up 3.5 s → tally "Dorf Kapitel 1,
Münzen gesammelt 2 / 55" → Return. (Village 4's direct exit is behind the Yzma boss fight.)
3. **TestLevel chapter 1** (Test World, start `44.3, 3.6, 15.5, 90 @ 9/1`, 3 coins, rewards
cheat) on top of the Village-1 save → tally → Return.
All three saved KSVs are **byte-identical in A and B** (`cmp`: 0 differences). Run 1: chapter
(2,2) coins 9 → 3, flags 0 → 3, gold 20 → 21, progress (2,2) → (2,3), cursor (2,3), last played
(2,2); every other preloaded coin/flag entry and bytes 0xB0–0xFF unchanged. Run 2: (1,1) coins
→ 2, flags → 3, gold 19 → 20, progress (1,2). Run 3 (world-9 leak check): only cursor world/chapter
(9,1) and last-played world 9 change; all 54 coin/flag entries, gold, progress and 0xB0–0xFF stay
as before – the world-9 coins/flags (buffer index 55) are not written (54 entries only). B's save
loaded again without mods: slot menu "Gespeichertes Spiel 3 / Dorf Kapitel 2 / Goldmünzen 20",
world map on Village 2 with the collectible and gold icons (preloaded flags 3).
![tallies, slot menu, map (B)](img/mods/testlevel_save_ab.jpg)
* **TestLevel tally** (question from the coaster test): Return does save (KSV rewritten, see run 3)
– there is never a visible "saved" step, also not in normal levels (the next level loads at
once). The Test World / coaster / arena exits lead back to the same chapter, so the restart zeroes
that chapter's coin counter (level start, FUN_0041cba0) → the map then shows 0; world-9 coins are
never in the KSV anyway. Expected and harmless. Esc on the tally asks "Möchtest du das Spiel
beenden? Ja / Nein" (Return = Nein, back to the tally) – identical with the old exe (A), so not
caused by the relocation. ![Esc on the tally](img/mods/testlevel_tally_esc.jpg)
## Before the fix
* The map let the player pick any unlocked world-9 chapter; empty ones are linked to an installed
world-9 WAD (ModManager), so playing e.g. chapter 9 from the map still clobbered progress. Unlocking
chapter 32+ unlocks 2–31 on the map too. Fixed by the relocation above.
@@ -6,6 +6,7 @@ import com.eliasmueller.llamamod.editor.model.Light;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.PathNode;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
@@ -45,7 +46,7 @@ public final class Templates {
LevelSettings st = s.settings();
st.setRaw("kind", LevelSettings.NEW);
st.setRaw("name", "New level");
st.setRaw("targetChapter", 1);
st.setRaw("targetChapter", TestLevelChapters.FIRST);
st.setRaw("poolReserve", NEW_LEVEL_RESERVE);
int[] table = s.regions().table().clone();
table[0] = 0xffffffff;
@@ -26,7 +26,7 @@ import com.eliasmueller.llamamod.format.TextChunk;
import com.eliasmueller.llamamod.format.Trak;
import com.eliasmueller.llamamod.format.Vertex;
import com.eliasmueller.llamamod.format.Wad;
import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
import com.eliasmueller.llamamod.levelbuild.BaseTextures;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
@@ -340,7 +340,7 @@ public final class WadImporter {
s.addQuiet(pg);
}
List<TextChunk.TextureEntry> te = TextChunk.textureEntries(d);
Map<Integer, String> names = textureNames(s.settings().base());
Map<Integer, String> names = textureNames(d);
for (int i = 0; i < te.size(); i++) {
TextureEntry t = textureEntry("tex:" + i, te.get(i));
t.setRaw("name", names.getOrDefault(i, ""));
@@ -383,11 +383,13 @@ public final class WadImporter {
return t;
}
/** Names of village textures (levelbuild names) for the outliner; other levels have none. */
private static Map<Integer, String> textureNames(String base) {
/** Village texture names (levelbuild names) for the outliner, wherever the level has the picture. */
private static Map<Integer, String> textureNames(byte[] text) {
Map<Integer, String> m = new HashMap<>();
if ("t1l1m001".equalsIgnoreCase(base)) {
LevelBuilder.TEX.forEach((k, v) -> m.putIfAbsent(v, k));
try {
BaseTextures.resolve(text).forEach((k, v) -> m.putIfAbsent(v, k));
} catch (RuntimeException e) {
// unusual TEXT layout: no names
}
return m;
}
@@ -57,7 +57,9 @@ public final class ViewerDemo {
o.put(a, args[++i]);
}
}
Path cd = Path.of(o.getOrDefault("--cd", System.getProperty("user.home") + "/.wine-lama/iso-dk/Setup/Eng/Cd"));
String envCd = System.getenv("LLAMAMOD_CD");
Path cd = Path.of(o.getOrDefault(
"--cd", envCd != null ? envCd : System.getProperty("user.home") + "/.wine-lama/iso-dk/Setup/Eng/Cd"));
byte[] wad;
if (args[0].equals("testworld")) {
byte[] base = Files.readAllBytes(cd.resolve(LevelBuilder.BASE_WAD));
@@ -191,8 +191,14 @@ public final class EnvMath {
"ENDGAME"
};
/** Ids whose {@code .ASF} file the (German) CD does not have: GAMEOVER, TALLY, PUZZLE2, PLATFORM. */
private static final List<Integer> NO_FILE = List.of(10, 11, 36, 37);
public static String musicLabel(int id) {
return id >= 0 && id < MUSIC.length ? id + " – " + MUSIC[id] : id + " – (unknown)";
if (id < 0 || id >= MUSIC.length) {
return id + " – (unknown)";
}
return id + " – " + MUSIC[id] + (NO_FILE.contains(id) ? " (no file on the CD)" : "");
}
// ------------------------------------------------------------ lights
@@ -17,6 +17,7 @@ import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.model.cmd.SetProp;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.mods.levels.LevelProjects;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.GridLayout;
@@ -38,6 +39,7 @@ import javax.swing.JScrollPane;
import javax.swing.JSpinner;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.SpinnerListModel;
/** Level → Settings: name, chapter, grid size, death plane, WFPC flags, object pool (one undo step). */
final class LevelSettingsDialog {
@@ -80,9 +82,13 @@ final class LevelSettingsDialog {
"Base level",
new JLabel(st.base() + " (object code, textures, sounds)"),
"Fixed when the project is created (the project stores only the differences to it)");
JSpinner chapter = Ui.number(Math.max(1, st.targetChapter()), 1, 49, 1, "0", v -> {});
JSpinner chapter = new JSpinner(new SpinnerListModel(TestLevelChapters.all()));
chapter.setValue(TestLevelChapters.migrate(st.targetChapter()));
JLabel used = new JLabel(usedChapters(ctx, st.targetChapter()));
f.row("TestLevel chapter", chapter, "New levels replace this chapter of world 9 (TestLevel)");
f.row(
"TestLevel chapter",
chapter,
"New levels replace this chapter of world 9 (TestLevel): 32–49, or 1 (the Test World's)");
f.full(used);
chapter.setEnabled(st.isNewLevel());
@@ -1,11 +1,15 @@
package com.eliasmueller.llamamod.editor.tools.geometry;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.SceneEvent;
import com.eliasmueller.llamamod.editor.model.TextureEntry;
import com.eliasmueller.llamamod.editor.model.TexturePage;
import com.eliasmueller.llamamod.editor.render.Legend;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.Widgets;
import com.eliasmueller.llamamod.format.TextChunk;
import com.eliasmueller.llamamod.levelbuild.BaseTextures;
import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
import com.eliasmueller.llamamod.util.SwingListeners;
import java.awt.BorderLayout;
@@ -19,11 +23,12 @@ import java.awt.Image;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;
import java.util.function.Consumer;
import java.util.function.IntConsumer;
@@ -145,12 +150,49 @@ final class GeoWidgets {
private static final Map<Scene, Map<String, BufferedImage>> CACHE = new WeakHashMap<>();
private static final Map<Scene, Map<String, Integer>> NAMES = new WeakHashMap<>();
/** Scenes whose texture/page edits clear their cache entries (listener added on first use). */
private static final Set<Scene> WATCHED = Collections.newSetFromMap(new WeakHashMap<>());
static void clearCache(Scene s) {
CACHE.remove(s);
NAMES.remove(s);
}
private static void watch(Scene s) {
if (WATCHED.add(s)) {
s.addListener(e -> {
if (e.type() == SceneEvent.Type.RELOADED
|| e.node().kind() == NodeKind.TEXTURE
|| e.node().kind() == NodeKind.TEXTURE_PAGE) {
clearCache(s);
}
});
}
}
/** Village texture names the scene's textures show (name → index), see {@link BaseTextures}. */
static Map<String, Integer> names(Scene s) {
if (s == null) {
return LevelBuilder.TEX;
}
watch(s);
return NAMES.computeIfAbsent(s, k -> {
List<TextChunk.TextureEntry> e = new ArrayList<>();
for (TextureEntry t : k.textures()) {
e.add(t.toFormat());
}
List<TextChunk.Page> p = new ArrayList<>();
for (TexturePage pg : k.pages()) {
p.add(new TextChunk.Page(pg.flags(), pg.width(), pg.height(), pg.pixels()));
}
return BaseTextures.resolve(e, p);
});
}
/** Texture index of a brush texture name (names, "#n", flats → -1), or -1. */
static int index(String tex) {
static int index(Scene s, String tex) {
if (tex == null || tex.isEmpty()) {
return -1;
}
@@ -161,7 +203,7 @@ final class GeoWidgets {
return -1;
}
}
Integer i = LevelBuilder.TEX.get(tex);
Integer i = names(s).get(tex);
return i == null ? -1 : i;
}
@@ -181,13 +223,14 @@ final class GeoWidgets {
if (s == null || tex == null || tex.isEmpty()) {
return null;
}
watch(s);
Map<String, BufferedImage> m = CACHE.computeIfAbsent(s, k -> new LinkedHashMap<>());
if (m.containsKey(tex)) {
return m.get(tex);
}
BufferedImage img = null;
Color flat = flatColor(tex);
int i = index(tex);
int i = index(s, tex);
if (flat != null) {
img = new BufferedImage(8, 8, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = img.createGraphics();
@@ -240,17 +283,17 @@ final class GeoWidgets {
return new ImageIcon(out);
}
/** All texture choices of the scene: named village textures, flat colours, then "#n". */
/**
* All texture choices of the scene: the village texture names its base has, flat colours, then
* "#n".
*/
static List<String> choices(Scene s) {
List<String> l = new ArrayList<>(LevelBuilder.TEX.keySet());
Map<String, Integer> names = names(s);
List<String> l = new ArrayList<>(names.keySet());
l.addAll(new java.util.TreeSet<>(LevelBuilder.FLAT.keySet()));
Map<Integer, Boolean> named = new IdentityHashMap<>();
for (int v : LevelBuilder.TEX.values()) {
named.put(v, true);
}
if (s != null) {
for (int i = 0; i < s.textures().size(); i++) {
if (!LevelBuilder.TEX.containsValue(i)) {
if (!names.containsValue(i)) {
l.add("#" + i);
}
}
@@ -337,21 +337,36 @@ public final class ManualContent {
return switch (o.type()) {
case BOOL -> "on / off";
case INT -> o.min() + " – " + o.max();
case CHOICE -> {
List<String> c = new ArrayList<>();
o.choices().forEach(ch -> c.add(ch.label()));
yield String.join(", ", c);
}
case MULTI -> {
List<String> c = new ArrayList<>();
o.choices().forEach(ch -> c.add(ch.label()));
yield "any of " + String.join(", ", c);
}
case CHOICE -> choiceList(o);
case MULTI -> "any of " + choiceList(o);
case SEED -> "any text";
case STRING -> "text";
};
}
/** Choice labels, comma-separated; runs of three or more consecutive numbers as "a – b". */
static String choiceList(ModOption o) {
List<String> labels = o.choices().stream().map(ModOption.Choice::label).toList();
List<String> out = new ArrayList<>();
for (int i = 0; i < labels.size(); ) {
int j = i;
while (j + 1 < labels.size() && consecutive(labels.get(j), labels.get(j + 1))) {
j++;
}
if (j - i >= 2) {
out.add(labels.get(i) + " – " + labels.get(j));
} else {
out.addAll(labels.subList(i, j + 1));
}
i = j + 1;
}
return String.join(", ", out);
}
private static boolean consecutive(String a, String b) {
return a.matches("-?\\d{1,9}") && b.matches("-?\\d{1,9}") && Integer.parseInt(b) == Integer.parseInt(a) + 1;
}
private static String defaultText(ModOption o) {
return switch (o.type()) {
case BOOL -> "true".equals(o.defaultValue()) ? "on" : "off";
@@ -17,6 +17,7 @@ import com.eliasmueller.llamamod.platform.Config;
import com.eliasmueller.llamamod.platform.GameLauncher;
import com.eliasmueller.llamamod.platform.Json;
import com.eliasmueller.llamamod.platform.Progress;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import com.eliasmueller.llamamod.save.Worlds;
import java.io.IOException;
import java.nio.file.Files;
@@ -50,7 +51,7 @@ public final class PlaytestRunner {
/** World/chapter of a project: TestLevel chapter, or the original's place; null if not on the map. */
public static int[] target(Document d) {
if (d.isNewLevel()) {
return new int[] {9, Math.max(1, d.scene().settings().targetChapter())};
return new int[] {9, TestLevelChapters.migrate(d.scene().settings().targetChapter())};
}
Matcher m = Pattern.compile("t1l(\\d)m(\\d{3})").matcher(d.base().toLowerCase(Locale.ROOT));
if (m.matches()) {
@@ -1,9 +1,8 @@
package com.eliasmueller.llamamod.editor.ui;
import com.eliasmueller.llamamod.editor.io.ProjectIO;
import com.eliasmueller.llamamod.editor.io.ProjectInfo;
import com.eliasmueller.llamamod.editor.io.Templates;
import com.eliasmueller.llamamod.mods.levels.LevelProjects;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
@@ -12,11 +11,8 @@ import java.awt.Font;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.swing.BorderFactory;
import javax.swing.BoxLayout;
import javax.swing.ButtonGroup;
@@ -28,7 +24,7 @@ import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JSpinner;
import javax.swing.JTextField;
import javax.swing.SpinnerNumberModel;
import javax.swing.SpinnerListModel;
import javax.swing.UIManager;
/** New level: name, base level (object types, textures, sky, music), template, TestLevel chapter. */
@@ -51,7 +47,8 @@ final class NewLevelDialog extends JDialog {
base.setSelectedItem(l);
}
}
JSpinner chapter = new JSpinner(new SpinnerNumberModel(nextFreeChapter(w), 1, 49, 1));
JSpinner chapter = new JSpinner(new SpinnerListModel(TestLevelChapters.all()));
chapter.setValue(LevelProjects.nextFreeChapter(w.env().levelsDir()));
PicturePanel pic = new PicturePanel(300, 170);
Runnable updatePic = () -> {
OriginalLevels.Level l = (OriginalLevels.Level) base.getSelectedItem();
@@ -75,7 +72,8 @@ final class NewLevelDialog extends JDialog {
row,
"TestLevel chapter",
chapter,
"The level replaces this chapter of world 9 (TestLevel) when it is enabled");
"The level replaces this chapter of world 9 (TestLevel) when it is enabled (32–49; 1 is the Test"
+ " World's)");
JPanel templates = new JPanel();
templates.setLayout(new BoxLayout(templates, BoxLayout.Y_AXIS));
ButtonGroup g = new ButtonGroup();
@@ -161,26 +159,6 @@ final class NewLevelDialog extends JDialog {
return row + 1;
}
/** First TestLevel chapter from 2 that no project in the levels folder uses. */
static int nextFreeChapter(EditorWindow w) {
Set<Integer> used = new HashSet<>();
for (Path p : LevelProjects.list(w.env().levelsDir())) {
try {
ProjectInfo i = ProjectIO.info(Files.readAllBytes(p));
if (i.isNewLevel()) {
used.add(i.targetChapter());
}
} catch (Exception e) {
// skip broken files
}
}
int c = 2;
while (used.contains(c) && c < 49) {
c++;
}
return c;
}
Result result() {
return result;
}
@@ -15,6 +15,7 @@ import com.eliasmueller.llamamod.editor.model.cmd.AddNode;
import com.eliasmueller.llamamod.editor.model.cmd.Batch;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.platform.Json;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.File;
@@ -592,7 +593,7 @@ public final class ProjectController {
? String.format(
Locale.ROOT,
"T1L9M%03d",
Math.max(1, d.scene().settings().targetChapter()))
TestLevelChapters.migrate(d.scene().settings().targetChapter()))
: d.base();
fc.setSelectedFile(new File(fc.getCurrentDirectory(), stem + ".wad"));
if (fc.showSaveDialog(w) != JFileChooser.APPROVE_OPTION) {
@@ -243,6 +243,16 @@ public final class TextChunk {
* SRCALPHA/INVSRCALPHA) with alpha in the fourth byte.
*/
public static Appended appendFlats(byte[] d, List<Flat> entries) {
List<TextureEntry> l = new ArrayList<>(entries.size());
for (Flat f : entries) {
boolean opaque = f.alpha == null;
l.add(new TextureEntry(opaque ? 0x105 : 0x1105, -1, -1, f.r, f.g, f.b, opaque ? 0 : f.alpha));
}
return appendEntries(d, l);
}
/** Appends entries to the texture table of a raw TEXT chunk (pages and trailer untouched). */
public static Appended appendEntries(byte[] d, List<TextureEntry> entries) {
int o = textureTableOffset(d);
int nt = Bytes.u32(d, o);
int e = o + 4 + 8 * nt;
@@ -251,9 +261,8 @@ public final class TextChunk {
s.bytes(d, 0, o);
s.i32(nt + entries.size());
s.bytes(d, o + 4, e - o - 4);
for (Flat f : entries) {
boolean opaque = f.alpha == null;
new TextureEntry(opaque ? 0x105 : 0x1105, -1, -1, f.r, f.g, f.b, opaque ? 0 : f.alpha).write(s);
for (TextureEntry t : entries) {
t.write(s);
}
s.bytes(d, e, d.length - e);
return new Appended(s.toByteArray(), nt);
@@ -1,6 +1,8 @@
package com.eliasmueller.llamamod.format.stpcmerge;
import com.eliasmueller.llamamod.format.TextChunk;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
@@ -87,6 +89,41 @@ public final class TextMerge {
}
}
/**
* TEXT chunk {@code orig} with the pages, entries and animations of {@code tex} (usually
* {@code Textures.of(orig)} extended by {@link #merge}); palettes and the rest are kept.
*/
public static byte[] rebuild(byte[] orig, Textures tex) {
Textures old = Textures.of(orig);
int head = 8 + 8 * old.entries.size();
for (Anim a : old.anims) {
head += 3 + a.data().length;
}
TextChunk tc = TextChunk.parse(orig);
byte[] rest = Arrays.copyOfRange(tc.tail, head, tc.tail.length);
ByteBuffer b = ByteBuffer.allocate(8 + 8 * tex.entries.size() + tex.anims.size() * 64 + rest.length + 1024)
.order(ByteOrder.LITTLE_ENDIAN);
b.putInt(tex.entries.size());
for (TextChunk.TextureEntry e : tex.entries) {
b.putShort((short) e.flags())
.put((byte) e.page())
.put((byte) e.page2())
.put((byte) e.u0())
.put((byte) e.u1())
.put((byte) e.v0())
.put((byte) e.v1());
}
b.putInt(tex.anims.size());
for (Anim a : tex.anims) {
b.putShort((short) a.texture()).put((byte) a.type()).put(a.data());
}
b.put(rest);
tc.pages.clear();
tc.pages.addAll(tex.pages);
tc.tail = Arrays.copyOf(b.array(), b.position());
return tc.build();
}
/** Result: map[srcIndex] = target index (−1 = not needed), counts of added items. */
public record Result(int[] map, int addedPages, int addedEntries, int addedAnims, int reusedEntries) {}
@@ -0,0 +1,180 @@
package com.eliasmueller.llamamod.levelbuild;
import com.eliasmueller.llamamod.format.TextChunk;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Finds the village textures of {@link LevelBuilder#TEX} in another level's texture set. The names
* are village (t1l1m001) indices; other levels keep many of the same pictures at other indices. A
* texture is identified by a fingerprint of its pixels (page format + rectangle contents).
*/
public final class BaseTextures {
private BaseTextures() {}
/** Pixel fingerprints of the named village textures (BaseTexturesTest checks them on the CD). */
static final Map<String, Long> FINGERPRINTS = fingerprints();
private static Map<String, Long> fingerprints() {
String t =
"""
grass=419a5525c7f58b04 grass2=671d01f02e888942 grass_light=419a5525c7f58b04
grass_striped=b4920e19ae581efa grass_dark=fa34c068db241c1d grass_patch=965f79a948e6468a
flowers=b8e8cb6bbdaf247d flowers2=e43bab4b3ae914f flowers3=8652f820ee0c5468
sand=e9e9f2fd4f0d3552 sand2=e6e97a6cc1f638d9 sand3=ff0abc5ca246e2ec earth=6fe2bda9159bdbc1
earth2=b2ccaa6359faeb27 earth3=ec511a1ba25e017d grass_edge=39d679647444b24c
grass_edge2=df5813f239d8376e grass_edge_sand=14cdf6de8bed5223 stone=1bb352dc759ba249
stone2=f60bf7909c561a41 stone_blue=ac372719c4bf01ee stone_edge=c63dc9efda418c15
stone_green=1da16b5078f91575 stone_green2=4594d40d9248b30b stone_moss=431a0d24bfbf6ce4
slab=a2b5338477c8f02f slab2=709f4ca57a8f0e80 slab_edge=d20d214d47dccd6d
wood_dark=e2d59f72fed34e16 bamboo=cc4f8b9bbfd44d1 bamboo2=1604b15c131fa29
wood_grain=eae4ab24cd50d94d wood_grain2=edb9ffd1d48be806 crate=e0e020adbae332ba
bark=2e99077ca66dc532 wood_brown=1882ab20613f93b9 wood_brown2=2e8c0285c4190c4d
beam=16ede74d663dd1d0 wood_rings=fa63f4f5af1db8f wood_edge=69fefa14e8827c27
inca=5646e95e7fa54bc0 pattern_purple=990a92db872c189b pattern_purple2=6f82cde8baffcb3d
pattern_purple3=aa4a68c089b42731 mushroom_red=2906abafeebf562 mushroom_red2=9f686c59d4fa492a
stone_stairs=3ee294816ed2529
""";
Map<String, Long> m = new LinkedHashMap<>();
for (String e : t.trim().split("\\s+")) {
int i = e.indexOf('=');
if (i > 0) {
m.put(e.substring(0, i), Long.parseUnsignedLong(e.substring(i + 1), 16));
}
}
return Collections.unmodifiableMap(m);
}
/**
* Fingerprint of a texture entry's picture (FNV-1a over page format, size and pixels), or 0 for
* flat colours and entries without a valid page rectangle.
*/
public static long fingerprint(TextChunk.TextureEntry e, List<TextChunk.Page> pages) {
if ((e.flags() & 1) != 0 || e.page() < 0 || e.page() >= pages.size()) {
return 0;
}
TextChunk.Page p = pages.get(e.page());
if (e.u1() < e.u0() || e.v1() < e.v0() || e.u1() >= p.w || e.v1() >= p.h) {
return 0;
}
long h = 0xcbf29ce484222325L;
int[] head = {p.flags & 0x7f, e.u1() - e.u0(), e.v1() - e.v0()};
for (int v : head) {
h = (h ^ v) * 0x100000001b3L;
}
for (int y = e.v0(); y <= e.v1(); y++) {
for (int x = e.u0(); x <= e.u1(); x++) {
h = (h ^ p.pixels[y * p.w + x]) * 0x100000001b3L;
}
}
return h == 0 ? 1 : h;
}
/**
* Index of every {@link LevelBuilder#TEX} name whose picture this texture set has: the village
* index itself if it shows that picture, otherwise the first entry with the same picture (same
* entry flags preferred). Names without a match are missing from the result.
*/
public static Map<String, Integer> resolve(List<TextChunk.TextureEntry> entries, List<TextChunk.Page> pages) {
Map<Long, List<Integer>> byFp = new HashMap<>();
long[] fps = new long[entries.size()];
for (int i = 0; i < entries.size(); i++) {
fps[i] = fingerprint(entries.get(i), pages);
if (fps[i] != 0) {
byFp.computeIfAbsent(fps[i], k -> new ArrayList<>()).add(i);
}
}
Map<String, Integer> out = new LinkedHashMap<>();
for (Map.Entry<String, Integer> n : LevelBuilder.TEX.entrySet()) {
long fp = FINGERPRINTS.get(n.getKey());
int own = n.getValue();
if (own < fps.length && fps[own] == fp) {
out.put(n.getKey(), own);
continue;
}
List<Integer> c = byFp.get(fp);
if (c == null) {
continue;
}
int pick = c.getFirst();
for (int i : c) {
if (entries.get(i).flags() == VILLAGE_FLAGS) {
pick = i;
break;
}
}
out.put(n.getKey(), pick);
}
return out;
}
/** Entry flags of all named village textures. */
static final int VILLAGE_FLAGS = 0x104;
private static byte[] lastText;
private static Map<String, Integer> lastNames;
/** {@link #resolve(List, List)} of a raw TEXT chunk (the last result is cached). */
public static synchronized Map<String, Integer> resolve(byte[] text) {
if (lastText != null && Arrays.equals(lastText, text)) {
return lastNames;
}
Map<String, Integer> r =
Collections.unmodifiableMap(resolve(TextChunk.textureEntries(text), TextChunk.parse(text).pages));
lastText = text.clone();
lastNames = r;
return r;
}
/**
* Copies the pictures of the named village textures onto new 256×256 pages of {@code dst} (one
* pixel of clamped border around each) and returns their texture entries (not added to dst's
* table).
*/
static Map<String, TextChunk.TextureEntry> copy(TextChunk village, List<String> names, TextChunk dst) {
List<TextChunk.TextureEntry> ve = village.textureEntries();
Map<String, TextChunk.TextureEntry> out = new LinkedHashMap<>();
Map<Integer, int[]> shelves = new HashMap<>(); // source page flags -> {page, x, y, shelf height}
for (String n : names) {
TextChunk.TextureEntry e = ve.get(LevelBuilder.TEX.get(n));
TextChunk.Page sp = village.pages.get(e.page());
int w = e.u1() - e.u0() + 1;
int h = e.v1() - e.v0() + 1;
int[] sh = shelves.get(sp.flags);
if (sh != null && sh[1] + w + 2 > 256) {
sh[1] = 0;
sh[2] += sh[3];
sh[3] = 0;
}
if (sh == null || sh[2] + h + 2 > 256) {
if (dst.pages.size() >= 127) {
throw new IllegalArgumentException(
"no room for another texture page (village texture '" + n + "')");
}
sh = new int[] {dst.pages.size(), 0, 0, 0};
shelves.put(sp.flags, sh);
dst.pages.add(new TextChunk.Page(sp.flags, 256, 256, new int[256 * 256]));
}
int[] px = dst.pages.get(sh[0]).pixels;
for (int y = -1; y <= h; y++) {
for (int x = -1; x <= w; x++) {
int sx = Math.clamp(e.u0() + x, e.u0(), e.u1());
int sy = Math.clamp(e.v0() + y, e.v0(), e.v1());
px[(sh[2] + 1 + y) * 256 + sh[1] + 1 + x] = sp.pixels[sy * sp.w + sx];
}
}
int u0 = sh[1] + 1;
int v0 = sh[2] + 1;
int p2 = e.page2() == e.page() ? sh[0] : e.page2();
out.put(n, new TextChunk.TextureEntry(e.flags(), sh[0], p2, u0, u0 + w - 1, v0, v0 + h - 1));
sh[1] += w + 2;
sh[3] = Math.max(sh[3], h + 2);
}
return out;
}
}
@@ -180,7 +180,13 @@ public final class LevelBuilder {
private double[] kill;
private final Map<String, int[]> flats = new HashMap<>(FLAT);
private final LinkedHashMap<String, Integer> flatsUsed = new LinkedHashMap<>();
/** Entries appended to the base's texture table, in index order: flat colours and TEX names
* the base does not have (copied from the village). */
private final LinkedHashMap<String, Integer> appended = new LinkedHashMap<>();
private List<TextChunk.TextureEntry> appendedEntries = List.of();
private Map<String, Integer> baseNames;
private final Function<String, byte[]> wads;
private final Wad baseWad;
private final byte[] baseMap;
@@ -204,6 +210,7 @@ public final class LevelBuilder {
this.name = name;
this.rng = new PyRandom(seed);
this.opts = opts == null ? Map.of() : Map.copyOf(opts);
this.wads = wads;
baseWad = Wad.parse(base);
wf = baseWad.wfpcFlags();
trak0 = Trak.parse(baseWad.require("TRAK"));
@@ -292,7 +299,11 @@ public final class LevelBuilder {
return String.format("rgb%02x%02x%02x", r, g, b);
}
/** Texture index: name from TEX, "#index", flat colour name or {@link #rgb}. */
/**
* Texture index: name from TEX, "#index", flat colour name or {@link #rgb}. TEX names are found
* in the base by picture ({@link BaseTextures}); a picture the base lacks is copied over from
* the village level (via the CD file loader) or, without it, rejected.
*/
public int tex(String t) {
if (t.startsWith("#")) {
int v = Integer.parseInt(t.substring(1));
@@ -305,18 +316,53 @@ public final class LevelBuilder {
int v = Integer.parseInt(t.substring(3), 16);
flats.put(t, new int[] {v >> 16, (v >> 8) & 0xff, v & 0xff});
}
Integer i = TEX.get(t);
if (i != null) {
return i;
if (TEX.containsKey(t)) {
return named(t);
}
if (flats.containsKey(t)) {
return flatsUsed.computeIfAbsent(t, k -> texs.size() + flatsUsed.size());
return appended.computeIfAbsent(t, k -> texs.size() + appended.size());
}
List<String> names = new ArrayList<>(new TreeSet<>(TEX.keySet()));
names.addAll(new TreeSet<>(flats.keySet()));
throw new IllegalArgumentException("unknown texture '" + t + "' (names: " + String.join(", ", names) + ")");
}
/** TEX names the base level shows (name → index in the base's texture table). */
public Map<String, Integer> baseTextureNames() {
if (baseNames == null) {
baseNames = BaseTextures.resolve(baseWad.require("TEXT"));
}
return baseNames;
}
private int named(String t) {
Integer i = baseTextureNames().get(t);
if (i == null) {
i = appended.get(t);
}
if (i != null) {
return i;
}
if (village() == null) {
throw new IllegalArgumentException("texture '" + t + "' is a village texture this base level does not have"
+ " (use a texture index '#n' or a flat colour)");
}
return appended.computeIfAbsent(t, k -> texs.size() + appended.size());
}
private TextChunk villageText;
/** The village's TEXT (source of copied textures), or null if the CD loader cannot read it. */
private TextChunk village() {
if (villageText == null && wads != null) {
byte[] w = wads.apply(BASE_WAD);
if (w != null) {
villageText = TextChunk.parse(Wad.parse(w).require("TEXT"));
}
}
return villageText;
}
private static boolean has(String t) {
return t != null && !t.isEmpty();
}
@@ -1011,18 +1057,10 @@ public final class LevelBuilder {
/** Builds the level WAD. */
public byte[] build() {
byte[] text = text();
List<Model> models = new ArrayList<>();
List<MapChunk.TileSpec> tiles = new ArrayList<>();
geometry(models, tiles);
byte[] text = baseWad.require("TEXT");
if (!flatsUsed.isEmpty()) {
List<TextChunk.Flat> add = new ArrayList<>();
for (String nm : flatsUsed.keySet()) {
int[] c = flats.get(nm);
add.add(new TextChunk.Flat(c[0], c[1], c[2], null));
}
text = TextChunk.appendFlats(text, add).text();
}
byte[] trak = Trak.build(models);
MapChunk m = buildMap(models, tiles);
byte[] mp = m.build();
@@ -1047,6 +1085,32 @@ public final class LevelBuilder {
return out.toBytes();
}
/** Base TEXT plus the appended entries (and pages for copied village textures). */
private byte[] text() {
byte[] text = baseWad.require("TEXT");
if (appended.isEmpty()) {
return text;
}
List<String> copied =
appended.keySet().stream().filter(TEX::containsKey).toList();
Map<String, TextChunk.TextureEntry> moved = Map.of();
if (!copied.isEmpty()) {
TextChunk tc = TextChunk.parse(text);
moved = BaseTextures.copy(village(), copied, tc);
text = tc.build();
}
List<TextChunk.TextureEntry> add = new ArrayList<>();
for (String nm : appended.keySet()) {
int[] c = flats.get(nm);
add.add(
TEX.containsKey(nm)
? moved.get(nm)
: new TextChunk.TextureEntry(0x105, -1, -1, c[0], c[1], c[2], 0));
}
appendedEntries = add;
return TextChunk.appendEntries(text, add).text();
}
private record TileKey(int cx, int cz, int band) implements Comparable<TileKey> {
@Override
public int compareTo(TileKey o) {
@@ -1088,6 +1152,10 @@ public final class LevelBuilder {
TextChunk.TextureEntry e = texs.get(t);
return new int[] {e.page(), e.flags()};
}
if (t - texs.size() < appendedEntries.size()) {
TextChunk.TextureEntry e = appendedEntries.get(t - texs.size());
return new int[] {e.page(), e.flags()};
}
return new int[] {-1, 0x105};
}
@@ -554,7 +554,7 @@ public final class ModManager {
}
}
/** original/hd/groove.exe with the HKCU patch, or null if there is no original exe (tests). */
/** original/hd/groove.exe with the base patch ({@link ExePatcher#changes}), or null if there is no original exe (tests). */
private ExeImage baseExe(ApplyLog log) throws IOException {
Path orig = Files.isDirectory(paths.originalHd()) ? Integrity.find(paths.originalHd(), "groove.exe") : null;
if (orig == null || !Files.isRegularFile(orig)) {
@@ -563,8 +563,8 @@ public final class ModManager {
byte[] b = Files.readAllBytes(orig);
ExeImage img = ExeImage.parse(b);
img.setOwner(BASE_EXE_OWNER);
for (int off : ExePatcher.patchHkcu(b).patchedOffsets()) {
img.patch(img.va(off), new byte[] {2}, new byte[] {1}, "registry HKLM -> HKCU");
for (ExePatcher.Change c : ExePatcher.changes(b)) {
img.patch(img.va(c.offset()), c.original(), c.replacement(), c.label());
}
logExe(log, img, BASE_EXE_OWNER);
return img;
@@ -24,6 +24,7 @@ public final class ModRegistry {
"com.eliasmueller.llamamod.mods.practice.PracticeMod",
"com.eliasmueller.llamamod.mods.freecam.FreeCamMod",
"com.eliasmueller.llamamod.mods.arena.ArenaMod",
"com.eliasmueller.llamamod.mods.coaster.CoasterMod",
};
/** {@link ModSource}s loaded by class name (dynamic mods, e.g. level projects). */
@@ -3,7 +3,6 @@ package com.eliasmueller.llamamod.mods.arena;
import com.eliasmueller.llamamod.format.Bytes;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.MapChunk;
import com.eliasmueller.llamamod.format.TextChunk;
import com.eliasmueller.llamamod.format.Wad;
import com.eliasmueller.llamamod.format.stpcmerge.DialogMerge;
import com.eliasmueller.llamamod.format.stpcmerge.SoundMerge;
@@ -106,10 +105,6 @@ final class ArenaImport {
StpcMerge.Analysis tgt = new StpcMerge.Analysis(new StpcImage(wad.require("STPC"), lv.wfpc()), troots);
byte[] text = wad.require("TEXT");
TextMerge.Textures tex = TextMerge.Textures.of(text);
int origTail = 8 + 8 * tex.entries.size();
for (TextMerge.Anim a : tex.anims) {
origTail += 3 + a.data().length;
}
List<SoundMerge.Sample> snd = SoundMerge.parse(wad.get("SMPC"));
DialogMerge.Texts texts = wad.get("LGPC") == null ? null : DialogMerge.Texts.parse(wad.get("LGPC"));
DialogMerge.Speech speech = DialogMerge.Speech.parse(wad.get("SRPC"));
@@ -143,40 +138,13 @@ final class ArenaImport {
Wad out = new Wad(wad.chunks());
out.set("STPC", res.stpc());
out.set("MAP", map.build());
out.set("TEXT", text(text, tex, origTail));
out.set("TEXT", TextMerge.rebuild(text, tex));
if (wad.get("SMPC") != null) {
out.set("SMPC", SoundMerge.build(snd));
}
return new Merged(out.toBytes(), codes, res.stats());
}
/** TEXT chunk with the (extended) pages, entries and animations of tex; the rest is kept. */
private static byte[] text(byte[] orig, TextMerge.Textures tex, int origTailHead) {
TextChunk tc = TextChunk.parse(orig);
byte[] rest = Arrays.copyOfRange(tc.tail, origTailHead, tc.tail.length);
ByteBuffer b = ByteBuffer.allocate(8 + 8 * tex.entries.size() + tex.anims.size() * 64 + rest.length + 1024)
.order(ByteOrder.LITTLE_ENDIAN);
b.putInt(tex.entries.size());
for (TextChunk.TextureEntry e : tex.entries) {
b.putShort((short) e.flags())
.put((byte) e.page())
.put((byte) e.page2())
.put((byte) e.u0())
.put((byte) e.u1())
.put((byte) e.v0())
.put((byte) e.v1());
}
b.putInt(tex.anims.size());
for (TextMerge.Anim a : tex.anims) {
b.putShort((short) a.texture()).put((byte) a.type()).put(a.data());
}
b.put(rest);
tc.pages.clear();
tc.pages.addAll(tex.pages);
tc.tail = Arrays.copyOf(b.array(), b.position());
return tc.build();
}
private static void putI32(byte[] d, int o, int v) {
ByteBuffer.wrap(d, o, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(v);
}
@@ -5,6 +5,8 @@ import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModContext;
import com.eliasmueller.llamamod.mods.ModOption;
import com.eliasmueller.llamamod.mods.levels.LevelTally;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
@@ -53,7 +55,29 @@ public final class ArenaMod implements Mod {
ModOption.integer(
"energy", "Llama energy", "Energy cap of the llama in the arena (5 = normal).", 4, 1, 5),
ModOption.integer("coins", "Coins per hit", "Coins that pop out per hit (5 more at the end).", 1, 0, 5),
ModOption.integer("chapter", "TestLevel chapter", "Chapter of world 9 the arena replaces.", 8, 2, 8));
chapterOption());
}
static final int DEFAULT_CHAPTER = TestLevelChapters.LAST;
/** TestLevel chapters 32–49 ({@link TestLevelChapters}); a stored 2–8 falls back to 49. */
static ModOption chapterOption() {
ModOption.Choice[] cs = new ModOption.Choice[TestLevelChapters.LAST - TestLevelChapters.FIRST + 1];
for (int c = TestLevelChapters.FIRST; c <= TestLevelChapters.LAST; c++) {
cs[c - TestLevelChapters.FIRST] = ModOption.of(Integer.toString(c), Integer.toString(c));
}
return ModOption.choice(
"chapter",
"TestLevel chapter",
"Chapter of world 9 the arena replaces (32–49; chapter 1 is the Test World's).",
Integer.toString(DEFAULT_CHAPTER),
cs);
}
/** Coins the arena can give beyond its placed ones: hit and death coins of every alligator, exit bonus. */
static int dynamicCoins(ArenaScripts.Settings s) {
int perGator = (s.lives() - 1) * s.coinsPerHit() + 5;
return ArenaScripts.totalAlligators(s.waves()) * perGator + 5;
}
/** After Test World (50) and level projects (60), so a clash with them is reported here. */
@@ -95,7 +119,8 @@ public final class ArenaMod implements Mod {
}
ctx.progress(0.0, "building alligator arena");
byte[] base = ctx.read(LevelBuilder.BASE_WAD);
byte[] wad = new Arena(settings(ctx))
ArenaScripts.Settings settings = settings(ctx);
byte[] wad = new Arena(settings)
.buildWad(
base,
rel -> {
@@ -106,7 +131,7 @@ public final class ArenaMod implements Mod {
}
},
scriptOpts);
ctx.write(target, wad);
ctx.write(target, LevelTally.counted(wad, dynamicCoins(settings)));
ctx.progress(0.9, "loading picture");
ctx.write("T19" + chapter + ".COR", loadingPicture(chapter));
ctx.progress(1.0, "arena done");
@@ -0,0 +1,446 @@
package com.eliasmueller.llamamod.mods.coaster;
import static com.eliasmueller.llamamod.levelbuild.Opts.o;
import com.eliasmueller.llamamod.format.Lin3;
import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
import com.eliasmueller.llamamod.levelbuild.LevelScript;
import com.eliasmueller.llamamod.mods.coaster.CoasterTrack.Corner;
import com.eliasmueller.llamamod.mods.coaster.CoasterTrack.Kind;
import com.eliasmueller.llamamod.mods.coaster.CoasterTrack.Node;
import com.eliasmueller.llamamod.mods.coaster.CoasterTrack.Segment;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
/**
* The coaster level, built on the original roller coaster level t1l7m001 (Catacombs 1): its strat
* code (RollerCoaster = the llama in its cart and the player, its Camera, CoinRollerCoaster,
* RollerDoor, FireworksTrigger), textures, sounds and environment stay; geometry and objects are
* new. The track bed is the floor the cart drives on, with rails as collision walls; the cart
* follows its waypoint chain ({@link CoasterTrack#nodes}).
*/
public final class CoasterLevel implements LevelScript {
public static final String BASE = "wads/t1l7m001.wad";
/** Base texture indices (t1l7m001 TEXT). */
static final String TEX_DECK = "#5";
static final String TEX_CORNER = "#20";
static final String TEX_STATION = "#72";
static final String TEX_BOOST = "#76";
static final String TEX_JUMP = "#79";
static final String TEX_SPIN = "#33";
static final String TEX_END = "#129";
static final String TEX_RAIL = "#191";
static final String TEX_RAIL_TOP = "#4";
static final String TEX_SIDE = "#21";
static final String TEX_PILLAR = "#22";
static final String TEX_GROUND = "#0";
static final double RAIL_HEIGHT = 0.35;
static final double RAIL_THICK = 0.15;
static final double DECK_THICK = 0.3;
/** Collision material of the ground far below (the original's death plane material). */
static final int GROUND_MAT = 30;
private static final double[] WHITE = {1, 1, 1};
/** Track, laps, speed factor, coins, exit target (tribe, world, chapter). */
public record Settings(CoasterTrack track, int laps, double speed, boolean coins, int[] target) {
public Settings {
if (laps < 1 || laps > 5) {
throw new IllegalArgumentException("laps must be 1..5");
}
if (speed < 0.5 || speed > 2) {
throw new IllegalArgumentException("speed factor must be 0.5..2");
}
}
}
private final Settings settings;
private int coinCount;
public CoasterLevel(Settings settings) {
this.settings = settings;
}
@Override
public String levelName() {
return "Llama Coaster";
}
@Override
public long seed() {
return 17;
}
/** CoinRollerCoaster objects placed by the last {@link #build}. */
int coinCount() {
return coinCount;
}
@Override
public byte[] buildWad(byte[] baseWad, Function<String, byte[]> wads, Map<String, String> opts) {
return LevelScript.super.buildWad(CoasterSpeed.patch(baseWad, settings.speed()), wads, opts);
}
@Override
public void build(LevelBuilder l) {
CoasterTrack t = settings.track();
l.sun.azimuth = 210;
l.sun.elevation = 60;
l.sun.strength = 0.55;
l.sun.ao = 0.4;
List<Segment> segs = t.segments;
for (int i = 0; i < segs.size(); i++) {
Segment s = segs.get(i);
if (s.kind() == Kind.GAP) {
continue;
}
deck(l, s);
Segment prev = i > 0 ? segs.get(i - 1) : null;
Segment next = i + 1 < segs.size() ? segs.get(i + 1) : null;
if (prev != null && prev.kind() == Kind.GAP && prev.leg() == s.leg()) {
endFace(l, s, false);
}
if (next != null && next.kind() == Kind.GAP && next.leg() == s.leg()) {
endFace(l, s, true);
}
}
for (Corner c : t.corners) {
corner(l, c);
}
supports(l, t);
station(l, t);
ground(l, t);
objects(l, t);
}
// ------------------------------------------------------------ geometry
private static double[] right(int heading) {
double[] h = CoasterTrack.dir(heading);
return new double[] {h[1], -h[0]};
}
private static double[] at(Segment s, double d, double lat, double dy) {
double[] h = CoasterTrack.dir(s.heading());
double[] r = right(s.heading());
return new double[] {s.x0() + h[0] * d + r[0] * lat, s.y(d) + dy, s.z0() + h[1] * d + r[1] * lat};
}
/** Adds the quad with its front towards {@code want} (corner order flipped if needed). */
private static void face(
LevelBuilder l, double[][] p, double[] want, String tex, boolean coll, int mat, boolean render) {
double[] nq = Lin3.cross(Lin3.sub(p[1], p[0]), Lin3.sub(p[2], p[0]));
if (-(nq[0] * want[0] + nq[1] * want[1] + nq[2] * want[2]) < 0) {
p = new double[][] {p[1], p[0], p[3], p[2]};
}
l.quad(p, tex, coll, mat, WHITE, 0, true, false, render);
}
private static String tex(Kind k) {
return switch (k) {
case BOOST -> TEX_BOOST;
case JUMP -> TEX_JUMP;
case SPIN -> TEX_SPIN;
case END -> TEX_END;
case STATION -> TEX_STATION;
default -> TEX_DECK;
};
}
/** Track bed (two 1-wide lanes per unit), rails on both sides, underside. */
private void deck(LevelBuilder l, Segment s) {
double len = s.length();
double[] up = {0, 1, 0};
double[] down = {0, -1, 0};
int mat = Math.max(0, s.kind().mat);
for (int k = 0; k < len; k++) {
double d0 = k, d1 = Math.min(len, k + 1);
for (double[] lane : new double[][] {{-1, 0}, {0, 1}}) {
face(
l,
new double[][] {
at(s, d0, lane[0], 0), at(s, d0, lane[1], 0), at(s, d1, lane[1], 0), at(s, d1, lane[0], 0)
},
up,
tex(s.kind()),
true,
mat,
true);
}
double w = 1 + RAIL_THICK;
for (double[] lane : new double[][] {{-w, 0}, {0, w}}) {
face(
l,
new double[][] {
at(s, d0, lane[0], -DECK_THICK),
at(s, d0, lane[1], -DECK_THICK),
at(s, d1, lane[1], -DECK_THICK),
at(s, d1, lane[0], -DECK_THICK)
},
down,
TEX_SIDE,
false,
0,
true);
}
for (int side = -1; side <= 1; side += 2) {
rail(l, at(s, d0, side, 0), at(s, d1, side, 0), right(s.heading()), side);
}
}
}
/**
* Rail along the bed edge a → b: inner face (collision, keeps the cart on the bed), top, outer
* face down to the underside. side: −1 left, +1 right of the travel direction r.
*/
private static void rail(LevelBuilder l, double[] a, double[] b, double[] r, int side) {
double[] out = {r[0] * side, 0, r[1] * side};
double[] in = {-out[0], 0, -out[2]};
double[] a2 = {a[0] + out[0] * RAIL_THICK, a[1], a[2] + out[2] * RAIL_THICK};
double[] b2 = {b[0] + out[0] * RAIL_THICK, b[1], b[2] + out[2] * RAIL_THICK};
face(l, new double[][] {a, b, lift(b, RAIL_HEIGHT), lift(a, RAIL_HEIGHT)}, in, TEX_RAIL, true, 0, true);
face(
l,
new double[][] {lift(a, RAIL_HEIGHT), lift(b, RAIL_HEIGHT), lift(b2, RAIL_HEIGHT), lift(a2, RAIL_HEIGHT)
},
new double[] {0, 1, 0},
TEX_RAIL_TOP,
false,
0,
true);
face(
l,
new double[][] {
lift(a2, -DECK_THICK), lift(b2, -DECK_THICK), lift(b2, RAIL_HEIGHT), lift(a2, RAIL_HEIGHT)
},
out,
TEX_SIDE,
true,
0,
true);
}
private static double[] lift(double[] p, double dy) {
return new double[] {p[0], p[1] + dy, p[2]};
}
/** Vertical face closing the bed at a gap: at the segment's end (forward) or start. */
private static void endFace(LevelBuilder l, Segment s, boolean atEnd) {
double d = atEnd ? s.length() : 0;
double[] h = CoasterTrack.dir(s.heading());
double[] want = atEnd ? new double[] {h[0], 0, h[1]} : new double[] {-h[0], 0, -h[1]};
double w = 1 + RAIL_THICK;
for (double[] lane : new double[][] {{-w, 0}, {0, w}}) {
face(
l,
new double[][] {
at(s, d, lane[0], -DECK_THICK),
at(s, d, lane[1], -DECK_THICK),
at(s, d, lane[1], 0),
at(s, d, lane[0], 0)
},
want,
TEX_SIDE,
true,
0,
true);
}
}
/** Flat 2 × 2 corner; rails on the two outer sides (ahead of the incoming leg, behind the outgoing). */
private static void corner(LevelBuilder l, Corner c) {
double y = c.y();
for (int i = 0; i < 2; i++) {
for (int k = 0; k < 2; k++) {
double x0 = c.x() - 1 + i, z0 = c.z() - 1 + k;
face(
l,
new double[][] {{x0, y, z0}, {x0 + 1, y, z0}, {x0 + 1, y, z0 + 1}, {x0, y, z0 + 1}},
new double[] {0, 1, 0},
TEX_CORNER,
true,
0,
true);
face(
l,
new double[][] {
{x0, y - DECK_THICK, z0},
{x0 + 1, y - DECK_THICK, z0},
{x0 + 1, y - DECK_THICK, z0 + 1},
{x0, y - DECK_THICK, z0 + 1}
},
new double[] {0, -1, 0},
TEX_SIDE,
false,
0,
true);
}
}
double[] h0 = CoasterTrack.dir(c.in());
double[] h1 = CoasterTrack.dir(c.out());
// outer walls: across the square at +h0 and at -h1, each 2 long plus the rail thickness at the outer corner
for (double[][] w : new double[][][] {{h0, h1}, {{-h1[0], -h1[1]}, {-h0[0], -h0[1]}}}) {
double[] n = w[0];
double[] along = w[1];
double ex = 1 + RAIL_THICK;
for (double[] part : new double[][] {{-1, 0}, {0, 1}}) {
double t0 = part[0], t1 = part[1];
// extend the outer end (towards -along) by the rail thickness
if (t0 == -1) {
t0 = -ex;
}
double[] a = {c.x() + n[0] + along[0] * t0, y, c.z() + n[1] + along[1] * t0};
double[] b = {c.x() + n[0] + along[0] * t1, y, c.z() + n[1] + along[1] * t1};
// rail() wants the travel direction's right vector and a side so that "out" = n
rail(l, a, b, new double[] {n[0], n[1]}, 1);
}
}
}
/** Pillars under the corners and every few units under the straights (not at gaps). */
private static void supports(LevelBuilder l, CoasterTrack t) {
for (Corner c : t.corners) {
l.pillar(
c.x(),
c.z(),
0,
c.y() - DECK_THICK,
0.5,
o().side(TEX_PILLAR).top(TEX_RAIL_TOP));
}
for (Segment s : t.segments) {
if (s.kind() == Kind.GAP) {
continue;
}
for (double d = 3; d < s.length() - 1; d += 5) {
double[] p = at(s, d, 0, 0);
l.pillar(
p[0],
p[2],
0,
s.y(d) - DECK_THICK - 0.1,
0.4,
o().side(TEX_PILLAR).top(TEX_RAIL_TOP));
}
}
}
/** Boarding platform on the left of the station. */
private static void station(LevelBuilder l, CoasterTrack t) {
Segment s = t.station();
double[] a = at(s, -2, -1 - RAIL_THICK, 0);
double[] b = at(s, s.length() + 1, -3.2, 0);
double y = s.y0();
l.box(
Math.min(a[0], b[0]),
Math.min(a[2], b[2]),
Math.max(a[0], b[0]),
Math.max(a[2], b[2]),
y - 1.2,
y + 0.1,
o().top(TEX_STATION).side(TEX_SIDE).bottom(TEX_SIDE).rot(0));
for (double d : new double[] {-1.5, s.length() + 0.5}) {
double[] p = at(s, d, -2.6, 0);
l.pillar(p[0], p[2], y + 0.1, y + 2.4, 0.35, o().side(TEX_PILLAR).top(TEX_RAIL_TOP));
l.pillar(p[0], p[2], 0, y - 1.2, 0.6, o().side(TEX_PILLAR).top(TEX_RAIL_TOP));
}
}
/** Ground plane far below (material 30 like the original's invisible bottom plane). */
private static void ground(LevelBuilder l, CoasterTrack t) {
double[] b = t.bounds();
int m = 6;
for (int x = (int) Math.floor(b[0]) - m; x < Math.ceil(b[2]) + m; x++) {
for (int z = (int) Math.floor(b[1]) - m; z < Math.ceil(b[3]) + m; z++) {
if (x < 0 || z < 0) {
continue;
}
face(
l,
new double[][] {{x, 0, z}, {x + 1, 0, z}, {x + 1, 0, z + 1}, {x, 0, z + 1}},
new double[] {0, 1, 0},
TEX_GROUND,
true,
GROUND_MAT,
true);
}
}
}
// ------------------------------------------------------------ objects
private static final int FX = 4096;
private void objects(LevelBuilder l, CoasterTrack t) {
double[] st = t.cartStart();
int[] tg = settings.target();
List<double[]> wps = new ArrayList<>();
for (Node n : t.nodes(settings.laps())) {
wps.add(new double[] {n.x(), n.z(), n.y(), n.heading(), n.station() ? FX : 0});
}
l.strat(
"RollerCoaster",
st[0],
st[1],
st[2],
st[3],
new int[] {0, tg[0] * FX, tg[1] * FX, tg[2] * FX},
LevelBuilder.Waypoints.of(wps),
0,
false);
// gate at the station exit
Segment s = t.station();
l.strat(
"RollerDoor",
s.x1(),
s.z1(),
s.y1(),
(double) s.heading(),
null,
LevelBuilder.Waypoints.NONE,
0,
false);
// fireworks over the end pad, started when the cart passes the run-in
Segment e = t.end();
double[] trig = at(e, -0.5, 0, 0);
List<double[]> fw = new ArrayList<>();
double[][] spots = {{1, -2.5, 2}, {3, 2.5, 2.5}, {5, -2.5, 3}, {2, 2.5, 1.5}, {4, -2.5, 2.5}, {6, 2.5, 3}};
for (double[] p : spots) {
double[] q = at(e, p[0], p[1], p[2]);
fw.add(new double[] {q[0], q[2], q[1]});
}
l.strat("FireworksTrigger", trig[0], trig[2], trig[1], 0.0, null, LevelBuilder.Waypoints.of(fw), 0, false);
coinCount = 0;
if (settings.coins()) {
for (Segment g : t.segments) {
if (g.leg() == 0 || g.kind() != Kind.TRACK || g.length() < 4) {
continue;
}
for (double d = 1.5; d < g.length() - 1; d += 1.5) {
double lat = Math.sin(d * 1.1) * 0.55;
double[] p = at(g, d, lat, 0);
l.strat(
"CoinRollerCoaster",
p[0],
p[2],
p[1] + 0.1,
null,
null,
LevelBuilder.Waypoints.NONE,
0,
false);
coinCount++;
}
}
}
}
}
@@ -0,0 +1,183 @@
package com.eliasmueller.llamamod.mods.coaster;
import com.eliasmueller.llamamod.format.CorEncoder;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModContext;
import com.eliasmueller.llamamod.mods.ModOption;
import com.eliasmueller.llamamod.mods.levels.LevelTally;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.GradientPaint;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.Path2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/** A roller coaster ride on a TestLevel (world 9) chapter, using the game's own coaster cart. */
public final class CoasterMod implements Mod {
static final int DEFAULT_CHAPTER = 48;
@Override
public String id() {
return "coaster";
}
@Override
public String name() {
return "Llama Coaster";
}
@Override
public String category() {
return LEVELS;
}
@Override
public String description() {
return "A roller coaster ride on a TestLevel chapter, with the cart from the Catacombs coaster levels:"
+ " drops, climbs, boost arrows, a spin pad that turns the cart backwards and a jump over a gap."
+ " Steer left and right to stay on the track and collect coins; the ride ends at the station."
+ " Unlock TestLevel on the Save games tab.";
}
@Override
public List<ModOption> options() {
return List.of(
ModOption.choice(
"track",
"Track",
"Short loop (about 100 units) or the grand circuit (about 150 units, two jumps, two spins).",
"loop",
ModOption.of("loop", "Loop"),
ModOption.of("grand", "Grand circuit")),
ModOption.integer("laps", "Laps", "Laps before the ride stops at the station.", 2, 1, 3),
ModOption.choice(
"speed",
"Speed",
"Cruise speed and boost strength of the cart.",
"normal",
ModOption.of("slow", "Slow"),
ModOption.of("normal", "Normal (as in the game)"),
ModOption.of("fast", "Fast")),
ModOption.bool("coins", "Coins", "Coins along the track.", true),
chapterOption());
}
static ModOption chapterOption() {
ModOption.Choice[] cs = new ModOption.Choice[TestLevelChapters.LAST - TestLevelChapters.FIRST + 1];
for (int c = TestLevelChapters.FIRST; c <= TestLevelChapters.LAST; c++) {
cs[c - TestLevelChapters.FIRST] = ModOption.of(Integer.toString(c), Integer.toString(c));
}
return ModOption.choice(
"chapter",
"TestLevel chapter",
"Chapter of world 9 the coaster replaces (32–49; chapter 1 is the Test World's, 49 the arena's).",
Integer.toString(DEFAULT_CHAPTER),
cs);
}
static double speedFactor(String speed) {
return switch (speed) {
case "slow" -> 0.8;
case "fast" -> 1.25;
default -> 1.0;
};
}
/** After Test World (50), level projects (60) and the arena (65). */
@Override
public int order() {
return 66;
}
static String wadPath(int chapter) {
return String.format(Locale.ROOT, "wads/T1L9M%03d.wad", chapter);
}
static CoasterLevel.Settings settings(ModContext ctx) {
return new CoasterLevel.Settings(
CoasterTrack.named(ctx.option("track")),
ctx.optionInt("laps"),
speedFactor(ctx.option("speed")),
ctx.optionBool("coins"),
new int[] {1, 9, ctx.optionInt("chapter")});
}
@Override
public void apply(ModContext ctx) throws IOException {
int chapter = ctx.optionInt("chapter");
String target = wadPath(chapter);
if (ctx.exists(target)) {
Path op = ctx.originalCd().resolve(target);
byte[] cur = ctx.read(target);
if (!Files.exists(op) || !Arrays.equals(cur, Files.readAllBytes(op))) {
throw new IOException("Llama Coaster: " + target + " is already changed by another mod (a level on"
+ " TestLevel chapter " + chapter + "). Choose another chapter in the coaster's options.");
}
}
if (!ctx.exists(CoasterLevel.BASE)) {
throw new IOException("Llama Coaster: " + CoasterLevel.BASE + " (the Catacombs coaster level) is missing");
}
ctx.progress(0.0, "building roller coaster");
CoasterLevel level = new CoasterLevel(settings(ctx));
byte[] wad = level.buildWad(
ctx.read(CoasterLevel.BASE),
rel -> {
try {
return ctx.exists(rel) ? ctx.read(rel) : null;
} catch (IOException e) {
throw new UncheckedIOException(e);
}
},
Map.of());
ctx.write(target, LevelTally.counted(wad, level.coinCount()));
ctx.progress(0.9, "loading picture");
ctx.write("T19" + chapter + ".COR", loadingPicture(chapter));
ctx.progress(1.0, "coaster done");
}
/**
* Loading and tally picture. The game prints the level title, the coin and secret counts and the
* key hints over most of the screen; the band y ≈ 265–335 (of 480) stays free for the title.
*/
static byte[] loadingPicture(int chapter) {
BufferedImage img = new BufferedImage(640, 480, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g.setPaint(new GradientPaint(0, 0, new Color(0x231238), 0, 480, new Color(0x07030d)));
g.fillRect(0, 0, 640, 480);
Path2D rail = new Path2D.Double();
rail.moveTo(20, 250);
rail.curveTo(140, 20, 250, 40, 320, 230);
rail.curveTo(390, 400, 500, 60, 620, 180);
g.setStroke(new BasicStroke(3f));
g.setColor(new Color(0x3a2a5c));
for (int x = 40; x < 620; x += 40) {
g.drawLine(x, 380, x, 230 + (int) (70 * Math.sin(x / 60.0)));
}
g.setStroke(new BasicStroke(8f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
g.setColor(new Color(0x1b5f86));
g.draw(rail);
String title = "Llama Coaster";
g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 46));
FontMetrics fm = g.getFontMetrics();
g.setColor(new Color(0, 0, 0, 170));
g.drawString(title, (640 - fm.stringWidth(title)) / 2 + 2, 324);
g.setColor(new Color(0xffd23f));
g.drawString(title, (640 - fm.stringWidth(title)) / 2, 322);
g.dispose();
return CorEncoder.encode(img);
}
}
@@ -0,0 +1,228 @@
package com.eliasmueller.llamamod.mods.coaster;
import com.eliasmueller.llamamod.mods.coaster.CoasterTrack.Corner;
import com.eliasmueller.llamamod.mods.coaster.CoasterTrack.Kind;
import com.eliasmueller.llamamod.mods.coaster.CoasterTrack.Segment;
import java.util.ArrayList;
import java.util.List;
/**
* Frame-by-frame model of the original RollerCoaster strat's speed and jump logic (t1l7m001 unit 0)
* on a {@link CoasterTrack}, to check a layout before building it: the speed v (units per frame)
* and the boost excess v22; outside corners v22 > 2r decays both by 2r, else v > cap falls to the
* cap; in a corner arc v < cap rises by 6r per frame; on BOOST v and v22 grow by the boost; JUMP
* starts a flight with vertical speed 163r (y += 2·g, g −= 14r) that lands when the cart is back on
* a floor. The station crawl leaves the cart at 0.025. Limits: a climb of more than 0.1 per frame
* loses the floor (the strat probes 0.1 above the cart); a flight must land before the commit
* zone of the next corner (0.75 before its waypoint), else the cart misses the turn.
*/
final class CoasterSim {
static final double R = 1 / 4096.0;
static final double CRAWL_SPEED = 0.025;
/** One flight: take-off and landing distance along the leg. */
record Flight(int leg, double takeOff, double landing, double needed, double limit, double speed) {
boolean ok() {
return landing >= needed && landing <= limit;
}
}
record Result(
int frames,
double minSpeed,
double maxSpeed,
double maxClimb,
List<Flight> flights,
List<String> problems) {}
private final CoasterTrack track;
private final double cap;
private final double boost;
private double v;
private double v22;
private int frames;
private double minV = Double.MAX_VALUE;
private double maxV;
private double maxClimb;
private final List<Flight> flights = new ArrayList<>();
private final List<String> problems = new ArrayList<>();
CoasterSim(CoasterTrack track, double cap, double boost) {
this.track = track;
this.cap = cap;
this.boost = boost;
}
static Result run(CoasterTrack track, double cap, double boost, int laps) {
return new CoasterSim(track, cap, boost).ride(laps);
}
private Result ride(int laps) {
List<Segment> segs = track.segments;
int station = segs.indexOf(track.station());
int end = segs.indexOf(track.end());
frames += (int) Math.ceil(track.station().length() / 0.03);
v = CRAWL_SPEED;
int i = station + 1;
int lap = 0;
double offset = 0;
while (true) {
Segment s = segs.get(i);
if (lap == laps && i == end) {
break;
}
if (s.kind() != Kind.GAP) {
double[] landing = straight(s, i, offset);
offset = 0;
if (landing != null) {
if (landing[0] < 0) {
break;
}
i = (int) landing[0];
offset = landing[1];
continue;
}
}
int n = (i + 1) % segs.size();
if (segs.get(n).leg() != s.leg()) {
corner(track.corners.get(s.leg()));
}
if (n == 0) {
lap++;
}
i = n;
if (lap > laps + 1) {
problems.add("no end");
break;
}
}
return new Result(frames, minV, maxV, maxClimb, flights, problems);
}
private void tick() {
frames++;
minV = Math.min(minV, v);
maxV = Math.max(maxV, v);
}
/** Rides segment s from offset; after a jump: {segment index, offset} of the landing ({-1} = fell). */
private double[] straight(Segment s, int index, double offset) {
double len = s.length();
for (double d = offset; d < len; ) {
d += v;
if (v22 > 2 * R) {
v -= 2 * R;
v22 -= 2 * R;
} else if (v > cap) {
v = Math.max(v - 2 * R, cap);
}
tick();
if (d >= len) {
break;
}
double climb = (s.y1() - s.y0()) / len * v;
maxClimb = Math.max(maxClimb, climb);
if (climb > 0.1) {
problems.add(String.format("leg %d: climb %.3f per frame loses the floor", s.leg(), climb));
}
if (s.kind() == Kind.BOOST) {
v += boost;
v22 += boost;
} else if (s.kind() == Kind.JUMP) {
return fly(s, index, d);
}
}
return null;
}
/** Distance along leg {@code s.leg()} of the start of segment s. */
private double legPos(Segment s) {
double[] h = CoasterTrack.dir(s.heading());
Segment first = null;
for (Segment g : track.segments) {
if (g.leg() == s.leg()) {
first = g;
break;
}
}
return (s.x0() - first.x0()) * h[0] + (s.z0() - first.z0()) * h[1];
}
private double[] fly(Segment pad, int index, double d) {
List<Segment> segs = track.segments;
double start = legPos(pad) + d;
double y = pad.y(d);
double g = 163 * R;
double x = start;
double needed = Double.NaN;
Segment landed = null;
while (true) {
y += 2 * g;
x += v;
g -= 14 * R;
tick();
if (g < -820 * R) {
problems.add(String.format("leg %d: jump fell (no floor)", pad.leg()));
break;
}
Segment under = null;
for (int k = index; k < segs.size() && segs.get(k).leg() == pad.leg(); k++) {
Segment c = segs.get(k);
double p0 = legPos(c);
if (c.kind() == Kind.GAP) {
needed = p0 + c.length();
continue;
}
if (x >= p0 && x < p0 + c.length()) {
under = c;
}
}
Corner corner = track.corners.get(pad.leg());
double legEnd = segs.stream()
.filter(c -> c.leg() == pad.leg())
.mapToDouble(c -> legPos(c) + c.length())
.max()
.orElseThrow();
double floor;
if (under != null) {
floor = under.y(x - legPos(under));
} else if (x >= legEnd && x < legEnd + 2) {
floor = corner.y();
} else {
continue;
}
if (y <= floor && y + 0.05 - 4 * g >= floor && g > -0.1) {
landed = under;
break;
}
}
double limit = segs.stream()
.filter(c -> c.leg() == pad.leg())
.mapToDouble(c -> legPos(c) + c.length())
.max()
.orElseThrow()
+ 0.5
- 0.75;
Flight f = new Flight(pad.leg(), start, x, needed, limit, v);
flights.add(f);
if (!f.ok()) {
problems.add(String.format(
"leg %d: jump at %.2f lands at %.2f (needs %.2f..%.2f)", pad.leg(), start, x, needed, limit));
}
if (landed == null) {
return new double[] {-1};
}
return new double[] {segs.indexOf(landed), x - legPos(landed)};
}
private void corner(Corner c) {
for (double a = 0; a < Math.PI / 4; ) {
if (v < cap) {
v = Math.min(v + 6 * R, cap);
}
a += v;
tick();
}
}
}
@@ -0,0 +1,88 @@
package com.eliasmueller.llamamod.mods.coaster;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.Wad;
import com.eliasmueller.llamamod.format.stpcmerge.StpcImage;
import com.eliasmueller.llamamod.format.stpcmerge.StratCode;
import com.eliasmueller.llamamod.format.strat.StratListing;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeSet;
/**
* Speed constants of the RollerCoaster strat (t1l7m001 unit 0), patched in place: the cruise speed
* 0.15 (614 raw; six {@code pushShort}: the decay floor after a boost and the corner speed-up cap)
* and the boost per frame on material 5 (20 raw, added to v19 and v22).
*/
final class CoasterSpeed {
static final int CRUISE = 614;
static final int BOOST = 20;
private CoasterSpeed() {}
/** STPC offsets of the operand words: {cruise…, boost…}. */
record Sites(List<Integer> cruise, List<Integer> boost) {}
static Sites find(byte[] wad) {
Level lv = Level.parse(wad);
StpcImage img = new StpcImage(lv.wad().require("STPC"), lv.wfpc());
TreeSet<Integer> roots = new TreeSet<>();
lv.map().strats.forEach(s -> roots.add(s.code()));
StratCode code = StratCode.analyse(img, roots);
for (int u = 0; u < img.units.size(); u++) {
StratListing li = StratListing.of(img, code, u, roots, StratListing.Names.NONE);
if (!li.strats.containsValue("RollerCoaster")) {
continue;
}
List<StratListing.Line> ins = li.lines.stream()
.filter(l -> l.kind() == StratListing.Kind.INSTR)
.toList();
List<Integer> cruise = new ArrayList<>();
List<Integer> boost = new ArrayList<>();
for (int i = 1; i + 1 < ins.size(); i++) {
StratListing.Line l = ins.get(i);
if (!l.constShort() || !l.text().startsWith("pushShort")) {
continue;
}
String prev = ins.get(i - 1).text();
String next = ins.get(i + 1).text();
if (l.value() == CRUISE && (prev.equals("pushVar v19") || next.equals("max") || next.equals("min"))) {
cruise.add(l.constAt());
} else if (l.value() == BOOST
&& (prev.equals("pushVar v19") || prev.equals("pushVar v22"))
&& next.equals("add")) {
boost.add(l.constAt());
}
}
return new Sites(cruise, boost);
}
throw new IllegalArgumentException("no RollerCoaster strat in this level");
}
/** The WAD with cruise and boost scaled by {@code factor} (1.0 = unchanged). */
static byte[] patch(byte[] wad, double factor) {
if (factor == 1.0) {
return wad;
}
Sites s = find(wad);
if (s.cruise().size() != 6 || s.boost().size() != 2) {
throw new IllegalStateException("unexpected RollerCoaster code: " + s);
}
Wad w = Wad.parse(wad);
byte[] st = w.require("STPC").clone();
for (int at : s.cruise()) {
setHigh(st, at, (int) Math.round(CRUISE * factor));
}
for (int at : s.boost()) {
setHigh(st, at, (int) Math.round(BOOST * factor));
}
w.set("STPC", st);
return w.toBytes();
}
/** pushShort keeps its operand in the high 16 bits of the opcode word. */
private static void setHigh(byte[] d, int at, int v) {
d[at + 2] = (byte) v;
d[at + 3] = (byte) (v >> 8);
}
}
@@ -0,0 +1,295 @@
package com.eliasmueller.llamamod.mods.coaster;
import java.util.ArrayList;
import java.util.List;
/**
* A closed roller coaster circuit for the original {@code RollerCoaster} strat: straight legs
* along the grid axes joined by flat 2 × 2 corners (the strat only turns by 90° on a radius-0.5 arc
* at a waypoint). The track bed is two units wide, its centre line on integer coordinates; heights
* change on straights only. Leg {@code i} starts behind corner {@code i - 1} (corner −1 = {@link
* #start}) and ends at corner {@code i}; the last corner is the start corner again.
*/
public final class CoasterTrack {
/** Floor kinds; {@code mat} is the collision material the strat reacts to. */
public enum Kind {
TRACK(0),
/** Speed boost while on it (+20 raw per frame). */
BOOST(5),
/** Jump (vertical speed 163 raw). */
JUMP(11),
/** Small hop and a half turn: the llama rides backwards until the next corner. */
SPIN(13),
/** Ends the level when the current waypoint is the last one. */
END(16),
/** Start: the cart crawls along it out of the station. */
STATION(0),
/** No floor. */
GAP(-1);
public final int mat;
Kind(int mat) {
this.mat = mat;
}
}
/** A piece of a leg: whole units long, changing the height by {@code rise}. */
public record Piece(Kind kind, int length, double rise) {}
/** A straight between two corners; heading in degrees (0 = +z, 90 = +x). */
public record Leg(int heading, List<Piece> pieces) {
int length() {
return pieces.stream().mapToInt(Piece::length).sum();
}
double rise() {
return pieces.stream().mapToDouble(Piece::rise).sum();
}
}
/** A placed piece: centre line from (x0, z0) to (x1, z1), heights y0 → y1. */
public record Segment(
Kind kind, int leg, int heading, double x0, double z0, double y0, double x1, double z1, double y1) {
public double length() {
return Math.hypot(x1 - x0, z1 - z0);
}
/** Height at distance d from the start. */
public double y(double d) {
double l = length();
return l == 0 ? y0 : y0 + (y1 - y0) * d / l;
}
}
/** A corner square centred on (x, z): turns from heading {@code in} to {@code out}. */
public record Corner(double x, double z, double y, int in, int out) {}
/** A waypoint of the strat's chain: param 1.0 = station (crawl to the next node). */
public record Node(double x, double z, double y, int heading, boolean station) {}
public final String name;
/** Start corner centre {x, z, y} (the corner in front of leg 0). */
public final double[] start;
public final List<Leg> legs;
public final List<Segment> segments = new ArrayList<>();
public final List<Corner> corners = new ArrayList<>();
public CoasterTrack(String name, double[] start, List<Leg> legs) {
this.name = name;
this.start = start.clone();
this.legs = List.copyOf(legs);
layout();
}
static double[] dir(int heading) {
double a = Math.toRadians(heading);
return new double[] {Math.round(Math.sin(a)), Math.round(Math.cos(a))};
}
private void layout() {
if (legs.size() < 4) {
throw new IllegalArgumentException(name + ": a circuit needs at least 4 legs");
}
double x = start[0], z = start[1];
double y = start[2];
int stations = 0, ends = 0;
for (int i = 0; i < legs.size(); i++) {
Leg l = legs.get(i);
if (l.heading() % 90 != 0) {
throw new IllegalArgumentException(name + ": leg " + i + " heading must be a multiple of 90°");
}
int next = legs.get((i + 1) % legs.size()).heading();
if (Math.abs(((next - l.heading()) % 360 + 360) % 360 - 180) != 90) {
throw new IllegalArgumentException(name + ": corner " + i + " must turn by 90°");
}
double[] d = dir(l.heading());
x += d[0];
z += d[1];
for (Piece p : l.pieces()) {
if (p.length() <= 0) {
throw new IllegalArgumentException(name + ": empty piece in leg " + i);
}
if (p.kind() == Kind.STATION) {
stations += i == 0 ? 1 : 100;
}
if (p.kind() == Kind.END) {
ends += i == 0 ? 1 : 100;
}
double nx = x + d[0] * p.length(), nz = z + d[1] * p.length(), ny = y + p.rise();
segments.add(new Segment(p.kind(), i, l.heading(), x, z, y, nx, nz, ny));
x = nx;
z = nz;
y = ny;
}
x += d[0];
z += d[1];
corners.add(new Corner(x, z, y, l.heading(), next));
}
if (Math.abs(x - start[0]) > 1e-9 || Math.abs(z - start[1]) > 1e-9 || Math.abs(y - start[2]) > 1e-9) {
throw new IllegalArgumentException(
String.format("%s: the circuit does not close (ends at %.2f, %.2f, %.2f)", name, x, z, y));
}
if (stations != 1 || ends != 1) {
throw new IllegalArgumentException(name + ": leg 0 needs exactly one STATION and one END piece");
}
int iEnd = indexOf(Kind.END), iSt = indexOf(Kind.STATION);
if (iEnd > iSt) {
throw new IllegalArgumentException(name + ": the END piece must come before the STATION");
}
}
private int indexOf(Kind k) {
for (int i = 0; i < segments.size(); i++) {
if (segments.get(i).kind() == k) {
return i;
}
}
return -1;
}
public Segment station() {
return segments.get(indexOf(Kind.STATION));
}
public Segment end() {
return segments.get(indexOf(Kind.END));
}
/** Centre-line length of one lap (corners counted as quarter arcs). */
public double lapLength() {
double s = 0;
for (Segment g : segments) {
s += g.length();
}
return s + corners.size() * (2 - 1 + Math.PI / 4);
}
/** The cart's start: station start, heading of leg 0. */
public double[] cartStart() {
Segment s = station();
return new double[] {s.x0(), s.z0(), s.y0(), s.heading()};
}
/** Distance of the last node behind the start of the END piece (the strat ends the level 30 frames after the pad). */
static final double END_NODE_AFTER_PAD = 9;
/**
* The waypoint chain for {@code laps} laps: station node 1 behind the cart and its crawl end
* (both param 1.0), one node per corner 0.5 before the corner centre (heading after the turn),
* and in the last lap a final node behind the END pad.
*/
public List<Node> nodes(int laps) {
List<Node> out = new ArrayList<>();
Segment st = station();
double[] d = dir(st.heading());
out.add(new Node(st.x0() - d[0], st.z0() - d[1], st.y0() + 1.5, st.heading(), true));
out.add(new Node(st.x1(), st.z1(), st.y1(), st.heading(), true));
for (int lap = 0; lap < laps; lap++) {
for (Corner c : corners) {
double[] h = dir(c.in());
out.add(new Node(c.x() - 0.5 * h[0], c.z() - 0.5 * h[1], c.y(), c.out(), false));
}
}
Segment e = end();
double[] h = dir(e.heading());
out.add(new Node(
e.x0() + END_NODE_AFTER_PAD * h[0], e.z0() + END_NODE_AFTER_PAD * h[1], e.y0(), e.heading(), false));
return out;
}
/** Bounding box {x0, z0, x1, z1} of the track bed. */
public double[] bounds() {
double x0 = Double.MAX_VALUE, z0 = Double.MAX_VALUE, x1 = -Double.MAX_VALUE, z1 = -Double.MAX_VALUE;
for (Corner c : corners) {
x0 = Math.min(x0, c.x() - 1);
z0 = Math.min(z0, c.z() - 1);
x1 = Math.max(x1, c.x() + 1);
z1 = Math.max(z1, c.z() + 1);
}
return new double[] {x0, z0, x1, z1};
}
public double minY() {
double m = Double.MAX_VALUE;
for (Segment s : segments) {
m = Math.min(m, Math.min(s.y0(), s.y1()));
}
return m;
}
// ------------------------------------------------------------ layouts
private static Piece p(Kind k, int len) {
return new Piece(k, len, 0);
}
private static Piece p(Kind k, int len, double rise) {
return new Piece(k, len, rise);
}
private static Leg leg(int heading, Piece... pieces) {
return new Leg(heading, List.of(pieces));
}
/** Leg 0 of every layout: run-in, END pad, brake run, station, then the first drop (boost at its top). */
private static Leg stationLeg(int heading, int boostLen, int dropLen, double drop, int tail) {
double b = drop * boostLen / dropLen;
return leg(
heading,
p(Kind.TRACK, 1),
p(Kind.END, 1),
p(Kind.TRACK, 2),
p(Kind.STATION, 5),
p(Kind.TRACK, 1),
p(Kind.BOOST, boostLen, -b),
p(Kind.TRACK, dropLen - boostLen, b - drop),
p(Kind.TRACK, tail));
}
/** Compact circuit (~100 units): first drop, climb, spin pad, jump over a gap, lift back up. */
public static CoasterTrack loop() {
return new CoasterTrack(
"loop",
new double[] {10, 10, 24},
List.of(
stationLeg(90, 4, 8, 4, 2),
leg(0, p(Kind.TRACK, 2), p(Kind.TRACK, 8, 2), p(Kind.TRACK, 2)),
leg(270, p(Kind.TRACK, 2), p(Kind.SPIN, 1), p(Kind.TRACK, 1), p(Kind.TRACK, 4, -2)),
leg(0, p(Kind.BOOST, 2), p(Kind.JUMP, 1), p(Kind.GAP, 2, -1), p(Kind.TRACK, 9)),
leg(270, p(Kind.TRACK, 3), p(Kind.TRACK, 5, -2), p(Kind.TRACK, 2)),
leg(180, p(Kind.BOOST, 1), p(Kind.TRACK, 2), p(Kind.TRACK, 25, 7))));
}
/** Longer circuit (~150 units): a bigger first drop, two jumps, two spin pads, a long lift hill. */
public static CoasterTrack grand() {
return new CoasterTrack(
"grand",
new double[] {8, 8, 28},
List.of(
stationLeg(90, 4, 12, 6, 2),
leg(0, p(Kind.TRACK, 3), p(Kind.TRACK, 6, 2), p(Kind.TRACK, 1)),
leg(90, p(Kind.TRACK, 1), p(Kind.SPIN, 1), p(Kind.TRACK, 2), p(Kind.TRACK, 6, -3)),
leg(0, p(Kind.BOOST, 2), p(Kind.JUMP, 1), p(Kind.GAP, 2, -1), p(Kind.TRACK, 9)),
leg(270, p(Kind.TRACK, 2), p(Kind.TRACK, 6, 2), p(Kind.TRACK, 2), p(Kind.TRACK, 6, -2)),
leg(0, p(Kind.TRACK, 2), p(Kind.SPIN, 1), p(Kind.TRACK, 5)),
leg(
270,
p(Kind.BOOST, 2),
p(Kind.JUMP, 1),
p(Kind.GAP, 2, -1),
p(Kind.TRACK, 4),
p(Kind.TRACK, 4, -1),
p(Kind.TRACK, 5)),
leg(180, p(Kind.TRACK, 1), p(Kind.BOOST, 1), p(Kind.TRACK, 34, 10))));
}
public static CoasterTrack named(String name) {
return switch (name) {
case "loop" -> loop();
case "grand" -> grand();
default -> throw new IllegalArgumentException("unknown track " + name);
};
}
}
@@ -83,6 +83,10 @@ public final class EditorCli {
need(rest, 1, "--editor-new <project.llamalevel> [base level]");
String base = rest.size() > 1 ? rest.get(1) : Templates.DEFAULT_BASE;
Project p = Project.create(cd, "new", base);
p.scene()
.settings()
.prop("targetChapter")
.setRaw(p.scene().settings(), LevelProjects.nextFreeChapter(LevelProjects.dir(paths)));
if (name != null) {
p.scene().settings().prop("name").setRaw(p.scene().settings(), name);
}
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more