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:
1 parent
d56f3c604f
commit
987b0104fa
14 files changed
+534
-42
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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};
|
||||
}
|
||||
|
||||
|
||||
+43
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user