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

This commit is contained in:
MineTec committed 2026-11-07 16:45:54 +01:00
1 parent 6d449257a7
commit f5fd411592
21 files changed
+1818 -17

No files matched your search

+3
View File
@@ -40,6 +40,9 @@ There are two downloads: **full** (launcher + level editor) and **launcher** (mo
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: 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.
+1 -1
View File
@@ -51,7 +51,7 @@ tutorials (first level, parkour, editing an original level, Blender import)
* [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): coming soon
* [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
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

+29
View File
@@ -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:
@@ -109,6 +124,20 @@ A minigame on a TestLevel chapter: alligators come in waves into a walled pit. E
| Coins per hit | 0 – 5 | 1 | Coins that pop out per hit (5 more at the end). |
| 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
Turns the hidden TestLevel (world 9, chapter 1) into a playground island: a gallery of every character and object model of all worlds (standing still, grouped by world) and a parkour course with checkpoints. To reach it, click "Unlock TestLevel" on the Save games tab.
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+1 -1
View File
@@ -12,7 +12,7 @@ can read every one of them, change their numbers, write new ones and move them b
| [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) | coming soon |
| [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)
+187 -15
View File
@@ -1,20 +1,192 @@
# Example: the roller coaster
> **Coming soon.** A roller-coaster mod is in development. This page will walk through it the way
> [the arena page](arena.md) does, once it is finished. Until then, its lab notes will appear as
> `notes/mod-coaster.md` and its code under `src/main/java/com/eliasmueller/llamamod/mods/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.
<!-- TODO (docs): fill in when mods/coaster is merged: overview + screenshots (docs/img/strats/),
files table, the track geometry, the cart/player strat scripts step by step, options, tests,
lessons learned. Link notes/mod-coaster.md and the 18-mods.md section. -->
![The ride: first drop, boost, jump, spin pad, climb, station](../img/strats/coaster_ride.jpg)
## What is already known about the original rides
| 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 |
* Catacombs chapters 1, 3 and 5 (`t1l7m001`, `t1l7m003`, `t1l7m005`) are roller-coaster rides. In
these levels the player is not the `Llama` strat but a **`RollerCoaster`** strat
([Objects](../reference/objects.md#special-objects)).
* The rides have the longest views of the game: fog end up to 23 cells
([Engine facts](../reference/engine-limits.md#drawing)).
* The ride music is track 25, `ROLLER` ([Music](../reference/audio.md#music)).
* Objects of these levels can be imported into other levels, but they only work with matching
geometry ([manual chapter 9](../manual/09-objects.md#limits-and-things-to-know)).
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.
+1
View File
@@ -29,6 +29,7 @@ Images used by the notes are in [`img/`](img/).
|---|---|
| [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 |
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

+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).
@@ -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). */
@@ -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);
};
}
}
@@ -0,0 +1,242 @@
package com.eliasmueller.llamamod.mods.coaster;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.eliasmueller.llamamod.format.Bytes;
import com.eliasmueller.llamamod.format.Collision;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.MapChunk;
import com.eliasmueller.llamamod.format.Model;
import com.eliasmueller.llamamod.format.Strats;
import com.eliasmueller.llamamod.format.Trak;
import com.eliasmueller.llamamod.format.Wad;
import com.eliasmueller.llamamod.mods.GameData;
import com.eliasmueller.llamamod.mods.ModOption;
import com.eliasmueller.llamamod.mods.ModRegistry;
import com.eliasmueller.llamamod.mods.TestModContext;
import com.eliasmueller.llamamod.mods.coaster.CoasterTrack.Kind;
import com.eliasmueller.llamamod.mods.coaster.CoasterTrack.Node;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
class CoasterTest {
private static final List<String> TRACKS = List.of("loop", "grand");
@Test
void layoutsCloseAndHaveTheFeatures() {
for (String n : TRACKS) {
CoasterTrack t = CoasterTrack.named(n);
Set<Kind> kinds = new HashSet<>();
t.segments.forEach(s -> kinds.add(s.kind()));
assertTrue(
kinds.containsAll(List.of(Kind.BOOST, Kind.JUMP, Kind.GAP, Kind.SPIN, Kind.END, Kind.STATION)), n);
assertTrue(t.segments.stream().anyMatch(s -> s.y1() > s.y0()), n + ": a climb");
assertTrue(t.segments.stream().anyMatch(s -> s.y1() < s.y0() && s.kind() != Kind.GAP), n + ": a drop");
// the waypoint funnel and the corner arcs need corners on the half-unit grid
for (CoasterTrack.Corner c : t.corners) {
assertEquals(Math.rint(c.x()), c.x(), n);
assertEquals(Math.rint(c.z()), c.z(), n);
}
// tracks must not overlap in plan (no crossings)
Set<Long> cells = new HashSet<>();
int overlaps = 0;
for (CoasterTrack.Segment s : t.segments) {
double[] h = CoasterTrack.dir(s.heading());
for (double d = 0.5; d < s.length(); d += 1) {
for (double lat : new double[] {-0.5, 0.5}) {
double x = s.x0() + h[0] * d + h[1] * lat, z = s.z0() + h[1] * d - h[0] * lat;
if (!cells.add((long) Math.floor(x) * 1000 + (long) Math.floor(z))) {
overlaps++;
}
}
}
}
assertEquals(0, overlaps, n);
}
assertTrue(CoasterTrack.loop().lapLength() > 100);
assertTrue(CoasterTrack.grand().lapLength() > CoasterTrack.loop().lapLength() + 40);
}
@Test
void badLayoutsAreRefused() {
assertThrows(
IllegalArgumentException.class,
() -> new CoasterTrack(
"open",
new double[] {10, 10, 5},
List.of(
new CoasterTrack.Leg(90, List.of(new CoasterTrack.Piece(Kind.TRACK, 4, 0))),
new CoasterTrack.Leg(0, List.of(new CoasterTrack.Piece(Kind.TRACK, 4, 0))),
new CoasterTrack.Leg(270, List.of(new CoasterTrack.Piece(Kind.TRACK, 4, 0))),
new CoasterTrack.Leg(180, List.of(new CoasterTrack.Piece(Kind.TRACK, 3, 0))))));
}
@Test
void nodesFollowTheStratsRules() {
CoasterTrack t = CoasterTrack.loop();
for (int laps = 1; laps <= 3; laps++) {
List<Node> ns = t.nodes(laps);
assertEquals(2 + laps * t.corners.size() + 1, ns.size());
double[] st = t.cartStart();
// node 0: station flag, one unit behind the cart (the cart starts past it and crawls)
assertTrue(ns.get(0).station() && ns.get(1).station());
assertEquals(1.0, Math.hypot(ns.get(0).x() - st[0], ns.get(0).z() - st[1]), 1e-9);
for (Node n : ns) {
// RollerCoaster rounds node x/z to halves and headings to quarter turns
assertEquals(Math.rint(n.x() * 2) / 2, n.x(), 1e-9);
assertEquals(Math.rint(n.z() * 2) / 2, n.z(), 1e-9);
assertEquals(0, n.heading() % 90);
}
assertTrue(ns.subList(2, ns.size()).stream().noneMatch(Node::station));
// the last node lies behind the END pad on leg 0: the strat ends the level there
Node last = ns.getLast();
CoasterTrack.Segment e = t.end();
assertEquals(e.heading(), last.heading());
assertEquals(e.z0(), last.z(), 1e-9);
assertTrue(last.x() > e.x1() + 5);
}
}
@Test
void everyJumpLandsAndNoClimbLosesTheFloor() {
for (String n : TRACKS) {
for (String sp : List.of("slow", "normal", "fast")) {
double f = CoasterMod.speedFactor(sp);
for (int laps = 1; laps <= 3; laps++) {
CoasterSim.Result r = CoasterSim.run(
CoasterTrack.named(n), CoasterSpeed.CRUISE * f / 4096, CoasterSpeed.BOOST * f / 4096, laps);
String what = n + "/" + sp + "/" + laps + ": " + r;
assertTrue(r.problems().isEmpty(), what);
assertEquals(
laps
* CoasterTrack.named(n).segments.stream()
.filter(s -> s.kind() == Kind.JUMP)
.count(),
r.flights().size(),
what);
assertTrue(r.minSpeed() > 0.02 && r.maxSpeed() < 0.45, what);
}
}
}
}
@Test
void registered() {
assertNotNull(new ModRegistry().get("coaster"));
}
@Test
void chapterOption() {
ModOption o = new CoasterMod()
.options().stream()
.filter(x -> x.key().equals("chapter"))
.findFirst()
.orElseThrow();
assertEquals("48", o.defaultValue());
assertEquals("32", o.normalize("32"));
for (String bad : List.of("1", "8", "31", "50")) {
assertThrows(IllegalArgumentException.class, () -> o.normalize(bad), bad);
}
}
@Test
void speedPatch() throws IOException {
Path cd = GameData.cd();
byte[] base = Files.readAllBytes(cd.resolve(CoasterLevel.BASE));
CoasterSpeed.Sites s = CoasterSpeed.find(base);
assertEquals(6, s.cruise().size());
assertEquals(2, s.boost().size());
assertTrue(CoasterSpeed.patch(base, 1.0) == base);
byte[] fast = CoasterSpeed.patch(base, 1.25);
byte[] st = Wad.parse(fast).require("STPC");
for (int at : s.cruise()) {
assertEquals(768, Bytes.i32(st, at) >>> 16);
}
for (int at : s.boost()) {
assertEquals(25, Bytes.i32(st, at) >>> 16);
}
// nothing else changed
byte[] st0 = Wad.parse(base).require("STPC");
int diff = 0;
for (int i = 0; i < st.length; i++) {
if (st[i] != st0[i]) {
diff++;
}
}
assertTrue(diff <= 2 * 8, "bytes changed: " + diff);
}
@Test
void builtLevel() throws IOException {
Path cd = GameData.cd();
CoasterMod mod = new CoasterMod();
TestModContext ctx = new TestModContext(cd, mod, Map.of("chapter", "40", "laps", "3"), null);
mod.apply(ctx);
byte[] wad = ctx.written.get("wads/T1L9M040.wad");
assertNotNull(wad, "written: " + ctx.written.keySet());
assertNotNull(ctx.written.get("T1940.COR"));
Level lv = Level.parse(wad);
Map<String, List<MapChunk.Strat>> t = Strats.levelTypes(lv.wad().require("STPC"), lv.wfpc(), lv.map());
assertEquals(1, t.get("RollerCoaster").size());
assertTrue(!t.containsKey("Llama"), t.keySet().toString());
assertEquals(1, t.get("RollerDoor").size());
assertEquals(1, t.get("FireworksTrigger").size());
int coins = t.get("CoinRollerCoaster").size();
assertTrue(coins > 10, "coins " + coins);
assertEquals(coins, Bytes.u32(lv.wad().require("LNFO"), 0));
MapChunk m = lv.map();
MapChunk.Strat rc = t.get("RollerCoaster").get(0);
// exit target (tribe, world, chapter) = this chapter
assertEquals(
List.of(0, 4096, 9 * 4096, 40 * 4096),
List.of(
m.params[rc.paramIndex()],
m.params[rc.paramIndex() + 1],
m.params[rc.paramIndex() + 2],
m.params[rc.paramIndex() + 3]));
List<MapChunk.PathNode> chain = new ArrayList<>();
for (int k = rc.waypoint(); ; k = m.nodes.get(k).next()) {
chain.add(m.nodes.get(k));
if (m.nodes.get(k).next() == 0) {
break;
}
}
CoasterTrack loop = CoasterTrack.loop();
assertEquals(loop.nodes(3).size(), chain.size());
assertEquals(4096, chain.get(0).unk2());
assertEquals(4096, chain.get(1).unk2());
assertEquals(0, chain.get(2).unk2());
// floor materials the strat reacts to are on the track bed
Set<Integer> mats = new HashSet<>();
List<Model> trak = Trak.parse(lv.wad().require("TRAK"));
for (MapChunk.Tile tile : m.tiles) {
for (byte[] r : trak.get(tile.model()).coll.get(0)) {
mats.add(Collision.decode(r).mat());
}
}
assertTrue(mats.containsAll(List.of(0, 5, 11, 13, 16, 30)), mats.toString());
assertEquals("Llama Coaster", lv.wad().levelName());
}
@Test
void refusesAChapterAnotherModChanged() throws IOException {
Path cd = GameData.cd();
CoasterMod mod = new CoasterMod();
TestModContext ctx = new TestModContext(cd, mod, Map.of(), null);
ctx.write("wads/T1L9M048.wad", new byte[] {1, 2, 3});
IOException e = assertThrows(IOException.class, () -> mod.apply(ctx));
assertTrue(e.getMessage().contains("chapter 48"), e.getMessage());
}
}