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
+1048 -170

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",
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 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",
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",
LevelBuilder.rgb(60, 150, 60),
LevelBuilder.rgb(70, 170, 200),
LevelBuilder.rgb(40, 90, 40),
LevelBuilder.rgb(150, 150, 170),
"red",
List.of(
"PressurePadDoor",
"RubyIdolDoor",
"SwitchPlatformPadVillage",
"AppearingPlatformsVillage",
"RisingColumns",
"SinkingStatue",
"FallingTree",
"CheckPointPost")));
LevelBuilder.rgb(130, 70, 160),
"blue",
"grey"
};
private static final List<String> ENCLOSURE =
List.of("Bunny", "Bunny", "Ladybird", "FlockOfBirds", "VillagerHerder");
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,85 +243,51 @@ 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.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.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));
14.97,
row.z() - 0.08,
15.13,
row.z() + 0.08,
y,
y + 0.12,
o().top(colour).side(colour).rot(0));
}
}
for (Layout.Slot sl : row.slots()) {
l.box(
x - 0.45,
zr - 0.45,
x + 0.45,
zr + 0.45,
sl.x() - sl.w() / 2,
sl.z() - sl.d() / 2,
sl.x() + sl.w() / 2,
sl.z() + sl.d() / 2,
0,
0.08,
PEDESTAL,
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);
}
}
x += DX;
}
}
// ---------------------------------------------------------------- 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}),
l.strat(
sl.entry().displayName(),
sl.x(),
sl.z(),
PEDESTAL,
FACING,
null,
LevelBuilder.Waypoints.NONE,
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);
false);
placed.add(sl.entry().type());
}
}
// 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
@@ -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")));
}