diff --git a/docs/manual/06-geometry.md b/docs/manual/06-geometry.md index aec6001..fb331e7 100644 --- a/docs/manual/06-geometry.md +++ b/docs/manual/06-geometry.md @@ -87,7 +87,7 @@ toggle snapping for this drag. **Shift+click** selects an existing brush without The options section in the Inspector changes with the primitive. Textures are chosen with a swatch button: click it for a searchable grid of all textures of the level (names are the -village texture names; `#n` is texture number *n* of the base level, see +village texture names the base level has; `#n` is texture number *n* of the base level, see [Textures](08-textures.md)). **none** leaves that part out. | Option | Primitives | Meaning | diff --git a/docs/manual/08-textures.md b/docs/manual/08-textures.md index 6627830..4c65bf3 100644 --- a/docs/manual/08-textures.md +++ b/docs/manual/08-textures.md @@ -10,8 +10,10 @@ paint them onto faces. * A level has up to **127 pages**. Each page has one *blend class* (opaque, cut-out, semi-transparent …); every texture on it should have the same class. * Textures are numbered: `#221` is texture 221 of the level. In brush texture fields you can type - `#221` or a name. The names (`grass`, `stone`, `bark` …) are those of the village textures; - other base levels mostly use numbers. + `#221` or a name. The names (`grass`, `stone`, `bark` …) are those of the village textures. + On another base level a name means the same picture wherever that level has it (Village 2 and 3 + have most of them); names the base level does not have are left out of the texture grid, and a + brush that uses one is skipped with a warning. * Brushes can also use flat colours (colour names, or `rgbRRGGBB`). * The game shows textures in **15-bit colour** (5 bits per channel) with 1 bit of transparency, so gradients get coarser and soft edges become hard. diff --git a/notes/editor-api.md b/notes/editor-api.md index d3c9cae..8283375 100644 --- a/notes/editor-api.md +++ b/notes/editor-api.md @@ -114,7 +114,7 @@ Kinds FLOOR, BOX, PLATFORM, PILLAR, RAMP, STAIRS, FENCE, TREE, SEA, KILL_PLANE, – the `levelbuild.LevelBuilder` primitives; parameters = props named as in `BrushKind.params()` (`Param(name, label, type, default, role, choices)`; role X/Y/Z/XZ_LIST/XYZ_LIST/XZ_RECT marks coordinates). `brush.bounds()` gives an approximate box. Texture parameters: village names -(`LevelBuilder.TEX`), `"#index"`, flat colour names (`LevelBuilder.FLAT`) or `"rgbRRGGBB"`. +(`LevelBuilder.TEX`, resolved per base by `BaseTextures`), `"#index"`, flat colour names (`LevelBuilder.FLAT`) or `"rgbRRGGBB"`. For a preview of the generated geometry: `BrushCompiler.compile(scene, warnings)` → generated `TileModel`s/`Tile`s/textures (same as the build). @@ -151,7 +151,9 @@ vertex colours tint the lighting. * Meaning of many raw fields is unknown (`unk*` props of zones/waypoints, texture entry flags, collision groups B/C, region value) – they are editable but not interpreted. * Tile swaps (WFPC 0x10000) can only exist in bases with that flag. -* Texture names exist only for the village (levelbuild names); other levels use `#index`. +* Texture names are the village's levelbuild names; on other bases they resolve to the same picture + (`levelbuild.BaseTextures`, pixel fingerprint); names the base lacks are not offered (GeoWidgets) and a + brush using one is skipped with a warning (the editor has no CD access to copy them over). * Lights: types other than 1/2/4 carry no data and are ignored by the engine. * Removing original tiles shifts tile indices used by strat scripts (see editor-format.md). * The engine limits: ≤ 8000 vertices per tile model, collision within ±8 of the tile origin diff --git a/notes/img/editor/village2_levelbuild_textures.jpg b/notes/img/editor/village2_levelbuild_textures.jpg new file mode 100644 index 0000000..a0fc7f2 Binary files /dev/null and b/notes/img/editor/village2_levelbuild_textures.jpg differ diff --git a/notes/img/editor/village2_levelbuild_textures2.jpg b/notes/img/editor/village2_levelbuild_textures2.jpg new file mode 100644 index 0000000..122fe03 Binary files /dev/null and b/notes/img/editor/village2_levelbuild_textures2.jpg differ diff --git a/notes/img/editor/village2_texture_picker.jpg b/notes/img/editor/village2_texture_picker.jpg new file mode 100644 index 0000000..e2ad268 Binary files /dev/null and b/notes/img/editor/village2_texture_picker.jpg differ diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/WadImporter.java b/src/main/java/com/eliasmueller/llamamod/editor/io/WadImporter.java index 126997e..c9f42c3 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/io/WadImporter.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/WadImporter.java @@ -26,7 +26,7 @@ import com.eliasmueller.llamamod.format.TextChunk; import com.eliasmueller.llamamod.format.Trak; import com.eliasmueller.llamamod.format.Vertex; import com.eliasmueller.llamamod.format.Wad; -import com.eliasmueller.llamamod.levelbuild.LevelBuilder; +import com.eliasmueller.llamamod.levelbuild.BaseTextures; import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -340,7 +340,7 @@ public final class WadImporter { s.addQuiet(pg); } List te = TextChunk.textureEntries(d); - Map names = textureNames(s.settings().base()); + Map names = textureNames(d); for (int i = 0; i < te.size(); i++) { TextureEntry t = textureEntry("tex:" + i, te.get(i)); t.setRaw("name", names.getOrDefault(i, "")); @@ -383,11 +383,13 @@ public final class WadImporter { return t; } - /** Names of village textures (levelbuild names) for the outliner; other levels have none. */ - private static Map textureNames(String base) { + /** Village texture names (levelbuild names) for the outliner, wherever the level has the picture. */ + private static Map textureNames(byte[] text) { Map m = new HashMap<>(); - if ("t1l1m001".equalsIgnoreCase(base)) { - LevelBuilder.TEX.forEach((k, v) -> m.putIfAbsent(v, k)); + try { + BaseTextures.resolve(text).forEach((k, v) -> m.putIfAbsent(v, k)); + } catch (RuntimeException e) { + // unusual TEXT layout: no names } return m; } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoWidgets.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoWidgets.java index 2f2d21b..7e0d2e9 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoWidgets.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoWidgets.java @@ -6,6 +6,8 @@ import com.eliasmueller.llamamod.editor.model.TexturePage; import com.eliasmueller.llamamod.editor.render.Legend; import com.eliasmueller.llamamod.editor.ui.EditorContext; import com.eliasmueller.llamamod.editor.ui.Widgets; +import com.eliasmueller.llamamod.format.TextChunk; +import com.eliasmueller.llamamod.levelbuild.BaseTextures; import com.eliasmueller.llamamod.levelbuild.LevelBuilder; import com.eliasmueller.llamamod.util.SwingListeners; import java.awt.BorderLayout; @@ -19,7 +21,6 @@ import java.awt.Image; import java.awt.RenderingHints; import java.awt.image.BufferedImage; import java.util.ArrayList; -import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -145,12 +146,33 @@ final class GeoWidgets { private static final Map> CACHE = new WeakHashMap<>(); + private static final Map> NAMES = new WeakHashMap<>(); + static void clearCache(Scene s) { CACHE.remove(s); + NAMES.remove(s); + } + + /** Village texture names the scene's textures show (name → index), see {@link BaseTextures}. */ + static Map names(Scene s) { + if (s == null) { + return LevelBuilder.TEX; + } + return NAMES.computeIfAbsent(s, k -> { + List e = new ArrayList<>(); + for (TextureEntry t : k.textures()) { + e.add(t.toFormat()); + } + List p = new ArrayList<>(); + for (TexturePage pg : k.pages()) { + p.add(new TextChunk.Page(pg.flags(), pg.width(), pg.height(), pg.pixels())); + } + return BaseTextures.resolve(e, p); + }); } /** Texture index of a brush texture name (names, "#n", flats → -1), or -1. */ - static int index(String tex) { + static int index(Scene s, String tex) { if (tex == null || tex.isEmpty()) { return -1; } @@ -161,7 +183,7 @@ final class GeoWidgets { return -1; } } - Integer i = LevelBuilder.TEX.get(tex); + Integer i = names(s).get(tex); return i == null ? -1 : i; } @@ -187,7 +209,7 @@ final class GeoWidgets { } BufferedImage img = null; Color flat = flatColor(tex); - int i = index(tex); + int i = index(s, tex); if (flat != null) { img = new BufferedImage(8, 8, BufferedImage.TYPE_INT_ARGB); Graphics2D g = img.createGraphics(); @@ -240,17 +262,17 @@ final class GeoWidgets { return new ImageIcon(out); } - /** All texture choices of the scene: named village textures, flat colours, then "#n". */ + /** + * All texture choices of the scene: the village texture names its base has, flat colours, then + * "#n". + */ static List choices(Scene s) { - List l = new ArrayList<>(LevelBuilder.TEX.keySet()); + Map names = names(s); + List l = new ArrayList<>(names.keySet()); l.addAll(new java.util.TreeSet<>(LevelBuilder.FLAT.keySet())); - Map named = new IdentityHashMap<>(); - for (int v : LevelBuilder.TEX.values()) { - named.put(v, true); - } if (s != null) { for (int i = 0; i < s.textures().size(); i++) { - if (!LevelBuilder.TEX.containsValue(i)) { + if (!names.containsValue(i)) { l.add("#" + i); } } diff --git a/src/main/java/com/eliasmueller/llamamod/format/TextChunk.java b/src/main/java/com/eliasmueller/llamamod/format/TextChunk.java index ebaa735..b1448a6 100644 --- a/src/main/java/com/eliasmueller/llamamod/format/TextChunk.java +++ b/src/main/java/com/eliasmueller/llamamod/format/TextChunk.java @@ -243,6 +243,16 @@ public final class TextChunk { * SRCALPHA/INVSRCALPHA) with alpha in the fourth byte. */ public static Appended appendFlats(byte[] d, List entries) { + List l = new ArrayList<>(entries.size()); + for (Flat f : entries) { + boolean opaque = f.alpha == null; + l.add(new TextureEntry(opaque ? 0x105 : 0x1105, -1, -1, f.r, f.g, f.b, opaque ? 0 : f.alpha)); + } + return appendEntries(d, l); + } + + /** Appends entries to the texture table of a raw TEXT chunk (pages and trailer untouched). */ + public static Appended appendEntries(byte[] d, List entries) { int o = textureTableOffset(d); int nt = Bytes.u32(d, o); int e = o + 4 + 8 * nt; @@ -251,9 +261,8 @@ public final class TextChunk { s.bytes(d, 0, o); s.i32(nt + entries.size()); s.bytes(d, o + 4, e - o - 4); - for (Flat f : entries) { - boolean opaque = f.alpha == null; - new TextureEntry(opaque ? 0x105 : 0x1105, -1, -1, f.r, f.g, f.b, opaque ? 0 : f.alpha).write(s); + for (TextureEntry t : entries) { + t.write(s); } s.bytes(d, e, d.length - e); return new Appended(s.toByteArray(), nt); diff --git a/src/main/java/com/eliasmueller/llamamod/levelbuild/BaseTextures.java b/src/main/java/com/eliasmueller/llamamod/levelbuild/BaseTextures.java new file mode 100644 index 0000000..3fad6f2 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/levelbuild/BaseTextures.java @@ -0,0 +1,180 @@ +package com.eliasmueller.llamamod.levelbuild; + +import com.eliasmueller.llamamod.format.TextChunk; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Finds the village textures of {@link LevelBuilder#TEX} in another level's texture set. The names + * are village (t1l1m001) indices; other levels keep many of the same pictures at other indices. A + * texture is identified by a fingerprint of its pixels (page format + rectangle contents). + */ +public final class BaseTextures { + private BaseTextures() {} + + /** Pixel fingerprints of the named village textures (BaseTexturesTest checks them on the CD). */ + static final Map FINGERPRINTS = fingerprints(); + + private static Map fingerprints() { + String t = + """ + grass=419a5525c7f58b04 grass2=671d01f02e888942 grass_light=419a5525c7f58b04 + grass_striped=b4920e19ae581efa grass_dark=fa34c068db241c1d grass_patch=965f79a948e6468a + flowers=b8e8cb6bbdaf247d flowers2=e43bab4b3ae914f flowers3=8652f820ee0c5468 + sand=e9e9f2fd4f0d3552 sand2=e6e97a6cc1f638d9 sand3=ff0abc5ca246e2ec earth=6fe2bda9159bdbc1 + earth2=b2ccaa6359faeb27 earth3=ec511a1ba25e017d grass_edge=39d679647444b24c + grass_edge2=df5813f239d8376e grass_edge_sand=14cdf6de8bed5223 stone=1bb352dc759ba249 + stone2=f60bf7909c561a41 stone_blue=ac372719c4bf01ee stone_edge=c63dc9efda418c15 + stone_green=1da16b5078f91575 stone_green2=4594d40d9248b30b stone_moss=431a0d24bfbf6ce4 + slab=a2b5338477c8f02f slab2=709f4ca57a8f0e80 slab_edge=d20d214d47dccd6d + wood_dark=e2d59f72fed34e16 bamboo=cc4f8b9bbfd44d1 bamboo2=1604b15c131fa29 + wood_grain=eae4ab24cd50d94d wood_grain2=edb9ffd1d48be806 crate=e0e020adbae332ba + bark=2e99077ca66dc532 wood_brown=1882ab20613f93b9 wood_brown2=2e8c0285c4190c4d + beam=16ede74d663dd1d0 wood_rings=fa63f4f5af1db8f wood_edge=69fefa14e8827c27 + inca=5646e95e7fa54bc0 pattern_purple=990a92db872c189b pattern_purple2=6f82cde8baffcb3d + pattern_purple3=aa4a68c089b42731 mushroom_red=2906abafeebf562 mushroom_red2=9f686c59d4fa492a + stone_stairs=3ee294816ed2529 + """; + Map m = new LinkedHashMap<>(); + for (String e : t.trim().split("\\s+")) { + int i = e.indexOf('='); + if (i > 0) { + m.put(e.substring(0, i), Long.parseUnsignedLong(e.substring(i + 1), 16)); + } + } + return Collections.unmodifiableMap(m); + } + + /** + * Fingerprint of a texture entry's picture (FNV-1a over page format, size and pixels), or 0 for + * flat colours and entries without a valid page rectangle. + */ + public static long fingerprint(TextChunk.TextureEntry e, List pages) { + if ((e.flags() & 1) != 0 || e.page() < 0 || e.page() >= pages.size()) { + return 0; + } + TextChunk.Page p = pages.get(e.page()); + if (e.u1() < e.u0() || e.v1() < e.v0() || e.u1() >= p.w || e.v1() >= p.h) { + return 0; + } + long h = 0xcbf29ce484222325L; + int[] head = {p.flags & 0x7f, e.u1() - e.u0(), e.v1() - e.v0()}; + for (int v : head) { + h = (h ^ v) * 0x100000001b3L; + } + for (int y = e.v0(); y <= e.v1(); y++) { + for (int x = e.u0(); x <= e.u1(); x++) { + h = (h ^ p.pixels[y * p.w + x]) * 0x100000001b3L; + } + } + return h == 0 ? 1 : h; + } + + /** + * Index of every {@link LevelBuilder#TEX} name whose picture this texture set has: the village + * index itself if it shows that picture, otherwise the first entry with the same picture (same + * entry flags preferred). Names without a match are missing from the result. + */ + public static Map resolve(List entries, List pages) { + Map> byFp = new HashMap<>(); + long[] fps = new long[entries.size()]; + for (int i = 0; i < entries.size(); i++) { + fps[i] = fingerprint(entries.get(i), pages); + if (fps[i] != 0) { + byFp.computeIfAbsent(fps[i], k -> new ArrayList<>()).add(i); + } + } + Map out = new LinkedHashMap<>(); + for (Map.Entry n : LevelBuilder.TEX.entrySet()) { + long fp = FINGERPRINTS.get(n.getKey()); + int own = n.getValue(); + if (own < fps.length && fps[own] == fp) { + out.put(n.getKey(), own); + continue; + } + List c = byFp.get(fp); + if (c == null) { + continue; + } + int pick = c.getFirst(); + for (int i : c) { + if (entries.get(i).flags() == VILLAGE_FLAGS) { + pick = i; + break; + } + } + out.put(n.getKey(), pick); + } + return out; + } + + /** Entry flags of all named village textures. */ + static final int VILLAGE_FLAGS = 0x104; + + private static byte[] lastText; + private static Map lastNames; + + /** {@link #resolve(List, List)} of a raw TEXT chunk (the last result is cached). */ + public static synchronized Map resolve(byte[] text) { + if (lastText != null && Arrays.equals(lastText, text)) { + return lastNames; + } + Map r = + Collections.unmodifiableMap(resolve(TextChunk.textureEntries(text), TextChunk.parse(text).pages)); + lastText = text.clone(); + lastNames = r; + return r; + } + + /** + * Copies the pictures of the named village textures onto new 256×256 pages of {@code dst} (one + * pixel of clamped border around each) and returns their texture entries (not added to dst's + * table). + */ + static Map copy(TextChunk village, List names, TextChunk dst) { + List ve = village.textureEntries(); + Map out = new LinkedHashMap<>(); + Map shelves = new HashMap<>(); // source page flags -> {page, x, y, shelf height} + for (String n : names) { + TextChunk.TextureEntry e = ve.get(LevelBuilder.TEX.get(n)); + TextChunk.Page sp = village.pages.get(e.page()); + int w = e.u1() - e.u0() + 1; + int h = e.v1() - e.v0() + 1; + int[] sh = shelves.get(sp.flags); + if (sh != null && sh[1] + w + 2 > 256) { + sh[1] = 0; + sh[2] += sh[3]; + sh[3] = 0; + } + if (sh == null || sh[2] + h + 2 > 256) { + if (dst.pages.size() >= 127) { + throw new IllegalArgumentException( + "no room for another texture page (village texture '" + n + "')"); + } + sh = new int[] {dst.pages.size(), 0, 0, 0}; + shelves.put(sp.flags, sh); + dst.pages.add(new TextChunk.Page(sp.flags, 256, 256, new int[256 * 256])); + } + int[] px = dst.pages.get(sh[0]).pixels; + for (int y = -1; y <= h; y++) { + for (int x = -1; x <= w; x++) { + int sx = Math.clamp(e.u0() + x, e.u0(), e.u1()); + int sy = Math.clamp(e.v0() + y, e.v0(), e.v1()); + px[(sh[2] + 1 + y) * 256 + sh[1] + 1 + x] = sp.pixels[sy * sp.w + sx]; + } + } + int u0 = sh[1] + 1; + int v0 = sh[2] + 1; + int p2 = e.page2() == e.page() ? sh[0] : e.page2(); + out.put(n, new TextChunk.TextureEntry(e.flags(), sh[0], p2, u0, u0 + w - 1, v0, v0 + h - 1)); + sh[1] += w + 2; + sh[3] = Math.max(sh[3], h + 2); + } + return out; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/levelbuild/LevelBuilder.java b/src/main/java/com/eliasmueller/llamamod/levelbuild/LevelBuilder.java index 9d4b1f8..049b341 100644 --- a/src/main/java/com/eliasmueller/llamamod/levelbuild/LevelBuilder.java +++ b/src/main/java/com/eliasmueller/llamamod/levelbuild/LevelBuilder.java @@ -180,7 +180,13 @@ public final class LevelBuilder { private double[] kill; private final Map flats = new HashMap<>(FLAT); - private final LinkedHashMap flatsUsed = new LinkedHashMap<>(); + /** Entries appended to the base's texture table, in index order: flat colours and TEX names + * the base does not have (copied from the village). */ + private final LinkedHashMap appended = new LinkedHashMap<>(); + + private List appendedEntries = List.of(); + private Map baseNames; + private final Function wads; private final Wad baseWad; private final byte[] baseMap; @@ -204,6 +210,7 @@ public final class LevelBuilder { this.name = name; this.rng = new PyRandom(seed); this.opts = opts == null ? Map.of() : Map.copyOf(opts); + this.wads = wads; baseWad = Wad.parse(base); wf = baseWad.wfpcFlags(); trak0 = Trak.parse(baseWad.require("TRAK")); @@ -292,7 +299,11 @@ public final class LevelBuilder { return String.format("rgb%02x%02x%02x", r, g, b); } - /** Texture index: name from TEX, "#index", flat colour name or {@link #rgb}. */ + /** + * Texture index: name from TEX, "#index", flat colour name or {@link #rgb}. TEX names are found + * in the base by picture ({@link BaseTextures}); a picture the base lacks is copied over from + * the village level (via the CD file loader) or, without it, rejected. + */ public int tex(String t) { if (t.startsWith("#")) { int v = Integer.parseInt(t.substring(1)); @@ -305,18 +316,53 @@ public final class LevelBuilder { int v = Integer.parseInt(t.substring(3), 16); flats.put(t, new int[] {v >> 16, (v >> 8) & 0xff, v & 0xff}); } - Integer i = TEX.get(t); - if (i != null) { - return i; + if (TEX.containsKey(t)) { + return named(t); } if (flats.containsKey(t)) { - return flatsUsed.computeIfAbsent(t, k -> texs.size() + flatsUsed.size()); + return appended.computeIfAbsent(t, k -> texs.size() + appended.size()); } List names = new ArrayList<>(new TreeSet<>(TEX.keySet())); names.addAll(new TreeSet<>(flats.keySet())); throw new IllegalArgumentException("unknown texture '" + t + "' (names: " + String.join(", ", names) + ")"); } + /** TEX names the base level shows (name → index in the base's texture table). */ + public Map baseTextureNames() { + if (baseNames == null) { + baseNames = BaseTextures.resolve(baseWad.require("TEXT")); + } + return baseNames; + } + + private int named(String t) { + Integer i = baseTextureNames().get(t); + if (i == null) { + i = appended.get(t); + } + if (i != null) { + return i; + } + if (village() == null) { + throw new IllegalArgumentException("texture '" + t + "' is a village texture this base level does not have" + + " (use a texture index '#n' or a flat colour)"); + } + return appended.computeIfAbsent(t, k -> texs.size() + appended.size()); + } + + private TextChunk villageText; + + /** The village's TEXT (source of copied textures), or null if the CD loader cannot read it. */ + private TextChunk village() { + if (villageText == null && wads != null) { + byte[] w = wads.apply(BASE_WAD); + if (w != null) { + villageText = TextChunk.parse(Wad.parse(w).require("TEXT")); + } + } + return villageText; + } + private static boolean has(String t) { return t != null && !t.isEmpty(); } @@ -1011,18 +1057,10 @@ public final class LevelBuilder { /** Builds the level WAD. */ public byte[] build() { + byte[] text = text(); List models = new ArrayList<>(); List tiles = new ArrayList<>(); geometry(models, tiles); - byte[] text = baseWad.require("TEXT"); - if (!flatsUsed.isEmpty()) { - List add = new ArrayList<>(); - for (String nm : flatsUsed.keySet()) { - int[] c = flats.get(nm); - add.add(new TextChunk.Flat(c[0], c[1], c[2], null)); - } - text = TextChunk.appendFlats(text, add).text(); - } byte[] trak = Trak.build(models); MapChunk m = buildMap(models, tiles); byte[] mp = m.build(); @@ -1047,6 +1085,32 @@ public final class LevelBuilder { return out.toBytes(); } + /** Base TEXT plus the appended entries (and pages for copied village textures). */ + private byte[] text() { + byte[] text = baseWad.require("TEXT"); + if (appended.isEmpty()) { + return text; + } + List copied = + appended.keySet().stream().filter(TEX::containsKey).toList(); + Map moved = Map.of(); + if (!copied.isEmpty()) { + TextChunk tc = TextChunk.parse(text); + moved = BaseTextures.copy(village(), copied, tc); + text = tc.build(); + } + List add = new ArrayList<>(); + for (String nm : appended.keySet()) { + int[] c = flats.get(nm); + add.add( + TEX.containsKey(nm) + ? moved.get(nm) + : new TextChunk.TextureEntry(0x105, -1, -1, c[0], c[1], c[2], 0)); + } + appendedEntries = add; + return TextChunk.appendEntries(text, add).text(); + } + private record TileKey(int cx, int cz, int band) implements Comparable { @Override public int compareTo(TileKey o) { @@ -1088,6 +1152,10 @@ public final class LevelBuilder { TextChunk.TextureEntry e = texs.get(t); return new int[] {e.page(), e.flags()}; } + if (t - texs.size() < appendedEntries.size()) { + TextChunk.TextureEntry e = appendedEntries.get(t - texs.size()); + return new int[] {e.page(), e.flags()}; + } return new int[] {-1, 0x105}; } diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/BaseTextureNamesTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/BaseTextureNamesTest.java new file mode 100644 index 0000000..c5af73b --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/BaseTextureNamesTest.java @@ -0,0 +1,43 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eliasmueller.llamamod.editor.io.WadImporter; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.mods.GameData; +import java.nio.file.Files; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Texture name choices and swatches follow the scene's base level (needs -Dllamamod.cd). */ +class BaseTextureNamesTest { + private static Scene scene(String base) throws Exception { + return WadImporter.importWad(Files.readAllBytes(GameData.cd().resolve("wads/" + base + ".wad")), base, null); + } + + @Test + void village2OffersOnlyItsPictures() throws Exception { + Scene s = scene("t1l1m002"); + assertEquals(0, GeoWidgets.index(s, "grass")); + assertEquals(108, GeoWidgets.index(s, "stone")); + assertEquals(-1, GeoWidgets.index(s, "flowers")); + List c = GeoWidgets.choices(s); + assertTrue(c.contains("grass")); + assertFalse(c.contains("flowers")); + assertFalse(c.contains("#0")); // named + assertTrue(c.contains("#1")); + assertEquals("grass", s.textures().get(0).name()); + assertEquals("", s.textures().get(1).name()); + } + + @Test + void villageKeepsItsIndices() throws Exception { + Scene s = scene("t1l1m001"); + assertEquals(1, GeoWidgets.index(s, "grass")); + assertEquals(149, GeoWidgets.index(s, "stone")); + assertTrue(GeoWidgets.choices(s).contains("flowers")); + assertEquals("grass", s.textures().get(1).name()); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/levelbuild/BaseTexturesTest.java b/src/test/java/com/eliasmueller/llamamod/levelbuild/BaseTexturesTest.java new file mode 100644 index 0000000..28f01e7 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/levelbuild/BaseTexturesTest.java @@ -0,0 +1,121 @@ +package com.eliasmueller.llamamod.levelbuild; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eliasmueller.llamamod.format.Face; +import com.eliasmueller.llamamod.format.Level; +import com.eliasmueller.llamamod.format.Model; +import com.eliasmueller.llamamod.format.TextChunk; +import com.eliasmueller.llamamod.format.Wad; +import com.eliasmueller.llamamod.mods.GameData; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +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; + +/** Village texture names on other base levels (needs -Dllamamod.cd). */ +class BaseTexturesTest { + private static byte[] wad(Path cd, String base) throws IOException { + return Files.readAllBytes(cd.resolve("wads/" + base + ".wad")); + } + + private static Function loader(Path cd) { + return rel -> { + try { + return Files.readAllBytes(cd.resolve(rel)); + } catch (IOException e) { + return null; + } + }; + } + + private static Set faceTextures(byte[] wad) { + Set s = new TreeSet<>(); + for (Model m : Level.parse(wad).models()) { + for (Face f : m.faces) { + s.add(f.tex()); + } + } + return s; + } + + @Test + void villageNamesAreTheirOwnIndices() throws IOException { + byte[] text = Wad.parse(wad(GameData.cd(), "t1l1m001")).require("TEXT"); + assertEquals(LevelBuilder.TEX, BaseTextures.resolve(text)); + } + + @Test + void village2FindsSamePicturesAtOtherIndices() throws IOException { + byte[] text = Wad.parse(wad(GameData.cd(), "t1l1m002")).require("TEXT"); + Map r = BaseTextures.resolve(text); + assertEquals(0, r.get("grass")); + assertEquals(108, r.get("stone")); + assertEquals(137, r.get("bark")); + assertFalse(r.containsKey("flowers")); + TextChunk tc = TextChunk.parse(text); + List e = tc.textureEntries(); + for (var n : r.entrySet()) { + assertEquals( + BaseTextures.FINGERPRINTS.get(n.getKey()), + BaseTextures.fingerprint(e.get(n.getValue()), tc.pages), + n.getKey()); + } + } + + @Test + void namesResolvePerBaseAndMissingOnesAreCopied() throws IOException { + Path cd = GameData.cd(); + LevelBuilder lb = new LevelBuilder(wad(cd, "t1l1m002"), loader(cd), "T", 1, Map.of()); + int nBase = TextChunk.textureEntries(Wad.parse(wad(cd, "t1l1m002")).require("TEXT")) + .size(); + lb.floor(8, 8, 10, 10, 1.0, "grass"); + lb.floor(10, 8, 12, 10, 1.0, "flowers"); + lb.floor(12, 8, 14, 10, 1.0, "red"); + lb.floor(14, 8, 16, 10, 1.0, "wood_rings"); + assertEquals(0, lb.tex("grass")); + assertEquals(nBase, lb.tex("flowers")); + assertEquals(nBase + 1, lb.tex("red")); + assertEquals(nBase + 2, lb.tex("wood_rings")); + byte[] out = lb.build(); + assertEquals(Set.of(0, nBase, nBase + 1, nBase + 2), faceTextures(out)); + byte[] text = Wad.parse(out).require("TEXT"); + TextChunk tc = TextChunk.parse(text); + List e = tc.textureEntries(); + assertEquals(nBase + 3, e.size()); + assertEquals(19, tc.pages.size()); // one page for both copies (same page format) + assertEquals(BaseTextures.FINGERPRINTS.get("flowers"), BaseTextures.fingerprint(e.get(nBase), tc.pages)); + assertEquals(BaseTextures.FINGERPRINTS.get("wood_rings"), BaseTextures.fingerprint(e.get(nBase + 2), tc.pages)); + assertEquals(0x105, e.get(nBase + 1).flags()); + // the copies are found by name again when the output is used as a base + Map again = BaseTextures.resolve(text); + assertEquals(nBase, again.get("flowers")); + assertEquals(nBase + 2, again.get("wood_rings")); + } + + @Test + void missingNameWithoutVillageIsRejected() throws IOException { + LevelBuilder lb = new LevelBuilder(wad(GameData.cd(), "t1l1m002"), rel -> null, "T", 1, Map.of()); + assertEquals(108, lb.tex("stone")); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> lb.tex("flowers")); + assertTrue(ex.getMessage().contains("flowers"), ex.getMessage()); + } + + @Test + void villageBaseKeepsIndices() throws IOException { + LevelBuilder lb = new LevelBuilder(wad(GameData.cd(), "t1l1m001"), rel -> null, "T", 1, Map.of()); + for (var n : LevelBuilder.TEX.entrySet()) { + assertEquals(n.getValue(), lb.tex(n.getKey()), n.getKey()); + } + assertNotEquals(0, BaseTextures.FINGERPRINTS.get("grass")); + } +} diff --git a/tools/levelbuild.py b/tools/levelbuild.py index ee87614..4b4eae9 100644 --- a/tools/levelbuild.py +++ b/tools/levelbuild.py @@ -13,7 +13,8 @@ ambient occlusion) and object placement (strats by type name, parameters from th Coordinates (as in the game / for strats): x east, y up, z = grid row (positive). 1 unit = 1 grid cell. The base level's grid is 96x96 (x,z in 0..96). Angles in degrees (0..360) about the y axis. -Texture names (TEX/FLAT below) are German keys and part of the level script API. +Texture names (TEX/FLAT below) are German keys and part of the level script API; TEX indices are +village indices, other bases (--base) map a name to the same picture or reject it. Docs: ../notes/levelbau.md """ import argparse @@ -97,6 +98,21 @@ def to_exp(p): return (p[0], p[1], -p[2]) +def _tex_fingerprints(pages, texs): + """Per texture entry: hash of page format + pixel rectangle, None for flat colours.""" + cache, out = {}, [] + for t in texs: + pg = t['page'] + if t['flags'] & 1 or pg < 0 or pg >= len(pages) or t['u1'] < t['u0'] or t['v1'] < t['v0']: + out.append(None) + continue + if pg not in cache: + cache[pg] = G.page_rgba(pages[pg]) + a = cache[pg][t['v0']:t['v1'] + 1, t['u0']:t['u1'] + 1] + out.append(hashlib.md5(bytes([pages[pg]['flags'] & 0x7f]) + str(a.shape).encode() + a.tobytes()).hexdigest()) + return out + + class Level: """Construction kit. All methods take game coordinates (x, z = grid cell, y up).""" @@ -150,6 +166,29 @@ class Level: self.protos[nm] = [tuple(s)] self.foreign_params[nm] = prm + def _base_names(self): + """TEX names on this base: same picture (page format + pixels) at the village index or elsewhere + (Java: levelbuild.BaseTextures).""" + if getattr(self, '_names', None) is None: + if os.path.abspath(self.base) == os.path.abspath(BASE): + self._names = dict(TEX) + else: + vp, vt = G.parse_text(G.wad_get(G.wad_read(BASE), 'TEXT')) + own = _tex_fingerprints(self.pages, self.texs) + vfp = _tex_fingerprints(vp, vt) + first = {} + for i, f in enumerate(own): + if f is not None and (f not in first or (self.texs[i]['flags'] == 0x104 + and self.texs[first[f]]['flags'] != 0x104)): + first[f] = i + self._names = {} + for n, i in TEX.items(): + if i < len(own) and own[i] is not None and own[i] == vfp[i]: + self._names[n] = i + elif vfp[i] in first: + self._names[n] = first[vfp[i]] + return self._names + def types(self): return sorted(self.protos) @@ -162,7 +201,11 @@ class Level: FLAT.setdefault(key, t) t = key if t in TEX: - return TEX[t] + names = self._base_names() + if t not in names: + raise KeyError('texture %r is a village texture the base level does not have (use an index, a flat ' + 'colour, or the Java builder, which copies it over)' % t) + return names[t] if t in FLAT: if t not in self.flats_used: self.flats_used[t] = len(self.texs) + len(self.flats_used)