levelbuild texture names mean the same picture on every base level (matched by pixels, missing village textures copied over in the java builder, clear error otherwise), editor texture choices follow the base

This commit is contained in:
MineTec committed 2026-11-07 15:27:08 +01:00
1 parent d56f3c604f
commit 987b0104fa
14 files changed
+534 -42

No files matched your search

+1 -1
View File
@@ -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 |
+4 -2
View File
@@ -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.
+4 -2
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

@@ -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<TextChunk.TextureEntry> te = TextChunk.textureEntries(d);
Map<Integer, String> names = textureNames(s.settings().base());
Map<Integer, String> 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<Integer, String> textureNames(String base) {
/** Village texture names (levelbuild names) for the outliner, wherever the level has the picture. */
private static Map<Integer, String> textureNames(byte[] text) {
Map<Integer, String> 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;
}
@@ -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<Scene, Map<String, BufferedImage>> CACHE = new WeakHashMap<>();
private static final Map<Scene, Map<String, Integer>> 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<String, Integer> names(Scene s) {
if (s == null) {
return LevelBuilder.TEX;
}
return NAMES.computeIfAbsent(s, k -> {
List<TextChunk.TextureEntry> e = new ArrayList<>();
for (TextureEntry t : k.textures()) {
e.add(t.toFormat());
}
List<TextChunk.Page> 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<String> choices(Scene s) {
List<String> l = new ArrayList<>(LevelBuilder.TEX.keySet());
Map<String, Integer> names = names(s);
List<String> l = new ArrayList<>(names.keySet());
l.addAll(new java.util.TreeSet<>(LevelBuilder.FLAT.keySet()));
Map<Integer, Boolean> 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);
}
}
@@ -243,6 +243,16 @@ public final class TextChunk {
* SRCALPHA/INVSRCALPHA) with alpha in the fourth byte.
*/
public static Appended appendFlats(byte[] d, List<Flat> entries) {
List<TextureEntry> 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<TextureEntry> 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);
@@ -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<String, Long> FINGERPRINTS = fingerprints();
private static Map<String, Long> 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<String, Long> 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<TextChunk.Page> 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<String, Integer> resolve(List<TextChunk.TextureEntry> entries, List<TextChunk.Page> pages) {
Map<Long, List<Integer>> 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<String, Integer> out = new LinkedHashMap<>();
for (Map.Entry<String, Integer> 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<Integer> 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<String, Integer> lastNames;
/** {@link #resolve(List, List)} of a raw TEXT chunk (the last result is cached). */
public static synchronized Map<String, Integer> resolve(byte[] text) {
if (lastText != null && Arrays.equals(lastText, text)) {
return lastNames;
}
Map<String, Integer> 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<String, TextChunk.TextureEntry> copy(TextChunk village, List<String> names, TextChunk dst) {
List<TextChunk.TextureEntry> ve = village.textureEntries();
Map<String, TextChunk.TextureEntry> out = new LinkedHashMap<>();
Map<Integer, int[]> 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;
}
}
@@ -180,7 +180,13 @@ public final class LevelBuilder {
private double[] kill;
private final Map<String, int[]> flats = new HashMap<>(FLAT);
private final LinkedHashMap<String, Integer> 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<String, Integer> appended = new LinkedHashMap<>();
private List<TextChunk.TextureEntry> appendedEntries = List.of();
private Map<String, Integer> baseNames;
private final Function<String, byte[]> 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<String> 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<String, Integer> 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<Model> models = new ArrayList<>();
List<MapChunk.TileSpec> tiles = new ArrayList<>();
geometry(models, tiles);
byte[] text = baseWad.require("TEXT");
if (!flatsUsed.isEmpty()) {
List<TextChunk.Flat> 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<String> copied =
appended.keySet().stream().filter(TEX::containsKey).toList();
Map<String, TextChunk.TextureEntry> moved = Map.of();
if (!copied.isEmpty()) {
TextChunk tc = TextChunk.parse(text);
moved = BaseTextures.copy(village(), copied, tc);
text = tc.build();
}
List<TextChunk.TextureEntry> 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<TileKey> {
@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};
}
@@ -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<String> 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());
}
}
@@ -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<String, byte[]> loader(Path cd) {
return rel -> {
try {
return Files.readAllBytes(cd.resolve(rel));
} catch (IOException e) {
return null;
}
};
}
private static Set<Integer> faceTextures(byte[] wad) {
Set<Integer> 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<String, Integer> 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<TextChunk.TextureEntry> 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<TextChunk.TextureEntry> 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<String, Integer> 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"));
}
}
+45 -2
View File
@@ -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)