test world: gallery of all 253 figures and objects of every world as static display strats (cross-world import, idle animation, no ai), packed rows per world with coloured posts, animal pen removed

This commit is contained in:
MineTec committed 2026-11-07 13:59:54 +01:00
1 parent 6a76cac69f
commit d030c9c6c7
15 files changed
+1053 -175

No files matched your search

+1 -1
View File
@@ -82,7 +82,7 @@ The following list is generated from the program, so it always matches your Llam
#### Test World
Turns the hidden TestLevel (world 9, chapter 1) into a playground island: a gallery of every object in the game, an animal enclosure and a parkour course with checkpoints. To reach it, click "Unlock TestLevel" on the Save games tab.
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.
*Id* `testworld`
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

+116
View File
@@ -0,0 +1,116 @@
# Test World (`testworld`, LEVELS)
Package `mods/testworld`: `TestWorldMod` (writes `wads/T1L9M001.wad` + loading picture `T191.COR`),
`TestWorld` (the level script), `Gallery` (all-worlds model gallery), `Layout` (row packing),
`JumpLab` (separate jump-measuring level, not used by the mod). No options in the launcher; the
script options below are for tests and tools (`ViewerDemo testworld`, `TestWorld.buildWad(…, opts)`).
| Script option | Default | Meaning |
|---|---|---|
| `gallery` | `all` | `none`, or comma-separated original type names (`TipoVillage,Spider`) |
| `start` | `plaza` | `plaza`, `rowN` (middle of the aisle in front of gallery row N), `pathN` (its west end), `parkour`, `pk1`..`pk5`, `goal`, `goal2` |
| `door` | `1,9,1` | exit parameters (tribe, world, chapter of the next level) |
## Layout (2026-11-07 rework)
Island (x 11..43.5, z 9.5..≈34) + strip under the parkour end. Start plaza x 15..21, z 12..19; the
parkour along z = 15.5 to the goal platform with the exit is unchanged. The animal enclosure and
viewing podium are gone. South of the plaza the **gallery**: rows along +x (x 16.6..43), one block of
rows per world (1..8, then the front end), figures before objects, ordered by catalogue category
(characters, player models, enemies, animals, other figures; collectibles, checkpoints, exits,
mechanisms …). Every item stands on a 0.08 high stone pedestal sized to its model (min 0.3 × 0.35,
gap 0.15); in front of every row a 0.75 wide sand aisle. Row posts at x 15.05 in the world colour;
the first row of a world carries one small block per world number (front end: white, none).
The island grows southwards with the gallery (12 rows, z 20.5..≈33).
![gallery from the editor viewer](img/mods/testworld_top.jpg)
## Gallery: every model of the game, without AI
`Gallery.build(wads)` scans the level WADs (t1l1m001…t1l8m006, t6l6m001/2) in order. For each strat
type of a level's MAP (first level that places it wins, names deduplicated):
1. model = the first `address model; setModel` of the type's code (`StratModels.modelOf`, follows
spawn stubs); otherwise the first one in any unit of its import closure (types that show their
model through sub-strats: RisingColumns, FlockOfBirds, spawners …) – not for cameras, triggers
and cut scenes. Types with their own model are handled first, so they win step 3.
2. skinned models also get an animation set: the first one the type's code addresses that has the
model's bone count (bone matrices, no vertex deltas), else any such set of the level.
3. "look" = SHA-256 of the model bytes with every face texture replaced by its pixels, plus the
animation bytes; a type that looks exactly like an earlier one is dropped (TipoLab = TipoCatacombs …).
4. A **display strat** is compiled with `StratCompiler` and appended as its own unit to the
*source* level's STPC:
```
strat GxTipoVillage {
setModel(model:17)
animPlay(anim:60) // skinned models only
loop {
if (!self.animPlaying) { animPlay(anim:60) }
yield
}
}
```
No collision, physics, waypoints, events, texts or sounds – it cannot move, attack, talk,
trigger anything or delete itself. Type fields in MAP: 1 variable, 0 slots, type flags 0, no
parameters, no waypoints (nothing for the engine's "no waypoints → remove" checks to find).
5. `mergeInto(base)` imports all display entries with `StpcMerge.merge` exactly like the editor's
cross-world import: models, animation sets and textures come along (identical ones shared), the
tiny units are appended, the base's own code is relocated (MAP strat codes remapped), TEXT gets
the new pages/entries. One unplaced prototype record per display type is added to the base MAP so
`LevelBuilder` knows the types (`l.strat("GxTipoVillage", …)`); the builder replaces all records.
Result (German CD): **253 entries**, merge +5.95 MB (227 models, 96 animation sets, 93 texture
pages → 111 of 127, 3126 texture entries, 0 sounds/texts); STPC 10.1 MB, WAD 14.6 MB. Scan + merge
≈ 10 s (mod apply).
| World | figures | objects | total |
|---|---|---|---|
| 1 Village | 23 | 27 | 50 |
| 2 Jungle | 11 | 7 | 18 |
| 3 River | 30 | 6 | 36 |
| 4 Jungle day | 13 | 9 | 22 |
| 5 Mountain | 7 | 11 | 18 |
| 6 City | 13 | 17 | 30 |
| 7 Catacombs | 7 | 19 | 26 |
| 8 Lab / end | 30 | 20 | 50 |
| front end | 3 | 0 | 3 |
Not shown (81 of 334 type names): 52 without a model of their own (cameras, cut-scene/dialogue triggers,
force/current boxes, sound/particle effects, LevelDoor (its door is level geometry), Secret,
CheatTeleport, AppearingPlatforms*, MouthPlatform …), 28 that look exactly like an earlier entry,
1 skinned model without a usable animation (EndGameGuardsBottle). `TestWorld.notShown()` lists them
with the reason.
## Engine facts found on the way
* **Strats are only drawn near the player.** Objects farther than ≈12.5 units from the llama are
not rendered (they still exist) – walking along the gallery reveals the rows; a free camera (mod
`freecam`) far away from the llama shows empty pedestals.
* Strat heap: `FUN_0041ae20` allocates in u16-sized word blocks from a heap initialised with
`(short)0x800000 − 3` = 65533 words (≈256 KB) for the strat pool (0x154 bytes × (poolExtra+30)),
collision blocks and per-strat variables – plenty for the gallery (≈280 strats).
## Verified in game (apptest :26, 2026-11-07, host load ≈30)
Mods `testworld` (+ `freecam` with `freeze=false`, noclip F9 for the walk): level loads (no crash),
all rows walked twice (254- and final 253-entry build), figures stand still and play their idle animation in place (screenshot
diff: only limbs change), no dialogue, cut scene or attack, the llama walks through them. The
game stayed responsive (no freeze); frame rate not measurable at that host load. Over the
south edge the llama falls into the sea and respawns at the plaza checkpoint as before.
![village and jungle figures](img/mods/testworld_village.jpg)
![Condor, guards and townsfolk](img/mods/testworld_condor.jpg)
![lab and end-game figures](img/mods/testworld_river.jpg)
## Tests
* `GalleryTest`: every entry is placed once, has a non-empty model (skinned = figure, posed with an
animation), its unit only uses `debugName address setModel animPlay pushProp not jz jmpYield end`,
type flags 0 / no parameters / no waypoints, merged code decodes without errors; rows do not
overlap; `gallery=none` imports nothing, `gallery=a,b,x` places a and b and reports x.
* `TestWorldGoldenTest`: SHA-256 of the test world (default and `start=parkour`) pins the current
output (no Python counterpart any more), 253 gallery objects; jump lab and loading picture still
compared with the Python tools.
* `TestWorldExampleTest` / `examples/testwelt.llamalevel` keep the **original** test world (enclosure,
base-level gallery, `TestWorldBrushes` = port of levels/testwelt.py): the brush project's build is
compared with the level builder's WAD of the same script instead of the mod's output.
@@ -0,0 +1,496 @@
package com.eliasmueller.llamamod.mods.testworld;
import com.eliasmueller.llamamod.editor.render.StratModels;
import com.eliasmueller.llamamod.editor.tools.catalog.ObjectCatalog;
import com.eliasmueller.llamamod.format.Bytes;
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.TextChunk;
import com.eliasmueller.llamamod.format.Vertex;
import com.eliasmueller.llamamod.format.Wad;
import com.eliasmueller.llamamod.format.stpcmerge.DialogMerge;
import com.eliasmueller.llamamod.format.stpcmerge.SoundMerge;
import com.eliasmueller.llamamod.format.stpcmerge.StpcImage;
import com.eliasmueller.llamamod.format.stpcmerge.StpcMerge;
import com.eliasmueller.llamamod.format.stpcmerge.StratCode;
import com.eliasmueller.llamamod.format.stpcmerge.TextMerge;
import com.eliasmueller.llamamod.format.strat.StratAssembler;
import com.eliasmueller.llamamod.format.strat.StratCompiler;
import com.eliasmueller.llamamod.format.strat.StratListing;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Function;
/**
* Gallery of every object model of the game for the test world. For each strat type of every
* level (first level that places it) that shows a model, a "display" strat is written into that
* level's STPC: it only sets the model, plays one animation set in a loop (skinned models) and
* yields – no movement, collision, events, texts or sounds. These display types are then imported
* into the base level with {@link StpcMerge} like the editor's cross-world import (models,
* animations, textures are copied; only the tiny display units come along, not the original
* behaviour). Types whose model and animation look exactly like an earlier entry's are dropped.
*/
public final class Gallery {
/** Prefix of the display types' names ("Gx" + original type name). */
public static final String PREFIX = "Gx";
/** Levels scanned, in gallery order: worlds 1..8, then the front-end levels. */
static List<String> levels(Function<String, byte[]> wads) {
List<String> out = new ArrayList<>();
for (int w = 1; w <= 8; w++) {
for (int c = 1; c <= 9; c++) {
String rel = String.format("wads/t1l%dm%03d.wad", w, c);
if (wads.apply(rel) != null) {
out.add(rel);
}
}
}
for (String fe : new String[] {"wads/t6l6m001.wad", "wads/t6l6m002.wad"}) {
if (wads.apply(fe) != null) {
out.add(fe);
}
}
return out;
}
/**
* One gallery entry. world 1..8 (0 = front end), kind: figure (skinned, animated) or object
* (rigid); w/h/d: posed model size in model units; code: entry of the display type in the
* merged STPC.
*/
public record Entry(
String type, String level, int world, String category, boolean figure, double w, double h, double d) {
public String displayName() {
return PREFIX + type;
}
}
/** A type left out and why. */
public record Skip(String type, String level, String reason) {}
/** Scan result: entries in gallery order and the skipped types. */
public record Scan(List<Entry> entries, List<Skip> skipped) {}
/** Base level with the display types merged in. */
public record Merged(byte[] wad, Map<String, Integer> codes, StpcMerge.Stats stats) {}
private record Pending(Entry entry, String source, int code, String key) {}
private record SourceLevel(String rel, Level level, byte[] stpc, StpcMerge.Analysis analysis) {}
private final Function<String, byte[]> wads;
private final List<Pending> pending = new ArrayList<>();
private final Map<String, SourceLevel> sources = new LinkedHashMap<>();
private final List<Skip> skipped = new ArrayList<>();
private Gallery(Function<String, byte[]> wads) {
this.wads = wads;
}
/** Scans the CD and imports the display types into base. */
public static Gallery build(Function<String, byte[]> wads) {
Gallery g = new Gallery(wads);
Set<String> seenNames = new HashSet<>();
Map<String, String> seenLooks = new HashMap<>();
for (String rel : levels(wads)) {
g.scanLevel(rel, seenNames, seenLooks);
}
return g;
}
public Scan scan() {
List<Entry> es = new ArrayList<>();
for (Pending p : pending) {
es.add(p.entry);
}
return new Scan(es, List.copyOf(skipped));
}
private static int worldOf(String rel) {
String f = rel.substring(rel.lastIndexOf('/') + 1);
return f.startsWith("t1l") ? f.charAt(3) - '0' : 0;
}
private static String base(String rel) {
String f = rel.substring(rel.lastIndexOf('/') + 1);
return f.substring(0, f.length() - 4);
}
private void scanLevel(String rel, Set<String> seenNames, Map<String, String> seenLooks) {
Level lv = Level.parse(wads.apply(rel));
byte[] st = lv.wad().require("STPC");
StpcImage img = new StpcImage(st, lv.wfpc());
TreeSet<Integer> roots = new TreeSet<>();
Map<Integer, String> first = new LinkedHashMap<>();
for (MapChunk.Strat s : lv.map().strats) {
roots.add(s.code());
first.putIfAbsent(s.code(), Strats.nameOr(st, s.code(), img.codeStart, "?"));
}
StpcMerge.Analysis an = new StpcMerge.Analysis(img, roots);
StratModels sm = new StratModels(st, lv.wfpc());
TextMerge.Textures tex = TextMerge.Textures.of(lv.wad().require("TEXT"));
String lvBase = base(rel);
List<String> sources = new ArrayList<>();
List<Entry> entries = new ArrayList<>();
List<String> keys = new ArrayList<>();
// types that set their own model first, so they win the look deduplication against
// spawners and cameras that only show a sub-strat's model
List<Map.Entry<Integer, String>> order = new ArrayList<>();
for (var e : first.entrySet()) {
if (sm.modelOf(e.getKey()) >= 0) {
order.add(e);
}
}
for (var e : first.entrySet()) {
if (sm.modelOf(e.getKey()) < 0) {
order.add(e);
}
}
for (var e : order) {
String name = e.getValue().replace("(spawn)", "");
if (!name.matches("[A-Za-z_][A-Za-z0-9_]*") || !seenNames.add(name)) {
continue;
}
int code = e.getKey();
int model = sm.modelOf(code);
if (model < 0) {
String cat = ObjectCatalog.category(name);
boolean logic = cat.equals("camera") || cat.equals("trigger") || name.matches("(?i).*cutscene.*");
model = logic ? -1 : firstModel(an, code);
}
if (model < 0) {
skipped.add(new Skip(name, lvBase, "no model of its own (logic, trigger, camera, sound or effect)"));
continue;
}
Model m = sm.models.get(model);
boolean skinned = StratModels.skinned(m);
int anim = -1;
if (skinned) {
anim = animFor(an, sm, code, m);
if (anim < 0) {
skipped.add(new Skip(name, lvBase, "skinned model without a matching animation"));
continue;
}
}
if (m.faces.isEmpty()) {
skipped.add(new Skip(name, lvBase, "empty model"));
continue;
}
String look = look(img, tex, model, anim);
String dup = seenLooks.putIfAbsent(look, name);
if (dup != null) {
skipped.add(new Skip(name, lvBase, "looks exactly like " + dup));
continue;
}
sources.add(source(name, model, anim));
String cat = ObjectCatalog.category(name);
entries.add(new Entry(name, lvBase, worldOf(rel), cat, skinned, 0, 0, 0));
keys.add(look);
}
if (entries.isEmpty()) {
return;
}
// append the display units to the source level's code region
byte[] d = st;
List<Integer> codes = new ArrayList<>();
for (int i = 0; i < sources.size(); i++) {
StpcImage cur = new StpcImage(d, lv.wfpc());
int base = d.length + 4;
StratAssembler.Result r =
StratAssembler.assemble(StratCompiler.compile(sources.get(i)), StratListing.layout(cur, base));
byte[] words = r.bytes();
byte[] nd = Arrays.copyOf(d, d.length + 4 + words.length);
putI32(nd, d.length, words.length / 4);
System.arraycopy(words, 0, nd, d.length + 4, words.length);
putI32(nd, cur.codeStart, Bytes.i32(nd, cur.codeStart) + 1);
d = nd;
codes.add(base + r.labels().get(entries.get(i).displayName()));
}
TreeSet<Integer> roots2 = new TreeSet<>(roots);
roots2.addAll(codes);
StpcImage img2 = new StpcImage(d, lv.wfpc());
StpcMerge.Analysis an2 = new StpcMerge.Analysis(img2, roots2);
if (!an2.code.errors.isEmpty()) {
throw new IllegalStateException(rel + ": display units do not decode: " + an2.code.errors.get(0));
}
StratModels sm2 = new StratModels(d, lv.wfpc());
for (int i = 0; i < entries.size(); i++) {
Entry e = entries.get(i);
int code = codes.get(i);
int mi = sm2.modelOf(code);
Model m = sm2.models.get(mi);
Model posed = e.figure() ? sm2.posed(code, mi) : m;
double[] b = bounds(posed == null ? m : posed);
Entry sized = new Entry(
e.type(), e.level(), e.world(), e.category(), e.figure(), b[3] - b[0], b[4] - b[1], b[5] - b[2]);
pending.add(new Pending(sized, rel, code, keys.get(i)));
}
sources.clear();
this.sources.put(rel, new SourceLevel(rel, lv, d, an2));
}
/** Display strat: model, looping animation (skinned models), nothing else. */
static String source(String name, int model, int anim) {
StringBuilder b = new StringBuilder();
b.append("strat ").append(PREFIX).append(name).append(" {\n");
b.append(" setModel(model:").append(model).append(")\n");
if (anim >= 0) {
b.append(" animPlay(anim:").append(anim).append(")\n");
}
b.append(" loop {\n");
if (anim >= 0) {
b.append(" if (!self.animPlaying) {\n");
b.append(" animPlay(anim:").append(anim).append(")\n");
b.append(" }\n");
}
b.append(" yield\n }\n}\n");
return b.toString();
}
/** First model set by any unit the type reaches (types that show their model through sub-strats). */
private static int firstModel(StpcMerge.Analysis an, int code) {
StpcMerge.Closure c = StpcMerge.closure(an, code, null);
int eu = an.img.unitOf(code);
List<Integer> units = new ArrayList<>();
units.add(eu);
for (int u : c.units()) {
if (u != eu) {
units.add(u);
}
}
for (int u : units) {
for (StratCode.Site s : an.code.addrSites) {
if (an.img.unitOf(s.offset()) == u && an.img.modelAt(s.value()) >= 0) {
int next = s.offset() + 4;
if (next + 4 <= an.img.data.length && (Bytes.u32L(an.img.data, next) & 0xffff) == 0x54) {
return an.img.modelAt(s.value());
}
}
}
}
return -1;
}
/** Animation set for a skinned model: first one of the type's code with the model's bone count. */
private static int animFor(StpcMerge.Analysis an, StratModels sm, int code, Model m) {
int bones = m.extra.length / 2;
StpcMerge.Closure c = StpcMerge.closure(an, code, null);
int eu = an.img.unitOf(code);
List<Integer> units = new ArrayList<>();
units.add(eu);
for (int u : c.units()) {
if (u != eu) {
units.add(u);
}
}
for (int u : units) {
for (StratCode.Site s : an.code.addrSites) {
if (an.img.unitOf(s.offset()) != u || s.offset() < code && u == eu) {
continue;
}
int a = an.img.animAt(s.value());
if (a >= 0 && usable(an.img.data, s.value(), bones)) {
return a;
}
}
}
for (int a = 0; a < an.img.anims.size(); a++) {
if (usable(an.img.data, an.img.anims.get(a).start(), bones)) {
return a;
}
}
return -1;
}
/** Bone-matrix animation set (no vertex deltas) with frames for the bone count. */
private static boolean usable(byte[] d, int h, int bones) {
return Bytes.u32(d, h + 24) == bones && Bytes.u32(d, h + 16) == 0 && Bytes.u32(d, h + 8) > 0;
}
/** Identity of the look: model bytes with face textures replaced by their pixels, plus the animation bytes. */
private static String look(StpcImage img, TextMerge.Textures tex, int model, int anim) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
StpcImage.Span sp = img.models.get(model);
byte[] b = Arrays.copyOfRange(img.data, sp.start(), sp.end());
Map<Integer, byte[]> texHash = new HashMap<>();
for (int o : faceTexOffsets(img.data, sp.start())) {
int t = Bytes.u16(img.data, o);
b[o - sp.start()] = 0;
b[o - sp.start() + 1] = 0;
md.update(texHash.computeIfAbsent(t, k -> texturePixels(tex, k)));
}
md.update(b);
if (anim >= 0) {
StpcImage.Span a = img.anims.get(anim);
md.update(img.data, a.start(), a.size());
}
return java.util.HexFormat.of().formatHex(md.digest());
} catch (NoSuchAlgorithmException e) {
throw new AssertionError(e);
}
}
private static byte[] texturePixels(TextMerge.Textures tex, int t) {
if (t < 0 || t >= tex.entries.size()) {
return new byte[] {(byte) t};
}
TextChunk.TextureEntry e = tex.entries.get(t);
if (e.page() < 0 || e.page() >= tex.pages.size()) {
return new byte[] {(byte) t};
}
TextChunk.Page p = tex.pages.get(e.page());
ByteArrayOutputStream o = new ByteArrayOutputStream();
o.write(p.flags);
int u0 = Math.min(e.u0(), e.u1()), u1 = Math.max(e.u0(), e.u1());
int v0 = Math.min(e.v0(), e.v1()), v1 = Math.max(e.v0(), e.v1());
for (int v = v0; v <= v1 && v < p.h; v++) {
for (int u = u0; u <= u1 && u < p.w; u++) {
int px = p.pixels[v * p.w + u];
o.write(px);
o.write(px >> 8);
}
}
return o.toByteArray();
}
/** Offsets of the u16 texture fields of a model's faces (STPC model header layout). */
private static int[] faceTexOffsets(byte[] d, int h) {
int nv = Bytes.u16(d, h + 0x6c), nf = Bytes.u16(d, h + 0x6e), nb = Bytes.u16(d, h + 0x86);
int f0 = h + 0x8c + nb * 2 + nv * 0x18;
int[] out = new int[nf];
for (int i = 0; i < nf; i++) {
out[i] = f0 + i * 0x1c + 8;
}
return out;
}
private static void putI32(byte[] d, int o, int v) {
ByteBuffer.wrap(d, o, 4).order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(v);
}
private static double[] bounds(Model m) {
double[] r = {1e9, 1e9, 1e9, -1e9, -1e9, -1e9};
for (Vertex v : m.verts) {
r[0] = Math.min(r[0], v.x());
r[1] = Math.min(r[1], v.y());
r[2] = Math.min(r[2], v.z());
r[3] = Math.max(r[3], v.x());
r[4] = Math.max(r[4], v.y());
r[5] = Math.max(r[5], v.z());
}
return r;
}
/**
* Merges the display types of the given entries into the base level: STPC, TEXT (new pages and
* entries), MAP strat codes relocated, plus one unplaced prototype record per display type so
* the level builder knows it.
*/
public Merged mergeInto(byte[] baseWad, Set<String> types) {
Level lv = Level.parse(baseWad);
Wad wad = lv.wad();
byte[] st = wad.require("STPC");
StpcImage ti = new StpcImage(st, lv.wfpc());
TreeSet<Integer> roots = new TreeSet<>();
for (MapChunk.Strat s : lv.map().strats) {
roots.add(s.code());
}
StpcMerge.Analysis tgt = new StpcMerge.Analysis(ti, roots);
byte[] text = wad.require("TEXT");
TextMerge.Textures tex = TextMerge.Textures.of(text);
int origTail = 8 + 8 * tex.entries.size();
for (TextMerge.Anim a : tex.anims) {
origTail += 3 + a.data().length;
}
List<SoundMerge.Sample> snd = SoundMerge.parse(wad.get("SMPC"));
DialogMerge.Texts texts = wad.get("LGPC") == null ? null : DialogMerge.Texts.parse(wad.get("LGPC"));
DialogMerge.Speech speech = DialogMerge.Speech.parse(wad.get("SRPC"));
Map<String, StpcMerge.Source> srcs = new HashMap<>();
List<StpcMerge.Request> reqs = new ArrayList<>();
List<Pending> used = new ArrayList<>();
for (Pending p : pending) {
if (!types.contains(p.entry.type())) {
continue;
}
StpcMerge.Source s = srcs.computeIfAbsent(p.source, k -> {
SourceLevel sl = sources.get(k);
Wad w = sl.level.wad();
return new StpcMerge.Source(
base(k),
sl.analysis,
TextMerge.Textures.of(w.require("TEXT")),
SoundMerge.parse(w.get("SMPC")),
w.get("LGPC") == null ? null : DialogMerge.Texts.parse(w.get("LGPC")),
DialogMerge.Speech.parse(w.get("SRPC")));
});
reqs.add(new StpcMerge.Request(s, p.code));
used.add(p);
}
StpcMerge.Result res = StpcMerge.merge(tgt, reqs, new StpcMerge.Target(tex, snd, texts, speech));
if (res.stats().addedSamples() != 0 || res.stats().addedTexts() != 0) {
throw new IllegalStateException("display types must not need sounds or texts");
}
Map<String, Integer> codes = new LinkedHashMap<>();
MapChunk map = lv.map();
List<MapChunk.Strat> recs = new ArrayList<>();
for (MapChunk.Strat s : map.strats) {
int[] a = s.toArray();
a[6] = res.target().map(s.code());
recs.add(MapChunk.Strat.of(a));
}
for (int i = 0; i < used.size(); i++) {
int code = res.entries().get(reqs.get(i));
codes.put(used.get(i).entry.type(), code);
// rx ry rz x y z code nParams paramIndex nVars slots typeFlags extra waypoint zero flags last
recs.add(MapChunk.Strat.of(new int[] {0, 0, 0, 0, 0, 0, code, 0, -1, 1, 0, 0, 0, -1, 0, 0, 0}));
}
map.strats.clear();
map.strats.addAll(recs);
Wad out = new Wad(wad.chunks());
out.set("STPC", res.stpc());
out.set("MAP", map.build());
out.set("TEXT", text(text, tex, origTail));
return new Merged(out.toBytes(), codes, res.stats());
}
/** TEXT chunk with the (extended) pages, entries and animations of tex; the rest is kept. */
private static byte[] text(byte[] orig, TextMerge.Textures tex, int origTailHead) {
TextChunk tc = TextChunk.parse(orig);
byte[] rest = Arrays.copyOfRange(tc.tail, origTailHead, tc.tail.length);
ByteBuffer b = ByteBuffer.allocate(8 + 8 * tex.entries.size() + tex.anims.size() * 64 + rest.length + 1024)
.order(java.nio.ByteOrder.LITTLE_ENDIAN);
b.putInt(tex.entries.size());
for (TextChunk.TextureEntry e : tex.entries) {
b.putShort((short) e.flags())
.put((byte) e.page())
.put((byte) e.page2())
.put((byte) e.u0())
.put((byte) e.u1())
.put((byte) e.v0())
.put((byte) e.v1());
}
b.putInt(tex.anims.size());
for (TextMerge.Anim a : tex.anims) {
b.putShort((short) a.texture()).put((byte) a.type()).put(a.data());
}
b.put(rest);
tc.pages.clear();
tc.pages.addAll(tex.pages);
tc.tail = Arrays.copyOf(b.array(), b.position());
return tc.build();
}
}
@@ -0,0 +1,115 @@
package com.eliasmueller.llamamod.mods.testworld;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* Packs the gallery into rows along +x: one block of rows per world (front end last), figures
* before objects, each item as wide as its model plus a gap. Rows are as deep as their biggest
* model; an aisle runs in front (towards −z) of every row.
*/
final class Layout {
/** Model units → game units (skinned models are posed by their bone matrices, rigid ones as stored). */
static final double SCALE = 1.0;
static final double GAP = 0.15;
static final double MIN_W = 0.3;
static final double MIN_D = 0.35;
static final double AISLE = 0.75;
record Slot(Gallery.Entry entry, double x, double z, double w, double d) {}
record Row(int world, double z, double depth, double x0, double x1, List<Slot> slots) {
/** Centre of the aisle in front of the row. */
double aisleZ() {
return z - depth / 2 - AISLE / 2;
}
}
private static final List<String> ORDER = List.of(
"character",
"player",
"enemy",
"creature",
"other",
"collectible",
"checkpoint",
"exit",
"mechanism",
"force",
"effect",
"trigger",
"camera");
private Layout() {}
static int rank(Gallery.Entry e) {
int i = ORDER.indexOf(e.category());
return (e.figure() ? 0 : 100) + (i < 0 ? ORDER.size() : i);
}
static double width(Gallery.Entry e) {
return Math.max(MIN_W, SCALE * e.w()) + GAP;
}
static double depth(Gallery.Entry e) {
return Math.max(MIN_D, SCALE * e.d());
}
/** Rows for the entries between x0 and x1, the first aisle starting at z0. */
static List<Row> pack(List<Gallery.Entry> entries, double x0, double x1, double z0) {
List<Integer> worlds = new ArrayList<>();
for (Gallery.Entry e : entries) {
int w = e.world() == 0 ? 99 : e.world();
if (!worlds.contains(w)) {
worlds.add(w);
}
}
worlds.sort(Integer::compare);
List<Row> rows = new ArrayList<>();
double front = z0;
for (int w : worlds) {
List<Gallery.Entry> group = new ArrayList<>();
for (Gallery.Entry e : entries) {
if ((e.world() == 0 ? 99 : e.world()) == w) {
group.add(e);
}
}
group.sort(Comparator.comparingInt(Layout::rank));
List<List<Gallery.Entry>> lines = new ArrayList<>();
List<Gallery.Entry> line = new ArrayList<>();
double x = x0;
for (Gallery.Entry e : group) {
double fw = width(e);
if (!line.isEmpty() && x + fw > x1) {
lines.add(line);
line = new ArrayList<>();
x = x0;
}
line.add(e);
x += fw;
}
if (!line.isEmpty()) {
lines.add(line);
}
for (List<Gallery.Entry> l : lines) {
double dep = 0;
for (Gallery.Entry e : l) {
dep = Math.max(dep, depth(e));
}
double z = front + AISLE + dep / 2;
List<Slot> slots = new ArrayList<>();
double cx = x0;
for (Gallery.Entry e : l) {
double fw = width(e);
slots.add(new Slot(e, cx + fw / 2, z, fw - GAP, depth(e)));
cx += fw;
}
rows.add(new Row(w == 99 ? 0 : w, z, dep, x0, cx, List.copyOf(slots)));
front = z + dep / 2;
}
}
return rows;
}
}
@@ -9,73 +9,51 @@ import com.eliasmueller.llamamod.levelbuild.PyRandom;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
/**
* Test world (port of levels/testwelt.py), replaces TestLevel chapter 1.
* Test world (based on levels/testwelt.py), replaces TestLevel chapter 1.
*
* <p>Options: {@code gallery=all|none|<comma-separated types>}, {@code start=plaza|row1..4|path1..4|
* enclosure|podium|parkour|pk1..pk5|goal|goal2} (llama start, for tests), {@code door=1,9,1} (exit
* parameters: tribe, world, chapter of the next level).
* <p>Options: {@code gallery=all|none|<comma-separated type names>}, {@code start=plaza|rowN|pathN|
* parkour|pk1..pk5|goal|goal2} (llama start, for tests; rowN = aisle in front of gallery row N,
* pathN = its west end), {@code door=1,9,1} (exit parameters: tribe, world, chapter of the next level).
*
* <p>Scale (measured in game): llama ~0.6 tall, runs ~1.1 units/s, jumps 0.35 high and ~1.3 far
* with a run-up. Steps ≤ 0.3 (without gap = stopper), gaps 0.3, otherwise stairs or ramps.
*
* <p>Layout: L-shaped island surrounded by sea (death plane, respawn at the checkpoint). Start
* plaza (x 15..21, z 12..19); object gallery south of it (4 rows: collectibles, crates/pots,
* characters, mechanics); animal enclosure east of the gallery with a viewing podium; parkour along
* z = 15.5 from the plaza's east edge to the goal platform (y 4) with the exit under a gate.
* <p>Layout: island surrounded by sea (death plane, respawn at the checkpoint). Start plaza (x
* 15..21, z 12..19); south of it the gallery: every object model of the game as a motionless
* display strat ({@link Gallery}), one block of rows per world (post colour and the number of
* blocks on the post = world, none = front end), figures before objects; parkour along z = 15.5 from
* the plaza's east edge to the goal platform (y 4) with the exit under a gate.
*/
public final class TestWorld implements LevelScript {
/** Gallery rows: category, post colour, types ("Name" or "Name#variant"). */
private static final List<Row> GALLERY = List.of(
new Row(
"collectibles",
"yellow",
List.of(
"Coin",
"ChargeBooster",
"RubyIdol",
"HealthFull",
"HealthGenerator",
"ChacaChargePotion",
"Secret")),
new Row(
"crates/pots",
"grey",
List.of(
"SmashPot#0",
"SmashPot#1",
"Jar",
"ObjectDontDropInBox",
"WaterReboundPoints",
"CheatTeleport")),
new Row(
"characters",
"blue",
List.of("TipoVillage", "ChacaVillage", "PachaGivingInfo", "KidOnLlama", "Wompy")),
new Row(
"mechanics",
"red",
List.of(
"PressurePadDoor",
"RubyIdolDoor",
"SwitchPlatformPadVillage",
"AppearingPlatformsVillage",
"RisingColumns",
"SinkingStatue",
"FallingTree",
"CheckPointPost")));
private static final double GX0 = 16.6;
private static final double GX1 = 43.0;
/** North edge of the gallery (first aisle). */
private static final double GZ0 = 20.5;
private static final List<String> ENCLOSURE =
List.of("Bunny", "Bunny", "Ladybird", "FlockOfBirds", "VillagerHerder");
private static final double PEDESTAL = 0.08;
/** Yaw of the gallery models: facing the aisle (−z). */
private static final double FACING = 180;
/** Post colour per world (index 0 = front end). */
private static final String[] WORLD_COLOURS = {
LevelBuilder.rgb(235, 235, 235),
"yellow",
LevelBuilder.rgb(60, 150, 60),
LevelBuilder.rgb(70, 170, 200),
LevelBuilder.rgb(40, 90, 40),
LevelBuilder.rgb(150, 150, 170),
"red",
LevelBuilder.rgb(130, 70, 160),
"blue",
"grey"
};
private record Row(String category, String colour, List<String> types) {}
private static final double GX0 = 17.4;
private static final double DX = 1.9;
private static final double[] ROW_Z = {22.5, 25.2, 27.9, 30.6};
/** Collision material "exit floor": LevelDoor only fires while the llama stands on it in the door's cell. */
private static final int DOOR_MAT = 16;
/** Offset of the parkour against its first version (moved only, sizes unchanged). */
@@ -83,6 +61,9 @@ public final class TestWorld implements LevelScript {
private final List<String> placed = new ArrayList<>();
private final List<String> skipped = new ArrayList<>();
private List<Gallery.Entry> gallery = List.of();
private List<Gallery.Skip> notShown = List.of();
private final List<Layout.Row> rows = new ArrayList<>();
@Override
public String levelName() {
@@ -94,22 +75,73 @@ public final class TestWorld implements LevelScript {
return 7;
}
/** Gallery types placed in the last build (original type names). */
public List<String> placed() {
return placed;
}
/** Selected types that are not in the gallery (unknown names). */
public List<String> skipped() {
return skipped;
}
/** Gallery entries of the last build in placement order. */
public List<Gallery.Entry> gallery() {
return gallery;
}
/** Types of the game that have no gallery entry, with the reason. */
public List<Gallery.Skip> notShown() {
return notShown;
}
public String report() {
String s = "Gallery: " + placed.size() + " objects: " + String.join(", ", placed);
if (!skipped.isEmpty()) {
s += "\nnot in the base level: " + String.join(", ", skipped);
s += "\nunknown types: " + String.join(", ", skipped);
}
return s;
}
/** Imports the gallery's display types into the base level, then builds as usual. */
@Override
public byte[] buildWad(byte[] baseWad, Function<String, byte[]> wads, Map<String, String> opts) {
String sel = opts == null ? "all" : opts.getOrDefault("gallery", "all");
byte[] base = baseWad;
gallery = List.of();
notShown = List.of();
skipped.clear();
if (!sel.equals("none")) {
Gallery g = Gallery.build(wads);
Gallery.Scan sc = g.scan();
List<Gallery.Entry> chosen = new ArrayList<>();
Set<String> names = new LinkedHashSet<>();
for (Gallery.Entry e : sc.entries()) {
if (selected(sel, e.type())) {
chosen.add(e);
names.add(e.type());
}
}
if (!sel.equals("all")) {
for (String n : sel.split(",")) {
if (!names.contains(n.trim())) {
skipped.add(n.trim());
}
}
}
if (!chosen.isEmpty()) {
base = g.mergeInto(baseWad, names).wad();
}
gallery = List.copyOf(chosen);
notShown = sc.skipped();
}
List<String> keep = List.copyOf(skipped);
byte[] out = LevelScript.super.buildWad(base, wads, opts);
skipped.clear();
skipped.addAll(keep);
return out;
}
private static double x(double v) {
return v + PX;
}
@@ -125,8 +157,11 @@ public final class TestWorld implements LevelScript {
@Override
public void build(LevelBuilder l) {
placed.clear();
skipped.clear();
String sel = l.opt("gallery", "all");
rows.clear();
rows.addAll(Layout.pack(gallery, GX0, GX1, GZ0));
double gz1 = rows.isEmpty()
? 32
: Math.max(32, rows.getLast().z() + rows.getLast().depth() / 2 + 0.8);
PyRandom rnd = new PyRandom(3);
l.sun.azimuth = 220;
l.sun.elevation = 48;
@@ -135,13 +170,13 @@ public final class TestWorld implements LevelScript {
l.sun.ground = new double[] {0.27, 0.25, 0.21};
// ---------------------------------------------------------------- island + sea
// main part (plaza, gallery, enclosure) plus a narrow strip under the end of the parkour
// main part (plaza, gallery) plus a narrow strip under the end of the parkour
double x0 = 11;
double z0 = 9.5;
double x1 = 43.5;
double z1 = 33;
double z1 = gz1 + 1;
double xb = 49;
l.sea(0, 0, 60, 42, -1.5, new double[] {x0 - 4, z0 - 4, 54, z1 + 4});
l.sea(0, 0, 60, z1 + 9, -1.5, new double[] {x0 - 4, z0 - 4, 54, z1 + 4});
l.box(
x0,
z0,
@@ -164,8 +199,7 @@ public final class TestWorld implements LevelScript {
l.floor(x0, 20.5, 14.5, z1, 0, "grass");
l.floor(21, 12, xb, 20.5, 0, "grass2");
l.floor(15, 19, 21, 20.5, 0, "grass");
l.floor(32.5, 20.5, x1, 32, 0, "grass");
l.floor(14.5, 32, x1, z1, 0, "grass");
l.floor(14.5, gz1, x1, z1, 0, "grass");
// ---------------------------------------------------------------- start plaza (6 x 7)
l.box(15, 12, 21, 19, -0.25, 0.0, o().top("stone").side("stone_edge"));
@@ -209,86 +243,52 @@ public final class TestWorld implements LevelScript {
}
// ---------------------------------------------------------------- gallery (south of the plaza)
l.box(14.5, 20.5, 32.5, 32, -0.2, 0.0, o().top(NONE).side("stone").solid(false));
l.floor(14.5, 20.5, 32.5, 32, 0, "grass");
l.floor(15.6, 20.5, 16.3, 32, 0.02, "sand", o().coll(false));
for (int r = 0; r < GALLERY.size(); r++) {
Row row = GALLERY.get(r);
double zr = ROW_Z[r];
double xe = GX0 + (row.types.size() - 1) * DX + 0.7;
l.floor(16.3, zr - 1.3, xe, zr - 0.7, 0.02, "sand", o().coll(false));
// row post in the category colour at the start of the path
l.pillar(15.05, zr - 0.3, 0, 1.1, 0.25, o().side(row.colour).top(row.colour));
l.box(
14.85,
zr - 0.5,
15.25,
zr - 0.1,
1.1,
1.25,
o().top(row.colour).side("wood_dark").rot(0));
double x = GX0;
for (String t : row.types) {
int hash = t.indexOf('#');
String name = hash < 0 ? t : t.substring(0, hash);
int var = hash < 0 ? 0 : Integer.parseInt(t.substring(hash + 1));
l.box(
x - 0.45,
zr - 0.45,
x + 0.45,
zr + 0.45,
0,
0.08,
o().top("stone_green").side("stone_edge").rot(0).sideColl(false));
if (selected(sel, name)) {
if (l.hasType(name) && var < l.variants(name)) {
l.strat(name, x, zr, var);
placed.add(var == 0 ? name : name + "#" + var);
} else {
skipped.add(name);
}
l.box(14.5, 20.5, x1, gz1, -0.2, 0.0, o().top(NONE).side("stone").solid(false));
l.floor(14.5, 20.5, x1, gz1, 0, "grass");
l.floor(15.6, 20.5, 16.3, gz1, 0.02, "sand", o().coll(false));
int[] count = new int[10];
for (Layout.Row row : rows) {
String colour = WORLD_COLOURS[row.world()];
double az = row.aisleZ();
l.floor(16.3, az - 0.3, row.x1(), az + 0.3, 0.02, "sand", o().coll(false));
// row post in the world colour, one block per world number on top (first row of a world)
l.pillar(15.05, row.z(), 0, 1.1, 0.25, o().side(colour).top(colour));
if (count[row.world()]++ == 0) {
for (int k = 0; k < row.world(); k++) {
double y = 1.1 + k * 0.16;
l.box(
14.97,
row.z() - 0.08,
15.13,
row.z() + 0.08,
y,
y + 0.12,
o().top(colour).side(colour).rot(0));
}
x += DX;
}
for (Layout.Slot sl : row.slots()) {
l.box(
sl.x() - sl.w() / 2,
sl.z() - sl.d() / 2,
sl.x() + sl.w() / 2,
sl.z() + sl.d() / 2,
0,
PEDESTAL,
o().top("stone_green").side("stone_edge").rot(0).sideColl(false));
l.strat(
sl.entry().displayName(),
sl.x(),
sl.z(),
PEDESTAL,
FACING,
null,
LevelBuilder.Waypoints.NONE,
0,
false);
placed.add(sl.entry().type());
}
}
// ---------------------------------------------------------------- enclosure (animals), east of the gallery
double gx0 = 33;
double gz0 = 20.8;
double gx1 = 42;
double gz1 = 30;
l.floor(gx0, gz0, gx1, gz1, 0.015, "grass_striped", o().coll(false));
l.fence(
List.of(
new double[] {gx0, gz0},
new double[] {gx1, gz0},
new double[] {gx1, gz1},
new double[] {gx0, gz1},
new double[] {gx0, gz0 + 3.2}),
0,
0.45,
0.07,
1.0);
tree(l, gx1 - 1.3, gz1 - 1.3, 1.8, 1.1);
tree(l, gx1 - 1.2, gz0 + 1.2, 1.4, 1.1);
for (int i = 0; i < ENCLOSURE.size(); i++) {
String name = ENCLOSURE.get(i);
if (selected(sel, name)) {
l.strat(name, gx0 + 1.8 + (i % 3) * 2.7, gz0 + 2.3 + (i / 3) * 4.5);
placed.add(name);
}
}
// viewing podium (stairs up, between rows 2 and 3; enclosure entrance north of it)
l.stairs(31.2, 25.3, 32.4, 26.9, 0, 0.6, 3, "+x", o().top("wood_grain").side("wood_dark"));
l.box(
32.4,
24.7,
33.0,
27.5,
0,
0.6,
o().top("wood_grain").side("wood_dark").rot(0));
// ---------------------------------------------------------------- parkour
// All on the line z = 15.5 heading east, so it can be run without turning the camera. Two
// "stopper" steps (+0.3, no gap) halt the llama at a fixed spot: jump from standing, then
@@ -445,11 +445,11 @@ public final class TestWorld implements LevelScript {
{12.3, 22.5},
{12.6, 27},
{12.2, 31.5},
{17.5, 32.6},
{25, 32.7},
{36, 32.5},
{42.6, 32.3},
{42.8, 23},
{17.5, z1 - 0.4},
{25, z1 - 0.3},
{36, z1 - 0.5},
{42.6, z1 - 0.7},
{12.4, z1 - 1.2},
{29, 10.5},
{35, 10.4},
{41, 10.6},
@@ -460,8 +460,9 @@ public final class TestWorld implements LevelScript {
double c = rnd.uniform(0.9, 1.3);
tree(l, p[0], p[1], h, c);
}
for (double[] p :
new double[][] {{13.2, 25}, {21.5, 32.5}, {30.5, 32.6}, {39, 32.6}, {26.5, 19.6}, {39.5, 19.4}}) {
for (double[] p : new double[][] {
{13.2, 25}, {21.5, z1 - 0.5}, {30.5, z1 - 0.4}, {39, z1 - 0.4}, {26.5, 19.6}, {39.5, 19.4}
}) {
double r = rnd.uniform(0.3, 0.5);
l.box(
p[0] - r,
@@ -473,8 +474,12 @@ public final class TestWorld implements LevelScript {
o().top("stone_moss").side("stone_blue"));
}
Object[][] beds = {
{11.4, 19.4, 2.0, 1.8, "flowers"}, {14.8, 32.1, 2.0, 0.8, "flowers2"}, {33.5, 32.1, 2.5, 0.8, "flowers3"},
{31.0, 11.0, 2.0, 1.0, "flowers2"}, {40.5, 18.6, 2.0, 1.6, "flowers"}, {25.0, 10.4, 1.5, 1.4, "flowers3"}
{11.4, 19.4, 2.0, 1.8, "flowers"},
{14.8, z1 - 0.9, 2.0, 0.8, "flowers2"},
{33.5, z1 - 0.9, 2.5, 0.8, "flowers3"},
{31.0, 11.0, 2.0, 1.0, "flowers2"},
{40.5, 18.6, 2.0, 1.6, "flowers"},
{25.0, 10.4, 1.5, 1.4, "flowers3"}
};
for (Object[] b : beds) {
double bx = (Double) b[0];
@@ -493,11 +498,10 @@ public final class TestWorld implements LevelScript {
starts.put("pk5", new double[] {x(46.0), 15.5, 90});
starts.put("goal", new double[] {x(49.0), 15.5, 90});
starts.put("goal2", new double[] {x(51.5), 17.8, 180});
starts.put("enclosure", new double[] {30.4, 26.1, 90});
starts.put("podium", new double[] {32.7, 26.1, 270});
for (int i = 0; i < ROW_Z.length; i++) {
starts.put("row" + (i + 1), new double[] {15.95, ROW_Z[i], 90});
starts.put("path" + (i + 1), new double[] {15.95, ROW_Z[i] - 1.0, 90});
for (int i = 0; i < rows.size(); i++) {
Layout.Row r = rows.get(i);
starts.put("row" + (i + 1), new double[] {(r.x0() + r.x1()) / 2, r.aisleZ(), 90});
starts.put("path" + (i + 1), new double[] {15.95, r.aisleZ(), 90});
}
String start = l.opt("start", "plaza");
double[] st = starts.get(start);
@@ -34,8 +34,8 @@ public final class TestWorldMod implements Mod {
@Override
public String description() {
return "Turns the hidden TestLevel (world 9, chapter 1) into a playground island: a gallery of every"
+ " object in the game, an animal enclosure and a parkour course with checkpoints. To reach it,"
+ " click \"Unlock TestLevel\" on the Save games tab.";
+ " 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.";
}
/** Before mods that rework every level (e.g. the debug wireframe), so they see the new level. */
@@ -8,7 +8,6 @@ import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.Wad;
import com.eliasmueller.llamamod.levelbuild.BrushRecorder;
import com.eliasmueller.llamamod.mods.testworld.TestWorld;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -17,15 +16,18 @@ import java.util.Map;
import org.junit.jupiter.api.Test;
/**
* The test world (levels/testwelt.py) as a brush project: examples/testwelt.llamalevel. Its build
* must equal the Test World mod's WAD except for the death plane model (generated instead of the
* village's model 0). -Dllamamod.writeExamples=true also writes the file into examples/.
* The original test world (levels/testwelt.py) as a brush project: examples/testwelt.llamalevel. Its
* build must equal the level builder's WAD of the same script except for the death plane model
* (generated instead of the village's model 0). -Dllamamod.writeExamples=true also writes the file
* into examples/.
*/
class TestWorldExampleTest {
private static byte[] reference;
static Project testWorld(Bases cd) throws IOException {
Project p = Project.create(cd, "new", Templates.DEFAULT_BASE);
Scene s = p.scene();
BrushRecorder.testWorld(s, cd.wad(Templates.DEFAULT_BASE), cd::fileOrNull, Map.of());
reference = BrushRecorder.testWorld(s, cd.wad(Templates.DEFAULT_BASE), cd::fileOrNull, Map.of());
s.settings().prop("targetChapter").setRaw(s.settings(), 2);
s.settings().prop("author").setRaw(s.settings(), "LlamaMod");
s.settings()
@@ -43,7 +45,7 @@ class TestWorldExampleTest {
Project p = testWorld(cd);
WadBuilder.Result r = WadBuilder.buildWithReport(p.scene());
assertTrue(r.warnings().isEmpty(), r.warnings().toString());
byte[] ref = new TestWorld().buildWad(cd.wad(Templates.DEFAULT_BASE), cd::fileOrNull, Map.of());
byte[] ref = reference;
Level a = Level.parse(r.wad());
Level b = Level.parse(ref);
assertEquals(b.models().size(), a.models().size(), "models");
@@ -29,13 +29,17 @@ public final class BrushRecorder {
this.sun = lb.sun;
}
/** Fills an empty new-level scene with the test world (levels/testwelt.py). */
public static void testWorld(Scene s, byte[] baseWad, Function<String, byte[]> cd, Map<String, String> opts) {
/**
* Fills an empty new-level scene with the original test world (levels/testwelt.py) and returns
* the WAD the level builder makes from the same calls (reference for the brush build).
*/
public static byte[] testWorld(Scene s, byte[] baseWad, Function<String, byte[]> cd, Map<String, String> opts) {
TestWorldBrushes tw = new TestWorldBrushes();
LevelBuilder lb = new LevelBuilder(baseWad, cd, tw.levelName(), tw.seed(), opts);
BrushRecorder r = new BrushRecorder(lb, s);
tw.build(r);
r.finish(tw.levelName(), tw.seed());
return lb.build();
}
public String opt(String key, String def) {
@@ -10,8 +10,8 @@ import java.util.List;
import java.util.Map;
/**
* Copy of mods.testworld.TestWorld (port of levels/testwelt.py) driving a {@link BrushRecorder}: generator of
* examples/testwelt.llamalevel.
* The original test world (port of levels/testwelt.py, before the Test World mod got its all-worlds
* gallery) driving a {@link BrushRecorder}: generator of examples/testwelt.llamalevel.
*
* <p>Options: {@code gallery=all|none|<comma-separated types>}, {@code start=plaza|row1..4|path1..4|
* enclosure|podium|parkour|pk1..pk5|goal|goal2} (llama start, for tests), {@code door=1,9,1} (exit
@@ -0,0 +1,138 @@
package com.eliasmueller.llamamod.mods.testworld;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.eliasmueller.llamamod.editor.render.StratModels;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.MapChunk;
import com.eliasmueller.llamamod.format.Strats;
import com.eliasmueller.llamamod.format.stpcmerge.StpcImage;
import com.eliasmueller.llamamod.format.stpcmerge.StratCode;
import com.eliasmueller.llamamod.format.strat.OpTable;
import com.eliasmueller.llamamod.format.strat.StratListing;
import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
import com.eliasmueller.llamamod.mods.GameData;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Function;
import org.junit.jupiter.api.Test;
/** The test world's gallery: every entry shows a model through a static display strat. */
class GalleryTest {
/** Everything a display strat may execute: name, model, animation restart, yield. */
private static final Set<String> STATIC_OPS =
Set.of("debugName", "address", "setModel", "animPlay", "pushProp", "not", "jz", "jmpYield", "end");
private static Function<String, byte[]> wads(Path cd) {
return rel -> {
try {
Path p = cd.resolve(rel);
return Files.exists(p) ? Files.readAllBytes(p) : null;
} catch (IOException e) {
return null;
}
};
}
@Test
void everyEntryHasAModelAndAStaticScript() throws IOException {
Path cd = GameData.cd();
TestWorld tw = new TestWorld();
byte[] wad = tw.buildWad(Files.readAllBytes(cd.resolve(LevelBuilder.BASE_WAD)), wads(cd), Map.of());
List<Gallery.Entry> entries = tw.gallery();
assertTrue(entries.size() > 200, "gallery entries: " + entries.size());
assertEquals(entries.size(), tw.placed().size());
Level lv = Level.parse(wad);
byte[] st = lv.wad().require("STPC");
StpcImage img = new StpcImage(st, lv.wfpc());
Map<String, Integer> codes = new HashMap<>();
Map<String, Integer> placed = new HashMap<>();
TreeSet<Integer> roots = new TreeSet<>();
for (MapChunk.Strat s : lv.map().strats) {
roots.add(s.code());
String n = Strats.nameOr(st, s.code(), img.codeStart, "?");
codes.put(n, s.code());
placed.merge(n, 1, Integer::sum);
if (n.startsWith(Gallery.PREFIX)) {
assertEquals(0, s.typeFlags(), n + ": no collision/physics block");
assertEquals(0, s.nParams(), n);
assertEquals(-1, s.waypoint(), n);
}
}
StratCode code = StratCode.analyse(img, roots);
assertTrue(code.errors.isEmpty(), code.errors.toString());
StratModels sm = new StratModels(st, lv.wfpc());
OpTable ops = OpTable.get();
Set<String> names = new HashSet<>();
for (Gallery.Entry e : entries) {
String n = e.displayName();
assertTrue(names.add(n), "duplicate " + n);
Integer c = codes.get(n);
assertTrue(c != null, n + " not placed");
assertEquals(1, placed.get(n), n + " placed once");
int mi = sm.modelOf(c);
assertTrue(mi >= 0, n + ": no model");
assertTrue(!sm.models.get(mi).faces.isEmpty(), n + ": empty model");
assertEquals(e.figure(), StratModels.skinned(sm.models.get(mi)), n + ": figure = skinned model");
if (e.figure()) {
assertTrue(sm.posed(c, mi) != null, n + ": skinned model without animation");
}
int unit = img.unitOf(c);
StratListing li = StratListing.of(img, code, unit, roots, StratListing.Names.NONE);
for (StratListing.Line l : li.lines) {
if (l.kind() == StratListing.Kind.INSTR) {
String m = ops.op(l.op()).name();
assertTrue(STATIC_OPS.contains(m), n + ": unexpected op " + m + " (" + l.text() + ")");
}
}
}
}
@Test
void rowsDoNotOverlap() throws IOException {
Path cd = GameData.cd();
Gallery g = Gallery.build(wads(cd));
List<Layout.Row> rows = Layout.pack(g.scan().entries(), 16.6, 43.0, 20.5);
double front = 20.5;
for (Layout.Row r : rows) {
assertTrue(r.z() - r.depth() / 2 >= front + Layout.AISLE - 1e-9, "aisle in front of row " + r);
double x = r.x0();
for (Layout.Slot s : r.slots()) {
double half = Math.max(s.w(), Layout.SCALE * s.entry().w()) / 2;
assertTrue(s.x() - half >= x - 1e-9, s.entry().type() + " overlaps its neighbour");
assertTrue(
Layout.SCALE * s.entry().d() <= r.depth() + 1e-9,
s.entry().type() + " deeper than the row");
x = s.x() + half;
}
assertTrue(x <= 43.0 + 1e-9 || r.slots().size() == 1, "row too long");
front = r.z() + r.depth() / 2;
}
}
@Test
void galleryOption() throws IOException {
Path cd = GameData.cd();
byte[] base = Files.readAllBytes(cd.resolve(LevelBuilder.BASE_WAD));
TestWorld none = new TestWorld();
byte[] w = none.buildWad(base, wads(cd), Map.of("gallery", "none"));
assertTrue(none.placed().isEmpty());
assertArrayEquals(
Level.parse(base).wad().require("STPC"), Level.parse(w).wad().require("STPC"), "nothing imported");
TestWorld some = new TestWorld();
some.buildWad(base, wads(cd), Map.of("gallery", "TipoVillage,Spider,NoSuchType"));
assertEquals(List.of("TipoVillage", "Spider"), some.placed());
assertEquals(List.of("NoSuchType"), some.skipped());
}
}
@@ -18,12 +18,17 @@ import java.util.Map;
import org.junit.jupiter.api.Test;
/**
* Generated levels vs. tools/levelbuild.py (needs -Dllamamod.cd). With -Dllamamod.lbref=DIR the
* files there are compared byte by byte (first difference reported), otherwise the SHA-256 of the
* Python output (python3 tools/levelbuild.py levels/testwelt.py OUT [--set start=parkour];
* levels/sprunglabor.py OUT --set spur=a; tools/png2cor.py ladebild.png OUT).
* Generated levels (needs -Dllamamod.cd). Jump lab and loading picture vs. the Python tools: with
* -Dllamamod.lbref=DIR the files there are compared byte by byte (first difference reported),
* otherwise the SHA-256 of the Python output (levels/sprunglabor.py OUT --set spur=a;
* tools/png2cor.py ladebild.png OUT). The test world has no Python counterpart any more (its
* all-worlds gallery is imported with format.stpcmerge): its SHA-256 pins the current output, so a
* change of the level (or of the cross-world merge) has to be confirmed here deliberately.
*/
class TestWorldGoldenTest {
private static final String TEST_WORLD_SHA = "786a8f9e907a6bbd22a08798e7eb1c07c2d8d96893fbbe6fdb37363c02b03643";
private static final String PARKOUR_SHA = "858f69883c04763b19ee98d23bef68df5563a093e1f37af864458286d98bcbc8";
private static final int GALLERY_SIZE = 253;
private static byte[] build(Path cd, LevelScript script, Map<String, String> opts) throws IOException {
byte[] base = Files.readAllBytes(cd.resolve(LevelBuilder.BASE_WAD));
@@ -68,14 +73,14 @@ class TestWorldGoldenTest {
void testWorldDefault() throws IOException {
TestWorld tw = new TestWorld();
byte[] out = build(GameData.cd(), tw, Map.of());
check(out, "testwelt.wad", "8cd6ff039e82a4fcbcf01e62c91c1931c685dd9907326349aedeb04c31d92c7b");
assertEquals(31, tw.placed().size());
assertEquals(TEST_WORLD_SHA, sha256(out), "test world SHA-256");
assertEquals(GALLERY_SIZE, tw.placed().size());
}
@Test
void testWorldParkourStart() throws IOException {
byte[] out = build(GameData.cd(), new TestWorld(), Map.of("start", "parkour"));
check(out, "testwelt_parkour.wad", "1ad12e03c23f267bba127aa57231295e2e681f6afbe2f1f7c286c2ae3c67bdf1");
assertEquals(PARKOUR_SHA, sha256(out), "test world (start=parkour) SHA-256");
}
@Test
@@ -126,9 +131,7 @@ class TestWorldGoldenTest {
public void progress(double fraction, String message) {}
});
assertEquals(List.of("T191.COR", "wads/T1L9M001.wad"), List.copyOf(written.keySet()));
assertEquals(
"8cd6ff039e82a4fcbcf01e62c91c1931c685dd9907326349aedeb04c31d92c7b",
sha256(written.get("wads/T1L9M001.wad")));
assertEquals(TEST_WORLD_SHA, sha256(written.get("wads/T1L9M001.wad")));
assertEquals(
"c55e3babf4855540f14daa60b979a2534e3732037854d9ad785f69f370d095d0", sha256(written.get("T191.COR")));
}