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

@@ -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());
}
}