diff --git a/docs/manual/18-mods.md b/docs/manual/18-mods.md index 6f58cb5..088bed4 100644 --- a/docs/manual/18-mods.md +++ b/docs/manual/18-mods.md @@ -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` diff --git a/notes/img/mods/testworld_condor.jpg b/notes/img/mods/testworld_condor.jpg new file mode 100644 index 0000000..d1e237c Binary files /dev/null and b/notes/img/mods/testworld_condor.jpg differ diff --git a/notes/img/mods/testworld_river.jpg b/notes/img/mods/testworld_river.jpg new file mode 100644 index 0000000..14ba890 Binary files /dev/null and b/notes/img/mods/testworld_river.jpg differ diff --git a/notes/img/mods/testworld_top.jpg b/notes/img/mods/testworld_top.jpg new file mode 100644 index 0000000..ea0fc1c Binary files /dev/null and b/notes/img/mods/testworld_top.jpg differ diff --git a/notes/img/mods/testworld_village.jpg b/notes/img/mods/testworld_village.jpg new file mode 100644 index 0000000..3a930df Binary files /dev/null and b/notes/img/mods/testworld_village.jpg differ diff --git a/notes/mod-testworld.md b/notes/mod-testworld.md new file mode 100644 index 0000000..873b6f3 --- /dev/null +++ b/notes/mod-testworld.md @@ -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. diff --git a/src/main/java/com/eliasmueller/llamamod/mods/testworld/Gallery.java b/src/main/java/com/eliasmueller/llamamod/mods/testworld/Gallery.java new file mode 100644 index 0000000..44e5590 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/mods/testworld/Gallery.java @@ -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 levels(Function wads) { + List 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 entries, List skipped) {} + + /** Base level with the display types merged in. */ + public record Merged(byte[] wad, Map 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 wads; + private final List pending = new ArrayList<>(); + private final Map sources = new LinkedHashMap<>(); + private final List skipped = new ArrayList<>(); + + private Gallery(Function wads) { + this.wads = wads; + } + + /** Scans the CD and imports the display types into base. */ + public static Gallery build(Function wads) { + Gallery g = new Gallery(wads); + Set seenNames = new HashSet<>(); + Map seenLooks = new HashMap<>(); + for (String rel : levels(wads)) { + g.scanLevel(rel, seenNames, seenLooks); + } + return g; + } + + public Scan scan() { + List 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 seenNames, Map seenLooks) { + Level lv = Level.parse(wads.apply(rel)); + byte[] st = lv.wad().require("STPC"); + StpcImage img = new StpcImage(st, lv.wfpc()); + TreeSet roots = new TreeSet<>(); + Map 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 sources = new ArrayList<>(); + List entries = new ArrayList<>(); + List 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> 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 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 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 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 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 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 types) { + Level lv = Level.parse(baseWad); + Wad wad = lv.wad(); + byte[] st = wad.require("STPC"); + StpcImage ti = new StpcImage(st, lv.wfpc()); + TreeSet 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 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 srcs = new HashMap<>(); + List reqs = new ArrayList<>(); + List 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 codes = new LinkedHashMap<>(); + MapChunk map = lv.map(); + List 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(); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/mods/testworld/Layout.java b/src/main/java/com/eliasmueller/llamamod/mods/testworld/Layout.java new file mode 100644 index 0000000..6ddc491 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/mods/testworld/Layout.java @@ -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 slots) { + /** Centre of the aisle in front of the row. */ + double aisleZ() { + return z - depth / 2 - AISLE / 2; + } + } + + private static final List 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 pack(List entries, double x0, double x1, double z0) { + List 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 rows = new ArrayList<>(); + double front = z0; + for (int w : worlds) { + List 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> lines = new ArrayList<>(); + List 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 l : lines) { + double dep = 0; + for (Gallery.Entry e : l) { + dep = Math.max(dep, depth(e)); + } + double z = front + AISLE + dep / 2; + List 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; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/mods/testworld/TestWorld.java b/src/main/java/com/eliasmueller/llamamod/mods/testworld/TestWorld.java index e265511..80bbfd8 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/testworld/TestWorld.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/testworld/TestWorld.java @@ -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. * - *

Options: {@code gallery=all|none|}, {@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). + *

Options: {@code gallery=all|none|}, {@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). * *

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. * - *

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. + *

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 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 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 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 placed = new ArrayList<>(); private final List skipped = new ArrayList<>(); + private List gallery = List.of(); + private List notShown = List.of(); + private final List 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 placed() { return placed; } + /** Selected types that are not in the gallery (unknown names). */ public List skipped() { return skipped; } + /** Gallery entries of the last build in placement order. */ + public List gallery() { + return gallery; + } + + /** Types of the game that have no gallery entry, with the reason. */ + public List 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 wads, Map 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 chosen = new ArrayList<>(); + Set 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 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); diff --git a/src/main/java/com/eliasmueller/llamamod/mods/testworld/TestWorldMod.java b/src/main/java/com/eliasmueller/llamamod/mods/testworld/TestWorldMod.java index 2cd78d5..c900b22 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/testworld/TestWorldMod.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/testworld/TestWorldMod.java @@ -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. */ diff --git a/src/test/java/com/eliasmueller/llamamod/editor/io/TestWorldExampleTest.java b/src/test/java/com/eliasmueller/llamamod/editor/io/TestWorldExampleTest.java index 10710b1..c98f8b6 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/io/TestWorldExampleTest.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/io/TestWorldExampleTest.java @@ -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"); diff --git a/src/test/java/com/eliasmueller/llamamod/levelbuild/BrushRecorder.java b/src/test/java/com/eliasmueller/llamamod/levelbuild/BrushRecorder.java index 820556d..2307bc0 100644 --- a/src/test/java/com/eliasmueller/llamamod/levelbuild/BrushRecorder.java +++ b/src/test/java/com/eliasmueller/llamamod/levelbuild/BrushRecorder.java @@ -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 cd, Map 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 cd, Map 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) { diff --git a/src/test/java/com/eliasmueller/llamamod/levelbuild/TestWorldBrushes.java b/src/test/java/com/eliasmueller/llamamod/levelbuild/TestWorldBrushes.java index a58ed09..dc1e601 100644 --- a/src/test/java/com/eliasmueller/llamamod/levelbuild/TestWorldBrushes.java +++ b/src/test/java/com/eliasmueller/llamamod/levelbuild/TestWorldBrushes.java @@ -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. * *

Options: {@code gallery=all|none|}, {@code start=plaza|row1..4|path1..4| * enclosure|podium|parkour|pk1..pk5|goal|goal2} (llama start, for tests), {@code door=1,9,1} (exit diff --git a/src/test/java/com/eliasmueller/llamamod/mods/testworld/GalleryTest.java b/src/test/java/com/eliasmueller/llamamod/mods/testworld/GalleryTest.java new file mode 100644 index 0000000..3d2e338 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/mods/testworld/GalleryTest.java @@ -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 STATIC_OPS = + Set.of("debugName", "address", "setModel", "animPlay", "pushProp", "not", "jz", "jmpYield", "end"); + + private static Function 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 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 codes = new HashMap<>(); + Map placed = new HashMap<>(); + TreeSet 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 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 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()); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/mods/testworld/TestWorldGoldenTest.java b/src/test/java/com/eliasmueller/llamamod/mods/testworld/TestWorldGoldenTest.java index d39e3d1..d46d591 100644 --- a/src/test/java/com/eliasmueller/llamamod/mods/testworld/TestWorldGoldenTest.java +++ b/src/test/java/com/eliasmueller/llamamod/mods/testworld/TestWorldGoldenTest.java @@ -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 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"))); }