initial commit: llamamod 0.2.0 – reverse-engineered level formats (wad, map, trak, text, stpc), python tools and notes, java mod manager (iso install, mod pipeline with exe patching, save editor, wine/windows launcher) with mods (test world, debug wireframe, mirror, randomizer, fog, cheats, intro skip, practice, free camera, boot to level, level projects) and the 3d level editor (geometry, objects, cross-world import, environment, textures, play-test, manual)

This commit is contained in:
MineTec committed 2026-11-07 08:15:40 +01:00
commit b36a366240
826 files changed
+105304

No files matched your search

@@ -0,0 +1,42 @@
package com.eliasmueller.llamamod;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
/** App version and build id from build-info.properties (generated by the Gradle task buildInfo). */
public final class BuildInfo {
private static final Properties PROPS = new Properties();
static {
try (InputStream in = BuildInfo.class.getResourceAsStream("build-info.properties")) {
if (in != null) {
PROPS.load(in);
}
} catch (IOException ignored) {
// unknown build
}
}
private BuildInfo() {}
/** Release version from build.gradle, "dev" when unknown. */
public static String version() {
return PROPS.getProperty("version", "dev");
}
/** Hash of the compiled classes and resources; changes with every code change. */
public static String build() {
return PROPS.getProperty("build", "unknown");
}
/** "0.2.0 (build 1a2b3c4d5e)". */
public static String display() {
return version() + " (build " + build() + ")";
}
/** Identifies the program code for the mod cache ("0.2.0+1a2b3c4d5e"). */
public static String cacheId() {
return version() + "+" + build();
}
}
@@ -0,0 +1,26 @@
package com.eliasmueller.llamamod;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
/** Build edition: the launcher-only edition ships without the level editor (edition.properties, editor=false). */
public final class Edition {
private static final Properties PROPS = new Properties();
static {
try (InputStream in = Edition.class.getResourceAsStream("edition.properties")) {
if (in != null) {
PROPS.load(in);
}
} catch (IOException ignored) {
// default edition
}
}
private Edition() {}
public static boolean editorEnabled() {
return !"false".equals(PROPS.getProperty("editor"));
}
}
@@ -0,0 +1,347 @@
package com.eliasmueller.llamamod;
import com.eliasmueller.llamamod.editor.ui.EditorApp;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModManager;
import com.eliasmueller.llamamod.mods.ModOption;
import com.eliasmueller.llamamod.mods.ModOptions;
import com.eliasmueller.llamamod.mods.ModRegistry;
import com.eliasmueller.llamamod.mods.levels.EditorCli;
import com.eliasmueller.llamamod.platform.AppPaths;
import com.eliasmueller.llamamod.platform.Config;
import com.eliasmueller.llamamod.platform.GameLauncher;
import com.eliasmueller.llamamod.platform.GameSource;
import com.eliasmueller.llamamod.platform.Installer;
import com.eliasmueller.llamamod.platform.Os;
import com.eliasmueller.llamamod.platform.Progress;
import com.eliasmueller.llamamod.save.Ksv;
import com.eliasmueller.llamamod.save.SaveStore;
import com.eliasmueller.llamamod.save.Worlds;
import com.eliasmueller.llamamod.ui.App;
import java.io.IOException;
import java.nio.file.Path;
import java.security.Provider;
import java.security.Security;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import javax.imageio.ImageIO;
/** Starts the GUI; a few command-line commands exist for scripting and tests. */
public final class Main {
private Main() {}
public static void main(String[] args) throws Exception {
// the UI is English; set first (ImageIO already loads AWT) so key names ("Ctrl", not "Strg"), dialog buttons
// and number fields (decimal point) match
Locale.setDefault(Locale.ENGLISH);
if (Os.isWindows()) {
// Use the OS RNG (CryptGenRandom) for SecureRandom: the default DRBG seeds itself by
// enumerating network interfaces, which crashes under older Wine.
Provider mscapi = Security.getProvider("SunMSCAPI");
if (mscapi != null) {
Security.removeProvider("SunMSCAPI");
Security.insertProviderAt(mscapi, 1);
}
}
ImageIO.setUseCache(false);
if (args.length > 0 && args[0].equals("--editor")) {
if (!Edition.editorEnabled()) {
System.err.println("This LlamaMod build has no level editor.");
System.exit(1);
}
EditorApp.main(Arrays.copyOfRange(args, 1, args.length));
return;
}
if (args.length > 0 && (args[0].equals("--version") || args[0].equals("-V"))) {
System.out.println(
"LlamaMod " + BuildInfo.display() + (Edition.editorEnabled() ? "" : ", launcher edition"));
return;
}
if (args.length == 0 || args[0].equals("--gui")) {
App.start();
return;
}
try {
System.exit(cli(args));
} catch (IOException e) {
System.err.println("Error: " + e.getMessage());
System.exit(1);
} catch (UsageException e) {
System.err.println("Error: " + e.getMessage());
System.exit(2);
}
}
/** Wrong command-line arguments (exit code 2). */
static final class UsageException extends IllegalArgumentException {
UsageException(String msg) {
super(msg);
}
}
private static int intArg(List<String> rest, int i, String what, int min, int max) {
if (i >= rest.size()) {
throw new UsageException("missing " + what);
}
int v;
try {
v = Integer.parseInt(rest.get(i).trim());
} catch (NumberFormatException e) {
throw new UsageException(what + " must be a number, got \"" + rest.get(i) + "\"");
}
if (v < min || v > max) {
throw new UsageException(what + " must be " + min + "–" + max + ", got " + v);
}
return v;
}
private static void requireInstalled(Config cfg) {
if (!cfg.installed()) {
throw new UsageException("LlamaMod is not set up yet. Run --install <iso|folder> first.");
}
}
private static void usage() {
System.out.println(
"""
LlamaMod %s – mod manager for The Emperor's New Groove (PC)
llamamod start the GUI
llamamod --version show the version and build id
llamamod --install <iso|folder> set up the game data
llamamod --mods list available mods
llamamod --apply [id,id,...] [--opt id.key=value ...]
apply mods (none = original); options are saved
llamamod --launch [--wait] start the game
llamamod --registry show the registry values
llamamod --saves show save games
llamamod --unlock <slot 1-3> <world> <chapter> [--cursor]
llamamod --editor [project.llamalevel | t1l1m001] level editor
llamamod --editor-build <project.llamalevel> <out.wad> [--cd <dir>]
llamamod --editor-import <level.wad|t1l1m001> <project.llamalevel> [--cd <dir>]
llamamod --editor-new <project.llamalevel> [base level] [--name <name>] [--cd <dir>]
llamamod --import-level <project.llamalevel> (into the level folder, shown as a mod)
Data folder: -Dllamamod.data=<folder> or LLAMAMOD_DATA (default %s)
"""
.formatted(BuildInfo.display(), AppPaths.defaultRoot()));
}
private static final String ANTIVIRUS_HINT =
"probably by antivirus software. Run --apply again (same mods) to rebuild it and add the data"
+ " folder %s as an exclusion in your antivirus program.";
private static void checkExe(ModManager mm, AppPaths paths) throws IOException {
ModManager.ExeStatus s = mm.checkGameExe();
if (s != ModManager.ExeStatus.OK) {
throw new IOException("game/groove.exe was removed or changed ("
+ s.name().toLowerCase(Locale.ROOT) + ") – " + ANTIVIRUS_HINT.formatted(paths.root()));
}
}
static int cli(String[] args) throws Exception {
AppPaths paths = AppPaths.resolve();
Config cfg = Config.load(paths.config());
Progress console = new Progress() {
String last = "";
long lastT;
@Override
public void update(double f, String m) {
long t = System.currentTimeMillis();
if (!m.equals(last) && t - lastT > 300 || f >= 1) {
System.out.printf(Locale.ROOT, "[%3.0f%%] %s%n", f * 100, m);
last = m;
lastT = t;
}
}
};
List<String> rest = new ArrayList<>(Arrays.asList(args).subList(1, args.length));
switch (args[0]) {
case "--install" -> {
if (rest.size() != 1) {
throw new UsageException("--install expects one ISO file or folder");
}
if (cfg.installed() && new GameLauncher(paths, cfg).isRunning()) {
throw new IOException("The game is running. Please quit it before setting up again.");
}
try (GameSource src = GameSource.open(Path.of(rest.getFirst()))) {
new Installer(paths).install(src, cfg, console);
}
new ModManager(paths, cfg).restoreOriginal(console);
System.out.println("Set up in " + paths.root());
}
case "--mods" -> listMods(paths, cfg);
case "--apply" -> {
return apply(paths, cfg, rest, console);
}
case "--launch" -> {
requireInstalled(cfg);
new SaveStore(paths).migrateNullSaves().forEach(System.out::println);
checkExe(new ModManager(paths, cfg), paths);
GameLauncher gl = new GameLauncher(paths, cfg);
Process p;
try {
p = gl.launch(console);
} catch (GameLauncher.BlockedException e) {
throw new IOException(e.getMessage() + " – " + ANTIVIRUS_HINT.formatted(paths.root()));
}
System.out.println("PID " + p.pid());
if (rest.contains("--wait")) {
System.out.println("exited: " + p.waitFor());
}
}
case "--registry" -> System.out.print(
new GameLauncher(paths, cfg).registry().text());
case "--saves" -> listSaves(paths);
case "--unlock" -> {
requireInstalled(cfg);
if (new GameLauncher(paths, cfg).isRunning()) {
throw new IOException("The game is running. Please quit it before changing save games.");
}
int slot = intArg(rest, 0, "slot", 1, SaveStore.SLOTS) - 1;
int w = intArg(rest, 1, "world", 1, Worlds.COUNT);
int c = intArg(rest, 2, "chapter", 1, Worlds.chapters(w));
SaveStore st = new SaveStore(paths);
Ksv k = st.load(slot);
k.ensureUsed();
k.setUnlocked(w, c);
if (rest.contains("--cursor")) {
k.setCursor(w, c);
}
Path b = st.save(slot, k);
System.out.println("saved" + (b != null ? ", backup: " + b : ""));
}
case "--editor-build", "--editor-import", "--editor-new", "--import-level" -> {
if (!args[0].equals("--import-level") && !Edition.editorEnabled()) {
throw new UsageException("This LlamaMod build has no level editor.");
}
return EditorCli.run(args[0], rest, paths);
}
default -> {
usage();
return args[0].equals("--help") || args[0].equals("-h") ? 0 : 2;
}
}
return 0;
}
private static void listMods(AppPaths paths, Config cfg) {
ModRegistry reg = new ModRegistry();
ModManager mm = new ModManager(paths, cfg);
for (Mod m : reg.all()) {
System.out.printf(
"%-20s %-8s %-10s %s – %s%s%n",
m.id(),
m.version(),
m.category(),
m.name(),
m.description(),
mm.modifiesExecutable(m) ? " [modifies groove.exe]" : "");
Map<String, String> values = ModOptions.resolve(m, cfg);
for (ModOption o : m.options()) {
String range =
switch (o.type()) {
case INT -> " " + o.min() + ".." + o.max();
case CHOICE -> " "
+ o.choices().stream()
.map(ModOption.Choice::value)
.toList();
default -> "";
};
System.out.printf(
" --opt %s.%s=%s (%s%s, default %s) %s%n",
m.id(),
o.key(),
values.get(o.key()),
o.type().name().toLowerCase(Locale.ROOT),
range,
o.defaultValue().isEmpty() ? "random" : o.defaultValue(),
o.label());
}
}
reg.problems().forEach(p -> System.out.println("! " + p));
}
/** {@code --apply [id,id,...] [--opt id.key=value ...]}; returns the exit code. */
private static int apply(AppPaths paths, Config cfg, List<String> rest, Progress console) throws IOException {
requireInstalled(cfg);
ModRegistry reg = new ModRegistry();
List<Mod> mods = new ArrayList<>();
List<String> opts = new ArrayList<>();
for (int i = 0; i < rest.size(); i++) {
String a = rest.get(i);
if (a.equals("--opt") && i + 1 < rest.size()) {
opts.add(rest.get(++i));
} else if (a.startsWith("--opt=")) {
opts.add(a.substring(6));
} else if (mods.isEmpty() && !a.startsWith("--")) {
for (String id : a.split(",")) {
if (id.isBlank()) {
continue;
}
Mod m = reg.get(id.trim());
if (m == null) {
System.err.println("Unknown mod: " + id);
return 2;
}
mods.add(m);
}
} else {
System.err.println("Unexpected argument: " + a);
return 2;
}
}
for (String o : opts) {
int dot = o.indexOf('.');
int eq = o.indexOf('=');
if (dot <= 0 || eq < dot) {
System.err.println("--opt expects id.key=value, got: " + o);
return 2;
}
Mod m = reg.get(o.substring(0, dot));
if (m == null) {
System.err.println("Unknown mod in --opt: " + o.substring(0, dot));
return 2;
}
try {
ModOptions.set(cfg, m, o.substring(dot + 1, eq), o.substring(eq + 1));
} catch (IllegalArgumentException e) {
System.err.println(e.getMessage());
return 2;
}
}
cfg.enabledMods = new ArrayList<>(mods.stream().map(Mod::id).toList());
ModManager mm = new ModManager(paths, cfg);
ModManager.Result r = mm.apply(mods, console);
System.out.println(
"Active: " + r.modIds() + ", changed files: " + r.changedFiles() + ", cached: " + r.cachedMods());
r.notes().forEach(n -> System.out.println(" " + n));
checkExe(mm, paths);
return 0;
}
private static void listSaves(AppPaths paths) {
SaveStore st = new SaveStore(paths);
for (int s = 0; s < SaveStore.SLOTS; s++) {
Ksv k = st.load(s);
if (!k.used()) {
System.out.printf("Slot %d: empty%n", s + 1);
continue;
}
System.out.printf(
"Slot %d: last %s %d, unlocked up to %s %d, map cursor %s %d, lives %d, gold coins %d%n",
s + 1,
Worlds.name(k.lastWorld()),
k.lastChapter(),
Worlds.name(k.maxWorld()),
k.maxChapter(),
Worlds.name(k.cursorWorld()),
k.cursorChapter(),
k.lives(),
k.goldCoins());
}
}
}
@@ -0,0 +1,72 @@
package com.eliasmueller.llamamod.editor.io;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import java.util.stream.Stream;
/**
* Source of original game files (the extracted CD): base level WADs and other levels for foreign
* object types. Paths are CD-relative with '/', matched case-insensitively.
*/
@FunctionalInterface
public interface Bases {
/** File content, or {@code null} if the CD has no such file. */
byte[] file(String relPath) throws IOException;
default byte[] wad(String base) throws IOException {
byte[] d = file("wads/" + base.toLowerCase(Locale.ROOT) + ".wad");
if (d == null) {
throw new IOException("base level " + base + " not found on the game CD");
}
return d;
}
/** Same as {@link #file} with IO errors as {@code null} (for optional lookups). */
default byte[] fileOrNull(String relPath) {
try {
return file(relPath);
} catch (IOException e) {
return null;
}
}
static Bases fromCd(Path cd) {
return rel -> {
Path p = resolveIgnoreCase(cd, rel);
return p == null ? null : Files.readAllBytes(p);
};
}
static Path resolveIgnoreCase(Path root, String rel) throws IOException {
Path cur = root;
for (String part : rel.split("/")) {
Path direct = cur.resolve(part);
if (Files.exists(direct)) {
cur = direct;
continue;
}
if (!Files.isDirectory(cur)) {
return null;
}
Path found = null;
try (Stream<Path> s = Files.list(cur)) {
for (Path c : s.toList()) {
if (c.getFileName().toString().equalsIgnoreCase(part)) {
found = c;
break;
}
}
} catch (UncheckedIOException e) {
throw e.getCause();
}
if (found == null) {
return null;
}
cur = found;
}
return cur;
}
}
@@ -0,0 +1,854 @@
package com.eliasmueller.llamamod.editor.io;
import com.eliasmueller.llamamod.editor.model.Brush;
import com.eliasmueller.llamamod.editor.model.BrushKind;
import com.eliasmueller.llamamod.editor.model.LevelSettings;
import com.eliasmueller.llamamod.editor.model.MeshAsset;
import com.eliasmueller.llamamod.editor.model.MeshData;
import com.eliasmueller.llamamod.editor.model.RawChunk;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.TextureEntry;
import com.eliasmueller.llamamod.editor.model.TexturePage;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.model.TileModel;
import com.eliasmueller.llamamod.format.Collision;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.MapChunk;
import com.eliasmueller.llamamod.format.Model;
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.Opts;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Brushes and mesh placements → generated tile models, tiles (with baked vertex colours and
* collision) and textures, through {@link LevelBuilder} (same primitives as levels/testwelt.py).
* Death planes (SEA with a death plane, KILL_PLANE) are placed in every cell of their area that is
* not completely covered by floor collision. Generated nodes are not part of the scene; their ids
* start with "gen:".
*/
public final class BrushCompiler {
/** Local y of the death plane in its model (as the village's model 0). */
static final double KILL_LOCAL_Y = -2.75;
static final int KILL_MATERIAL = 30;
static final int MESH_TEXTURE_FLAGS = 0x104;
private BrushCompiler() {}
public record Output(
List<TileModel> models, List<Tile> tiles, List<TexturePage> pages, List<TextureEntry> textures) {
static final Output EMPTY = new Output(List.of(), List.of(), List.of(), List.of());
}
/** Kill area {y, x0, z0, x1, z1} in game space. */
private record Kill(double y, double x0, double z0, double x1, double z1) {}
/** One mesh triangle as a builder quad. */
private record MeshQuad(double[][] points, String tex, double[] tint, boolean doubleSided) {}
/** Most quads one brush may split into (keeps the live preview responsive). */
static final long MAX_PIECES = 200_000;
/**
* Parameter values the builder cannot handle (they would hang it, run out of memory or index
* past an array): non-numbers, tile sizes / spacings near zero, too many steps, arrays of the
* wrong length. null if fine.
*/
public static String paramProblem(Brush b) {
BrushKind k = b.brushKind();
for (BrushKind.Param p : k.params()) {
Object v = b.get(p.name());
if (v instanceof Double d && !Double.isFinite(d)) {
return p.label() + " is not a number";
}
if (v instanceof double[] a) {
for (double x : a) {
if (!Double.isFinite(x)) {
return p.label() + " contains a value that is not a number";
}
}
}
}
for (String n : List.of("tint", "rotation", "stretch", "normal")) {
if (has(k, n) && b.get(n) instanceof double[] a && a.length != 3) {
return k.param(n).label() + " needs 3 values";
}
}
if (has(k, "killArea") && "area".equals(b.get("kill")) && b.a("killArea").length != 4) {
return k.param("killArea").label() + " needs 4 values";
}
if (has(k, "step")) {
double step = b.d("step");
if (step < 0.125 || step > 64) {
return "tile size must be 0.125..64";
}
if (has(k, "x0")) {
double nx = Math.abs(b.d("x1") - b.d("x0")) / step + 2, nz = Math.abs(b.d("z1") - b.d("z0")) / step + 2;
if (nx * nz > MAX_PIECES) {
return "too many tiles (" + (long) (nx * nz) + "): use a larger tile size";
}
}
}
if (has(k, "postsEvery") && b.d("postsEvery") < 0.1) {
return "post spacing must be at least 0.1";
}
if (has(k, "steps") && (b.i("steps") < 1 || b.i("steps") > 256)) {
return "steps must be 1..256";
}
for (String n : List.of("thick", "size", "radius", "height", "crown", "trunkSize", "w", "d")) {
if (has(k, n) && (b.d(n) < 0 || b.d(n) > 256)) {
return k.param(n).label() + " must be 0..256";
}
}
if (has(k, "texSize") && (b.i("texSize") < 16 || b.i("texSize") > 256)) {
return "texture size must be 16..256";
}
if (has(k, "uvSteps") && (b.i("uvSteps") < 1 || b.i("uvSteps") > 256)) {
return "UV precision must be 1..256";
}
if (k == BrushKind.FENCE && b.a("points").length / 2 > 1024
|| (k == BrushKind.QUAD || k == BrushKind.COLLIDER) && b.a("points").length > 12) {
return "too many points";
}
if (k == BrushKind.FENCE) {
double len = 0;
double[] a = b.a("points");
for (int i = 0; i + 3 < a.length; i += 2) {
len += Math.hypot(a[i + 2] - a[i], a[i + 3] - a[i + 1]);
}
if (len / b.d("postsEvery") > MAX_PIECES) {
return "too many fence posts: use a larger post spacing";
}
}
return null;
}
private static boolean has(BrushKind k, String param) {
for (BrushKind.Param p : k.params()) {
if (p.name().equals(param)) {
return true;
}
}
return false;
}
/** Cells a brush may reach beyond the grid before the compiler skips it (memory guard). */
static final double FAR_MARGIN = 64;
/** Highest |y| (cells) of brush geometry. */
static final double MAX_HEIGHT = 4096;
/**
* Why a brush's bounds are unusable: non-finite coordinates, more than margin cells outside
* the w × h grid, or beyond ±{@link #MAX_HEIGHT}; null if fine.
*/
public static String extentProblem(Brush b, int w, int h, double margin) {
double[] bb = b.bounds();
for (double v : bb) {
if (Double.isNaN(v)) {
return "coordinates are not numbers";
}
}
if (!Double.isFinite(bb[0]) && !Double.isFinite(bb[3])) {
return null;
}
for (double v : bb) {
if (!Double.isFinite(v)) {
return "coordinates are infinite";
}
}
if (bb[0] < -margin || bb[2] < -margin || bb[3] > w + margin || bb[5] > h + margin) {
return String.format(
java.util.Locale.ROOT,
"extends outside the %d×%d grid (x %.1f..%.1f, z %.1f..%.1f)",
w,
h,
bb[0],
bb[3],
bb[2],
bb[5]);
}
if (bb[1] < -MAX_HEIGHT || bb[4] > MAX_HEIGHT) {
return String.format(java.util.Locale.ROOT, "height %.1f..%.1f is out of range", bb[1], bb[4]);
}
return null;
}
/** {@link #paramProblem}, then {@link #extentProblem}; null if the brush can be built. */
static String problem(Brush b, int w, int h, double margin) {
String bad = paramProblem(b);
return bad != null ? bad : extentProblem(b, w, h, margin);
}
/** Brushes near the cell named in a "geometry outside the grid: cell x,z" error (to find them). */
static String culprits(List<Brush> brushes, String msg) {
java.util.regex.Matcher m =
java.util.regex.Pattern.compile("cell (-?\\d+),(-?\\d+)").matcher(msg == null ? "" : msg);
if (!m.find()) {
return "";
}
int cx = Integer.parseInt(m.group(1));
int cz = Integer.parseInt(m.group(2));
List<String> ids = new ArrayList<>();
for (Brush b : brushes) {
double[] bb = b.bounds();
if (Double.isFinite(bb[0])
&& bb[0] - 1 < cx + 1
&& bb[3] + 1 > cx
&& bb[2] - 1 < cz + 1
&& bb[5] + 1 > cz) {
ids.add(b.id());
}
}
return ids.isEmpty()
? ""
: " – move " + String.join(", ", ids.subList(0, Math.min(5, ids.size()))) + " into the grid";
}
public static Output compile(Scene s, List<String> warn) {
List<Brush> brushes = s.brushes().stream().filter(Brush::enabled).toList();
if (brushes.isEmpty()) {
return Output.EMPTY;
}
LevelSettings st = s.settings();
List<TexturePage> genPages = new ArrayList<>();
List<TextureEntry> genTex = new ArrayList<>();
MeshTextures mt = new MeshTextures(s, genPages, genTex);
// mesh materials first: their pages and entries must exist in the TEXT the builder sees
Map<Brush, List<MeshQuad>> meshQuads = new HashMap<>();
for (Brush b : brushes) {
if (b.brushKind() == BrushKind.MESH) {
meshQuads.put(b, mt.triangles(b, warn));
}
}
List<TexturePage> pages = new ArrayList<>(s.pages());
pages.addAll(genPages);
List<TextureEntry> entries = new ArrayList<>(s.textures());
entries.addAll(genTex);
byte[] text = ChunkWriter.text(s, pages, entries);
LevelBuilder lb = new LevelBuilder(syntheticBase(s, text), rel -> null, st.levelName(), st.seed(), Map.of());
LevelBuilder.Sun sun = lb.sun;
sun.azimuth = st.sunAzimuth();
sun.elevation = st.sunElevation();
sun.strength = st.sunStrength();
sun.color = st.sunColor().clone();
sun.sky = st.sky().clone();
sun.ground = st.ground().clone();
sun.ao = st.ao();
sun.aoRadius = st.aoRadius();
sun.shadow = st.shadow();
List<Kill> kills = new ArrayList<>();
for (Brush b : brushes) {
try {
String bad = problem(b, st.width(), st.height(), FAR_MARGIN);
if (bad != null) {
throw new IllegalArgumentException(bad);
}
int q0 = lb.quadCount();
int s0 = lb.solidCount();
replay(lb, b, kills, meshQuads.get(b));
postProcess(lb, b, q0, s0);
} catch (IllegalArgumentException e) {
warn.add(b.label() + ": " + e.getMessage() + " – skipped");
} catch (RuntimeException e) {
// one broken brush must not take all the others with it (live preview and build)
warn.add(b.label() + ": cannot be built (" + e + ") – skipped");
}
}
byte[] wad;
try {
wad = lb.build();
} catch (IllegalStateException e) {
throw new IllegalStateException("brushes: " + e.getMessage() + culprits(brushes, e.getMessage()), e);
}
Level lv = Level.parse(wad);
List<TileModel> models = new ArrayList<>();
for (int i = 0; i < lv.models().size(); i++) {
TileModel m = WadImporter.tileModel("gen:model:" + i, lv.models().get(i));
m.setRaw("header", null);
models.add(m);
}
List<Tile> tiles = new ArrayList<>();
MapChunk map = lv.map();
List<MapChunk.TileColors> cols = map.tileColors(Trak.vertCounts(lv.models()));
for (int i = 0; i < map.tiles.size(); i++) {
MapChunk.Tile mt2 = map.tiles.get(i);
Tile t = new Tile("gen:tile:" + i);
t.setRaw("x", mt2.x());
t.setRaw("y", mt2.y());
t.setRaw("z", mt2.z());
t.setRaw("rot", mt2.rot());
t.setRaw("model", models.get(mt2.model()));
t.setRaw("colors", cols.get(i).frames().getFirst());
tiles.add(t);
}
List<TextChunk.TextureEntry> outTex = TextChunk.textureEntries(lv.wad().require("TEXT"));
for (int i = entries.size(); i < outTex.size(); i++) {
genTex.add(WadImporter.textureEntry("gen:tex:" + i, outTex.get(i)));
}
if (!kills.isEmpty()) {
addKillPlanes(s, kills, models, tiles);
}
return new Output(models, tiles, genPages, genTex);
}
/** WAD the builder takes object code, textures and grid size from (no tiles). */
private static byte[] syntheticBase(Scene s, byte[] text) {
List<Wad.Chunk> ch = new ArrayList<>();
for (RawChunk c : s.chunks()) {
if (c.tag().equals("WFPC") || c.tag().equals("STPC")) {
ch.add(new Wad.Chunk(c.tag(), c.data()));
}
}
ch.add(new Wad.Chunk("TEXT", text));
ch.add(new Wad.Chunk("TRAK", Trak.build(List.of())));
ch.add(new Wad.Chunk("MAP ", ChunkWriter.map(s, List.of(), List.of())));
ch.add(new Wad.Chunk("END ", new byte[0]));
return new Wad(ch).toBytes();
}
private static Opts opts(Brush b) {
Opts o = Opts.o();
for (BrushKind.Param p : b.brushKind().params()) {
Object v = b.get(p.name());
if (v == null) {
continue;
}
switch (p.name()) {
case "top" -> o.top((String) v);
case "side" -> o.side((String) v);
case "sideTop" -> o.sideTop((String) v);
case "bottom" -> o.bottom((String) v);
case "sides" -> o.sides((String) v);
case "coll" -> o.coll((Boolean) v);
case "sideColl" -> o.sideColl((Boolean) v);
case "mat" -> o.mat((Integer) v);
case "solid" -> o.solid((Boolean) v);
case "texRot" -> o.rot((Integer) v);
case "step" -> o.step((Double) v);
case "thick" -> o.thick((Double) v);
case "base" -> o.base((Double) v);
case "walkable" -> o.walkable((Boolean) v);
case "tint" -> {
double[] t = (double[]) v;
o.tint(t[0], t[1], t[2]);
}
default -> {}
}
}
return o;
}
private static double[][] points(double[] a) {
if (a.length != 9 && a.length != 12) {
throw new IllegalArgumentException("3 or 4 corners (x, y, z) needed");
}
double[][] p = new double[a.length / 3][];
for (int i = 0; i < p.length; i++) {
p[i] = new double[] {a[3 * i], a[3 * i + 1], a[3 * i + 2]};
}
return p;
}
private static void replay(LevelBuilder lb, Brush b, List<Kill> kills, List<MeshQuad> mesh) {
Opts o = opts(b);
switch (b.brushKind()) {
case FLOOR -> {
if (b.i("mat") == 0) {
lb.floor(b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1"), b.d("y"), b.s("tex"), o);
} else {
// same surface as floor(), with a collision material
o.top(b.s("tex")).side(Opts.NONE).solid(false);
lb.box(b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1"), b.d("y"), b.d("y"), o);
}
}
case BOX -> lb.box(b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1"), b.d("y0"), b.d("y1"), o);
case PLATFORM -> lb.platform(b.d("x"), b.d("z"), b.d("w"), b.d("d"), b.d("y"), o);
case PILLAR -> lb.pillar(b.d("x"), b.d("z"), b.d("y0"), b.d("y1"), b.d("size"), o);
case CYLINDER -> lb.cylinder(b.d("x"), b.d("z"), b.d("y0"), b.d("y1"), b.d("radius"), b.i("segments"), o);
case RAMP -> lb.ramp(b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1"), b.d("ya"), b.d("yb"), b.s("direction"), o);
case STAIRS -> lb.stairs(
b.d("x0"),
b.d("z0"),
b.d("x1"),
b.d("z1"),
b.d("y0"),
b.d("y1"),
b.i("steps"),
b.s("direction"),
o);
case FENCE -> {
double[] a = b.a("points");
List<double[]> pts = new ArrayList<>();
for (int i = 0; i + 1 < a.length; i += 2) {
pts.add(new double[] {a[i], a[i + 1]});
}
lb.fence(pts, b.d("y"), b.d("height"), b.d("thick"), b.s("post"), b.s("rail"), b.d("postsEvery"));
}
case TREE -> lb.tree(
b.d("x"),
b.d("z"),
b.d("y"),
b.d("height"),
b.d("crown"),
b.s("trunk"),
b.s("leaves"),
b.d("trunkSize"));
case SEA -> {
double y = b.d("y");
lb.sea(b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1"), y, b.s("tex"), new double[0], b.d("step"));
switch (b.s("kill")) {
case "whole" -> kills.add(new Kill(y + 0.05, b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1")));
case "area" -> {
double[] k = b.a("killArea");
kills.add(new Kill(y + 0.05, k[0], k[1], k[2], k[3]));
}
default -> {}
}
}
case KILL_PLANE -> kills.add(new Kill(b.d("y"), b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1")));
case QUAD -> {
double[] t = b.a("tint");
lb.quad(
points(b.a("points")),
b.s("tex"),
b.b("coll"),
b.i("mat"),
t.clone(),
b.i("texRot"),
b.b("shade"),
b.b("doubleSided"),
b.b("render"));
}
case COLLIDER -> lb.collider(
points(b.a("points")), b.i("mat"), b.a("normal").clone());
case MESH -> {
if (mesh != null) {
for (MeshQuad q : mesh) {
lb.quad(
q.points(),
q.tex(),
b.b("coll"),
b.i("mat"),
q.tint(),
0,
b.b("shade"),
q.doubleSided(),
true);
}
}
}
}
}
// ------------------------------------------------------------ generic brush parameters
/** Face class (index in {@link BrushKind#FACES}) of a quad from its front normal (game space). */
public static int faceClass(double[] n) {
if (n[1] >= 0.35) {
return 0;
}
if (n[1] <= -0.35) {
return 1;
}
if (Math.abs(n[0]) > Math.abs(n[2])) {
return n[0] > 0 ? 3 : 5;
}
return n[2] > 0 ? 4 : 2;
}
/** Front normal (game space) of builder quad corners. */
public static double[] frontNormal(double[][] p) {
double[] a = {p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2]};
double[] c = {p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2]};
// builder corners are wound for export space (z mirrored): the cross product points inwards
double[] n = {-(a[1] * c[2] - a[2] * c[1]), -(a[2] * c[0] - a[0] * c[2]), -(a[0] * c[1] - a[1] * c[0])};
double l = Math.sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]);
return l < 1e-12 ? new double[] {0, 1, 0} : new double[] {n[0] / l, n[1] / l, n[2] / l};
}
/**
* Applies the generic parameters of transformable kinds to the quads/shadow boxes a brush added:
* per-face collision and material, visibility and the rotation about the pivot.
*/
static void postProcess(LevelBuilder lb, Brush b, int q0, int s0) {
if (!b.brushKind().transformable()) {
return;
}
String cf = b.s("collFaces");
int[] fm = b.get("faceMats") instanceof int[] a && a.length == 6 ? a : null;
boolean visible = b.visible();
boolean perFace =
!BrushKind.FACES.equals(cf) || fm != null && Arrays.stream(fm).anyMatch(m -> m >= 0);
boolean rot = b.hasRotation();
if (!perFace && visible && !rot) {
return;
}
double[] pv = b.pivot();
double[] m = Brush.rotationMatrix(b.rotation());
lb.editQuads(q0, e -> {
if (perFace) {
int k = faceClass(frontNormal(e.points));
char c = BrushKind.FACES.charAt(k);
if (cf.indexOf(c) < 0) {
e.coll = false;
}
if (fm != null && fm[k] >= 0) {
e.mat = fm[k];
}
}
if (!visible) {
e.render = false;
}
if (rot) {
for (int i = 0; i < e.points.length; i++) {
e.points[i] = Brush.rotate(m, pv, e.points[i]);
}
}
});
lb.editSolids(s0, a -> {
if (!visible) {
return null;
}
double[] o = {
Double.MAX_VALUE,
Double.MAX_VALUE,
Double.MAX_VALUE,
-Double.MAX_VALUE,
-Double.MAX_VALUE,
-Double.MAX_VALUE
};
for (int c = 0; c < 8; c++) {
double[] p = {a[(c & 1) != 0 ? 3 : 0], a[(c & 2) != 0 ? 4 : 1], a[(c & 4) != 0 ? 5 : 2]};
double[] w = rot ? Brush.rotate(m, pv, p) : p;
for (int k = 0; k < 3; k++) {
o[k] = Math.min(o[k], w[k]);
o[k + 3] = Math.max(o[k + 3], w[k]);
}
}
return o;
});
}
// ------------------------------------------------------------ death planes
static Model killModel() {
Model m = new Model();
double y = KILL_LOCAL_Y;
double[][] c = {{-0.5, y, -0.5}, {-0.5, y, 0.5}, {0.5, y, 0.5}, {0.5, y, -0.5}};
for (double[] p : c) {
m.verts.add(new Vertex(p[0], p[1], p[2], 0, 1, 0));
}
m.coll.get(0).add(Collision.fromPolygon(Arrays.asList(c), KILL_MATERIAL, null));
m.dirty = true;
return m;
}
private static void addKillPlanes(Scene s, List<Kill> kills, List<TileModel> models, List<Tile> tiles) {
Map<Long, Double> floor = new HashMap<>();
List<Tile> all =
new ArrayList<>(s.tiles().stream().filter(t -> !t.excluded()).toList());
all.addAll(tiles);
for (Tile t : all) {
int cx = t.cellX();
int cz = t.cellZ();
for (TileModel.PlacedPolygon p : t.worldCollisionPolygons()) {
if (p.material() == KILL_MATERIAL || Math.abs(p.normal()[1]) < 0.7) {
continue;
}
double[][] xz = new double[p.corners().length][];
for (int i = 0; i < xz.length; i++) {
xz[i] = new double[] {p.corners()[i][0], -p.corners()[i][2]};
}
double a = clippedArea(xz, cx, cz);
if (a > 0) {
floor.merge(cellKey(cx, cz), a, Double::sum);
}
}
}
TileModel km = WadImporter.tileModel("gen:model:kill", killModel());
km.setRaw("header", null);
models.add(km);
int[] kc = new int[km.vertexCount()];
Arrays.fill(kc, 0x01404040);
int w = s.settings().width();
int h = s.settings().height();
Set<Long> placed = new HashSet<>();
int n = 0;
for (Kill k : kills) {
for (long cx = Math.max(0, (long) Math.floor(k.x0)); cx < Math.min(w, (long) Math.ceil(k.x1)); cx++) {
for (long cz = Math.max(0, (long) Math.floor(k.z0)); cz < Math.min(h, (long) Math.ceil(k.z1)); cz++) {
long key = cellKey(cx, cz);
if (floor.getOrDefault(key, 0.0) > 0.999 || !placed.add(key)) {
continue;
}
Tile t = new Tile("gen:kill:" + n++);
t.setRaw("x", cx + 0.5);
t.setRaw("y", (double) (float) (k.y - KILL_LOCAL_Y));
t.setRaw("z", -(cz + 0.5));
t.setRaw("rot", 0);
t.setRaw("model", km);
t.setRaw("colors", kc.clone());
tiles.add(t);
}
}
}
}
private static long cellKey(long cx, long cz) {
return (cx << 32) ^ (cz & 0xffffffffL);
}
/** Area of a polygon (x, z) clipped to the cell [cx, cx+1] × [cz, cz+1]. */
static double clippedArea(double[][] p, int cx, int cz) {
List<double[]> poly = new ArrayList<>(Arrays.asList(p));
double[][] edges = {{1, 0, cx}, {-1, 0, -(cx + 1)}, {0, 1, cz}, {0, -1, -(cz + 1)}};
for (double[] e : edges) {
List<double[]> out = new ArrayList<>();
for (int i = 0; i < poly.size(); i++) {
double[] a = poly.get(i);
double[] b = poly.get((i + 1) % poly.size());
double da = e[0] * a[0] + e[1] * a[1] - e[2];
double db = e[0] * b[0] + e[1] * b[1] - e[2];
if (da >= 0) {
out.add(a);
}
if ((da >= 0) != (db >= 0)) {
double t = da / (da - db);
out.add(new double[] {a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t});
}
}
poly = out;
if (poly.isEmpty()) {
return 0;
}
}
double a = 0;
for (int i = 0; i < poly.size(); i++) {
double[] u = poly.get(i);
double[] v = poly.get((i + 1) % poly.size());
a += u[0] * v[1] - v[0] * u[1];
}
return Math.abs(a) / 2;
}
// ------------------------------------------------------------ meshes
/**
* Mesh materials → texture pages/entries; triangles → builder quads {points, texture, tint,
* double-sided}. The engine shows each triangle as one half of a texture rectangle, so every
* triangle gets the rectangle around its UVs on a (possibly flipped) copy of its texture with
* the corner order that fits best; UVs that tile beyond 0..1 are clamped.
*/
private static final class MeshTextures {
private final Scene s;
private final List<TextureEntry> genTex;
private final TextureAtlas atlas;
private final Map<String, TextureAtlas.Slot> slots = new HashMap<>();
private final Map<String, Integer> entries = new HashMap<>();
private final int texBase;
MeshTextures(Scene s, List<TexturePage> genPages, List<TextureEntry> genTex) {
this.s = s;
this.genTex = genTex;
int[] n = {0};
atlas = new TextureAtlas(s.pages().size(), () -> {
TexturePage p = new TexturePage("gen:page:" + n[0]++);
genPages.add(p);
return p;
});
texBase = s.textures().size();
}
List<MeshQuad> triangles(Brush b, List<String> warn) {
MeshAsset a = (MeshAsset) b.ref("asset");
if (a == null) {
warn.add(b.label() + ": no mesh asset");
return List.of();
}
MeshData m = a.mesh();
if (m == null) {
try {
m = MeshImport.load(s, a);
} catch (java.io.IOException | RuntimeException e) {
warn.add(b.label() + ": " + a.file() + " cannot be read: " + e.getMessage());
return List.of();
}
}
double sc = b.d("scale");
double[] st = b.get("stretch") instanceof double[] x && x.length == 3 ? x : new double[] {1, 1, 1};
boolean mirror = st[0] * st[1] * st[2] < 0;
double ang = Math.toRadians(b.d("rotY"));
double ca = Math.cos(ang);
double sa = Math.sin(ang);
double ox = b.d("x");
double oy = b.d("y");
double oz = b.d("z");
int size = b.i("texSize");
int steps = Math.max(1, b.i("uvSteps"));
double[] tint = b.a("tint");
List<MeshQuad> out = new ArrayList<>();
double[] pos = m.positions();
for (int t = 0; t < m.triangleCount(); t++) {
double[][] p = new double[3][];
for (int k = 0; k < 3; k++) {
int v = m.triangles()[3 * t + k];
double x = pos[3 * v] * sc * st[0];
double y = pos[3 * v + 1] * sc * st[1];
double z = pos[3 * v + 2] * sc * st[2];
p[k] = new double[] {ox + x * ca + z * sa, oy + y, oz - x * sa + z * ca};
}
MeshData.Material mat = m.materialList().get(m.materials()[t]);
double[] uv = Arrays.copyOfRange(m.uvs(), 6 * t, 6 * t + 6);
if (mirror) {
double[] q = p[1];
p[1] = p[2];
p[2] = q;
double u1 = uv[2], v1 = uv[3];
uv[2] = uv[4];
uv[3] = uv[5];
uv[4] = u1;
uv[5] = v1;
}
double[] tn = tint.clone();
if (m.colors() != null) {
double[] c = new double[3];
for (int k = 0; k < 3; k++) {
int col = m.colors()[m.triangles()[3 * t + k]];
c[0] += (col >> 16 & 0xff) / 255.0 / 3;
c[1] += (col >> 8 & 0xff) / 255.0 / 3;
c[2] += (col & 0xff) / 255.0 / 3;
}
for (int k = 0; k < 3; k++) {
tn[k] *= c[k];
}
}
String tex;
if (mat.image() == null || Double.isNaN(uv[0])) {
tex = LevelBuilder.rgb(mat.rgb() >> 16 & 0xff, mat.rgb() >> 8 & 0xff, mat.rgb() & 0xff);
} else {
int[] best = fit(uv);
int r = best[0];
int variant = best[1];
p = new double[][] {p[r], p[(r + 1) % 3], p[(r + 2) % 3]};
double[] u2 = new double[6];
for (int k = 0; k < 3; k++) {
int src = (r + k) % 3;
u2[2 * k] = flip(uv[2 * src], (variant & 1) != 0);
u2[2 * k + 1] = flip(uv[2 * src + 1], (variant & 2) != 0);
}
tex = "#" + entry(m.materials()[t], a, mat, variant, size, steps, u2);
}
out.add(new MeshQuad(p, tex, tn, b.b("doubleSided") || mat.doubleSided()));
}
return out;
}
private static double flip(double v, boolean f) {
return f ? 1 - v : v;
}
/** {rotation, flip variant (bit 0 U, bit 1 V)} with the smallest corner error. */
static int[] fit(double[] uv) {
int[] best = {0, 0};
double err = Double.MAX_VALUE;
for (int variant = 0; variant < 4; variant++) {
for (int r = 0; r < 3; r++) {
double[] u = new double[3];
double[] v = new double[3];
for (int k = 0; k < 3; k++) {
int src = (r + k) % 3;
u[k] = flip(uv[2 * src], (variant & 1) != 0);
v[k] = flip(uv[2 * src + 1], (variant & 2) != 0);
}
double umin = Math.min(u[0], Math.min(u[1], u[2]));
double umax = Math.max(u[0], Math.max(u[1], u[2]));
double vmin = Math.min(v[0], Math.min(v[1], v[2]));
double vmax = Math.max(v[0], Math.max(v[1], v[2]));
// pattern of LevelBuilder triangles (0x820): bottom-left, bottom-right, top-right
double e = Math.abs(u[0] - umin)
+ Math.abs(v[0] - vmax)
+ Math.abs(u[1] - umax)
+ Math.abs(v[1] - vmax)
+ Math.abs(u[2] - umax)
+ Math.abs(v[2] - vmin);
if (e < err - 1e-9) {
err = e;
best = new int[] {r, variant};
}
}
}
return best;
}
private int entry(
int matIndex, MeshAsset a, MeshData.Material mat, int variant, int size, int steps, double[] uv) {
String sk = a.id() + "/" + matIndex + "/" + variant + "/" + size;
TextureAtlas.Slot slot = slots.get(sk);
if (slot == null) {
BufferedImage img = new BufferedImage(mat.width(), mat.height(), BufferedImage.TYPE_INT_ARGB);
for (int y = 0; y < mat.height(); y++) {
for (int x = 0; x < mat.width(); x++) {
int sx = (variant & 1) != 0 ? mat.width() - 1 - x : x;
int sy = (variant & 2) != 0 ? mat.height() - 1 - y : y;
img.setRGB(x, y, mat.image()[sy * mat.width() + sx]);
}
}
slot = atlas.add(img, size);
slots.put(sk, slot);
}
double umin = Double.MAX_VALUE, umax = -Double.MAX_VALUE, vmin = Double.MAX_VALUE, vmax = -Double.MAX_VALUE;
for (int k = 0; k < 3; k++) {
umin = Math.min(umin, uv[2 * k]);
umax = Math.max(umax, uv[2 * k]);
vmin = Math.min(vmin, uv[2 * k + 1]);
vmax = Math.max(vmax, uv[2 * k + 1]);
}
double fu = Math.floor(umin);
double fv = Math.floor(vmin);
umin = clamp01(umin - fu);
umax = clamp01(umax - fu);
vmin = clamp01(vmin - fv);
vmax = clamp01(vmax - fv);
int q = Math.max(1, slot.size() / steps);
int u0 = (int) Math.floor(umin * slot.size() / q) * q;
int u1 = (int) Math.ceil(umax * slot.size() / q) * q - 1;
int v0 = (int) Math.floor(vmin * slot.size() / q) * q;
int v1 = (int) Math.ceil(vmax * slot.size() / q) * q - 1;
u1 = Math.max(u0, Math.min(slot.size() - 1, u1));
v1 = Math.max(v0, Math.min(slot.size() - 1, v1));
String ek = slot.page() + "/" + (slot.x() + u0) + "/" + (slot.x() + u1) + "/" + (slot.y() + v0) + "/"
+ (slot.y() + v1);
Integer idx = entries.get(ek);
if (idx == null) {
idx = texBase + genTex.size();
TextureEntry te = new TextureEntry("gen:tex:" + idx);
te.setRaw("flags", MESH_TEXTURE_FLAGS);
te.setRaw("page", slot.page());
te.setRaw("page2", -1);
te.setRaw("u0", slot.x() + u0);
te.setRaw("u1", slot.x() + u1);
te.setRaw("v0", slot.y() + v0);
te.setRaw("v1", slot.y() + v1);
genTex.add(te);
entries.put(ek, idx);
}
return idx;
}
private static double clamp01(double v) {
return Math.max(0, Math.min(1, v));
}
}
}
@@ -0,0 +1,319 @@
package com.eliasmueller.llamamod.editor.io;
import com.eliasmueller.llamamod.editor.model.EnvZone;
import com.eliasmueller.llamamod.editor.model.LevelSettings;
import com.eliasmueller.llamamod.editor.model.Light;
import com.eliasmueller.llamamod.editor.model.PathNode;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.TextureAnim;
import com.eliasmueller.llamamod.editor.model.TextureEntry;
import com.eliasmueller.llamamod.editor.model.TexturePage;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.model.TileModel;
import com.eliasmueller.llamamod.format.ByteSink;
import com.eliasmueller.llamamod.format.Face;
import com.eliasmueller.llamamod.format.MapChunk;
import com.eliasmueller.llamamod.format.Model;
import com.eliasmueller.llamamod.format.TextChunk;
import com.eliasmueller.llamamod.format.Trak;
import com.eliasmueller.llamamod.format.Vertex;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
/** Serialises scene parts into chunk data (inverse of {@link WadImporter}). */
final class ChunkWriter {
private ChunkWriter() {}
/** Tile model to the format layer (header kept while the vertices are unchanged). */
static Model toModel(TileModel tm) {
Model m = new Model();
double[] v = tm.vertices();
for (int i = 0; i < v.length; i += TileModel.VERTEX_STRIDE) {
m.verts.add(new Vertex(v[i], v[i + 1], v[i + 2], v[i + 3], v[i + 4], v[i + 5]));
}
int[] f = tm.faces();
double[] p = tm.planes();
for (int i = 0, k = 0; i < f.length; i += TileModel.FACE_STRIDE, k += TileModel.PLANE_STRIDE) {
m.faces.add(new Face(
f[i], f[i + 1], f[i + 2], f[i + 3], f[i + 4], f[i + 5], p[k], p[k + 1], p[k + 2], p[k + 3]));
}
for (int g = 0; g < 3; g++) {
byte[] c = tm.collision(g);
for (int o = 0; o + TileModel.COLLISION_SIZE <= c.length; o += TileModel.COLLISION_SIZE) {
m.coll.get(g).add(Arrays.copyOfRange(c, o, o + TileModel.COLLISION_SIZE));
}
}
if (tm.header() != null) {
m.header = Arrays.copyOf(tm.header(), Trak.TRAK_HEADER);
m.origVertCount = m.verts.size();
} else {
m.dirty = true;
}
return m;
}
static byte[] trak(List<TileModel> models) {
List<Model> l = new ArrayList<>(models.size());
for (TileModel tm : models) {
l.add(toModel(tm));
}
return Trak.build(l);
}
static Map<Object, Integer> indexMap(List<?> l) {
Map<Object, Integer> m = new IdentityHashMap<>(l.size() * 2);
for (int i = 0; i < l.size(); i++) {
m.put(l.get(i), i);
}
return m;
}
/** Effective strat pool size written to MAP (enough slots for all placed strats). */
static int poolExtra(Scene s) {
LevelSettings st = s.settings();
return Math.max(st.poolExtra(), s.strats().size() + st.poolReserve());
}
private static final int[] DUMMY_NODE = new int[10];
/**
* MAP chunk. models/tiles: the scene's lists plus generated geometry (brushes); every tile's
* model must be in models.
*/
static byte[] map(Scene s, List<TileModel> models, List<Tile> tiles) {
LevelSettings st = s.settings();
int wf = st.wfpc();
Map<Object, Integer> mi = indexMap(models);
Map<Object, Integer> ni = indexMap(s.pathNodes());
ByteSink o = new ByteSink(1 << 20);
o.i32(tiles.size()).i32(st.width()).i32(st.height());
boolean alts = (wf & MapChunk.WF_ALTS) != 0;
int lastSwap = -1;
if (alts) {
for (int i = 0; i < tiles.size(); i++) {
if (tiles.get(i).hasSwap()) {
lastSwap = i;
}
}
}
for (int i = 0; i < tiles.size(); i++) {
Tile t = tiles.get(i);
int a = t.alt();
if (alts && t.hasSwap() && a == 0) {
a = 1;
} else if (alts && !t.hasSwap() && a != 0 && i < lastSwap) {
a = 0;
}
o.f32(t.x())
.f32(t.y())
.f32(t.z())
.i32(t.rot())
.i32(index(mi, t.model(), t))
.i32(a);
}
List<Integer> pool = new ArrayList<>();
int[] pidx = paramIndices(s, pool);
o.i32(pool.size());
for (int v : pool) {
o.i32(v);
}
o.i32(s.zones().size());
for (EnvZone z : s.zones()) {
for (int i = 0; i < EnvZone.FIELDS.length; i++) {
if (i == 14 || i == 21 || i == 22) {
o.u16(z.field(i));
} else {
o.i32(z.field(i));
}
}
}
boolean dummy = s.pathNodes().isEmpty() && st.isNewLevel();
o.i32(dummy ? 1 : s.pathNodes().size());
if (dummy) {
node(o, DUMMY_NODE);
}
for (PathNode n : s.pathNodes()) {
int next = n.next() != null ? index(ni, n.next(), n) : n.nextRaw();
node(o, new int[] {next, n.unk1(), n.rx(), n.ry(), n.rz(), n.x(), n.y(), n.z(), n.unk2(), n.unk3()});
}
o.i32s(s.regions().table());
o.i32s(s.regions().grid());
for (Tile t : tiles) {
o.i32s(t.effectiveFixed());
}
for (Tile t : tiles) {
o.i32(index(mi, t.model(), t));
}
if (alts) {
int n = 0;
for (Tile t : tiles) {
n += t.hasSwap() ? 1 : 0;
}
o.i32(n);
for (Tile t : tiles) {
if (t.hasSwap()) {
int[] c = t.swapCell();
o.i32(c[0]).i32(c[1]).i32(c[2]);
o.i32(t.swapModel() != null ? index(mi, t.swapModel(), t) : index(mi, t.model(), t));
o.i32(index(mi, t.swapAltModel(), t));
}
}
}
for (Tile t : tiles) {
o.i32s(colors(t.colors(), t.model().vertexCount()));
if (alts && t.hasSwap()) {
int[] ac = t.altColors();
o.i32s(colors(ac == null ? new int[0] : ac, t.swapAltModel().vertexCount()));
}
}
o.i32(s.strats().size()).i32(poolExtra(s));
for (int k = 0; k < pidx.length; k++) {
Strat x = s.strats().get(k);
int wp = x.waypoint() != null ? index(ni, x.waypoint(), x) : x.waypointRaw();
o.u16(x.rx()).u16(x.ry()).u16(x.rz());
o.i32(x.x()).i32(x.y()).i32(x.z());
o.i32(x.code()).i32(x.params().length).i32(pidx[k]);
o.i32(x.nVars()).i32(x.slots()).i32(x.typeFlags()).i32(x.extra());
o.i32(wp).i32(x.zero()).u16(x.flags()).u16(x.last());
}
o.bytes(st.mapTail());
return o.toByteArray();
}
/**
* Fills pool with the parameter pool and returns each strat's pool index: unchanged parameters
* keep their original index, changed ones are appended.
*/
private static int[] paramIndices(Scene s, List<Integer> pool) {
int[] pool0 = s.settings().paramPool();
for (int v : pool0) {
pool.add(v);
}
int[] pidx = new int[s.strats().size()];
for (int k = 0; k < pidx.length; k++) {
Strat x = s.strats().get(k);
int[] p = x.params();
Integer pi = x.paramIndex();
if (p.length == 0) {
pidx[k] = pi != null ? pi : -1;
} else if (pi != null
&& pi >= 0
&& pi + p.length <= pool0.length
&& Arrays.equals(p, Arrays.copyOfRange(pool0, pi, pi + p.length))) {
pidx[k] = pi;
} else {
pidx[k] = pool.size();
for (int v : p) {
pool.add(v);
}
}
}
return pidx;
}
private static void node(ByteSink o, int[] v) {
o.i32(v[0]).i32(v[1]).u16(v[2]).u16(v[3]).u16(v[4]);
o.i32(v[5]).i32(v[6]).i32(v[7]).i32(v[8]).i32(v[9]);
}
private static int index(Map<Object, Integer> m, Object target, Object user) {
Integer i = m.get(target);
if (i == null) {
throw new IllegalStateException(user + " references " + target + ", which is not part of the level");
}
return i;
}
/**
* Colour block of one tile: frames × nv words. A length that does not fit the model (vertices
* edited) is repaired: the first frame is resized (new vertices grey) and the frame count
* byte kept consistent.
*/
static int[] colors(int[] c, int nv) {
if (nv == 0) {
return new int[0];
}
int frames = 1;
for (int i = 0; i < Math.min(nv, c.length); i++) {
frames = Math.max(frames, c[i] >>> 24);
}
if (c.length == frames * nv) {
return c;
}
int[] out = new int[nv];
for (int i = 0; i < nv; i++) {
out[i] = i < c.length ? c[i] & 0x00ffffff : Tile.GREY;
}
out[0] |= 0x01000000;
return out;
}
static byte[] text(Scene s, List<TexturePage> pages, List<TextureEntry> entries) {
ByteSink o = new ByteSink(1 << 20);
o.bytes(s.settings().textPalettes());
o.i32(pages.size());
for (TexturePage p : pages) {
o.bytes(encodePage(p));
}
o.i32(entries.size());
for (TextureEntry e : entries) {
o.u16(e.flags())
.u8(e.page())
.u8(e.page2())
.u8(e.u0())
.u8(e.u1())
.u8(e.v0())
.u8(e.v1());
}
o.i32(s.textureAnims().size());
for (TextureAnim a : s.textureAnims()) {
o.u16(a.texture()).u8(a.type()).bytes(a.data());
}
o.bytes(s.settings().textTail());
return o.toByteArray();
}
/** Page record {flags, w, h, data} (cached original bytes while unchanged). */
static byte[] encodePage(TexturePage p) {
if (p.encoded() != null) {
return p.encoded();
}
ByteSink o = new ByteSink(p.pixels().length * 2 + 16);
int f = p.flags();
o.i32(f).i32(p.width()).i32(p.height());
if (f == 0) {
for (int v : p.pixels()) {
o.u8(v);
}
} else if ((f & 0x80) != 0) {
byte[] b = TextChunk.rleEncode(p.pixels());
o.i32(b.length).bytes(b);
} else {
for (int v : p.pixels()) {
o.u16(v);
}
}
byte[] out = o.toByteArray();
p.setEncodedCache(out);
return out;
}
static byte[] lights(Scene s) {
ByteSink o = new ByteSink(1024);
o.i32(s.lights().size());
for (Light l : s.lights()) {
o.u8(l.type()).u8(l.r()).u8(l.g()).u8(l.b());
if (l.type() == Light.DIRECTIONAL) {
o.f32(l.x()).f32(l.y()).f32(l.z());
} else if (l.type() == Light.POINT || l.type() == Light.POINT_BRIGHT) {
o.f32(l.x()).f32(l.y()).f32(l.z()).f32(l.a()).f32(l.rangeB()).u8(l.c());
}
}
o.bytes(s.settings().lghtTail());
return o.toByteArray();
}
}
@@ -0,0 +1,99 @@
package com.eliasmueller.llamamod.editor.io;
import com.eliasmueller.llamamod.editor.model.TexturePage;
import com.eliasmueller.llamamod.format.TextChunk;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import javax.imageio.ImageIO;
/** PNG ↔ texture page pixels. ABGR1555 → RGBA → ABGR1555 is lossless (alpha 0 / 128 / 255). */
public final class Images {
private Images() {}
public static BufferedImage pageImage(TexturePage p) {
BufferedImage img = new BufferedImage(p.width(), p.height(), BufferedImage.TYPE_INT_ARGB);
img.setRGB(0, 0, p.width(), p.height(), p.argb(), 0, p.width());
return img;
}
public static byte[] pagePng(TexturePage p) {
return png(pageImage(p));
}
public static byte[] png(BufferedImage img) {
try {
ByteArrayOutputStream o = new ByteArrayOutputStream();
ImageIO.write(img, "png", o);
return o.toByteArray();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
public static BufferedImage read(byte[] data) throws IOException {
BufferedImage img = ImageIO.read(new ByteArrayInputStream(data));
if (img == null) {
throw new IOException("not a readable image (PNG or JPEG expected)");
}
return img;
}
/** ABGR1555 pixels of an image (scaled to w×h if the size differs). */
public static int[] pagePixels(byte[] png, int w, int h) {
try {
return toAbgr1555(read(png), w, h);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
public static int[] toAbgr1555(BufferedImage img, int w, int h) {
BufferedImage src = img.getWidth() == w && img.getHeight() == h ? img : scale(img, w, h);
boolean alpha = src.getColorModel().hasAlpha();
int[] out = new int[w * h];
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
int c = src.getRGB(x, y);
out[y * w + x] = TextChunk.rgbaToAbgr1555(c >> 16 & 0xff, c >> 8 & 0xff, c & 0xff, c >>> 24, alpha);
}
}
return out;
}
/** Area-averaged scaling (premultiplied by alpha so transparent pixels do not bleed). */
public static BufferedImage scale(BufferedImage img, int w, int h) {
BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
int sw = img.getWidth();
int sh = img.getHeight();
for (int y = 0; y < h; y++) {
int y0 = y * sh / h;
int y1 = Math.max(y0 + 1, (y + 1) * sh / h);
for (int x = 0; x < w; x++) {
int x0 = x * sw / w;
int x1 = Math.max(x0 + 1, (x + 1) * sw / w);
long r = 0, g = 0, b = 0, a = 0;
int n = 0;
for (int yy = y0; yy < y1; yy++) {
for (int xx = x0; xx < x1; xx++) {
int c = img.getRGB(xx, yy);
int ca = c >>> 24;
a += ca;
r += (c >> 16 & 0xff) * ca;
g += (c >> 8 & 0xff) * ca;
b += (c & 0xff) * ca;
n++;
}
}
int oa = (int) (a / n);
int or = a == 0 ? 0 : (int) (r / a);
int og = a == 0 ? 0 : (int) (g / a);
int ob = a == 0 ? 0 : (int) (b / a);
out.setRGB(x, y, oa << 24 | or << 16 | og << 8 | ob);
}
}
return out;
}
}
@@ -0,0 +1,685 @@
package com.eliasmueller.llamamod.editor.io;
import com.eliasmueller.llamamod.editor.model.MeshAsset;
import com.eliasmueller.llamamod.editor.model.MeshData;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.platform.Json;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Function;
/**
* Imports 3D models as {@link MeshAsset}s: Wavefront OBJ (+ MTL, diffuse colour and map_Kd
* texture, optional vertex colours "v x y z r g b") and glTF 2.0 (.gltf with external or embedded
* buffers/images, .glb; node transforms, base colour factor/texture, COLOR_0, doubleSided).
* Files are copied into the project assets; the mesh is parsed from there, so a project is
* self-contained. Axes: files are y-up right-handed (or z-up, {@code MeshAsset.upAxis}); 1 file
* unit = {@code unitScale} cells. In game space z is mirrored (the result is not mirrored).
*/
public final class MeshImport {
/** Diffuse colour of materials without one. */
private static final int GREY = 0xc0c0c0;
private static final MeshData.Material DEFAULT_MATERIAL = new MeshData.Material("default", GREY, null, 0, 0, false);
private MeshImport() {}
/** Copies the file (and the files it references) into the scene assets; returns a detached asset node. */
public static MeshAsset importFile(Scene s, Path file) throws IOException {
String fn = file.getFileName().toString();
String stem = fn.contains(".") ? fn.substring(0, fn.lastIndexOf('.')) : fn;
String dir = "assets/" + slug(stem);
int n = 2;
while (hasDir(s, dir)) {
dir = "assets/" + slug(stem) + "-" + n++;
}
Path parent = file.toAbsolutePath().normalize().getParent();
Map<String, byte[]> files = new LinkedHashMap<>();
byte[] main = Files.readAllBytes(file);
files.put(fn, main);
for (String dep : dependencies(fn, main, rel -> read(parent, rel))) {
byte[] d = read(parent, dep);
if (d != null) {
files.put(dep, d);
}
}
MeshAsset a = (MeshAsset) s.create(NodeKind.MESH_ASSET);
a.setRaw("name", stem);
String d0 = dir;
files.forEach((k, v) -> s.assets().put(d0 + "/" + k, v));
a.setRaw("file", dir + "/" + fn);
a.setMesh(parse(fn, main, rel -> s.assets().get(d0 + "/" + rel), a.upAxis(), a.unitScale()));
return a;
}
private static boolean hasDir(Scene s, String dir) {
return s.assets().keySet().stream().anyMatch(k -> k.startsWith(dir + "/"));
}
private static byte[] read(Path dir, String rel) {
try {
Path p = dir.resolve(rel.replace('\\', '/')).normalize();
return p.startsWith(dir) && Files.isRegularFile(p) ? Files.readAllBytes(p) : null;
} catch (IOException | RuntimeException e) {
return null;
}
}
static String slug(String s) {
String r = s.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9._-]+", "-").replaceAll("^-+|-+$", "");
return r.isEmpty() ? "mesh" : r;
}
/** Parses the asset from the scene's assets (and caches it in the node). */
public static MeshData load(Scene s, MeshAsset a) throws IOException {
String f = a.file();
byte[] main = s.assets().get(f);
if (main == null) {
throw new IOException("asset " + f + " missing in the project");
}
String dir = f.contains("/") ? f.substring(0, f.lastIndexOf('/') + 1) : "";
MeshData m = parse(
f.substring(dir.length()),
main,
rel -> s.assets().get(dir + rel.replace('\\', '/')),
a.upAxis(),
a.unitScale());
a.setMesh(m);
return m;
}
/** Files referenced by an OBJ (MTL, textures) or glTF (buffers, images). */
static List<String> dependencies(String name, byte[] main, Function<String, byte[]> files) {
List<String> out = new ArrayList<>();
String lower = name.toLowerCase(Locale.ROOT);
if (lower.endsWith(".obj")) {
for (String line : new String(main, StandardCharsets.UTF_8).split("\r?\n")) {
String t = line.trim();
if (t.startsWith("mtllib ")) {
String mtl = t.substring(7).trim();
out.add(mtl);
byte[] md = files.apply(mtl);
if (md != null) {
for (String ml : new String(md, StandardCharsets.UTF_8).split("\r?\n")) {
String m = ml.trim();
if (m.toLowerCase(Locale.ROOT).startsWith("map_kd ")) {
out.add(lastToken(m));
}
}
}
}
}
} else if (lower.endsWith(".gltf")) {
Map<?, ?> j = (Map<?, ?>) Json.parse(new String(main, StandardCharsets.UTF_8));
for (String k : List.of("buffers", "images")) {
if (j.get(k) instanceof List<?> l) {
for (Object o : l) {
if (((Map<?, ?>) o).get("uri") instanceof String u && !u.startsWith("data:")) {
out.add(URLDecoder.decode(u, StandardCharsets.UTF_8));
}
}
}
}
}
return out;
}
private static String lastToken(String s) {
String[] p = s.trim().split("\\s+");
return p[p.length - 1];
}
public static MeshData parse(String name, byte[] main, Function<String, byte[]> files, String up, double scale)
throws IOException {
String lower = name.toLowerCase(Locale.ROOT);
Builder b = new Builder(up, scale);
if (lower.endsWith(".obj")) {
obj(main, files, b);
} else if (lower.endsWith(".glb")) {
glb(main, files, b);
} else if (lower.endsWith(".gltf")) {
gltf((Map<?, ?>) Json.parse(new String(main, StandardCharsets.UTF_8)), null, files, b);
} else {
throw new IOException("unsupported model format (OBJ, glTF or GLB expected): " + name);
}
if (b.tris.isEmpty()) {
throw new IOException(name + " contains no triangles");
}
return b.build();
}
/** Collects triangles in game space. */
private static final class Builder {
final List<double[]> pos = new ArrayList<>();
final List<Integer> cols = new ArrayList<>();
boolean hasCols;
final List<int[]> tris = new ArrayList<>();
final List<double[]> uvs = new ArrayList<>();
final List<Integer> mats = new ArrayList<>();
final List<MeshData.Material> materials = new ArrayList<>();
final String up;
final double scale;
Builder(String up, double scale) {
this.up = up;
this.scale = scale;
}
/** File coordinates (right-handed) → game space. */
int vertex(double x, double y, double z, Integer rgb) {
double gx = x;
double gy = "z".equals(up) ? z : y;
double gz = "z".equals(up) ? y : -z;
pos.add(new double[] {gx * scale, gy * scale, gz * scale});
cols.add(rgb == null ? 0xffffff : rgb);
hasCols |= rgb != null;
return pos.size() - 1;
}
void tri(int a, int b, int c, double[] uv, int mat) {
tris.add(new int[] {a, b, c});
uvs.add(uv);
mats.add(mat);
}
int material(MeshData.Material m) {
materials.add(m);
return materials.size() - 1;
}
MeshData build() {
if (materials.isEmpty()) {
materials.add(DEFAULT_MATERIAL);
}
double[] p = new double[pos.size() * 3];
for (int i = 0; i < pos.size(); i++) {
System.arraycopy(pos.get(i), 0, p, 3 * i, 3);
}
int[] t = new int[tris.size() * 3];
double[] u = new double[tris.size() * 6];
int[] m = new int[tris.size()];
for (int i = 0; i < tris.size(); i++) {
System.arraycopy(tris.get(i), 0, t, 3 * i, 3);
System.arraycopy(uvs.get(i), 0, u, 6 * i, 6);
m[i] = Math.min(mats.get(i), materials.size() - 1);
}
int[] c = null;
if (hasCols) {
c = cols.stream().mapToInt(Integer::intValue).toArray();
}
return new MeshData(p, t, u, m, c, List.copyOf(materials));
}
}
private static int[] argb(BufferedImage img) {
int w = img.getWidth();
int h = img.getHeight();
return img.getRGB(0, 0, w, h, null, 0, w);
}
private static final double[] NO_UV = {Double.NaN, Double.NaN, Double.NaN, Double.NaN, Double.NaN, Double.NaN};
// ------------------------------------------------------------ OBJ
private static void obj(byte[] data, Function<String, byte[]> files, Builder b) throws IOException {
List<double[]> v = new ArrayList<>();
List<Integer> vc = new ArrayList<>();
List<double[]> vt = new ArrayList<>();
Map<String, MeshData.Material> lib = new HashMap<>();
Map<String, Integer> used = new HashMap<>();
int cur = -1;
Map<Integer, Integer> vmap = new HashMap<>();
for (String raw : new String(data, StandardCharsets.UTF_8).split("\r?\n")) {
String line = raw.trim();
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
String[] p = line.split("\\s+");
switch (p[0]) {
case "v" -> {
v.add(new double[] {d(p[1]), d(p[2]), d(p[3])});
vc.add(p.length >= 7 ? rgb(d(p[4]), d(p[5]), d(p[6])) : null);
}
case "vt" -> vt.add(new double[] {d(p[1]), p.length > 2 ? d(p[2]) : 0});
case "mtllib" -> lib.putAll(mtl(files.apply(line.substring(6).trim()), files));
case "usemtl" -> {
String n = line.substring(6).trim();
Integer i = used.get(n);
if (i == null) {
MeshData.Material m = lib.getOrDefault(n, new MeshData.Material(n, GREY, null, 0, 0, false));
i = b.material(m);
used.put(n, i);
}
cur = i;
}
case "f" -> {
int n = p.length - 1;
int[] vi = new int[n];
double[][] uv = new double[n][];
for (int k = 0; k < n; k++) {
String[] q = p[k + 1].split("/");
int a = idx(q[0], v.size());
vi[k] = vmap.computeIfAbsent(a, x -> {
double[] c = v.get(x);
return b.vertex(c[0], c[1], c[2], vc.get(x));
});
uv[k] = q.length > 1 && !q[1].isEmpty() ? vt.get(idx(q[1], vt.size())) : null;
}
if (cur < 0) {
cur = b.material(DEFAULT_MATERIAL);
used.put("", cur);
}
for (int k = 1; k + 1 < n; k++) {
int[] c = {0, k, k + 1};
double[] u = NO_UV.clone();
if (uv[0] != null && uv[k] != null && uv[k + 1] != null) {
for (int j = 0; j < 3; j++) {
u[2 * j] = uv[c[j]][0];
u[2 * j + 1] = 1 - uv[c[j]][1];
}
}
b.tri(vi[0], vi[k], vi[k + 1], u, cur);
}
}
default -> {}
}
}
}
private static int idx(String s, int n) {
int i = Integer.parseInt(s);
return i > 0 ? i - 1 : n + i;
}
private static double d(String s) {
return Double.parseDouble(s);
}
private static int rgb(double r, double g, double b) {
return c8(r) << 16 | c8(g) << 8 | c8(b);
}
private static int c8(double v) {
return (int) Math.max(0, Math.min(255, Math.round(v * 255)));
}
private static Map<String, MeshData.Material> mtl(byte[] data, Function<String, byte[]> files) throws IOException {
Map<String, MeshData.Material> out = new HashMap<>();
if (data == null) {
return out;
}
String name = null;
int kd = GREY;
String map = null;
List<String> lines = new ArrayList<>(List.of(new String(data, StandardCharsets.UTF_8).split("\r?\n")));
lines.add("newmtl");
for (String raw : lines) {
String line = raw.trim();
String[] p = line.split("\\s+");
if (p[0].equals("newmtl")) {
if (name != null) {
out.put(name, material(name, kd, map == null ? null : files.apply(map), false));
}
name = line.length() > 7 ? line.substring(7).trim() : null;
kd = GREY;
map = null;
} else if (p[0].equals("Kd") && p.length >= 4) {
kd = rgb(d(p[1]), d(p[2]), d(p[3]));
} else if (p[0].equalsIgnoreCase("map_Kd")) {
map = lastToken(line);
}
}
return out;
}
private static MeshData.Material material(String name, int rgb, byte[] image, boolean dbl) throws IOException {
if (image == null) {
return new MeshData.Material(name, rgb, null, 0, 0, dbl);
}
BufferedImage img = Images.read(image);
return new MeshData.Material(name, rgb, argb(img), img.getWidth(), img.getHeight(), dbl);
}
// ------------------------------------------------------------ glTF
private static void glb(byte[] d, Function<String, byte[]> files, Builder b) throws IOException {
ByteBuffer bb = ByteBuffer.wrap(d).order(ByteOrder.LITTLE_ENDIAN);
if (d.length < 20 || bb.getInt(0) != 0x46546c67) {
throw new IOException("not a GLB file");
}
int o = 12;
Map<?, ?> json = null;
byte[] bin = null;
while (o + 8 <= d.length) {
int len = bb.getInt(o);
int type = bb.getInt(o + 4);
byte[] c = Arrays.copyOfRange(d, o + 8, o + 8 + len);
if (type == 0x4e4f534a) {
json = (Map<?, ?>) Json.parse(new String(c, StandardCharsets.UTF_8));
} else if (type == 0x004e4942) {
bin = c;
}
o += 8 + len;
}
if (json == null) {
throw new IOException("GLB without JSON chunk");
}
gltf(json, bin, files, b);
}
private static List<?> list(Map<?, ?> m, String k) {
return m.get(k) instanceof List<?> l ? l : List.of();
}
private static int num(Object o, int def) {
return o instanceof Number n ? n.intValue() : def;
}
private static void gltf(Map<?, ?> j, byte[] glbBin, Function<String, byte[]> files, Builder b) throws IOException {
List<byte[]> buffers = new ArrayList<>();
for (Object o : list(j, "buffers")) {
Object uri = ((Map<?, ?>) o).get("uri");
if (uri == null) {
buffers.add(glbBin);
} else {
buffers.add(uri((String) uri, files));
}
}
Gltf g = new Gltf(j, buffers, files);
Map<Integer, Integer> matIndex = new HashMap<>();
List<?> scenes = list(j, "scenes");
List<Object> roots = new ArrayList<>();
if (!scenes.isEmpty()) {
roots.addAll(list((Map<?, ?>) scenes.get(num(j.get("scene"), 0)), "nodes"));
} else {
for (int i = 0; i < list(j, "nodes").size(); i++) {
roots.add((long) i);
}
}
for (Object r : roots) {
g.node(num(r, 0), identity(), b, matIndex);
}
if (roots.isEmpty()) {
for (int m = 0; m < list(j, "meshes").size(); m++) {
g.mesh(m, identity(), b, matIndex);
}
}
}
private static byte[] uri(String uri, Function<String, byte[]> files) throws IOException {
if (uri.startsWith("data:")) {
int c = uri.indexOf(',');
return Base64.getDecoder().decode(uri.substring(c + 1));
}
byte[] d = files.apply(URLDecoder.decode(uri, StandardCharsets.UTF_8));
if (d == null) {
throw new IOException("referenced file " + uri + " missing");
}
return d;
}
private static double[] identity() {
return new double[] {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
}
/** Column-major 4×4 product a·b. */
private static double[] mul(double[] a, double[] b) {
double[] r = new double[16];
for (int c = 0; c < 4; c++) {
for (int row = 0; row < 4; row++) {
double s = 0;
for (int k = 0; k < 4; k++) {
s += a[k * 4 + row] * b[c * 4 + k];
}
r[c * 4 + row] = s;
}
}
return r;
}
private static final class Gltf {
final Map<?, ?> j;
final List<byte[]> buffers;
final Function<String, byte[]> files;
Gltf(Map<?, ?> j, List<byte[]> buffers, Function<String, byte[]> files) {
this.j = j;
this.buffers = buffers;
this.files = files;
}
void node(int i, double[] parent, Builder b, Map<Integer, Integer> mats) throws IOException {
Map<?, ?> n = (Map<?, ?>) list(j, "nodes").get(i);
double[] m = local(n);
double[] w = mul(parent, m);
if (n.get("mesh") != null) {
mesh(num(n.get("mesh"), 0), w, b, mats);
}
for (Object c : list(n, "children")) {
node(num(c, 0), w, b, mats);
}
}
static double[] local(Map<?, ?> n) {
if (n.get("matrix") instanceof List<?> l) {
double[] m = new double[16];
for (int k = 0; k < 16; k++) {
m[k] = ((Number) l.get(k)).doubleValue();
}
return m;
}
double[] t = vec(n.get("translation"), new double[] {0, 0, 0});
double[] q = vec(n.get("rotation"), new double[] {0, 0, 0, 1});
double[] s = vec(n.get("scale"), new double[] {1, 1, 1});
double x = q[0], y = q[1], z = q[2], w = q[3];
double[] r = {
1 - 2 * (y * y + z * z),
2 * (x * y + z * w),
2 * (x * z - y * w),
0,
2 * (x * y - z * w),
1 - 2 * (x * x + z * z),
2 * (y * z + x * w),
0,
2 * (x * z + y * w),
2 * (y * z - x * w),
1 - 2 * (x * x + y * y),
0,
t[0],
t[1],
t[2],
1
};
for (int c = 0; c < 3; c++) {
for (int row = 0; row < 3; row++) {
r[c * 4 + row] *= s[c];
}
}
return r;
}
static double[] vec(Object o, double[] def) {
if (!(o instanceof List<?> l)) {
return def;
}
double[] v = new double[l.size()];
for (int k = 0; k < v.length; k++) {
v[k] = ((Number) l.get(k)).doubleValue();
}
return v;
}
void mesh(int mi, double[] w, Builder b, Map<Integer, Integer> mats) throws IOException {
Map<?, ?> mesh = (Map<?, ?>) list(j, "meshes").get(mi);
boolean mirror = det3(w) < 0;
for (Object po : list(mesh, "primitives")) {
Map<?, ?> p = (Map<?, ?>) po;
if (num(p.get("mode"), 4) != 4) {
continue;
}
Map<?, ?> attr = (Map<?, ?>) p.get("attributes");
double[][] pos = accessor(num(attr.get("POSITION"), -1));
double[][] uv = attr.get("TEXCOORD_0") != null ? accessor(num(attr.get("TEXCOORD_0"), 0)) : null;
double[][] col = attr.get("COLOR_0") != null ? accessor(num(attr.get("COLOR_0"), 0)) : null;
int[] idx;
if (p.get("indices") != null) {
double[][] ix = accessor(num(p.get("indices"), 0));
idx = new int[ix.length];
for (int k = 0; k < ix.length; k++) {
idx[k] = (int) ix[k][0];
}
} else {
idx = new int[pos.length];
for (int k = 0; k < idx.length; k++) {
idx[k] = k;
}
}
int matI = num(p.get("material"), -1);
Integer bm = mats.get(matI);
if (bm == null) {
bm = b.material(material(matI));
mats.put(matI, bm);
}
int[] vi = new int[pos.length];
for (int k = 0; k < pos.length; k++) {
double[] q = pos[k];
double x = w[0] * q[0] + w[4] * q[1] + w[8] * q[2] + w[12];
double y = w[1] * q[0] + w[5] * q[1] + w[9] * q[2] + w[13];
double z = w[2] * q[0] + w[6] * q[1] + w[10] * q[2] + w[14];
Integer c = col == null ? null : rgb(col[k][0], col[k][1], col[k][2]);
vi[k] = b.vertex(x, y, z, c);
}
for (int k = 0; k + 2 < idx.length; k += 3) {
int a = idx[k];
int c1 = mirror ? idx[k + 2] : idx[k + 1];
int c2 = mirror ? idx[k + 1] : idx[k + 2];
double[] u = NO_UV.clone();
if (uv != null) {
int[] cs = {a, c1, c2};
for (int q = 0; q < 3; q++) {
u[2 * q] = uv[cs[q]][0];
u[2 * q + 1] = uv[cs[q]][1];
}
}
b.tri(vi[a], vi[c1], vi[c2], u, bm);
}
}
}
static double det3(double[] m) {
return m[0] * (m[5] * m[10] - m[9] * m[6])
- m[4] * (m[1] * m[10] - m[9] * m[2])
+ m[8] * (m[1] * m[6] - m[5] * m[2]);
}
MeshData.Material material(int i) throws IOException {
if (i < 0) {
return DEFAULT_MATERIAL;
}
Map<?, ?> m = (Map<?, ?>) list(j, "materials").get(i);
String name = m.get("name") instanceof String s ? s : "material" + i;
boolean dbl = Boolean.TRUE.equals(m.get("doubleSided"));
Map<?, ?> pbr = m.get("pbrMetallicRoughness") instanceof Map<?, ?> x ? x : Map.of();
double[] f = vec(pbr.get("baseColorFactor"), new double[] {1, 1, 1, 1});
int rgb = rgb(f[0], f[1], f[2]);
if (pbr.get("baseColorTexture") instanceof Map<?, ?> bt) {
Map<?, ?> tex = (Map<?, ?>) list(j, "textures").get(num(bt.get("index"), 0));
Object src = tex.get("source");
if (src != null) {
Map<?, ?> img = (Map<?, ?>) list(j, "images").get(num(src, 0));
byte[] data;
if (img.get("uri") instanceof String u) {
data = uri(u, files);
} else {
data = view(num(img.get("bufferView"), 0));
}
BufferedImage bi = Images.read(data);
int[] px = argb(bi);
if (f[0] != 1 || f[1] != 1 || f[2] != 1) {
for (int k = 0; k < px.length; k++) {
int c = px[k];
px[k] = (c & 0xff000000)
| c8((c >> 16 & 0xff) / 255.0 * f[0]) << 16
| c8((c >> 8 & 0xff) / 255.0 * f[1]) << 8
| c8((c & 0xff) / 255.0 * f[2]);
}
}
return new MeshData.Material(name, rgb, px, bi.getWidth(), bi.getHeight(), dbl);
}
}
return new MeshData.Material(name, rgb, null, 0, 0, dbl);
}
byte[] view(int i) {
Map<?, ?> v = (Map<?, ?>) list(j, "bufferViews").get(i);
byte[] buf = buffers.get(num(v.get("buffer"), 0));
int off = num(v.get("byteOffset"), 0);
return Arrays.copyOfRange(buf, off, off + num(v.get("byteLength"), 0));
}
double[][] accessor(int i) throws IOException {
Map<?, ?> a = (Map<?, ?>) list(j, "accessors").get(i);
int count = num(a.get("count"), 0);
int comps =
switch ((String) a.get("type")) {
case "SCALAR" -> 1;
case "VEC2" -> 2;
case "VEC3" -> 3;
case "VEC4" -> 4;
default -> throw new IOException("unsupported accessor type " + a.get("type"));
};
int ct = num(a.get("componentType"), 5126);
int size =
switch (ct) {
case 5120, 5121 -> 1;
case 5122, 5123 -> 2;
case 5125, 5126 -> 4;
default -> throw new IOException("unsupported component type " + ct);
};
boolean norm = Boolean.TRUE.equals(a.get("normalized"));
double[][] out = new double[count][comps];
if (a.get("bufferView") == null) {
return out;
}
Map<?, ?> v = (Map<?, ?>) list(j, "bufferViews").get(num(a.get("bufferView"), 0));
byte[] buf = buffers.get(num(v.get("buffer"), 0));
if (buf == null) {
throw new IOException("buffer missing");
}
int base = num(v.get("byteOffset"), 0) + num(a.get("byteOffset"), 0);
int stride = num(v.get("byteStride"), comps * size);
ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN);
for (int k = 0; k < count; k++) {
for (int c = 0; c < comps; c++) {
int o = base + k * stride + c * size;
double x =
switch (ct) {
case 5120 -> norm ? Math.max(bb.get(o) / 127.0, -1) : bb.get(o);
case 5121 -> norm ? (bb.get(o) & 0xff) / 255.0 : bb.get(o) & 0xff;
case 5122 -> norm ? Math.max(bb.getShort(o) / 32767.0, -1) : bb.getShort(o);
case 5123 -> norm ? (bb.getShort(o) & 0xffff) / 65535.0 : bb.getShort(o) & 0xffff;
case 5125 -> Integer.toUnsignedLong(bb.getInt(o));
default -> bb.getFloat(o);
};
out[k][c] = x;
}
}
return out;
}
}
}
@@ -0,0 +1,195 @@
package com.eliasmueller.llamamod.editor.io;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.Prop;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.cmd.Values;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Property values ↔ JSON values (platform.Json types). Arrays longer than {@link #INLINE} are
* stored as base64 ({"i32": …}, {"f64": …}, {"bytes": …}); a changed array of the same length is
* stored as runs of changed elements ({"runs": [[start, values], …]}) so unchanged (original)
* data is never written.
*/
final class NodeCodec {
static final int INLINE = 64;
private NodeCodec() {}
static Object encode(Prop p, Object v) {
if (v == null) {
return null;
}
return switch (p.type()) {
case INT, FIXED, ANGLE, FLAGS, COLOR -> ((Integer) v).longValue();
case DOUBLE -> v;
case BOOL, STRING, CHOICE -> v;
case REF -> ((Node) v).id();
case INT_ARRAY -> ints((int[]) v);
case DOUBLE_ARRAY -> doubles((double[]) v);
case BYTES -> Map.of("bytes", Base64.getEncoder().encodeToString((byte[]) v));
};
}
private static Object ints(int[] a) {
if (a.length <= INLINE) {
List<Object> l = new ArrayList<>(a.length);
for (int x : a) {
l.add((long) x);
}
return l;
}
ByteBuffer b = ByteBuffer.allocate(4 * a.length).order(ByteOrder.LITTLE_ENDIAN);
for (int x : a) {
b.putInt(x);
}
return Map.of("i32", Base64.getEncoder().encodeToString(b.array()));
}
private static Object doubles(double[] a) {
if (a.length <= INLINE) {
List<Object> l = new ArrayList<>(a.length);
for (double x : a) {
l.add(x);
}
return l;
}
ByteBuffer b = ByteBuffer.allocate(8 * a.length).order(ByteOrder.LITTLE_ENDIAN);
for (double x : a) {
b.putDouble(x);
}
return Map.of("f64", Base64.getEncoder().encodeToString(b.array()));
}
/** Decodes a value; refs are resolved in scene (missing → IllegalArgumentException). */
static Object decode(Prop p, Object j, Scene s, Object current) {
if (j == null) {
return null;
}
if (j instanceof Map<?, ?> m && m.containsKey("runs")) {
return applyRuns(p, current, (List<?>) m.get("runs"));
}
return switch (p.type()) {
case INT, FIXED, ANGLE, FLAGS, COLOR -> (int) ((Number) j).longValue();
case DOUBLE -> ((Number) j).doubleValue();
case BOOL -> (Boolean) j;
case STRING, CHOICE -> (String) j;
case REF -> {
Node n = s.node((String) j);
if (n == null) {
throw new IllegalArgumentException("reference to unknown node " + j);
}
yield n;
}
case INT_ARRAY -> decodeInts(j);
case DOUBLE_ARRAY -> decodeDoubles(j);
case BYTES -> Base64.getDecoder().decode((String) ((Map<?, ?>) j).get("bytes"));
};
}
private static int[] decodeInts(Object j) {
if (j instanceof Map<?, ?> m) {
ByteBuffer b = ByteBuffer.wrap(Base64.getDecoder().decode((String) m.get("i32")))
.order(ByteOrder.LITTLE_ENDIAN);
int[] a = new int[b.remaining() / 4];
for (int i = 0; i < a.length; i++) {
a[i] = b.getInt();
}
return a;
}
List<?> l = (List<?>) j;
int[] a = new int[l.size()];
for (int i = 0; i < a.length; i++) {
a[i] = (int) ((Number) l.get(i)).longValue();
}
return a;
}
private static double[] decodeDoubles(Object j) {
if (j instanceof Map<?, ?> m) {
ByteBuffer b = ByteBuffer.wrap(Base64.getDecoder().decode((String) m.get("f64")))
.order(ByteOrder.LITTLE_ENDIAN);
double[] a = new double[b.remaining() / 8];
for (int i = 0; i < a.length; i++) {
a[i] = b.getDouble();
}
return a;
}
List<?> l = (List<?>) j;
double[] a = new double[l.size()];
for (int i = 0; i < a.length; i++) {
a[i] = ((Number) l.get(i)).doubleValue();
}
return a;
}
/** Value for "changed from base to cur": runs for same-length arrays, else the full value. */
static Object encodeChange(Prop p, Object base, Object cur) {
if (p.type().isArray() && base != null && cur != null && length(base) == length(cur) && length(cur) > INLINE) {
List<Object> runs = new ArrayList<>();
int n = length(cur);
int i = 0;
while (i < n) {
if (same(base, cur, i)) {
i++;
continue;
}
int j = i;
while (j < n && !same(base, cur, j)) {
j++;
}
runs.add(List.of((long) i, encode(p, slice(cur, i, j))));
i = j;
}
Map<String, Object> m = new LinkedHashMap<>();
m.put("runs", runs);
return m;
}
return encode(p, cur);
}
private static int length(Object a) {
return switch (a) {
case int[] x -> x.length;
case double[] x -> x.length;
case byte[] x -> x.length;
default -> -1;
};
}
private static boolean same(Object a, Object b, int i) {
return switch (a) {
case int[] x -> x[i] == ((int[]) b)[i];
case double[] x -> Double.doubleToRawLongBits(x[i]) == Double.doubleToRawLongBits(((double[]) b)[i]);
case byte[] x -> x[i] == ((byte[]) b)[i];
default -> false;
};
}
private static Object slice(Object a, int from, int to) {
return switch (a) {
case int[] x -> java.util.Arrays.copyOfRange(x, from, to);
case double[] x -> java.util.Arrays.copyOfRange(x, from, to);
case byte[] x -> java.util.Arrays.copyOfRange(x, from, to);
default -> throw new IllegalArgumentException();
};
}
private static Object applyRuns(Prop p, Object current, List<?> runs) {
Object out = Values.copy(current);
for (Object r : runs) {
List<?> run = (List<?>) r;
int start = (int) ((Number) run.get(0)).longValue();
Object vals = decode(p, run.get(1), null, null);
System.arraycopy(vals, 0, out, start, length(vals));
}
return out;
}
}
@@ -0,0 +1,67 @@
package com.eliasmueller.llamamod.editor.io;
import com.eliasmueller.llamamod.editor.model.LevelSettings;
import com.eliasmueller.llamamod.editor.model.Scene;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* A loaded {@code .llamalevel} project: the scene plus what the file carries besides it (thumbnail,
* fields of newer format versions that are written back unchanged) and load warnings.
*/
public final class Project {
private final Scene scene;
final Scene reference;
private byte[] thumbnail;
final Map<String, Object> unknownTop = new LinkedHashMap<>();
final SceneDiff.Unknown unknownNodes = new SceneDiff.Unknown();
private final List<String> warnings = new ArrayList<>();
/** reference: the unedited scene the project is stored against (same kind and base). */
public Project(Scene reference, Scene scene) {
this.reference = reference;
this.scene = scene;
}
/**
* New project for an original level (kind "edit") or an empty level (kind "new", without the base
* level's teleport zones, point lights and region masks: {@link Templates#clearBaseLeftovers}).
*/
public static Project create(Bases cd, String kind, String base) throws java.io.IOException {
Scene scene = Templates.reference(cd, kind, base);
if (LevelSettings.NEW.equals(kind)) {
Templates.clearBaseLeftovers(scene);
}
return new Project(Templates.reference(cd, kind, base), scene);
}
public Scene scene() {
return scene;
}
/** The unedited scene the project is diffed against (extensions that change both use it). */
public Scene reference() {
return reference;
}
/** Top-level level.json entries not written by the core (kept on save; extensions store data here). */
public Map<String, Object> extra() {
return unknownTop;
}
/** PNG or {@code null}. */
public byte[] thumbnail() {
return thumbnail;
}
public void setThumbnail(byte[] png) {
thumbnail = png;
}
/** Problems found while loading (base level changed, unknown references, …). */
public List<String> warnings() {
return warnings;
}
}
@@ -0,0 +1,241 @@
package com.eliasmueller.llamamod.editor.io;
import com.eliasmueller.llamamod.editor.model.LevelSettings;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.platform.Json;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
/**
* {@code .llamalevel} files (zip: level.json + assets/ + thumbnail.png), see notes/editor-format.md.
* Only differences to the reference scene are stored, so a project contains no original game data.
*/
public final class ProjectIO {
public static final int FORMAT_VERSION = 1;
public static final String EXTENSION = ".llamalevel";
static final String LEVEL_JSON = "level.json";
static final String THUMBNAIL = "thumbnail.png";
private static final long ZIP_TIME = 946684800000L;
private static final List<String> TOP_KEYS = List.of(
"formatVersion",
"app",
"kind",
"base",
"baseSha256",
"name",
"targetChapter",
"nextId",
"settings",
"regions",
"nodes");
private ProjectIO() {}
/**
* Extension that adjusts reference and current scene identically before the stored changes are
* applied (e.g. object types imported from other levels, keyed by a top-level level.json entry).
*/
public interface LoadHook {
void beforeChanges(Map<String, Object> json, Bases cd, Scene reference, Scene scene, List<String> warnings)
throws IOException;
}
private static final List<LoadHook> HOOKS = new CopyOnWriteArrayList<>();
static {
try {
Class.forName("com.eliasmueller.llamamod.editor.tools.catalog.CrossImport");
} catch (ClassNotFoundException | LinkageError e) {
// optional extension
}
}
public static void addLoadHook(LoadHook h) {
HOOKS.add(h);
}
public static Project load(Path file, Bases cd) throws IOException {
return read(Files.readAllBytes(file), cd);
}
/** Reads a project; a damaged file (wrong value types, broken references) gives an IOException. */
public static Project read(byte[] zip, Bases cd) throws IOException {
try {
return readChecked(zip, cd);
} catch (RuntimeException e) {
throw damaged(e);
}
}
private static IOException damaged(RuntimeException e) {
return new IOException("the project file is damaged: " + e, e);
}
private static int intOr(Map<String, Object> m, String key, long def) {
return ((Number) m.getOrDefault(key, def)).intValue();
}
private static Project readChecked(byte[] zip, Bases cd) throws IOException {
Map<String, byte[]> files = unzip(zip);
Map<String, Object> json = json(files);
int ver = intOr(json, "formatVersion", 1);
String kind = (String) json.getOrDefault("kind", LevelSettings.EDIT);
String base = (String) json.getOrDefault("base", Templates.DEFAULT_BASE);
Scene ref = Templates.reference(cd, kind, base);
Scene cur = Templates.reference(cd, kind, base);
Project p = new Project(ref, cur);
if (ver > FORMAT_VERSION) {
p.warnings().add("the project was written by a newer editor (format " + ver + "); unknown parts are kept");
}
String sha = (String) json.get("baseSha256");
if (sha != null && !sha.equals(sha256(cd.wad(base)))) {
p.warnings().add("the base level " + base + " differs from the one the project was made with");
}
for (Map.Entry<String, byte[]> e : files.entrySet()) {
if (e.getKey().startsWith("assets/")) {
cur.assets().put(e.getKey(), e.getValue());
}
}
for (LoadHook h : HOOKS) {
h.beforeChanges(json, cd, ref, cur, p.warnings());
}
cur.setNextIdCounter(intOr(json, "nextId", 1));
SceneDiff.apply(cur, json, cur.assets(), p.unknownNodes, p.warnings());
cur.assets().keySet().removeIf(k -> k.startsWith("assets/pages/"));
for (Map.Entry<String, Object> e : json.entrySet()) {
if (!TOP_KEYS.contains(e.getKey())) {
p.unknownTop.put(e.getKey(), e.getValue());
}
}
p.setThumbnail(files.get(THUMBNAIL));
return p;
}
/** Header only (no base level needed). */
public static ProjectInfo info(byte[] zip) throws IOException {
try {
return infoChecked(zip);
} catch (RuntimeException e) {
throw damaged(e);
}
}
@SuppressWarnings("unchecked")
private static ProjectInfo infoChecked(byte[] zip) throws IOException {
Map<String, Object> json = json(unzip(zip));
Map<String, Object> st = (Map<String, Object>) json.getOrDefault("settings", Map.of());
return new ProjectInfo(
intOr(json, "formatVersion", 1),
(String) json.getOrDefault("kind", LevelSettings.EDIT),
(String) json.getOrDefault("base", Templates.DEFAULT_BASE),
(String) json.getOrDefault("name", ""),
(String) st.getOrDefault("author", ""),
(String) st.getOrDefault("description", ""),
intOr(json, "targetChapter", 0));
}
public static void save(Project p, Path file, Bases cd) throws IOException {
byte[] d = write(p, cd);
Path tmp = file.resolveSibling(file.getFileName() + ".tmp");
Files.write(tmp, d);
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
}
public static byte[] write(Project p, Bases cd) throws IOException {
Scene s = p.scene();
LevelSettings st = s.settings();
Map<String, byte[]> assets = new TreeMap<>(s.assets());
Map<String, Object> json = new LinkedHashMap<>();
json.put("formatVersion", (long) FORMAT_VERSION);
json.put("app", "LlamaMod level editor");
json.put("kind", st.projectKind());
json.put("base", st.base());
json.put("baseSha256", sha256(cd.wad(st.base())));
json.put("name", st.levelName());
json.put("targetChapter", (long) st.targetChapter());
json.put("nextId", (long) s.nextIdCounter());
json.putAll(SceneDiff.diff(p.reference, s, assets, p.unknownNodes));
p.unknownTop.forEach(json::putIfAbsent);
Map<String, byte[]> files = new TreeMap<>(assets);
files.put(LEVEL_JSON, Json.write(json).getBytes(StandardCharsets.UTF_8));
if (p.thumbnail() != null) {
files.put(THUMBNAIL, p.thumbnail());
}
ByteArrayOutputStream bo = new ByteArrayOutputStream();
try (ZipOutputStream z = new ZipOutputStream(bo)) {
for (String name : order(files)) {
ZipEntry e = new ZipEntry(name);
e.setTime(ZIP_TIME);
z.putNextEntry(e);
z.write(files.get(name));
z.closeEntry();
}
}
return bo.toByteArray();
}
/** level.json first, then the thumbnail, then assets (sorted). */
private static List<String> order(Map<String, byte[]> files) {
return files.keySet().stream()
.sorted(Comparator.comparingInt((String n) -> n.equals(LEVEL_JSON) ? 0 : n.equals(THUMBNAIL) ? 1 : 2)
.thenComparing(Comparator.naturalOrder()))
.toList();
}
@SuppressWarnings("unchecked")
private static Map<String, Object> json(Map<String, byte[]> files) throws IOException {
byte[] j = files.get(LEVEL_JSON);
if (j == null) {
throw new IOException("not a level project (level.json missing)");
}
try {
return (Map<String, Object>) Json.parse(new String(j, StandardCharsets.UTF_8));
} catch (RuntimeException e) {
throw new IOException("level.json is damaged: " + e.getMessage(), e);
}
}
static Map<String, byte[]> unzip(byte[] zip) throws IOException {
Map<String, byte[]> out = new LinkedHashMap<>();
try (ZipInputStream z = new ZipInputStream(new ByteArrayInputStream(zip))) {
ZipEntry e;
while ((e = z.getNextEntry()) != null) {
String n = e.getName();
if (e.isDirectory() || n.contains("..") || n.startsWith("/")) {
continue;
}
out.put(n, z.readAllBytes());
}
}
if (out.isEmpty()) {
throw new IOException("not a level project (empty or not a zip file)");
}
return out;
}
public static String sha256(byte[] d) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(d));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,15 @@
package com.eliasmueller.llamamod.editor.io;
/** Header of a project file, read without the game data (mod lists). */
public record ProjectInfo(
int formatVersion,
String kind,
String base,
String name,
String author,
String description,
int targetChapter) {
public boolean isNewLevel() {
return "new".equals(kind);
}
}
@@ -0,0 +1,258 @@
package com.eliasmueller.llamamod.editor.io;
import com.eliasmueller.llamamod.editor.model.Brush;
import com.eliasmueller.llamamod.editor.model.BrushKind;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.NodeList;
import com.eliasmueller.llamamod.editor.model.Prop;
import com.eliasmueller.llamamod.editor.model.PropType;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.TexturePage;
import com.eliasmueller.llamamod.editor.model.cmd.Values;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Differences between a reference scene (original level or empty template) and the edited scene,
* as JSON-ready maps (the "settings", "regions" and "nodes" parts of level.json, see
* notes/editor-format.md).
*/
final class SceneDiff {
private SceneDiff() {}
/** Unknown node fields read from a newer file, kept for writing back: id → field → value. */
static final class Unknown {
final Map<String, Map<String, Object>> fields = new HashMap<>();
}
/** Diff; added texture pages put their pixels as PNG into assets. */
static Map<String, Object> diff(Scene ref, Scene cur, Map<String, byte[]> assets, Unknown unknown) {
Map<String, Object> out = new LinkedHashMap<>();
Map<String, Object> st = changed(ref.settings(), cur.settings(), unknown);
if (!st.isEmpty()) {
out.put("settings", st);
}
Map<String, Object> rg = changed(ref.regions(), cur.regions(), unknown);
if (!rg.isEmpty()) {
out.put("regions", rg);
}
Map<String, Object> nodes = new LinkedHashMap<>();
for (NodeKind k : Scene.LIST_KINDS) {
NodeList<Node> rl = ref.listOf(k);
NodeList<Node> cl = cur.listOf(k);
Map<String, Object> d = new LinkedHashMap<>();
List<Object> removed = new ArrayList<>();
Map<String, Object> changedNodes = new LinkedHashMap<>();
List<String> expected = new ArrayList<>();
for (Node r : rl) {
Node c = cur.node(r.id());
if (c == null || c.kind() != k) {
removed.add(r.id());
continue;
}
expected.add(r.id());
Map<String, Object> ch = changed(r, c, unknown);
if (!ch.isEmpty()) {
changedNodes.put(r.id(), ch);
}
}
List<Object> added = new ArrayList<>();
for (Node c : cl) {
if (ref.node(c.id()) == null) {
added.add(full(c, assets, unknown));
expected.add(c.id());
}
}
List<String> actual = cl.stream().map(Node::id).toList();
if (!removed.isEmpty()) {
d.put("removed", removed);
}
if (!changedNodes.isEmpty()) {
d.put("changed", changedNodes);
}
if (!added.isEmpty()) {
d.put("added", added);
}
if (!actual.equals(expected)) {
d.put("order", new ArrayList<Object>(actual));
}
if (!d.isEmpty()) {
nodes.put(k.prefix(), d);
}
}
if (!nodes.isEmpty()) {
out.put("nodes", nodes);
}
return out;
}
private static boolean editable(Prop p) {
return p.persistent() && p.settable();
}
private static Map<String, Object> changed(Node r, Node c, Unknown unknown) {
Map<String, Object> m = new LinkedHashMap<>();
for (Prop p : c.props()) {
if (!editable(p)) {
continue;
}
Object a = p.get(r);
Object b = p.get(c);
boolean same = p.type() == PropType.REF ? idOf(a).equals(idOf(b)) : Values.same(a, b);
if (!same) {
m.put(p.name(), NodeCodec.encodeChange(p, a, b));
}
}
return withUnknown(m, c, unknown);
}
private static String idOf(Object n) {
return n == null ? "" : ((Node) n).id();
}
private static Map<String, Object> full(Node c, Map<String, byte[]> assets, Unknown unknown) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", c.id());
if (c instanceof Brush b) {
m.put("brushKind", b.brushKind().name());
}
for (Prop p : c.props()) {
if (!editable(p) || p.name().equals("brushKind")) {
continue;
}
Object v = p.get(c);
if (c instanceof TexturePage pg && p.name().equals("pixels") && pg.flags() != 0) {
String path = "assets/pages/" + c.id().replace(':', '_') + ".png";
assets.put(path, Images.pagePng(pg));
m.put(p.name(), Map.of("png", path));
continue;
}
m.put(p.name(), NodeCodec.encode(p, v));
}
return withUnknown(m, c, unknown);
}
/** Adds the unknown fields kept for node c (written back unchanged). */
private static Map<String, Object> withUnknown(Map<String, Object> m, Node c, Unknown unknown) {
Map<String, Object> u = unknown == null ? null : unknown.fields.get(c.id());
if (u != null) {
u.forEach(m::putIfAbsent);
}
return m;
}
/** Applies a diff to the reference scene (which becomes the edited scene). */
@SuppressWarnings("unchecked")
static void apply(
Scene s, Map<String, Object> json, Map<String, byte[]> assets, Unknown unknown, List<String> warn) {
Map<String, Object> nodes = (Map<String, Object>) json.getOrDefault("nodes", Map.of());
// 1. removals, 2. create added nodes (all kinds, so references resolve), 3. set values
List<Map.Entry<Node, Map<String, Object>>> fill = new ArrayList<>();
for (NodeKind k : Scene.LIST_KINDS) {
Map<String, Object> d = (Map<String, Object>) nodes.get(k.prefix());
if (d == null) {
continue;
}
for (Object id : (List<Object>) d.getOrDefault("removed", List.of())) {
Node n = s.node((String) id);
if (n == null) {
warn.add("removed " + id + " does not exist in the base level");
} else {
s.remove(n);
}
}
for (Object o : (List<Object>) d.getOrDefault("added", List.of())) {
Map<String, Object> m = (Map<String, Object>) o;
String id = (String) m.get("id");
if (s.node(id) != null) {
warn.add("added node " + id + " already exists – skipped");
continue;
}
BrushKind bk = k == NodeKind.BRUSH ? BrushKind.valueOf((String) m.get("brushKind")) : null;
Node n = Scene.createWithId(k, id, bk);
s.add(n, -1);
fill.add(Map.entry(n, m));
}
Map<String, Object> ch = (Map<String, Object>) d.getOrDefault("changed", Map.of());
for (Map.Entry<String, Object> e : ch.entrySet()) {
Node n = s.node(e.getKey());
if (n == null) {
warn.add("changed " + e.getKey() + " does not exist in the base level");
continue;
}
fill.add(Map.entry(n, (Map<String, Object>) e.getValue()));
}
}
if (json.get("settings") instanceof Map<?, ?> m) {
fill.add(Map.entry(s.settings(), (Map<String, Object>) m));
}
if (json.get("regions") instanceof Map<?, ?> m) {
fill.add(Map.entry(s.regions(), (Map<String, Object>) m));
}
for (Map.Entry<Node, Map<String, Object>> e : fill) {
Node n = e.getKey();
Set<String> known = new HashSet<>(List.of("id", "brushKind"));
for (Prop p : n.props()) {
known.add(p.name());
if (!e.getValue().containsKey(p.name()) || !editable(p)) {
continue;
}
Object j = e.getValue().get(p.name());
Object v;
if (n instanceof TexturePage pg
&& p.name().equals("pixels")
&& j instanceof Map<?, ?> pm
&& pm.containsKey("png")) {
byte[] png = assets.get((String) pm.get("png"));
if (png == null) {
warn.add(n.id() + ": page image " + pm.get("png") + " missing");
continue;
}
v = Images.pagePixels(png, pg.width(), pg.height());
} else {
v = NodeCodec.decode(p, j, s, p.get(n));
}
try {
p.setRaw(n, v);
} catch (IllegalArgumentException ex) {
warn.add(n.id() + "." + p.name() + ": " + ex.getMessage());
}
}
for (Map.Entry<String, Object> f : e.getValue().entrySet()) {
if (!known.contains(f.getKey())) {
unknown.fields
.computeIfAbsent(n.id(), x -> new LinkedHashMap<>())
.put(f.getKey(), f.getValue());
}
}
}
for (NodeKind k : Scene.LIST_KINDS) {
Map<String, Object> d = (Map<String, Object>) nodes.get(k.prefix());
if (d == null || !d.containsKey("order")) {
continue;
}
List<Node> order = new ArrayList<>();
for (Object id : (List<Object>) d.get("order")) {
Node n = s.node((String) id);
if (n != null && n.kind() == k) {
order.add(n);
}
}
for (Node n : s.listOf(k)) {
if (!order.contains(n)) {
order.add(n);
}
}
s.reorder(k, order);
}
for (var st : s.strats()) {
st.setTypeName(s.catalog().nameOf(st.code()));
}
}
}
@@ -0,0 +1,92 @@
package com.eliasmueller.llamamod.editor.io;
import com.eliasmueller.llamamod.editor.model.EnvZone;
import com.eliasmueller.llamamod.editor.model.LevelSettings;
import com.eliasmueller.llamamod.editor.model.Light;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.PathNode;
import com.eliasmueller.llamamod.editor.model.Scene;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
/** Reference scenes projects are stored against: an original level, or an empty level on a base. */
public final class Templates {
/** Default base for new levels: the village (object code, textures and sound of world 1). */
public static final String DEFAULT_BASE = "t1l1m001";
/** Strat pool reserve for new levels (levelbuild: objects + 80). */
public static final int NEW_LEVEL_RESERVE = 80;
private Templates() {}
/** The original level as a scene (kind "edit"). */
public static Scene original(Bases cd, String base) throws IOException {
String b = base.toLowerCase(Locale.ROOT);
return WadImporter.importWad(cd.wad(b), b, cd::fileOrNull);
}
/**
* Empty level (kind "new") on a base level: tiles, tile models, objects and waypoints removed,
* visibility regions off (one region that sees everything). Textures, zones (fog, music),
* lights, object code and sound of the base stay.
*/
public static Scene newLevel(Bases cd, String base) throws IOException {
Scene s = original(cd, base);
makeEmpty(s);
return s;
}
static void makeEmpty(Scene s) {
s.clearQuiet(NodeKind.TILE);
s.clearQuiet(NodeKind.TILE_MODEL);
s.clearQuiet(NodeKind.STRAT);
s.clearQuiet(NodeKind.PATH_NODE);
LevelSettings st = s.settings();
st.setRaw("kind", LevelSettings.NEW);
st.setRaw("name", "New level");
st.setRaw("targetChapter", 1);
st.setRaw("poolReserve", NEW_LEVEL_RESERVE);
int[] table = s.regions().table().clone();
table[0] = 0xffffffff;
s.regions().setRaw("table", table);
s.regions().setRaw("grid", new int[st.width() * st.height()]);
}
/**
* Removes what a new level would inherit from the base level at the base's own positions: the
* teleport zones (the start zone with its fog, ambient colour and music stays), the point lights
* (the directional lights stay, they light the objects) and region masks (every region sees
* everything). Textures and object code stay – STPC object models use the textures by index.
* Applied to the scene of a new project, not to the reference, so saved projects keep their content.
*/
public static void clearBaseLeftovers(Scene s) {
EnvZone start = s.zones().stream()
.filter(EnvZone::isDefault)
.findFirst()
.orElse(s.zones().isEmpty() ? null : s.zones().get(0));
for (EnvZone z : List.copyOf(s.zones())) {
if (z != start) {
s.remove(z);
}
}
for (Light l : List.copyOf(s.lights())) {
if (l.type() != Light.DIRECTIONAL) {
s.remove(l);
}
}
for (PathNode n : List.copyOf(s.pathNodes())) {
s.remove(n);
}
int[] table = s.regions().table().clone();
for (int i = 0; i < table.length; i += 2) {
table[i] = 0xffffffff;
}
s.regions().setRaw("table", table);
}
/** Reference scene of a project kind. */
public static Scene reference(Bases cd, String kind, String base) throws IOException {
return LevelSettings.NEW.equals(kind) ? newLevel(cd, base) : original(cd, base);
}
}
@@ -0,0 +1,109 @@
package com.eliasmueller.llamamod.editor.io;
import com.eliasmueller.llamamod.editor.model.TexturePage;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
/**
* Packs square images (power-of-two sizes ≤ 256) into new 256×256 texture pages (largest first
* is the caller's job; slots are filled row by row per size).
*/
final class TextureAtlas {
static final int PAGE = 256;
/** Opaque RLE page / page with transparent pixels (formats of the original pages). */
static final int OPAQUE = 0x81;
static final int ALPHA = 0x84;
record Slot(int page, int x, int y, int size) {}
private final List<TexturePage> pages = new ArrayList<>();
private final List<boolean[]> used = new ArrayList<>();
private final int firstIndex;
private final Supplier<TexturePage> factory;
/** firstIndex: index the first new page will have in the TEXT page list. */
TextureAtlas(int firstIndex, Supplier<TexturePage> factory) {
this.firstIndex = firstIndex;
this.factory = factory;
}
List<TexturePage> pages() {
return pages;
}
static int fitSize(int requested) {
int s = 16;
while (s * 2 <= Math.min(PAGE, Math.max(16, requested))) {
s *= 2;
}
return s;
}
/** Places an image scaled to size×size; returns its slot (page = index in the full page list). */
Slot add(BufferedImage img, int size) {
int s = fitSize(size);
int cells = PAGE / s;
for (int p = 0; p <= pages.size(); p++) {
if (p == pages.size()) {
TexturePage pg = factory.get();
pg.setRaw("flags", OPAQUE);
pg.setRaw("pixels", new int[PAGE * PAGE]);
pages.add(pg);
used.add(new boolean[(PAGE / 16) * (PAGE / 16)]);
}
boolean[] u = used.get(p);
int g = s / 16;
for (int cy = 0; cy < cells; cy++) {
for (int cx = 0; cx < cells; cx++) {
if (free(u, cx * g, cy * g, g)) {
mark(u, cx * g, cy * g, g);
draw(pages.get(p), img, cx * s, cy * s, s);
return new Slot(firstIndex + p, cx * s, cy * s, s);
}
}
}
}
throw new IllegalStateException("unreachable");
}
private static boolean free(boolean[] u, int x, int y, int g) {
int w = PAGE / 16;
for (int j = y; j < y + g; j++) {
for (int i = x; i < x + g; i++) {
if (u[j * w + i]) {
return false;
}
}
}
return true;
}
private static void mark(boolean[] u, int x, int y, int g) {
int w = PAGE / 16;
for (int j = y; j < y + g; j++) {
for (int i = x; i < x + g; i++) {
u[j * w + i] = true;
}
}
}
private static void draw(TexturePage pg, BufferedImage img, int ox, int oy, int s) {
int[] px = Images.toAbgr1555(img, s, s);
int[] dst = pg.pixels();
boolean alpha = false;
for (int y = 0; y < s; y++) {
for (int x = 0; x < s; x++) {
int v = px[y * s + x];
dst[(oy + y) * PAGE + ox + x] = v;
alpha |= v == 0 || (v & 0x8000) == 0;
}
}
if (alpha) {
pg.setRaw("flags", ALPHA);
}
pg.setEncodedCache(null);
}
}
@@ -0,0 +1,78 @@
package com.eliasmueller.llamamod.editor.io;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.TextureEntry;
import com.eliasmueller.llamamod.editor.model.TexturePage;
import com.eliasmueller.llamamod.editor.model.cmd.AddNode;
import com.eliasmueller.llamamod.editor.model.cmd.Batch;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* Images (PNG, JPEG) → new texture pages and texture table entries. Each image is scaled to a
* square power-of-two size (16..256) and packed into new 256×256 pages; the entry covers the whole
* image. Transparent pixels (alpha &lt; 64) become 0x0000 (the engine's transparent colour), half
* transparent ones STP = 0. Existing pages are never touched.
*/
public final class TextureImport {
/** Normal opaque texture entry flags (most entries of the original levels). */
public static final int ENTRY_FLAGS = 0x104;
private TextureImport() {}
public record NamedImage(String name, BufferedImage image) {}
/** command adds the pages and entries; entries[i] = texture index of image i after executing it. */
public record Result(Command command, int[] entries, List<TexturePage> pages) {}
public static Result importImages(Scene s, List<NamedImage> images, int size) {
List<TexturePage> pages = new ArrayList<>();
TextureAtlas atlas = new TextureAtlas(s.pages().size(), () -> {
TexturePage p = (TexturePage) s.create(NodeKind.TEXTURE_PAGE);
pages.add(p);
return p;
});
List<Command> cmds = new ArrayList<>();
List<TextureEntry> entries = new ArrayList<>();
int[] idx = new int[images.size()];
for (int i = 0; i < images.size(); i++) {
NamedImage ni = images.get(i);
TextureAtlas.Slot slot = atlas.add(ni.image(), size);
TextureEntry e = (TextureEntry) s.create(NodeKind.TEXTURE);
e.setRaw("name", ni.name());
e.setRaw("flags", ENTRY_FLAGS);
e.setRaw("page", slot.page());
e.setRaw("page2", -1);
e.setRaw("u0", slot.x());
e.setRaw("u1", slot.x() + slot.size() - 1);
e.setRaw("v0", slot.y());
e.setRaw("v1", slot.y() + slot.size() - 1);
entries.add(e);
idx[i] = s.textures().size() + i;
}
for (TexturePage p : pages) {
p.setRaw("name", "imported");
cmds.add(new AddNode(p));
}
for (TextureEntry e : entries) {
cmds.add(new AddNode(e));
}
return new Result(new Batch("Import textures", cmds), idx, pages);
}
public static Result importFiles(Scene s, List<Path> files, int size) throws IOException {
List<NamedImage> l = new ArrayList<>();
for (Path f : files) {
String n = f.getFileName().toString();
l.add(new NamedImage(
n.contains(".") ? n.substring(0, n.lastIndexOf('.')) : n, Images.read(Files.readAllBytes(f))));
}
return importImages(s, l, size);
}
}
@@ -0,0 +1,128 @@
package com.eliasmueller.llamamod.editor.io;
import com.eliasmueller.llamamod.editor.model.Brush;
import com.eliasmueller.llamamod.editor.model.LevelSettings;
import com.eliasmueller.llamamod.editor.model.PathNode;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.StratCatalog;
import com.eliasmueller.llamamod.editor.model.TexturePage;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.model.TileModel;
import com.eliasmueller.llamamod.format.MapChunk;
import java.util.ArrayList;
import java.util.List;
/**
* Checks what the engine needs before a build. Errors make the WAD unusable (or crash the game);
* warnings are suspicious but buildable. Also usable by the UI for a problems list.
*/
public final class Validator {
/** Vertex transform buffer limit per model (0x58b0f0..0x5d15f8, 36 bytes per vertex). */
public static final int MAX_VERTS = 8000;
private Validator() {}
/** Errors; warnings are added to warn. */
public static List<String> validate(Scene s, List<String> warn) {
List<String> err = new ArrayList<>();
LevelSettings st = s.settings();
int w = st.width();
int h = st.height();
if (s.regions().grid().length != w * h) {
err.add("region grid has " + s.regions().grid().length + " cells, the grid " + w * h);
}
if (s.regions().table().length != 64) {
err.add("region table must have 64 values");
}
int nTex = s.textures().size();
for (TileModel m : s.tileModels()) {
if (m.vertexCount() > MAX_VERTS) {
err.add(m.id() + ": " + m.vertexCount() + " vertices (engine limit " + MAX_VERTS + ")");
}
if (m.vertices().length % TileModel.VERTEX_STRIDE != 0
|| m.faces().length % TileModel.FACE_STRIDE != 0
|| m.planes().length != m.faceCount() * TileModel.PLANE_STRIDE) {
err.add(m.id() + ": array lengths do not match");
continue;
}
for (int g = 0; g < 3; g++) {
if (m.collision(g).length % TileModel.COLLISION_SIZE != 0) {
err.add(m.id() + ": collision group " + g + " is not a multiple of 32 bytes");
}
}
int nv = m.vertexCount();
for (int f = 0; f < m.faceCount(); f++) {
for (int k = 0; k < 3; k++) {
if (m.faceVertex(f, k) >= nv) {
err.add(m.id() + " face " + f + ": vertex index out of range");
break;
}
}
if (m.faceTexture(f) >= nTex) {
warn.add(m.id() + " face " + f + ": texture " + m.faceTexture(f) + " beyond the table (" + nTex
+ ")");
}
}
}
boolean alts = (st.wfpc() & MapChunk.WF_ALTS) != 0;
for (Tile t : s.tiles()) {
if (t.model() == null || !s.contains(t.model())) {
err.add(t.id() + ": model missing");
continue;
}
int cx = t.cellX();
int cz = t.cellZ();
if (cx < 0 || cx >= w || cz < 0 || cz >= h) {
err.add(t.id() + ": outside the " + w + "×" + h + " grid (cell " + cx + "," + cz + ")");
}
if (t.hasSwap()) {
if (!alts) {
err.add(t.id() + ": tile swaps need WFPC flag 0x10000, which this base level does not have");
} else if (t.swapAltModel() == null || !s.contains(t.swapAltModel())) {
err.add(t.id() + ": swap without alternative model");
}
}
}
for (Strat x : s.strats()) {
if (x.waypoint() != null && !s.contains(x.waypoint())) {
err.add(x.id() + ": waypoint is not part of the level");
}
StratCatalog.Type t = s.catalog().type(x.type());
if (x.type().isEmpty()) {
warn.add(x.id() + ": unknown strat code 0x" + Integer.toHexString(x.code()));
} else if (t != null && t.variants().stream().noneMatch(v -> v.nParams() == x.params().length)) {
warn.add(x.id() + " (" + x.type() + "): " + x.params().length + " parameters, the type uses "
+ t.variants().getFirst().nParams());
}
double gx = x.gx();
double gz = x.gz();
if (gx < 0 || gx >= w || gz < 0 || gz >= h) {
warn.add(x.id() + " (" + x.type() + ") is outside the grid");
}
}
for (Brush b : s.brushes()) {
String bad = b.enabled() ? BrushCompiler.problem(b, w, h, 8) : null;
if (bad != null) {
err.add(b.id() + " (" + b.label() + "): " + bad);
}
}
for (PathNode n : s.pathNodes()) {
if (n.next() != null && !s.contains(n.next())) {
err.add(n.id() + ": next node is not part of the level");
}
}
for (TexturePage p : s.pages()) {
if (p.pixels().length != p.width() * p.height()) {
err.add(p.id() + ": pixel count does not match " + p.width() + "×" + p.height());
}
}
if (s.pages().size() > 127) {
err.add("more than 127 texture pages");
}
if (st.isNewLevel() && (st.targetChapter() < 0 || st.targetChapter() > 49)) {
err.add("TestLevel chapter must be 1..49");
}
return err;
}
}
@@ -0,0 +1,107 @@
package com.eliasmueller.llamamod.editor.io;
import com.eliasmueller.llamamod.editor.model.RawChunk;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.TextureEntry;
import com.eliasmueller.llamamod.editor.model.TexturePage;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.model.TileModel;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.Wad;
import java.util.ArrayList;
import java.util.List;
/**
* Scene → level WAD. Brushes and mesh placements are converted to tiles ({@link BrushCompiler})
* and appended after the scene's own tiles and models. Without edits the result is byte-identical
* to the imported WAD. The output is validated first ({@link Validator}) and parsed back after.
*/
public final class WadBuilder {
private WadBuilder() {}
/** Statistics and non-fatal notes of a build. */
public record Result(byte[] wad, Stats stats, List<String> warnings) {}
public record Stats(
int models,
int tiles,
int generatedModels,
int generatedTiles,
int strats,
int waypoints,
int textures,
int pages,
int wadBytes) {}
public static byte[] build(Scene s) {
return buildWithReport(s).wad();
}
public static Result buildWithReport(Scene s) {
List<String> warn = new ArrayList<>();
List<String> errors = Validator.validate(s, warn);
if (!errors.isEmpty()) {
throw new IllegalStateException("The level is not valid:\n " + String.join("\n ", errors));
}
BrushCompiler.Output gen = BrushCompiler.compile(s, warn);
List<TileModel> models = new ArrayList<>(s.tileModels());
models.addAll(gen.models());
List<Tile> tiles =
new ArrayList<>(s.tiles().stream().filter(t -> !t.excluded()).toList());
tiles.addAll(gen.tiles());
List<TexturePage> pages = new ArrayList<>(s.pages());
pages.addAll(gen.pages());
List<TextureEntry> entries = new ArrayList<>(s.textures());
entries.addAll(gen.textures());
List<Wad.Chunk> out = new ArrayList<>();
boolean nameDone = false;
for (RawChunk c : s.chunks()) {
String tag = c.tag();
byte[] d =
switch (tag) {
case "TRAK" -> ChunkWriter.trak(models);
case "MAP " -> ChunkWriter.map(s, models, tiles);
case "TEXT" -> c.managed() ? ChunkWriter.text(s, pages, entries) : c.data();
case "LGHT" -> c.managed() ? ChunkWriter.lights(s) : c.data();
case "NAME" -> name(s, c);
default -> c.data();
};
nameDone |= tag.equals("NAME");
out.add(new Wad.Chunk(tag, d));
}
if (!nameDone) {
warn.add("the base level has no NAME chunk; the level name is not stored");
}
byte[] wad = new Wad(out).toBytes();
Level chk = Level.parse(wad);
if (chk.map().tiles.size() != tiles.size()
|| chk.map().strats.size() != s.strats().size()) {
throw new IllegalStateException("MAP does not parse back (tiles/strats)");
}
Stats st = new Stats(
models.size(),
tiles.size(),
gen.models().size(),
gen.tiles().size(),
s.strats().size(),
s.pathNodes().size(),
entries.size(),
pages.size(),
wad.length);
return new Result(wad, st, warn);
}
private static byte[] name(Scene s, RawChunk c) {
String n = s.settings().levelName();
if (c.managed()) {
return WadImporter.nameChunk(s.settings().nameChunkPrefix(), n);
}
byte[] d = c.data();
Wad w = new Wad(List.of(new Wad.Chunk("NAME", d)));
if (n.equals(w.levelName())) {
return d;
}
return WadImporter.nameChunk(java.util.Arrays.copyOf(d, 4), n);
}
}
@@ -0,0 +1,496 @@
package com.eliasmueller.llamamod.editor.io;
import com.eliasmueller.llamamod.editor.model.EnvZone;
import com.eliasmueller.llamamod.editor.model.LevelSettings;
import com.eliasmueller.llamamod.editor.model.Light;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.PathNode;
import com.eliasmueller.llamamod.editor.model.RawChunk;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.StratCatalog;
import com.eliasmueller.llamamod.editor.model.TextureAnim;
import com.eliasmueller.llamamod.editor.model.TextureEntry;
import com.eliasmueller.llamamod.editor.model.TexturePage;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.model.TileModel;
import com.eliasmueller.llamamod.format.Bytes;
import com.eliasmueller.llamamod.format.Face;
import com.eliasmueller.llamamod.format.FormatException;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.MapChunk;
import com.eliasmueller.llamamod.format.Model;
import com.eliasmueller.llamamod.format.Stpc;
import com.eliasmueller.llamamod.format.Strats;
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 java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
/**
* Original level WAD → {@link Scene}. Node ids follow the position in the WAD ("tile:0", "strat:12",
* …), so the same WAD always gives the same ids (projects store edits against them).
*/
public final class WadImporter {
/** Chunks the editor regenerates; all others are kept verbatim. */
static final Set<String> MANAGED = Set.of("TRAK", "MAP ", "TEXT", "LGHT", "NAME");
/**
* Strat types placed in no instance of a level whose code nevertheless has them: type fields from
* another level (as levelbuild does).
*/
private WadImporter() {}
/**
* @param base level name, e.g. "t1l1m001" (stored as the project base)
* @param cd loads other CD files by relative path for foreign strat types (may be {@code null})
*/
public static Scene importWad(byte[] wadBytes, String base, Function<String, byte[]> cd) {
Wad wad = Wad.parse(wadBytes);
Scene s = new Scene();
LevelSettings st = s.settings();
int wf = wad.wfpcFlags();
st.setRaw("wfpc", wf);
st.setRaw("base", base == null ? "" : base);
st.setRaw("kind", LevelSettings.EDIT);
Set<String> managed = new HashSet<>();
Level lv = Level.parse(wadBytes);
importTrak(s, lv.models());
managed.add("TRAK");
importMap(s, lv);
managed.add("MAP ");
if (wad.has("TEXT")) {
importText(s, wad.require("TEXT"));
managed.add("TEXT");
}
if (wad.has("LGHT") && importLights(s, wad.require("LGHT"))) {
managed.add("LGHT");
}
if (wad.has("NAME")) {
byte[] nm = wad.require("NAME");
String name = wad.levelName();
if (name != null && Arrays.equals(nm, nameChunk(Arrays.copyOf(nm, 4), name))) {
st.setRaw("name", name);
st.setRaw("nameChunkPrefix", Arrays.copyOf(nm, 4));
managed.add("NAME");
} else if (name != null) {
st.setRaw("name", name);
}
}
int i = 0;
for (Wad.Chunk c : wad.chunks()) {
RawChunk r = new RawChunk("chunk:" + i++);
r.setRaw("tag", c.tag());
r.setRaw("data", managed.contains(c.tag()) ? null : c.data());
s.addQuiet(r);
}
if (wad.has("STPC")) {
s.setCatalog(catalog(wad.require("STPC"), wf, lv.map(), base, cd));
}
return s;
}
static byte[] nameChunk(byte[] prefix, String name) {
byte[] n = name.getBytes(StandardCharsets.ISO_8859_1);
byte[] out = new byte[4 + n.length + 1];
System.arraycopy(prefix, 0, out, 0, 4);
System.arraycopy(n, 0, out, 4, n.length);
return out;
}
static TileModel tileModel(String id, Model m) {
TileModel t = new TileModel(id);
double[] v = new double[m.verts.size() * TileModel.VERTEX_STRIDE];
int k = 0;
for (Vertex x : m.verts) {
v[k++] = x.x();
v[k++] = x.y();
v[k++] = x.z();
v[k++] = x.nx();
v[k++] = x.ny();
v[k++] = x.nz();
}
int[] f = new int[m.faces.size() * TileModel.FACE_STRIDE];
double[] p = new double[m.faces.size() * TileModel.PLANE_STRIDE];
int a = 0;
int b = 0;
for (Face x : m.faces) {
f[a++] = x.flags();
f[a++] = x.v0();
f[a++] = x.v1();
f[a++] = x.v2();
f[a++] = x.tex();
f[a++] = x.pad();
p[b++] = x.nx();
p[b++] = x.ny();
p[b++] = x.nz();
p[b++] = x.d();
}
t.setRaw("vertices", v);
t.setRaw("faces", f);
t.setRaw("planes", p);
for (int g = 0; g < 3; g++) {
ByteArrayOutputStream bo = new ByteArrayOutputStream();
for (byte[] r : m.coll.get(g)) {
bo.writeBytes(r);
}
t.setRaw("collision" + g, bo.toByteArray());
}
t.setRaw("header", m.header == null ? null : Arrays.copyOf(m.header, 0x6c));
return t;
}
private static void importTrak(Scene s, List<Model> models) {
for (int i = 0; i < models.size(); i++) {
s.addQuiet(tileModel("model:" + i, models.get(i)));
}
}
private static void importMap(Scene s, Level lv) {
MapChunk m = lv.map();
LevelSettings st = s.settings();
List<TileModel> models = s.tileModels();
int[] vc = Trak.vertCounts(lv.models());
st.setRaw("width", m.width);
st.setRaw("height", m.height);
List<MapChunk.TileColors> cols = m.tileColors(vc);
int[] altIdx = m.altIndexPerTile();
for (int i = 0; i < m.tiles.size(); i++) {
MapChunk.Tile mt = m.tiles.get(i);
Tile t = new Tile("tile:" + i);
if (m.tileModels[i] != mt.model()) {
throw new FormatException("tile " + i + ": model index differs between MAP blocks");
}
t.setRaw("x", mt.x());
t.setRaw("y", mt.y());
t.setRaw("z", mt.z());
t.setRaw("rot", mt.rot());
t.setRaw("model", models.get(mt.model()));
t.setRaw("alt", mt.alt());
int[] fx = m.tilesFixed.get(i);
t.setRaw(
"fixed", Arrays.equals(fx, Tile.deriveFixed(mt.x(), mt.y(), mt.z(), mt.rot())) ? null : fx.clone());
t.setRaw("colors", flatten(cols.get(i).frames()));
if (altIdx[i] >= 0) {
int[] a = m.alts.get(altIdx[i]);
t.setRaw("swapCell", new int[] {a[0], a[1], a[2]});
t.setRaw("swapModel", models.get(a[3]));
t.setRaw("swapAltModel", models.get(a[4]));
t.setRaw("altColors", flatten(cols.get(i).altFrames()));
}
s.addQuiet(t);
}
long flagged = m.tiles.stream().filter(x -> x.alt() != 0).count();
if (m.alts.size() > flagged) {
throw new FormatException("more tile swap entries than flagged tiles");
}
st.setRaw("paramPool", m.params.clone());
for (int i = 0; i < m.envZones.size(); i++) {
EnvZone z = new EnvZone("zone:" + i);
int[] f = m.envZones.get(i).f();
for (int k = 0; k < f.length; k++) {
z.setRaw(EnvZone.FIELDS[k], f[k]);
}
s.addQuiet(z);
}
List<PathNode> nodes = importPathNodes(s, m);
s.regions().setRaw("table", m.regionTable.clone());
s.regions().setRaw("grid", m.regionGrid.clone());
importStrats(s, m, nodes);
st.setRaw("poolExtra", m.stratExtra);
st.setRaw("poolReserve", m.stratExtra - m.strats.size());
// everything after the strats (paths, WFPC 0x10 value, u16, rest) stays verbatim
byte[] full = lv.wad().require("MAP");
st.setRaw("mapTail", new byte[0]);
byte[] head = ChunkWriter.map(s, models, s.tiles());
if (head.length > full.length || !Arrays.equals(head, 0, head.length, full, 0, head.length)) {
throw new FormatException("MAP does not rebuild (" + firstDiff(head, full) + ")");
}
st.setRaw("mapTail", Arrays.copyOfRange(full, head.length, full.length));
}
private static List<PathNode> importPathNodes(Scene s, MapChunk m) {
List<PathNode> nodes = new ArrayList<>();
for (int i = 0; i < m.nodes.size(); i++) {
nodes.add(new PathNode("wp:" + i));
}
for (int i = 0; i < m.nodes.size(); i++) {
MapChunk.PathNode p = m.nodes.get(i);
PathNode n = nodes.get(i);
if (p.next() > 0 && p.next() < nodes.size()) {
n.setRaw("next", nodes.get(p.next()));
n.setRaw("nextRaw", 0);
} else {
n.setRaw("nextRaw", p.next());
}
n.setRaw("unk1", p.unk1());
n.setRaw("rx", p.rx());
n.setRaw("ry", p.ry());
n.setRaw("rz", p.rz());
n.setRaw("x", p.x());
n.setRaw("y", p.y());
n.setRaw("z", p.z());
n.setRaw("unk2", p.unk2());
n.setRaw("unk3", p.unk3());
s.addQuiet(n);
}
return nodes;
}
private static void importStrats(Scene s, MapChunk m, List<PathNode> nodes) {
for (int i = 0; i < m.strats.size(); i++) {
MapChunk.Strat r = m.strats.get(i);
Strat x = new Strat("strat:" + i);
x.setRaw("code", r.code());
x.setRaw("rx", r.rx());
x.setRaw("ry", r.ry());
x.setRaw("rz", r.rz());
x.setRaw("x", r.x());
x.setRaw("y", r.y());
x.setRaw("z", r.z());
int n = r.nParams();
int pi = r.paramIndex();
if (n > 0 && (pi < 0 || pi + n > m.params.length)) {
throw new FormatException("strat " + i + ": parameters outside the pool");
}
x.setRaw("params", n == 0 ? new int[0] : Arrays.copyOfRange(m.params, pi, pi + n));
x.setRaw("paramIndex", pi);
x.setRaw("nVars", r.nVars());
x.setRaw("slots", r.slots());
x.setRaw("typeFlags", r.typeFlags());
x.setRaw("extra", r.extra());
if (r.waypoint() >= 0 && r.waypoint() < nodes.size()) {
x.setRaw("waypoint", nodes.get(r.waypoint()));
} else {
x.setRaw("waypointRaw", r.waypoint());
}
x.setRaw("zero", r.zero());
x.setRaw("flags", r.flags() & 0xffff);
x.setRaw("last", r.last());
s.addQuiet(x);
}
}
private static String firstDiff(byte[] a, byte[] b) {
int n = Math.min(a.length, b.length);
for (int i = 0; i < n; i++) {
if (a[i] != b[i]) {
return "first difference at " + i;
}
}
return "length " + a.length + " vs " + b.length;
}
private static int[] flatten(List<int[]> frames) {
int n = 0;
for (int[] f : frames) {
n += f.length;
}
int[] out = new int[n];
int o = 0;
for (int[] f : frames) {
System.arraycopy(f, 0, out, o, f.length);
o += f.length;
}
return out;
}
private static void importText(Scene s, byte[] d) {
TextChunk tc = TextChunk.parse(d);
int o = 0;
int npal = Bytes.u32(d, o);
o += 4;
for (int i = 0; i < npal; i++) {
o += 8 + 3 * Bytes.u32(d, o + 4);
}
s.settings().setRaw("textPalettes", Arrays.copyOfRange(d, 0, o));
o += 4;
for (int i = 0; i < tc.pages.size(); i++) {
TextChunk.Page p = tc.pages.get(i);
int start = o;
int fl = Bytes.i32(d, o);
int w = Bytes.u32(d, o + 4);
int h = Bytes.u32(d, o + 8);
o += 12;
if (fl == 0) {
o += w * h;
} else if ((fl & 0x80) != 0) {
o += 4 + Bytes.u32(d, o);
} else {
o += w * h * 2;
}
TexturePage pg = new TexturePage("page:" + i);
pg.setRaw("flags", p.flags);
pg.setRaw("width", p.w);
pg.setRaw("height", p.h);
pg.setRaw("pixels", p.pixels);
pg.setEncodedCache(Arrays.copyOfRange(d, start, o));
s.addQuiet(pg);
}
List<TextChunk.TextureEntry> te = TextChunk.textureEntries(d);
Map<Integer, String> names = textureNames(s.settings().base());
for (int i = 0; i < te.size(); i++) {
TextureEntry t = textureEntry("tex:" + i, te.get(i));
t.setRaw("name", names.getOrDefault(i, ""));
s.addQuiet(t);
}
o += 4 + 8 * te.size();
int na = Bytes.u32(d, o);
o += 4;
for (int i = 0; i < na; i++) {
Bytes.need(d, o, 3);
int tex = Bytes.u16(d, o);
int type = d[o + 2] & 0xff;
o += 3;
int len = TextureAnim.fixedPayload(type);
if (type == 8) {
len = 3 + 2 * (d[o + 1] & 0xff);
} else if (type == 10 || type == 11) {
len = 4 + 2 * (d[o + 3] & 0xff);
}
Bytes.need(d, o, len);
TextureAnim a = new TextureAnim("anim:" + i);
a.setRaw("texture", tex);
a.setRaw("type", type);
a.setRaw("data", Arrays.copyOfRange(d, o, o + len));
o += len;
s.addQuiet(a);
}
s.settings().setRaw("textTail", Arrays.copyOfRange(d, o, d.length));
}
static TextureEntry textureEntry(String id, TextChunk.TextureEntry e) {
TextureEntry t = new TextureEntry(id);
t.setRaw("flags", e.flags());
t.setRaw("page", e.page());
t.setRaw("page2", e.page2());
t.setRaw("u0", e.u0());
t.setRaw("u1", e.u1());
t.setRaw("v0", e.v0());
t.setRaw("v1", e.v1());
return t;
}
/** Names of village textures (levelbuild names) for the outliner; other levels have none. */
private static Map<Integer, String> textureNames(String base) {
Map<Integer, String> m = new HashMap<>();
if ("t1l1m001".equalsIgnoreCase(base)) {
LevelBuilder.TEX.forEach((k, v) -> m.putIfAbsent(v, k));
}
return m;
}
/** False if the chunk does not parse exactly (then it stays raw). */
private static boolean importLights(Scene s, byte[] d) {
try {
int n = Bytes.u32(d, 0);
int o = 4;
List<Light> out = new ArrayList<>();
for (int i = 0; i < n; i++) {
Bytes.need(d, o, 4);
Light l = new Light("light:" + i);
int type = d[o] & 0xff;
l.setRaw("type", type);
l.setRaw("r", d[o + 1] & 0xff);
l.setRaw("g", d[o + 2] & 0xff);
l.setRaw("b", d[o + 3] & 0xff);
o += 4;
if (type == Light.DIRECTIONAL || type == Light.POINT || type == Light.POINT_BRIGHT) {
Bytes.need(d, o, 12);
l.setRaw("x", (double) Bytes.f32(d, o));
l.setRaw("y", (double) Bytes.f32(d, o + 4));
l.setRaw("z", (double) Bytes.f32(d, o + 8));
o += 12;
}
if (type == Light.POINT || type == Light.POINT_BRIGHT) {
Bytes.need(d, o, 9);
l.setRaw("a", (double) Bytes.f32(d, o));
l.setRaw("rangeB", (double) Bytes.f32(d, o + 4));
l.setRaw("c", d[o + 8] & 0xff);
o += 9;
}
out.add(l);
}
for (Light l : out) {
s.addQuiet(l);
}
s.settings().setRaw("lghtTail", Arrays.copyOfRange(d, o, d.length));
return Arrays.equals(ChunkWriter.lights(s), d);
} catch (FormatException e) {
s.clearQuiet(NodeKind.LIGHT);
return false;
}
}
/** Placeable types of the level (levelbuild's prototype rules). */
static StratCatalog catalog(byte[] stpc, int wf, MapChunk map, String base, Function<String, byte[]> cd) {
StratCatalog c = new StratCatalog();
int lo = Stpc.codeStart(stpc, wf);
for (MapChunk.Strat s : map.strats) {
String nm = Strats.nameOr(stpc, s.code(), lo, "?@" + Integer.toHexString(s.code()))
.replace("(spawn)", "");
c.add(nm, variant(s, map, base));
}
Map<String, Integer> named = new HashMap<>();
for (Strats.Named n : Strats.allNamed(stpc, lo)) {
named.putIfAbsent(n.name(), n.offset());
c.addCodeOnly(n.name(), n.offset());
}
if (cd != null) {
for (Map.Entry<String, String> f : Strats.BORROWED.entrySet()) {
String nm = f.getKey();
if (c.has(nm) || !named.containsKey(nm) || f.getValue().contains(base == null ? "?" : base)) {
continue;
}
Strats.Borrowed b = Strats.borrow(nm, f.getValue(), cd, named.get(nm));
if (b != null) {
c.add(nm, variant(b.strat(), b.map(), f.getValue()));
}
}
}
return c;
}
private static StratCatalog.Variant variant(MapChunk.Strat s, MapChunk map, String src) {
int[] prm = s.nParams() > 0 && s.paramIndex() >= 0 && s.paramIndex() + s.nParams() <= map.params.length
? Arrays.copyOfRange(map.params, s.paramIndex(), s.paramIndex() + s.nParams())
: new int[0];
List<int[]> chain = new ArrayList<>();
int k = s.waypoint();
while (k >= 0 && k < map.nodes.size() && chain.size() <= 200) {
MapChunk.PathNode n = map.nodes.get(k);
chain.add(new int[] {
n.x() - s.x(), n.y() - s.y(), n.z() - s.z(), n.rx(), n.ry(), n.rz(), n.unk1(), n.unk2(), n.unk3()
});
if (n.next() == 0) {
break;
}
k = n.next();
}
return new StratCatalog.Variant(
s.code(),
s.nVars(),
s.slots(),
s.typeFlags(),
s.extra(),
s.flags() & 0xffff,
s.rx(),
s.ry(),
s.rz(),
prm,
List.copyOf(chain),
src);
}
}
@@ -0,0 +1,19 @@
package com.eliasmueller.llamamod.editor.model;
/** Axis-aligned bounds as {x0, y0, z0, x1, y1, z1}. */
final class Bounds {
private Bounds() {}
/** Empty bounds (+∞ minimum, −∞ maximum) to {@link #grow}. */
static double[] empty() {
double p = Double.POSITIVE_INFINITY;
double n = Double.NEGATIVE_INFINITY;
return new double[] {p, p, p, n, n, n};
}
/** Extends axis k (0 = x, 1 = y, 2 = z) to include v. */
static void grow(double[] b, int k, double v) {
b[k] = Math.min(b[k], v);
b[k + 3] = Math.max(b[k + 3], v);
}
}
@@ -0,0 +1,325 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Primitive building block converted to tiles (TRAK models, MAP tiles, vertex colours, collision)
* when the level is built. Brushes are replayed in list order, so the order matters for overlapping
* geometry and for the random texture rotation. Parameters depend on {@link #brushKind()}; every
* parameter is a {@link Prop} named like the {@link BrushKind.Param}.
*/
public final class Brush extends Node {
BrushKind brushKind;
String name = "";
boolean enabled = true;
final Map<String, Object> values = new LinkedHashMap<>();
private static final Map<BrushKind, List<Prop>> PROPS = new EnumMap<>(BrushKind.class);
public Brush(String id, BrushKind kind) {
super(id);
brushKind = kind;
for (BrushKind.Param p : kind.params()) {
Object d = p.def();
values.put(p.name(), d instanceof double[] a ? a.clone() : d instanceof int[] ia ? ia.clone() : d);
}
}
@Override
public NodeKind kind() {
return NodeKind.BRUSH;
}
@Override
public List<Prop> props() {
synchronized (PROPS) {
return PROPS.computeIfAbsent(brushKind, Brush::define);
}
}
private static List<Prop> define(BrushKind k) {
Props<Brush> l = new Props<Brush>()
.group("Brush")
.p(
"brushKind",
"Kind",
PropType.CHOICE,
n -> n.brushKind.name(),
(n, v) -> n.brushKind = BrushKind.valueOf(v),
b -> b.choices(Arrays.stream(BrushKind.values())
.map(Enum::name)
.toList())
.readOnly())
.str("name", "Name", n -> n.name, (n, v) -> n.name = v)
.p("enabled", "Enabled", PropType.BOOL, n -> n.enabled, (n, v) -> n.enabled = v)
.group(k.label());
for (BrushKind.Param p : k.params()) {
l.p(p.name(), p.label(), p.type(), n -> n.values.get(p.name()), (n, v) -> n.values.put(p.name(), v), b -> {
if (p.nullable()) {
b.nullable();
}
if (p.type() == PropType.CHOICE) {
b.choices(p.choices());
}
if (p.type() == PropType.REF) {
b.ref(NodeKind.valueOf(p.choices().getFirst()));
}
return b;
});
}
return l.build();
}
@Override
public String label() {
return (name.isEmpty() ? brushKind.label() : name) + " (" + id() + ")";
}
public BrushKind brushKind() {
return brushKind;
}
public String name() {
return name;
}
public boolean enabled() {
return enabled;
}
/** False only for kinds with a "visible" parameter that is switched off (invisible collision). */
public boolean visible() {
return !(values.get("visible") instanceof Boolean v) || v;
}
public double d(String p) {
Object v = values.get(p);
return v == null ? Double.NaN : ((Number) v).doubleValue();
}
public Double dOrNull(String p) {
Object v = values.get(p);
return v == null ? null : ((Number) v).doubleValue();
}
public int i(String p) {
return ((Number) values.get(p)).intValue();
}
public boolean b(String p) {
return (Boolean) values.get(p);
}
public String s(String p) {
Object v = values.get(p);
return v == null ? "" : (String) v;
}
public double[] a(String p) {
return (double[]) values.get(p);
}
public Node ref(String p) {
return (Node) values.get(p);
}
/** Parameter values after a translation by (dx, dy, dz) (for a move command). */
public Map<String, Object> translated(double dx, double dy, double dz) {
Map<String, Object> out = new LinkedHashMap<>();
for (BrushKind.Param p : brushKind.params()) {
Object v = values.get(p.name());
if (v == null) {
continue;
}
Object nv =
switch (p.role()) {
case X -> (Double) v + dx;
case Y -> (Double) v + dy;
case Z -> (Double) v + dz;
case XZ_LIST -> shift((double[]) v, new double[] {dx, dz});
case XYZ_LIST -> shift((double[]) v, new double[] {dx, dy, dz});
case XZ_RECT -> shift((double[]) v, new double[] {dx, dz});
case NONE -> null;
};
if (nv != null) {
out.put(p.name(), nv);
}
}
return out;
}
private static double[] shift(double[] a, double[] d) {
double[] r = a.clone();
for (int i = 0; i < r.length; i++) {
r[i] += d[i % d.length];
}
return r;
}
/**
* Axis-aligned world bounds {x0, y0, z0, x1, y1, z1} (approximate): {@link #localBounds()} turned
* by the brush rotation about {@link #pivot()}.
*/
public double[] bounds() {
double[] lb = localBounds();
if (!hasRotation() || !Double.isFinite(lb[0])) {
return lb;
}
double[] m = rotationMatrix(rotation());
double[] pv = pivot();
double[] b = Bounds.empty();
for (int c = 0; c < 8; c++) {
double[] p = {lb[(c & 1) != 0 ? 3 : 0], lb[(c & 2) != 0 ? 4 : 1], lb[(c & 4) != 0 ? 5 : 2]};
double[] w = rotate(m, pv, p);
for (int k = 0; k < 3; k++) {
Bounds.grow(b, k, w[k]);
}
}
return b;
}
/** Bounds before the rotation, including the sizes of platforms, pillars, trees, meshes, … */
public double[] localBounds() {
double[] b = roleBounds();
if (!Double.isFinite(b[0])) {
return b;
}
switch (brushKind) {
case PLATFORM -> {
Bounds.grow(b, 0, d("x") + d("w"));
Bounds.grow(b, 2, d("z") + d("d"));
Bounds.grow(b, 1, d("y") - d("thick"));
}
case PILLAR -> square(b, d("size") / 2);
case CYLINDER -> square(b, d("radius"));
case TREE -> {
square(b, d("crown") / 2);
Bounds.grow(b, 1, d("y") + d("height") + d("crown") * 1.1);
}
case FENCE -> Bounds.grow(b, 1, d("y") + d("height"));
case RAMP, STAIRS -> {
Double base = dOrNull("base");
Bounds.grow(b, 1, base != null ? base : b[1] - 0.5);
}
case MESH -> {
Node a = ref("asset");
if (a instanceof MeshAsset ma && ma.mesh() != null) {
double[] mb = ma.mesh().bounds();
double sc = d("scale");
double[] st = values.get("stretch") instanceof double[] s ? s : new double[] {1, 1, 1};
double ang = Math.toRadians(d("rotY"));
double ca = Math.cos(ang);
double sa = Math.sin(ang);
for (int c = 0; c < 8; c++) {
double x = (c & 1) != 0 ? mb[3] : mb[0];
double y = (c & 2) != 0 ? mb[4] : mb[1];
double z = (c & 4) != 0 ? mb[5] : mb[2];
x *= sc * st[0];
y *= sc * st[1];
z *= sc * st[2];
Bounds.grow(b, 0, d("x") + x * ca + z * sa);
Bounds.grow(b, 1, d("y") + y);
Bounds.grow(b, 2, d("z") - x * sa + z * ca);
}
}
}
default -> {}
}
return b;
}
private void square(double[] b, double h) {
double cx = (b[0] + b[3]) / 2;
double cz = (b[2] + b[5]) / 2;
Bounds.grow(b, 0, cx - h);
Bounds.grow(b, 0, cx + h);
Bounds.grow(b, 2, cz - h);
Bounds.grow(b, 2, cz + h);
}
/** Point the rotation turns about: the mesh origin for meshes, else the centre of the local bounds. */
public double[] pivot() {
if (brushKind == BrushKind.MESH) {
return new double[] {d("x"), d("y"), d("z")};
}
double[] b = localBounds();
return new double[] {(b[0] + b[3]) / 2, (b[1] + b[4]) / 2, (b[2] + b[5]) / 2};
}
/** Rotation {x, y, z} in degrees (game space, applied as x·y·z like JOML rotateXYZ), zeros if none. */
public double[] rotation() {
return values.get("rotation") instanceof double[] r && r.length == 3 ? r : new double[3];
}
public boolean hasRotation() {
double[] r = rotation();
return r[0] % 360 != 0 || r[1] % 360 != 0 || r[2] % 360 != 0;
}
/** Row-major 3×3 matrix Rx·Ry·Rz of rotation angles in degrees (game space, right-handed). */
public static double[] rotationMatrix(double[] deg) {
double ax = Math.toRadians(deg[0]);
double ay = Math.toRadians(deg[1]);
double az = Math.toRadians(deg[2]);
double cx = Math.cos(ax), sx = Math.sin(ax);
double cy = Math.cos(ay), sy = Math.sin(ay);
double cz = Math.cos(az), sz = Math.sin(az);
return new double[] {
cy * cz,
-cy * sz,
sy,
sx * sy * cz + cx * sz,
-sx * sy * sz + cx * cz,
-sx * cy,
-cx * sy * cz + sx * sz,
cx * sy * sz + sx * cz,
cx * cy
};
}
/** m·(p − pivot) + pivot. */
public static double[] rotate(double[] m, double[] pivot, double[] p) {
double x = p[0] - pivot[0], y = p[1] - pivot[1], z = p[2] - pivot[2];
return new double[] {
pivot[0] + m[0] * x + m[1] * y + m[2] * z,
pivot[1] + m[3] * x + m[4] * y + m[5] * z,
pivot[2] + m[6] * x + m[7] * y + m[8] * z
};
}
private double[] roleBounds() {
double[] b = Bounds.empty();
for (BrushKind.Param p : brushKind.params()) {
Object v = values.get(p.name());
if (v == null) {
continue;
}
switch (p.role()) {
case X -> Bounds.grow(b, 0, (Double) v);
case Y -> Bounds.grow(b, 1, (Double) v);
case Z -> Bounds.grow(b, 2, (Double) v);
case XZ_LIST, XZ_RECT -> {
double[] a = (double[]) v;
for (int i = 0; i + 1 < a.length; i += 2) {
Bounds.grow(b, 0, a[i]);
Bounds.grow(b, 2, a[i + 1]);
}
}
case XYZ_LIST -> {
double[] a = (double[]) v;
for (int i = 0; i + 2 < a.length; i += 3) {
Bounds.grow(b, 0, a[i]);
Bounds.grow(b, 1, a[i + 1]);
Bounds.grow(b, 2, a[i + 2]);
}
}
case NONE -> {}
}
}
return b;
}
}
@@ -0,0 +1,310 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.ArrayList;
import java.util.List;
/**
* Brush (primitive) types and their parameters. Brushes are converted to tiles at build time by
* {@code levelbuild.LevelBuilder} (same primitives as levels/testwelt.py). Coordinates are game
* space (x east, y up, z = grid row, positive), 1 unit = 1 cell. Textures: a name of {@code
* LevelBuilder.TEX} (village names), "#index" (texture table index), a flat colour name of {@code
* LevelBuilder.FLAT} or "rgbRRGGBB"; "" = leave that part out.
*/
public enum BrushKind {
FLOOR("Floor", "Flat horizontal surface split into 1×1 tiles"),
BOX("Box", "Axis-aligned block with top, sides and optional bottom"),
PLATFORM("Platform", "Floating platform (box with edge and bottom textures)"),
PILLAR("Pillar", "Square pillar around a centre point"),
CYLINDER("Cylinder", "Round platform or column: n-sided prism around a centre point"),
RAMP("Ramp", "Slope rising in one direction, sides closed to the base"),
STAIRS("Stairs", "Steps rising in one direction (walkable = invisible slope as collision)"),
FENCE("Fence", "Posts and rail along a polyline"),
TREE("Tree", "Block tree: trunk and two crown cubes"),
SEA("Sea / water", "Water surface without collision, optionally with a death plane"),
KILL_PLANE("Death plane", "Invisible no-floor plane (material 30) in all cells not covered by floor"),
QUAD("Quad", "Single quad or triangle (corners seen from the front)"),
COLLIDER("Collider", "Invisible collision surface"),
MESH("Mesh", "Imported mesh (OBJ/glTF) split into tiles, with collision");
/** Coordinate role of a parameter (for moving brushes). */
public enum Role {
NONE,
X,
Y,
Z,
/** double[] of (x, z) pairs. */
XZ_LIST,
/** double[] of (x, y, z) triples. */
XYZ_LIST,
/** double[4] {x0, z0, x1, z1}. */
XZ_RECT
}
public record Param(String name, String label, PropType type, Object def, Role role, List<String> choices) {
public boolean nullable() {
return def == null;
}
}
public static final List<String> DIRECTIONS = List.of("+x", "-x", "+z", "-z");
public static final List<String> KILL_MODES = List.of("none", "whole", "area");
/** Face classes of the generic face parameters: top, bottom, north (z0), east (x1), south (z1), west (x0). */
public static final String FACES = "tbnesw";
/** Kinds with the generic parameters rotation, visible, collFaces, faceMats (cell-based kinds have none). */
public boolean transformable() {
return this != SEA && this != KILL_PLANE && this != COLLIDER;
}
private final String label;
private final String description;
private List<Param> params;
BrushKind(String label, String description) {
this.label = label;
this.description = description;
}
public String label() {
return label;
}
public String description() {
return description;
}
public synchronized List<Param> params() {
if (params == null) {
params = List.copyOf(define(this));
}
return params;
}
public Param param(String name) {
for (Param p : params()) {
if (p.name.equals(name)) {
return p;
}
}
throw new IllegalArgumentException(this + " has no parameter " + name);
}
private static final double[] WHITE = {1, 1, 1};
private static List<Param> define(BrushKind k) {
P p = new P();
defineKind(k, p);
if (k.transformable()) {
p.a("rotation", "Rotation (x, y, z °)", new double[] {0, 0, 0}, Role.NONE);
if (k == MESH) {
p.a("stretch", "Stretch (x, y, z)", new double[] {1, 1, 1}, Role.NONE);
}
if (k != QUAD) {
p.b("visible", "Visible (off = invisible collision)", true);
}
p.s("collFaces", "Faces with collision (t b n e s w)", FACES);
p.ia("faceMats", "Material per face (t b n e s w, −1 = brush)", new int[] {-1, -1, -1, -1, -1, -1});
}
return p.l;
}
private static void defineKind(BrushKind k, P p) {
switch (k) {
case FLOOR -> p.rect()
.d("y", "Height", 0.0, Role.Y)
.s("tex", "Texture", "grass")
.i("texRot", "Texture rotation (0-3, −1 random)", -1)
.d("step", "Tile size", 1.0, Role.NONE)
.b("coll", "Collision", true)
.i("mat", "Collision material", 0)
.tint();
case BOX -> p.rect()
.d("y0", "Bottom", 0.0, Role.Y)
.d("y1", "Top", 1.0, Role.Y)
.s("top", "Top texture", "grass")
.s("side", "Side texture", "earth")
.s("sideTop", "Top row of the sides", "")
.s("bottom", "Bottom texture", "")
.s("sides", "Sides (n, e, s, w)", "nesw")
.b("coll", "Collision", true)
.b("sideColl", "Side collision", true)
.i("mat", "Collision material", 0)
.b("solid", "Casts shadows", true)
.i("texRot", "Texture rotation (0-3, −1 random)", -1)
.d("step", "Tile size", 1.0, Role.NONE)
.tint();
case PLATFORM -> p.d("x", "X", 0.0, Role.X)
.d("z", "Z", 0.0, Role.Z)
.d("w", "Width (x)", 2.0, Role.NONE)
.d("d", "Depth (z)", 2.0, Role.NONE)
.d("y", "Top", 1.0, Role.Y)
.d("thick", "Thickness", 0.5, Role.NONE)
.s("top", "Top texture", "grass")
.s("side", "Side texture", "earth")
.s("sideTop", "Top row of the sides", "grass_edge")
.s("bottom", "Bottom texture", "earth")
.i("mat", "Collision material", 0)
.i("texRot", "Texture rotation (0-3, −1 random)", -1)
.tint();
case CYLINDER -> p.d("x", "X (centre)", 0.0, Role.X)
.d("z", "Z (centre)", 0.0, Role.Z)
.d("y0", "Bottom", 0.0, Role.Y)
.d("y1", "Top", 1.0, Role.Y)
.d("radius", "Radius", 1.5, Role.NONE)
.i("segments", "Segments (3-64)", 16)
.s("top", "Top texture", "stone")
.s("side", "Side texture", "stone2")
.s("sideTop", "Top row of the sides", "")
.s("bottom", "Bottom texture", "")
.b("coll", "Collision", true)
.b("sideColl", "Side collision", true)
.i("mat", "Collision material", 0)
.tint();
case PILLAR -> p.d("x", "X (centre)", 0.0, Role.X)
.d("z", "Z (centre)", 0.0, Role.Z)
.d("y0", "Bottom", 0.0, Role.Y)
.d("y1", "Top", 1.5, Role.Y)
.d("size", "Size", 0.5, Role.NONE)
.s("side", "Side texture", "stone")
.s("top", "Top texture", "stone")
.i("texRot", "Texture rotation (0-3, −1 random)", -1)
.tint();
case RAMP -> p.rect()
.d("ya", "Low end height", 0.0, Role.Y)
.d("yb", "High end height", 1.0, Role.Y)
.c("direction", "Rises towards", "+x", DIRECTIONS)
.s("top", "Top texture", "sand")
.s("side", "Side texture", "earth")
.n("base", "Base height (empty = low end − 0.5)", Role.Y)
.b("coll", "Collision", true)
.d("step", "Tile size", 1.0, Role.NONE)
.tint();
case STAIRS -> p.rect()
.d("y0", "Start height (floor in front)", 0.0, Role.Y)
.d("y1", "Top height", 1.0, Role.Y)
.i("steps", "Steps", 4)
.c("direction", "Rises towards", "+x", DIRECTIONS)
.s("top", "Top texture", "stone")
.s("side", "Side texture", "stone2")
.n("base", "Base height (empty = bottom − 0.5)", Role.Y)
.b("walkable", "Walkable (invisible slope)", true)
.tint();
case FENCE -> p.a("points", "Points (x, z pairs)", new double[] {0, 0, 4, 0}, Role.XZ_LIST)
.d("y", "Ground height", 0.0, Role.Y)
.d("height", "Height", 0.45, Role.NONE)
.d("thick", "Post size", 0.07, Role.NONE)
.s("post", "Post texture", "wood_dark")
.s("rail", "Rail texture", "wood_grain")
.d("postsEvery", "Post spacing", 1.0, Role.NONE);
case TREE -> p.d("x", "X", 0.0, Role.X)
.d("z", "Z", 0.0, Role.Z)
.d("y", "Ground height", 0.0, Role.Y)
.d("height", "Trunk height", 1.6, Role.NONE)
.d("crown", "Crown size", 1.1, Role.NONE)
.s("trunk", "Trunk texture", "bark")
.s("leaves", "Leaves texture", "grass_dark")
.d("trunkSize", "Trunk size (0 = auto)", 0.0, Role.NONE);
case SEA -> p.rect()
.d("y", "Water height", -1.5, Role.Y)
.s("tex", "Texture", "sea")
.c("kill", "Death plane", "whole", KILL_MODES)
.a("killArea", "Death plane area (x0, z0, x1, z1)", new double[] {0, 0, 0, 0}, Role.XZ_RECT)
.d("step", "Tile size", 4.0, Role.NONE);
case KILL_PLANE -> p.rect().d("y", "Height", -1.5, Role.Y);
case QUAD -> p.a(
"points",
"Corners (x, y, z) × 3 or 4",
new double[] {0, 0, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1},
Role.XYZ_LIST)
.s("tex", "Texture", "stone")
.b("coll", "Collision", true)
.i("mat", "Collision material", 0)
.i("texRot", "Texture rotation (0-3)", 0)
.b("doubleSided", "Double-sided", false)
.b("shade", "Lit (vertex colours)", true)
.b("render", "Visible", true)
.tint();
case COLLIDER -> p.a(
"points",
"Corners (x, y, z) × 3 or 4",
new double[] {0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1},
Role.XYZ_LIST)
.i("mat", "Collision material", 0)
.a("normal", "Front side normal", new double[] {0, 1, 0}, Role.NONE);
case MESH -> p.r("asset", "Mesh asset", NodeKind.MESH_ASSET)
.d("x", "X", 0.0, Role.X)
.d("y", "Y", 0.0, Role.Y)
.d("z", "Z", 0.0, Role.Z)
.d("rotY", "Rotation (°)", 0.0, Role.NONE)
.d("scale", "Scale", 1.0, Role.NONE)
.b("coll", "Collision", true)
.i("mat", "Collision material", 0)
.b("doubleSided", "Double-sided", false)
.b("shade", "Lit (vertex colours)", true)
.i("texSize", "Texture size on the page (16-256)", 128)
.i("uvSteps", "UV rectangle precision (steps per texture)", 8)
.tint();
}
}
private static final class P {
final List<Param> l = new ArrayList<>();
P d(String n, String lb, double def, Role r) {
l.add(new Param(n, lb, PropType.DOUBLE, def, r, List.of()));
return this;
}
P n(String n, String lb, Role r) {
l.add(new Param(n, lb, PropType.DOUBLE, null, r, List.of()));
return this;
}
P i(String n, String lb, int def) {
l.add(new Param(n, lb, PropType.INT, def, Role.NONE, List.of()));
return this;
}
P b(String n, String lb, boolean def) {
l.add(new Param(n, lb, PropType.BOOL, def, Role.NONE, List.of()));
return this;
}
P s(String n, String lb, String def) {
l.add(new Param(n, lb, PropType.STRING, def, Role.NONE, List.of()));
return this;
}
P c(String n, String lb, String def, List<String> ch) {
l.add(new Param(n, lb, PropType.CHOICE, def, Role.NONE, ch));
return this;
}
P a(String n, String lb, double[] def, Role r) {
l.add(new Param(n, lb, PropType.DOUBLE_ARRAY, def, r, List.of()));
return this;
}
P ia(String n, String lb, int[] def) {
l.add(new Param(n, lb, PropType.INT_ARRAY, def, Role.NONE, List.of()));
return this;
}
P r(String n, String lb, NodeKind k) {
l.add(new Param(n, lb, PropType.REF, null, Role.NONE, List.of(k.name())));
return this;
}
P rect() {
return d("x0", "X0", 0.0, Role.X)
.d("z0", "Z0", 0.0, Role.Z)
.d("x1", "X1", 1.0, Role.X)
.d("z1", "Z1", 1.0, Role.Z);
}
P tint() {
return a("tint", "Tint (r, g, b)", WHITE.clone(), Role.NONE);
}
}
}
@@ -0,0 +1,206 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.List;
/**
* Environment / teleport zone (MAP 90-byte record, 0x5c bytes in memory; FUN_0054d560,
* FUN_0054de00, FUN_0041e1c0). A zone is entered through its trigger cell; it moves the player to
* the target position (teleport/respawn) and switches fog, colours and music. The entry with
* target (1, 1, 1) is the level-wide default. Fog distances are 20.12 fixed point (4096 = 1 cell):
* "low" = detail Minimum, "high" = detail Maximum (if high start ≤ low start the engine uses 1.5 ×
* the low pair). Field names with "unk" are not understood.
*/
public final class EnvZone extends Node {
/** Field names in file order; u16 fields: unk14, unk21, unk22. */
public static final String[] FIELDS = {
"cellX",
"cellY",
"cellZ",
"x",
"y",
"z",
"unk6",
"angleFlags",
"flags",
"unk9",
"stpcOffset",
"skyYaw",
"unk12",
"fogMode",
"unk14",
"fogNearLow",
"fogFarLow",
"fogNearHigh",
"fogFarHigh",
"ambientColor",
"fogColor",
"unk21",
"unk22",
"music"
};
private static final String[] LABELS = {
"Trigger cell X",
"Trigger cell Y",
"Trigger cell Z",
"Target X",
"Target Y",
"Target Z",
"Field +18",
"Facing (bits 4-11) + y compare (bits 0-1)",
"Flags (bit 8 = start zone)",
"Field +24",
"STPC offset (0 = none)",
"Sky yaw offset",
"Field +30",
"Fog mode (3 = vertex fog, 2 = other)",
"Field +38 (u16)",
"Fog start (detail Minimum)",
"Fog end (detail Minimum)",
"Fog start (detail Maximum)",
"Fog end (detail Maximum)",
"Ambient colour",
"Fog / backdrop colour",
"Field +52 (u16)",
"Field +54 (u16)",
"Music id"
};
private static final PropType[] TYPES = {
PropType.INT,
PropType.INT,
PropType.INT,
PropType.FIXED,
PropType.FIXED,
PropType.FIXED,
PropType.INT,
PropType.FLAGS,
PropType.FLAGS,
PropType.INT,
PropType.FLAGS,
PropType.INT,
PropType.INT,
PropType.INT,
PropType.INT,
PropType.FIXED,
PropType.FIXED,
PropType.FIXED,
PropType.FIXED,
PropType.COLOR,
PropType.COLOR,
PropType.INT,
PropType.INT,
PropType.INT
};
private static final String[] GROUPS = {
"Trigger", "Trigger", "Trigger", "Target", "Target", "Target", "Raw", "Target", "Zone", "Raw", "Zone", "Zone",
"Raw", "Fog", "Raw", "Fog", "Fog", "Fog", "Fog", "Fog", "Fog", "Raw", "Raw", "Music"
};
final int[] f = new int[FIELDS.length];
private static final List<Prop> PROPS = defineProps();
private static List<Prop> defineProps() {
Props<EnvZone> l = new Props<>();
for (int i = 0; i < FIELDS.length; i++) {
int k = i;
l.group(GROUPS[i]).p(FIELDS[i], LABELS[i], TYPES[i], n -> n.f[k], (n, v) -> n.f[k] = v);
}
return l.build();
}
public EnvZone(String id) {
super(id);
}
@Override
public NodeKind kind() {
return NodeKind.ENV_ZONE;
}
@Override
public List<Prop> props() {
return PROPS;
}
@Override
public String label() {
return isDefault() ? "default zone" : "zone " + f[0] + "," + f[2];
}
/** All 24 fields in file order (copy). */
public int[] fields() {
return f.clone();
}
public int field(int i) {
return f[i];
}
public boolean isDefault() {
return f[3] == 1 && f[4] == 1 && f[5] == 1;
}
public int cellX() {
return f[0];
}
public int cellY() {
return f[1];
}
public int cellZ() {
return f[2];
}
public double targetX() {
return f[3] / 4096.0;
}
public double targetY() {
return f[4] / 4096.0;
}
public double targetZ() {
return f[5] / 4096.0;
}
/** Facing angle at the target, 4096 = 360° (bits 4..11 of the angle field, as 1/256 turns). */
public int facing() {
return ((f[7] >> 4) & 0xff) << 4;
}
public int fogMode() {
return f[13];
}
public double fogNearLow() {
return f[15] / 4096.0;
}
public double fogFarLow() {
return f[16] / 4096.0;
}
public double fogNearHigh() {
return f[17] / 4096.0;
}
public double fogFarHigh() {
return f[18] / 4096.0;
}
public int ambientColor() {
return f[19];
}
public int fogColor() {
return f[20];
}
public int music() {
return f[23];
}
}
@@ -0,0 +1,265 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.List;
/**
* Level-wide settings (singleton node "settings:0"): name, grid, strat pool, project metadata, the
* lighting used to bake vertex colours of brushes, and verbatim MAP trailer data.
*/
public final class LevelSettings extends Node {
/** Project kinds. */
public static final String EDIT = "edit";
public static final String NEW = "new";
String levelName = "";
String kindName = EDIT;
String base = "";
String author = "";
String description = "";
int targetChapter;
int wfpc;
int width;
int height;
int poolExtra;
int poolReserve;
int[] paramPool = new int[0];
byte[] mapTail = new byte[0];
byte[] nameChunkPrefix = new byte[4];
byte[] textPalettes = new byte[4];
byte[] textTail = new byte[0];
byte[] lghtTail = new byte[0];
long seed = 1;
double sunAzimuth = 215;
double sunElevation = 50;
double sunStrength = 0.62;
double[] sunColor = {1.0, 0.93, 0.80};
double[] sky = {0.50, 0.55, 0.66};
double[] ground = {0.30, 0.28, 0.24};
double ao = 0.55;
double aoRadius = 1.3;
double shadow = 0.35;
private static final List<Prop> PROPS = new Props<LevelSettings>()
.group("Level")
.str("name", "Level name", n -> n.levelName, (n, v) -> n.levelName = v)
.p("kind", "Project kind", PropType.CHOICE, n -> n.kindName, (n, v) -> n.kindName = v, b -> b.choices(
List.of(EDIT, NEW))
.readOnly()
.desc("edit = changes to an original level, new = own level on a TestLevel chapter"))
.p("base", "Base level", PropType.STRING, n -> n.base, (n, v) -> n.base = v, b -> b.readOnly()
.desc("original level the project builds on (object code, textures, sound)"))
.integer("targetChapter", "TestLevel chapter", n -> n.targetChapter, (n, v) -> n.targetChapter = v)
.str("author", "Author", n -> n.author, (n, v) -> n.author = v)
.str("description", "Description", n -> n.description, (n, v) -> n.description = v)
.group("Grid")
.p(
"wfpc",
"Feature flags (WFPC)",
PropType.FLAGS,
n -> n.wfpc,
(n, v) -> n.wfpc = v,
Prop.Builder::readOnly)
.p("width", "Grid width", PropType.INT, n -> n.width, (n, v) -> n.width = v, Prop.Builder::readOnly)
.p("height", "Grid height", PropType.INT, n -> n.height, (n, v) -> n.height = v, Prop.Builder::readOnly)
.group("Objects")
.p(
"poolExtra",
"Strat pool extra",
PropType.INT,
n -> n.poolExtra,
(n, v) -> n.poolExtra = v,
b -> b.desc("strat slots = poolExtra + 30; the build uses max(poolExtra, objects + reserve)"))
.integer("poolReserve", "Strat pool reserve", n -> n.poolReserve, (n, v) -> n.poolReserve = v)
.group("Lighting")
.integer("seed", "Texture rotation seed", n -> (int) n.seed, (n, v) -> n.seed = v)
.dbl("sunAzimuth", "Sun azimuth (°)", n -> n.sunAzimuth, (n, v) -> n.sunAzimuth = v)
.dbl("sunElevation", "Sun elevation (°)", n -> n.sunElevation, (n, v) -> n.sunElevation = v)
.dbl("sunStrength", "Sun strength", n -> n.sunStrength, (n, v) -> n.sunStrength = v)
.doubles("sunColor", "Sun colour (r, g, b)", n -> n.sunColor, (n, v) -> n.sunColor = v)
.doubles("sky", "Sky light (r, g, b)", n -> n.sky, (n, v) -> n.sky = v)
.doubles("ground", "Ground light (r, g, b)", n -> n.ground, (n, v) -> n.ground = v)
.dbl("ao", "Ambient occlusion", n -> n.ao, (n, v) -> n.ao = v)
.dbl("aoRadius", "AO radius", n -> n.aoRadius, (n, v) -> n.aoRadius = v)
.dbl("shadow", "Shadow", n -> n.shadow, (n, v) -> n.shadow = v)
.group("Raw")
.p(
"paramPool",
"Original parameter pool",
PropType.INT_ARRAY,
n -> n.paramPool,
(n, v) -> n.paramPool = v,
b -> b.readOnly()
.desc("strats whose parameters are unchanged keep their pool index; changed ones are"
+ " appended"))
.p(
"mapTail",
"MAP data after the strats (paths, u32, u16, rest)",
PropType.BYTES,
n -> n.mapTail,
(n, v) -> n.mapTail = v,
Prop.Builder::readOnly)
.p(
"nameChunkPrefix",
"NAME chunk prefix",
PropType.BYTES,
n -> n.nameChunkPrefix,
(n, v) -> n.nameChunkPrefix = v,
Prop.Builder::readOnly)
.p(
"textPalettes",
"TEXT palettes",
PropType.BYTES,
n -> n.textPalettes,
(n, v) -> n.textPalettes = v,
Prop.Builder::readOnly)
.p(
"textTail",
"TEXT trailer",
PropType.BYTES,
n -> n.textTail,
(n, v) -> n.textTail = v,
Prop.Builder::readOnly)
.p(
"lghtTail",
"LGHT trailer",
PropType.BYTES,
n -> n.lghtTail,
(n, v) -> n.lghtTail = v,
Prop.Builder::readOnly)
.build();
public LevelSettings(String id) {
super(id);
}
@Override
public NodeKind kind() {
return NodeKind.SETTINGS;
}
@Override
public List<Prop> props() {
return PROPS;
}
@Override
public String label() {
return "Level settings";
}
public String levelName() {
return levelName;
}
public String projectKind() {
return kindName;
}
public boolean isNewLevel() {
return NEW.equals(kindName);
}
public String base() {
return base;
}
public String author() {
return author;
}
public String description() {
return description;
}
/** TestLevel (world 9) chapter 1..49 for new levels, 0 = not chosen. */
public int targetChapter() {
return targetChapter;
}
public int wfpc() {
return wfpc;
}
public int width() {
return width;
}
public int height() {
return height;
}
public int poolExtra() {
return poolExtra;
}
public int poolReserve() {
return poolReserve;
}
public int[] paramPool() {
return paramPool;
}
public byte[] mapTail() {
return mapTail;
}
public byte[] nameChunkPrefix() {
return nameChunkPrefix;
}
/** Raw palette block of TEXT (u32 n + palettes; empty in all originals). */
public byte[] textPalettes() {
return textPalettes;
}
/** Bytes after the texture animations (none in the originals). */
public byte[] textTail() {
return textTail;
}
public byte[] lghtTail() {
return lghtTail;
}
public long seed() {
return seed;
}
public double sunAzimuth() {
return sunAzimuth;
}
public double sunElevation() {
return sunElevation;
}
public double sunStrength() {
return sunStrength;
}
public double[] sunColor() {
return sunColor;
}
public double[] sky() {
return sky;
}
public double[] ground() {
return ground;
}
public double ao() {
return ao;
}
public double aoRadius() {
return aoRadius;
}
public double shadow() {
return shadow;
}
}
@@ -0,0 +1,108 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.List;
/**
* Light (LGHT chunk, FUN_0042c180; evaluated per object vertex by FUN_0041ba10, objects get the
* directional lights plus the nearest point lights, at most 6, FUN_0041bdb0): colour r, g, b
* (×2 / 255 in the engine, added to the zone's ambient colour); type 1 = directional (x, y, z =
* direction towards the light), types 2 and 4 = point lights at (x, y, z) in game space cells (z =
* grid row; the engine negates z for its render space) with full intensity up to radius {@code a}
* and a linear falloff to 0 at radius {@code b}; {@code c} = 1 scales the light ×1.5 / ×0.5 with
* the level's light state (DAT_005865d4 1 / 3). Type 4 is a negative light: colour −(1 + 2r/255)/2 per channel.
* Tiles are not lit by lights (baked vertex colours). Other types carry no data and are ignored.
*/
public final class Light extends Node {
public static final int DIRECTIONAL = 1;
public static final int POINT = 2;
public static final int POINT_BRIGHT = 4;
int type = DIRECTIONAL;
int r = 128;
int g = 128;
int b = 128;
double x;
double y;
double z;
double a;
double b2;
int c;
private static final List<Prop> PROPS = new Props<Light>()
.group("Light")
.integer("type", "Type (1 directional, 2/4 point)", n -> n.type, (n, v) -> n.type = v)
.integer("r", "Red", n -> n.r, (n, v) -> n.r = v)
.integer("g", "Green", n -> n.g, (n, v) -> n.g = v)
.integer("b", "Blue", n -> n.b, (n, v) -> n.b = v)
.group("Position / direction")
.dbl("x", "X", n -> n.x, (n, v) -> n.x = v)
.dbl("y", "Y", n -> n.y, (n, v) -> n.y = v)
.dbl("z", "Z", n -> n.z, (n, v) -> n.z = v)
.group("Range")
.dbl("a", "Range A", n -> n.a, (n, v) -> n.a = v)
.dbl("rangeB", "Range B", n -> n.b2, (n, v) -> n.b2 = v)
.integer("c", "Byte C", n -> n.c, (n, v) -> n.c = v)
.build();
public Light(String id) {
super(id);
}
@Override
public NodeKind kind() {
return NodeKind.LIGHT;
}
@Override
public List<Prop> props() {
return PROPS;
}
@Override
public String label() {
return (type == DIRECTIONAL
? "directional light "
: type == POINT || type == POINT_BRIGHT ? "point light " : "light ")
+ id();
}
public int type() {
return type;
}
public int r() {
return r;
}
public int g() {
return g;
}
public int b() {
return b;
}
public double x() {
return x;
}
public double y() {
return y;
}
public double z() {
return z;
}
public double a() {
return a;
}
public double rangeB() {
return b2;
}
public int c() {
return c;
}
}
@@ -0,0 +1,92 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.List;
/**
* Imported 3D model (OBJ + MTL or glTF 2.0) stored in the project's {@code assets/} folder. Placed
* with {@link BrushKind#MESH} brushes. {@code file} is the main file's asset path (e.g.
* "assets/rock/rock.glb"); referenced files (MTL, textures, .bin) live next to it. The parsed mesh
* is cached in {@link #mesh()} ({@code editor.io.MeshImport} fills it).
*/
public final class MeshAsset extends Node {
String name = "";
String file = "";
/** Source axis convention: "y" (glTF/OBJ default, y up) or "z" (z up, e.g. some CAD exports). */
String upAxis = "y";
double unitScale = 1.0;
transient MeshData mesh;
private static final List<Prop> PROPS = new Props<MeshAsset>()
.group("Mesh")
.str("name", "Name", n -> n.name, (n, v) -> n.name = v)
.p(
"file",
"File",
PropType.STRING,
n -> n.file,
(n, v) -> {
n.file = v;
n.mesh = null;
},
Prop.Builder::readOnly)
.p(
"upAxis",
"Up axis",
PropType.CHOICE,
n -> n.upAxis,
(n, v) -> {
n.upAxis = v;
n.mesh = null;
},
b -> b.choices(List.of("y", "z")))
.dbl("unitScale", "Unit scale (file units → cells)", n -> n.unitScale, (n, v) -> {
n.unitScale = v;
n.mesh = null;
})
.build();
public MeshAsset(String id) {
super(id);
}
@Override
public NodeKind kind() {
return NodeKind.MESH_ASSET;
}
@Override
public List<Prop> props() {
return PROPS;
}
@Override
public String label() {
return (name.isEmpty() ? file : name) + (mesh == null ? "" : " (" + mesh.triangleCount() + " triangles)");
}
public String name() {
return name;
}
public String file() {
return file;
}
public String upAxis() {
return upAxis;
}
public double unitScale() {
return unitScale;
}
/** Parsed mesh (game space, unplaced), or {@code null} until loaded. */
public MeshData mesh() {
return mesh;
}
public void setMesh(MeshData m) {
mesh = m;
}
}
@@ -0,0 +1,40 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.List;
/**
* A triangle mesh in game space (x east, y up, z = grid row; unplaced, 1 unit = 1 cell). Triangles
* are wound so that (p1−p0)×(p2−p0) in export space (z mirrored) points outwards, which is the
* order {@code LevelBuilder.quad} expects.
*
* @param positions 3 doubles per vertex
* @param triangles 3 vertex indices per triangle
* @param uvs 6 doubles per triangle (u, v per corner; v = 0 at the top of the image), NaN = none
* @param materials material index per triangle
* @param colors per-vertex 0xRRGGBB colour or {@code null}
*/
public record MeshData(
double[] positions, int[] triangles, double[] uvs, int[] materials, int[] colors, List<Material> materialList) {
/**
* @param image texture as 0xAARRGGBB, {@code null} = untextured (flat colour {@code rgb})
*/
public record Material(String name, int rgb, int[] image, int width, int height, boolean doubleSided) {}
public int vertexCount() {
return positions.length / 3;
}
public int triangleCount() {
return triangles.length / 3;
}
/** {x0, y0, z0, x1, y1, z1}. */
public double[] bounds() {
double[] b = Bounds.empty();
for (int i = 0; i < positions.length; i++) {
Bounds.grow(b, i % 3, positions[i]);
}
return b;
}
}
@@ -0,0 +1,59 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.List;
/**
* Base of every scene element. Ids are stable: imported nodes get "kind:index" from their position
* in the original WAD, nodes created in the editor "kind:nN". All persistent state is exposed as
* {@link Prop}s.
*/
public abstract class Node {
private final String id;
Scene scene;
protected Node(String id) {
this.id = id;
}
public final String id() {
return id;
}
public abstract NodeKind kind();
/** Property descriptors (same list for all nodes of a class, except brushes). */
public abstract List<Prop> props();
/** Short human-readable name for outliners. */
public String label() {
return id;
}
/** The scene this node is part of, or {@code null} while detached. */
public Scene scene() {
return scene;
}
public final Prop prop(String name) {
for (Prop p : props()) {
if (p.name().equals(name)) {
return p;
}
}
throw new IllegalArgumentException(kind() + " has no property " + name);
}
public final Object get(String name) {
return prop(name).get(this);
}
/** {@link Prop#setRaw} by name: no undo, no events (import, loading, nodes not yet added). */
public final void setRaw(String name, Object value) {
prop(name).setRaw(this, value);
}
@Override
public String toString() {
return id;
}
}
@@ -0,0 +1,35 @@
package com.eliasmueller.llamamod.editor.model;
/** Node types; the id prefix is used for stable node ids ("tile:12", "strat:n3"). */
public enum NodeKind {
SETTINGS("settings", "Level settings"),
REGIONS("regions", "Visibility regions"),
TILE_MODEL("model", "Tile model"),
TILE("tile", "Tile"),
STRAT("strat", "Object"),
PATH_NODE("wp", "Waypoint"),
ENV_ZONE("zone", "Zone"),
LIGHT("light", "Light"),
TEXTURE_PAGE("page", "Texture page"),
TEXTURE("tex", "Texture"),
TEXTURE_ANIM("anim", "Texture animation"),
BRUSH("brush", "Brush"),
MESH_ASSET("mesh", "Mesh asset"),
RAW_CHUNK("chunk", "Raw chunk");
private final String prefix;
private final String label;
NodeKind(String prefix, String label) {
this.prefix = prefix;
this.label = label;
}
public String prefix() {
return prefix;
}
public String label() {
return label;
}
}
@@ -0,0 +1,77 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.AbstractList;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.RandomAccess;
/**
* Ordered node collection of a {@link Scene}. Read-only as a {@link List}; change it through the
* scene (events) or commands. The order is the order written to the WAD.
*/
public final class NodeList<T extends Node> extends AbstractList<T> implements RandomAccess {
private final NodeKind kind;
private final List<T> items = new ArrayList<>();
private Map<Node, Integer> index;
NodeList(NodeKind kind) {
this.kind = kind;
}
public NodeKind kind() {
return kind;
}
@Override
public T get(int i) {
return items.get(i);
}
@Override
public int size() {
return items.size();
}
/** Position by identity (O(1) after the first call), -1 if absent. */
@Override
public int indexOf(Object o) {
if (index == null) {
index = new IdentityHashMap<>(items.size() * 2);
for (int i = 0; i < items.size(); i++) {
index.put(items.get(i), i);
}
}
Integer i = index.get(o);
return i == null ? -1 : i;
}
@Override
public boolean contains(Object o) {
return indexOf(o) >= 0;
}
void insert(int i, T n) {
items.add(i, n);
index = null;
}
void append(T n) {
items.add(n);
if (index != null) {
index.put(n, items.size() - 1);
}
}
T delete(int i) {
T n = items.remove(i);
index = null;
return n;
}
void clearAll() {
items.clear();
index = null;
}
}
@@ -0,0 +1,117 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.List;
/**
* Waypoint / path / camera node (MAP 34-byte record). Nodes form chains through {@code next}
* (written as the index of the next node, 0 = end of chain); strats point at the first node of
* their chain. Uses: patrol paths, camera start (first node of the llama's chain), checkpoint
* respawn points (one node per CheckPointPost), box areas (two nodes = corners). Position 20.12
* fixed point in game space, angles 4096 = 360°.
*/
public final class PathNode extends Node {
PathNode next;
int nextRaw;
int unk1;
int rx;
int ry;
int rz;
int x;
int y;
int z;
int unk2;
int unk3;
private static final List<Prop> PROPS = new Props<PathNode>()
.group("Transform")
.fixed("x", "X", n -> n.x, (n, v) -> n.x = v)
.fixed("y", "Y", n -> n.y, (n, v) -> n.y = v)
.fixed("z", "Z", n -> n.z, (n, v) -> n.z = v)
.angle("rx", "Rotation X", n -> n.rx, (n, v) -> n.rx = v)
.angle("ry", "Rotation Y", n -> n.ry, (n, v) -> n.ry = v)
.angle("rz", "Rotation Z", n -> n.rz, (n, v) -> n.rz = v)
.group("Chain")
.p("next", "Next node", PropType.REF, n -> n.next, (n, v) -> n.next = v, b -> b.ref(NodeKind.PATH_NODE)
.nullable())
.group("Raw")
.integer("nextRaw", "Next index without node", n -> n.nextRaw, (n, v) -> n.nextRaw = v)
.integer("unk1", "Field +4", n -> n.unk1, (n, v) -> n.unk1 = v)
.integer("unk2", "Field +26", n -> n.unk2, (n, v) -> n.unk2 = v)
.integer("unk3", "Field +30", n -> n.unk3, (n, v) -> n.unk3 = v)
.build();
public PathNode(String id) {
super(id);
}
@Override
public NodeKind kind() {
return NodeKind.PATH_NODE;
}
@Override
public List<Prop> props() {
return PROPS;
}
@Override
public String label() {
return "waypoint " + id();
}
public PathNode next() {
return next;
}
public int nextRaw() {
return nextRaw;
}
public int unk1() {
return unk1;
}
public int rx() {
return rx;
}
public int ry() {
return ry;
}
public int rz() {
return rz;
}
public int x() {
return x;
}
public int y() {
return y;
}
public int z() {
return z;
}
public double gx() {
return x / 4096.0;
}
public double gy() {
return y / 4096.0;
}
public double gz() {
return z / 4096.0;
}
public int unk2() {
return unk2;
}
public int unk3() {
return unk3;
}
}
@@ -0,0 +1,191 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.ArrayList;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.Function;
/**
* A typed, named property of a node. All persistent node state is reachable through props: the
* inspector edits them, {@link com.eliasmueller.llamamod.editor.model.cmd.SetProp} undoes them and
* the project format stores them. Array values are shared, not copied: never mutate an array
* returned by {@link #get}; set a new array instead.
*/
public final class Prop {
private final String name;
private final String label;
private final String group;
private final PropType type;
private final Function<Node, Object> getter;
private final BiConsumer<Node, Object> setter;
private final boolean readOnly;
private final boolean nullable;
private final boolean persistent;
private final NodeKind refKind;
private final List<String> choices;
private final String description;
private Prop(Builder b) {
name = b.name;
label = b.label;
group = b.group;
type = b.type;
getter = b.getter;
setter = b.setter;
readOnly = b.readOnly;
nullable = b.nullable;
persistent = b.persistent;
refKind = b.refKind;
choices = List.copyOf(b.choices);
description = b.description;
}
public String name() {
return name;
}
public String label() {
return label;
}
/** Inspector section, e.g. "Transform", "Type", "Fog". */
public String group() {
return group;
}
public PropType type() {
return type;
}
/** Not editable in the UI (still set by import and project loading). */
public boolean readOnly() {
return readOnly;
}
public boolean nullable() {
return nullable;
}
/** Stored in projects (false for derived values such as a strat's type name). */
public boolean persistent() {
return persistent;
}
public NodeKind refKind() {
return refKind;
}
public List<String> choices() {
return choices;
}
public String description() {
return description;
}
public Object get(Node n) {
return getter.apply(n);
}
/**
* Sets the value without undo or events (import/loading). Edits go through {@link
* Scene#set(Node, Prop, Object)} or a command.
*/
public void setRaw(Node n, Object v) {
if (setter == null) {
throw new UnsupportedOperationException("property " + name + " cannot be set");
}
if (v == null && !nullable) {
throw new IllegalArgumentException("property " + name + " must not be null");
}
if (v != null && !type.valueClass().isInstance(v)) {
throw new IllegalArgumentException(
"property " + name + " needs " + type.valueClass().getSimpleName() + ", not "
+ v.getClass().getSimpleName());
}
if (v != null && type == PropType.REF && refKind != null && ((Node) v).kind() != refKind) {
throw new IllegalArgumentException("property " + name + " needs a " + refKind + " node");
}
if (v != null && type == PropType.CHOICE && !choices.contains(v)) {
throw new IllegalArgumentException("property " + name + ": '" + v + "' is not one of " + choices);
}
setter.accept(n, v);
}
public boolean settable() {
return setter != null;
}
@Override
public String toString() {
return name;
}
@SuppressWarnings("unchecked")
public static <N extends Node> Builder of(
String name, String label, PropType type, Function<N, ?> get, BiConsumer<N, ?> set) {
Builder b = new Builder();
b.name = name;
b.label = label;
b.type = type;
b.getter = n -> ((Function<N, Object>) get).apply((N) n);
b.setter = set == null ? null : (n, v) -> ((BiConsumer<N, Object>) set).accept((N) n, v);
return b;
}
/** Fluent construction; finish with {@link #build()}. */
public static final class Builder {
private String name;
private String label;
private String group = "";
private PropType type;
private Function<Node, Object> getter;
private BiConsumer<Node, Object> setter;
private boolean readOnly;
private boolean nullable;
private boolean persistent = true;
private NodeKind refKind;
private final List<String> choices = new ArrayList<>();
private String description = "";
public Builder group(String g) {
group = g;
return this;
}
public Builder readOnly() {
readOnly = true;
return this;
}
public Builder nullable() {
nullable = true;
return this;
}
public Builder derived() {
persistent = false;
readOnly = true;
return this;
}
public Builder ref(NodeKind k) {
refKind = k;
return this;
}
public Builder choices(List<String> c) {
choices.addAll(c);
return this;
}
public Builder desc(String d) {
description = d;
return this;
}
public Prop build() {
return new Prop(this);
}
}
}
@@ -0,0 +1,42 @@
package com.eliasmueller.llamamod.editor.model;
/**
* Semantics of a {@link Prop} value. The Java value class is fixed per type (see {@link
* #valueClass()}); the type tells the inspector how to show and edit it.
*/
public enum PropType {
/** Integer, shown decimal. */
INT(Integer.class),
/** 20.12 fixed point integer (4096 = 1 cell); show as value / 4096. */
FIXED(Integer.class),
/** Angle in 1/4096 turns (4096 = 360°); show in degrees. */
ANGLE(Integer.class),
/** Bit flags, show hex. */
FLAGS(Integer.class),
/** Colour 0x00BBGGRR as stored by the engine (byte order R, G, B, X). */
COLOR(Integer.class),
DOUBLE(Double.class),
BOOL(Boolean.class),
STRING(String.class),
/** One of {@link Prop#choices()}. */
CHOICE(String.class),
/** Reference to another node ({@link Prop#refKind()}); {@code null} if allowed. */
REF(Node.class),
INT_ARRAY(int[].class),
DOUBLE_ARRAY(double[].class),
BYTES(byte[].class);
private final Class<?> valueClass;
PropType(Class<?> c) {
valueClass = c;
}
public Class<?> valueClass() {
return valueClass;
}
public boolean isArray() {
return this == INT_ARRAY || this == DOUBLE_ARRAY || this == BYTES;
}
}
@@ -0,0 +1,74 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.ArrayList;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.function.UnaryOperator;
/** Small helper to declare the prop lists of node classes; props get the current group. */
final class Props<N extends Node> {
private final List<Prop> list = new ArrayList<>();
private String group = "";
Props<N> group(String g) {
group = g;
return this;
}
/** A prop with extra settings (read-only, nullable, ref kind, choices, description, …). */
<T> Props<N> p(
String name,
String label,
PropType t,
Function<N, T> get,
BiConsumer<N, T> set,
UnaryOperator<Prop.Builder> mod) {
list.add(mod.apply(Prop.of(name, label, t, get, set).group(group)).build());
return this;
}
<T> Props<N> p(String name, String label, PropType t, Function<N, T> get, BiConsumer<N, T> set) {
return p(name, label, t, get, set, b -> b);
}
Props<N> integer(String name, String label, Function<N, Integer> get, BiConsumer<N, Integer> set) {
return p(name, label, PropType.INT, get, set);
}
Props<N> fixed(String name, String label, Function<N, Integer> get, BiConsumer<N, Integer> set) {
return p(name, label, PropType.FIXED, get, set);
}
Props<N> angle(String name, String label, Function<N, Integer> get, BiConsumer<N, Integer> set) {
return p(name, label, PropType.ANGLE, get, set);
}
Props<N> flags(String name, String label, Function<N, Integer> get, BiConsumer<N, Integer> set) {
return p(name, label, PropType.FLAGS, get, set);
}
Props<N> dbl(String name, String label, Function<N, Double> get, BiConsumer<N, Double> set) {
return p(name, label, PropType.DOUBLE, get, set);
}
Props<N> str(String name, String label, Function<N, String> get, BiConsumer<N, String> set) {
return p(name, label, PropType.STRING, get, set);
}
Props<N> ints(String name, String label, Function<N, int[]> get, BiConsumer<N, int[]> set) {
return p(name, label, PropType.INT_ARRAY, get, set);
}
Props<N> doubles(String name, String label, Function<N, double[]> get, BiConsumer<N, double[]> set) {
return p(name, label, PropType.DOUBLE_ARRAY, get, set);
}
Props<N> bytes(String name, String label, Function<N, byte[]> get, BiConsumer<N, byte[]> set) {
return p(name, label, PropType.BYTES, get, set);
}
List<Prop> build() {
return List.copyOf(list);
}
}
@@ -0,0 +1,52 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.List;
/**
* A WAD chunk the editor does not interpret (STPC strat code and object models, sound banks, LNFO,
* …), kept verbatim in its original position. Managed chunks (TRAK, MAP, TEXT, LGHT, NAME) appear
* in the list as placeholders with {@code data == null} so the chunk order is preserved.
*/
public final class RawChunk extends Node {
String tag = "";
byte[] data;
private static final List<Prop> PROPS = new Props<RawChunk>()
.group("Chunk")
.p("tag", "Tag", PropType.STRING, n -> n.tag, (n, v) -> n.tag = v, Prop.Builder::readOnly)
.p("data", "Data", PropType.BYTES, n -> n.data, (n, v) -> n.data = v, b -> b.nullable()
.readOnly())
.build();
public RawChunk(String id) {
super(id);
}
@Override
public NodeKind kind() {
return NodeKind.RAW_CHUNK;
}
@Override
public List<Prop> props() {
return PROPS;
}
@Override
public String label() {
return tag.trim() + (data == null ? " (edited by the editor)" : " (" + data.length + " bytes)");
}
/** Four-character tag as in {@link com.eliasmueller.llamamod.format.Wad} ("MAP ", "STPC"). */
public String tag() {
return tag;
}
public byte[] data() {
return data;
}
public boolean managed() {
return data == null;
}
}
@@ -0,0 +1,71 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.List;
/**
* Visibility regions (singleton node "regions:0"): every grid cell has a region 0..31; the camera's
* region selects a 32-bit mask of regions whose cells are drawn (MAP blocks 6/7, groove.exe
* FUN_0042bf40). Tiles in a cell whose region bit is not in the mask are invisible. The second
* value per region is read into DAT_00586424 (meaning unknown).
*/
public final class Regions extends Node {
public static final int COUNT = 32;
int[] table = new int[2 * COUNT];
int[] grid = new int[0];
private static final List<Prop> PROPS = new Props<Regions>()
.group("Regions")
.ints("table", "Region table (mask, value) × 32", n -> n.table, (n, v) -> n.table = v)
.ints("grid", "Region per cell (row = z)", n -> n.grid, (n, v) -> n.grid = v)
.build();
public Regions(String id) {
super(id);
}
@Override
public NodeKind kind() {
return NodeKind.REGIONS;
}
@Override
public List<Prop> props() {
return PROPS;
}
@Override
public String label() {
return "Visibility regions";
}
/** 64 ints: mask0, value0, mask1, value1, … */
public int[] table() {
return table;
}
public int[] grid() {
return grid;
}
public int mask(int region) {
return table[2 * region];
}
public int value(int region) {
return table[2 * region + 1];
}
/** Region of cell (cx, cz) with the grid width w, or -1 outside. */
public int regionAt(int cx, int cz, int w) {
int i = cx + cz * w;
return cx < 0 || cx >= w || i < 0 || i >= grid.length ? -1 : grid[i];
}
/** Whether cell (cx, cz) is drawn while the camera is in cell (camX, camZ). */
public boolean visibleFrom(int camX, int camZ, int cx, int cz, int w) {
int rc = regionAt(camX, camZ, w);
int r = regionAt(cx, cz, w);
return rc >= 0 && r >= 0 && (mask(rc) & (1 << r)) != 0;
}
}
@@ -0,0 +1,375 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* A level as the editor sees it: every part of a level WAD the editor can touch, as nodes with
* stable ids, plus brushes and imported meshes that become tiles when the level is built ({@code
* editor.io.WadBuilder}). Pure data, not thread-safe; change it on one thread (the EDT) through
* {@link com.eliasmueller.llamamod.editor.model.cmd.History} commands to get undo and events.
*
* <p>Coordinates: tiles and tile models use export space (cells, z ≤ 0); strats, waypoints and
* zone targets use game space in 20.12 fixed point (z = −export z); brushes use game space in
* cells.
*/
public final class Scene {
private final LevelSettings settings = new LevelSettings("settings:0");
private final Regions regions = new Regions("regions:0");
private final Map<NodeKind, NodeList<?>> lists = new EnumMap<>(NodeKind.class);
private final NodeList<TileModel> tileModels = list(NodeKind.TILE_MODEL);
private final NodeList<Tile> tiles = list(NodeKind.TILE);
private final NodeList<Strat> strats = list(NodeKind.STRAT);
private final NodeList<PathNode> pathNodes = list(NodeKind.PATH_NODE);
private final NodeList<EnvZone> zones = list(NodeKind.ENV_ZONE);
private final NodeList<Light> lights = list(NodeKind.LIGHT);
private final NodeList<TexturePage> pages = list(NodeKind.TEXTURE_PAGE);
private final NodeList<TextureEntry> textures = list(NodeKind.TEXTURE);
private final NodeList<TextureAnim> textureAnims = list(NodeKind.TEXTURE_ANIM);
private final NodeList<Brush> brushes = list(NodeKind.BRUSH);
private final NodeList<MeshAsset> meshAssets = list(NodeKind.MESH_ASSET);
private final NodeList<RawChunk> chunks = list(NodeKind.RAW_CHUNK);
private final Map<String, Node> byId = new HashMap<>();
private final Map<String, byte[]> assets = new LinkedHashMap<>();
private final List<SceneListener> listeners = new CopyOnWriteArrayList<>();
private StratCatalog catalog = new StratCatalog();
private int nextId = 1;
private int mute;
public Scene() {
settings.scene = this;
regions.scene = this;
byId.put(settings.id(), settings);
byId.put(regions.id(), regions);
}
private <T extends Node> NodeList<T> list(NodeKind k) {
NodeList<T> l = new NodeList<>(k);
lists.put(k, l);
return l;
}
// ------------------------------------------------------------ collections
public LevelSettings settings() {
return settings;
}
public Regions regions() {
return regions;
}
public NodeList<TileModel> tileModels() {
return tileModels;
}
public NodeList<Tile> tiles() {
return tiles;
}
/**
* The player start: the Llama, else the first object of category "player" (levels where the
* player rides or is carried: RollerCoaster, LlamaChase, KuscoAndPachaTogetherOnATree); null if none.
*/
public Strat playerStart() {
Strat other = null;
for (Strat st : strats) {
if ("Llama".equals(st.type())) {
return st;
}
if (other == null && "player".equals(StratCatalog.category(st.type()))) {
other = st;
}
}
return other;
}
public NodeList<Strat> strats() {
return strats;
}
public NodeList<PathNode> pathNodes() {
return pathNodes;
}
public NodeList<EnvZone> zones() {
return zones;
}
public NodeList<Light> lights() {
return lights;
}
public NodeList<TexturePage> pages() {
return pages;
}
/** Texture table; faces use the index in this list. */
public NodeList<TextureEntry> textures() {
return textures;
}
public NodeList<TextureAnim> textureAnims() {
return textureAnims;
}
public NodeList<Brush> brushes() {
return brushes;
}
public NodeList<MeshAsset> meshAssets() {
return meshAssets;
}
/** WAD chunks in file order; managed chunks are placeholders ({@link RawChunk#managed()}). */
public NodeList<RawChunk> chunks() {
return chunks;
}
/** List holding nodes of a kind ({@code null} for the singletons SETTINGS and REGIONS). */
@SuppressWarnings("unchecked")
public NodeList<Node> listOf(NodeKind k) {
return (NodeList<Node>) lists.get(k);
}
/** Collections in a fixed order (project files and outliners). */
public static final List<NodeKind> LIST_KINDS = List.of(
NodeKind.RAW_CHUNK,
NodeKind.TEXTURE_PAGE,
NodeKind.TEXTURE,
NodeKind.TEXTURE_ANIM,
NodeKind.TILE_MODEL,
NodeKind.TILE,
NodeKind.PATH_NODE,
NodeKind.STRAT,
NodeKind.ENV_ZONE,
NodeKind.LIGHT,
NodeKind.MESH_ASSET,
NodeKind.BRUSH);
public Node node(String id) {
return byId.get(id);
}
public boolean contains(Node n) {
return n != null && byId.get(n.id()) == n;
}
/** Project assets (imported meshes, images): path inside the project zip → bytes. */
public Map<String, byte[]> assets() {
return assets;
}
public StratCatalog catalog() {
return catalog;
}
public void setCatalog(StratCatalog c) {
catalog = Objects.requireNonNull(c);
for (Strat s : strats) {
s.setTypeName(c.nameOf(s.code()));
}
}
// ------------------------------------------------------------ ids and creation
/** Next id for a node created in the editor ("kind:nN"). */
public String newId(NodeKind k) {
String id;
do {
id = k.prefix() + ":n" + nextId++;
} while (byId.containsKey(id));
return id;
}
public int nextIdCounter() {
return nextId;
}
public void setNextIdCounter(int n) {
nextId = Math.max(1, n);
}
/** New detached node of a list kind with a fresh id (add it with a command). */
public Node create(NodeKind k) {
if (k == NodeKind.BRUSH) {
throw new IllegalArgumentException("use createBrush");
}
return createWithId(k, newId(k), null);
}
public Brush createBrush(BrushKind kind) {
return new Brush(newId(NodeKind.BRUSH), kind);
}
/** Node with a given id (import and project loading). */
public static Node createWithId(NodeKind k, String id, BrushKind brushKind) {
return switch (k) {
case TILE_MODEL -> new TileModel(id);
case TILE -> new Tile(id);
case STRAT -> new Strat(id);
case PATH_NODE -> new PathNode(id);
case ENV_ZONE -> new EnvZone(id);
case LIGHT -> new Light(id);
case TEXTURE_PAGE -> new TexturePage(id);
case TEXTURE -> new TextureEntry(id);
case TEXTURE_ANIM -> new TextureAnim(id);
case MESH_ASSET -> new MeshAsset(id);
case RAW_CHUNK -> new RawChunk(id);
case BRUSH -> new Brush(id, brushKind);
case SETTINGS, REGIONS -> throw new IllegalArgumentException(k + " is a singleton");
};
}
// ------------------------------------------------------------ mutation (with events)
/** Inserts a detached node at index (-1 = end) and fires ADDED. */
public void add(Node n, int index) {
if (n.scene != null) {
throw new IllegalStateException(n.id() + " is already part of a scene");
}
if (byId.containsKey(n.id())) {
throw new IllegalStateException("duplicate node id " + n.id());
}
NodeList<Node> l = listOf(n.kind());
int i = index < 0 || index > l.size() ? l.size() : index;
if (i == l.size()) {
l.append(n);
} else {
l.insert(i, n);
}
n.scene = this;
byId.put(n.id(), n);
if (n instanceof Strat s) {
s.setTypeName(catalog.nameOf(s.code()));
}
fire(SceneEvent.added(n, i));
}
/** Appends without an event (bulk import). */
public void addQuiet(Node n) {
mute++;
try {
add(n, -1);
} finally {
mute--;
}
}
/** Removes a node and fires REMOVED; returns its former index. References to it are not touched. */
public int remove(Node n) {
NodeList<Node> l = listOf(n.kind());
int i = l == null ? -1 : l.indexOf(n);
if (i < 0) {
throw new IllegalStateException(n.id() + " is not part of this scene");
}
l.delete(i);
byId.remove(n.id());
n.scene = null;
fire(SceneEvent.removed(n, i));
return i;
}
/** Sets a property and fires CHANGED (no undo; use {@code SetProp} for that). */
public void set(Node n, Prop p, Object value) {
Object old = p.get(n);
p.setRaw(n, value);
if (n instanceof Strat s && p.name().equals("code")) {
s.setTypeName(catalog.nameOf(s.code()));
}
fire(SceneEvent.changed(n, p, old));
}
public void set(Node n, String prop, Object value) {
set(n, n.prop(prop), value);
}
/** Removes all nodes of a list kind without events (templates, loading). */
public void clearQuiet(NodeKind k) {
NodeList<Node> l = listOf(k);
for (Node n : l) {
byId.remove(n.id());
n.scene = null;
}
l.clearAll();
}
/** Puts the nodes of a list kind into the given order (same set of nodes) and fires RELOADED. */
public void reorder(NodeKind k, List<? extends Node> order) {
NodeList<Node> l = listOf(k);
if (order.size() != l.size() || !order.stream().allMatch(l::contains)) {
throw new IllegalArgumentException("reorder needs exactly the nodes of the list");
}
List<Node> copy = new ArrayList<>(order);
l.clearAll();
for (Node n : copy) {
l.append(n);
}
fire(SceneEvent.reloaded());
}
// ------------------------------------------------------------ queries
/** Properties (of nodes in this scene) that reference {@code target}. */
public List<Map.Entry<Node, Prop>> referencesTo(Node target) {
List<Map.Entry<Node, Prop>> out = new ArrayList<>();
for (NodeKind k : LIST_KINDS) {
for (Node n : listOf(k)) {
for (Prop p : n.props()) {
if (p.type() == PropType.REF && p.get(n) == target) {
out.add(Map.entry(n, p));
}
}
}
}
return out;
}
/** Nodes of a chain from start, following {@code next} (stops at loops). */
public List<PathNode> chain(PathNode start) {
List<PathNode> out = new ArrayList<>();
Map<PathNode, Boolean> seen = new IdentityHashMap<>();
for (PathNode p = start; p != null && seen.put(p, true) == null; p = p.next()) {
out.add(p);
}
return out;
}
// ------------------------------------------------------------ events
public void addListener(SceneListener l) {
listeners.add(l);
}
public void removeListener(SceneListener l) {
listeners.remove(l);
}
public void fire(SceneEvent e) {
if (mute > 0) {
return;
}
for (SceneListener l : listeners) {
l.sceneChanged(e);
}
}
/** Runs r without events, then fires RELOADED (bulk changes). */
public void bulk(Runnable r) {
mute++;
try {
r.run();
} finally {
mute--;
}
fire(SceneEvent.reloaded());
}
}
@@ -0,0 +1,30 @@
package com.eliasmueller.llamamod.editor.model;
/**
* Change notification. ADDED/REMOVED carry the list index; CHANGED the property and its previous
* value; RELOADED means "everything may have changed" (project loaded, bulk operation).
*/
public record SceneEvent(Type type, Node node, Prop prop, int index, Object oldValue) {
public enum Type {
ADDED,
REMOVED,
CHANGED,
RELOADED
}
public static SceneEvent added(Node n, int i) {
return new SceneEvent(Type.ADDED, n, null, i, null);
}
public static SceneEvent removed(Node n, int i) {
return new SceneEvent(Type.REMOVED, n, null, i, null);
}
public static SceneEvent changed(Node n, Prop p, Object old) {
return new SceneEvent(Type.CHANGED, n, p, -1, old);
}
public static SceneEvent reloaded() {
return new SceneEvent(Type.RELOADED, null, null, -1, null);
}
}
@@ -0,0 +1,7 @@
package com.eliasmueller.llamamod.editor.model;
/** Receives {@link SceneEvent}s on the thread that changed the scene (normally the Swing EDT). */
@FunctionalInterface
public interface SceneListener {
void sceneChanged(SceneEvent e);
}
@@ -0,0 +1,196 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.List;
/**
* Object instance ("strat", MAP 58-byte record). The type is the strat code offset in STPC
* ({@code code}); {@code type} is its name from the code. Position is 20.12 fixed point in game
* space (x east, y up, z = grid row, positive), angles 4096 = 360° (ry: 0 = facing +z, 1024 = +x).
* {@code nVars}, {@code slots}, {@code typeFlags}, {@code extra} are constant per type (copy them
* from {@link StratCatalog}). {@code params} are pushed on the strat's stack (MAP parameter pool).
*/
public final class Strat extends Node {
/** Flag bit: not spawned at level start (spawned by other strats). */
public static final int FLAG_HIDDEN = 2;
String type = "";
int code;
int rx;
int ry;
int rz;
int x;
int y;
int z;
int[] params = new int[0];
Integer paramIndex;
int nVars;
int slots;
int typeFlags;
int extra;
PathNode waypoint;
int waypointRaw = -1;
int zero;
int flags;
int last;
private static final List<Prop> PROPS = new Props<Strat>()
.group("Type")
.p("type", "Type", PropType.STRING, n -> n.type, (n, v) -> n.type = v, Prop.Builder::derived)
.p(
"code",
"Code offset (STPC)",
PropType.FLAGS,
n -> n.code,
(n, v) -> n.code = v,
b -> b.desc("selects the type; set together with the type fields (StratCatalog)"))
.integer("nVars", "Variables", n -> n.nVars, (n, v) -> n.nVars = v)
.integer("slots", "Slots", n -> n.slots, (n, v) -> n.slots = v)
.flags("typeFlags", "Type flags", n -> n.typeFlags, (n, v) -> n.typeFlags = v)
.integer("extra", "Extra memory", n -> n.extra, (n, v) -> n.extra = v)
.group("Transform")
.fixed("x", "X", n -> n.x, (n, v) -> n.x = v)
.fixed("y", "Y", n -> n.y, (n, v) -> n.y = v)
.fixed("z", "Z", n -> n.z, (n, v) -> n.z = v)
.angle("rx", "Rotation X", n -> n.rx, (n, v) -> n.rx = v)
.angle("ry", "Rotation Y (yaw)", n -> n.ry, (n, v) -> n.ry = v)
.angle("rz", "Rotation Z", n -> n.rz, (n, v) -> n.rz = v)
.group("Behaviour")
.ints("params", "Parameters", n -> n.params, (n, v) -> n.params = v)
.p("waypoint", "Waypoint chain", PropType.REF, n -> n.waypoint, (n, v) -> n.waypoint = v, b -> b.ref(
NodeKind.PATH_NODE)
.nullable())
.flags("flags", "Flags (2 = not spawned at start)", n -> n.flags, (n, v) -> n.flags = v)
.group("Raw")
.p(
"paramIndex",
"Original pool index",
PropType.INT,
n -> n.paramIndex,
(n, v) -> n.paramIndex = v,
b -> b.nullable().readOnly())
.integer("waypointRaw", "Waypoint index without chain", n -> n.waypointRaw, (n, v) -> n.waypointRaw = v)
.integer("zero", "Field +50", n -> n.zero, (n, v) -> n.zero = v)
.integer("last", "Field +56", n -> n.last, (n, v) -> n.last = v)
.build();
public Strat(String id) {
super(id);
}
@Override
public NodeKind kind() {
return NodeKind.STRAT;
}
@Override
public List<Prop> props() {
return PROPS;
}
@Override
public String label() {
return (type.isEmpty() ? "?" : type) + " (" + id() + ")";
}
public String type() {
return type;
}
/** Called when the code offset is resolved against a catalog (not undoable, derived). */
public void setTypeName(String t) {
type = t == null ? "" : t;
}
public int code() {
return code;
}
public int rx() {
return rx;
}
public int ry() {
return ry;
}
public int rz() {
return rz;
}
public int x() {
return x;
}
public int y() {
return y;
}
public int z() {
return z;
}
public double gx() {
return x / 4096.0;
}
public double gy() {
return y / 4096.0;
}
public double gz() {
return z / 4096.0;
}
/** Yaw in degrees (0 = facing +z, 90 = facing +x). */
public double yawDegrees() {
return (ry & 0xfff) * 360.0 / 4096;
}
public int[] params() {
return params;
}
public Integer paramIndex() {
return paramIndex;
}
public int nVars() {
return nVars;
}
public int slots() {
return slots;
}
public int typeFlags() {
return typeFlags;
}
public int extra() {
return extra;
}
public PathNode waypoint() {
return waypoint;
}
public int waypointRaw() {
return waypointRaw;
}
public int zero() {
return zero;
}
public int flags() {
return flags;
}
public int last() {
return last;
}
public boolean hidden() {
return (flags & FLAG_HIDDEN) != 0;
}
}
@@ -0,0 +1,136 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* Object types of the base level (derived from its STPC code and MAP instances, not stored in
* projects). Only types with an original instance are placeable, because the per-type fields
* (variables, slots, flags, extra memory) come from the compiler and are copied from it. Each code
* entry of a type is a {@link Variant} (e.g. SmashPot has two spawn stubs).
*/
public final class StratCatalog {
/**
* Prototype of one code entry: type fields and default parameters of the first original
* instance, and its waypoint chain relative to the instance position.
*
* @param chain per node {dx, dy, dz, rx, ry, rz, unk1, unk2, unk3} (fixed point relative to the
* prototype position)
*/
public record Variant(
int code,
int nVars,
int slots,
int typeFlags,
int extra,
int flags,
int rx,
int ry,
int rz,
int[] params,
List<int[]> chain,
String sourceLevel) {
public int nParams() {
return params.length;
}
public boolean hasWaypoints() {
return !chain.isEmpty();
}
}
public record Type(String name, List<Variant> variants) {
public Variant variant(int i) {
return variants.get(i);
}
}
private final Map<String, Type> types = new TreeMap<>();
private final Map<Integer, String> names = new LinkedHashMap<>();
private final List<String> codeOnly = new ArrayList<>();
/** Adds a variant (types are created on first use; variants with a known code are ignored). */
public void add(String name, Variant v) {
Type t = types.computeIfAbsent(name, n -> new Type(n, new ArrayList<>()));
if (t.variants.stream().noneMatch(x -> x.code == v.code)) {
t.variants.add(v);
}
names.putIfAbsent(v.code, name);
}
/** A named strat entry of the code that has no instance (not placeable). */
public void addCodeOnly(String name, int code) {
names.putIfAbsent(code, name);
if (!types.containsKey(name) && !codeOnly.contains(name)) {
codeOnly.add(name);
}
}
public void addName(int code, String name) {
names.putIfAbsent(code, name);
}
/** Placeable types, sorted by name. */
public List<Type> types() {
return List.copyOf(types.values());
}
public Type type(String name) {
return types.get(name);
}
public boolean has(String name) {
return types.containsKey(name);
}
/** Type name for a code offset, or {@code null}. */
public String nameOf(int code) {
return names.get(code);
}
/** Every known code offset with its type name (read-only). */
public Map<Integer, String> names() {
return Collections.unmodifiableMap(names);
}
/** Strat names in the code without an instance to copy type fields from. */
public List<String> codeOnly() {
return Collections.unmodifiableList(codeOnly);
}
/** Debug-view category of a type name (same colours as mods.debug), for icons and filters. */
public static String category(String name) {
if (name == null) {
return "other";
}
if (name.equals("Llama")
|| name.equals("KuscoAndPachaTogetherOnATree")
|| name.equals("RollerCoaster")
|| name.equals("LlamaChase")) {
return "player";
}
if (name.contains("LevelDoor") || name.equals("VillageEndDoor") || name.equals("EndGameDoor")) {
return "exit";
}
if (name.startsWith("CheckPoint")) {
return "checkpoint";
}
if (name.startsWith("Camera") || name.startsWith("CutSceneCamera") || name.endsWith("Camera")) {
return "camera";
}
if (name.contains("Trigger") || name.startsWith("Say")) {
return "trigger";
}
if (name.startsWith("Coin") || name.equals("Secret") || name.startsWith("Health") || name.equals("RubyIdol")) {
return "collectible";
}
if (name.startsWith("Force") || name.contains("Rebound") || name.contains("Current")) {
return "force";
}
return "other";
}
}
@@ -0,0 +1,82 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.List;
/**
* Texture animation (TEXT, FUN_00406e40): animates texture entry {@code texture}. {@code data} are
* the payload bytes by type: 1/2 = 3 bytes, 7/9 = u16, u16, u8, u8, 8 = u8, u8 n, u8, n × u16
* texture index (flip-book), 10/11 = u8, u8, u8, u8 n, n × u16; other types have no payload.
*/
public final class TextureAnim extends Node {
int texture;
int type;
byte[] data = new byte[0];
private static final List<Prop> PROPS = new Props<TextureAnim>()
.group("Animation")
.integer("texture", "Texture index", n -> n.texture, (n, v) -> n.texture = v)
.integer("type", "Type", n -> n.type, (n, v) -> n.type = v)
.bytes("data", "Payload", n -> n.data, (n, v) -> n.data = v)
.build();
public TextureAnim(String id) {
super(id);
}
@Override
public NodeKind kind() {
return NodeKind.TEXTURE_ANIM;
}
@Override
public List<Prop> props() {
return PROPS;
}
@Override
public String label() {
return "animation type " + type + " of #" + texture;
}
public int texture() {
return texture;
}
public int type() {
return type;
}
public byte[] data() {
return data;
}
/** Payload length of a type, or -1 if it depends on a count byte. */
public static int fixedPayload(int type) {
return switch (type) {
case 1, 2 -> 3;
case 7, 9 -> 6;
case 8, 10, 11 -> -1;
default -> 0;
};
}
/** Flip-book frames (types 8, 10, 11) as texture indices, else empty. */
public int[] frames() {
int n;
int o;
if (type == 8 && data.length >= 3) {
n = data[1] & 0xff;
o = 3;
} else if ((type == 10 || type == 11) && data.length >= 4) {
n = data[3] & 0xff;
o = 4;
} else {
return new int[0];
}
int[] f = new int[n];
for (int i = 0; i < n && o + 2 * i + 1 < data.length; i++) {
f[i] = (data[o + 2 * i] & 0xff) | (data[o + 2 * i + 1] & 0xff) << 8;
}
return f;
}
}
@@ -0,0 +1,114 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.List;
/**
* Texture table entry (TEXT, 8 bytes): a rectangle u0..u1 × v0..v1 (inclusive) on page {@code page}
* (−1 = flat colour: u0, u1, v0 = r, g, b, v1 = alpha). Faces reference entries by their index in
* {@link Scene#textures()}, so entries are only ever appended. Flags: bit 0 flat colour, bit 2
* one-pixel border, bits 12-14 blend mode (1 = alpha from v1 for flat colours). The engine has no
* free UVs: each triangle shows one half of the rectangle ({@link
* com.eliasmueller.llamamod.format.Geometry#faceUvs}).
*/
public final class TextureEntry extends Node {
public static final int FLAT = 0x1;
int flags;
int page;
int page2 = -1;
int u0;
int u1;
int v0;
int v1;
String name = "";
private static final List<Prop> PROPS = new Props<TextureEntry>()
.group("Texture")
.str("name", "Name", n -> n.name, (n, v) -> n.name = v)
.flags("flags", "Flags", n -> n.flags, (n, v) -> n.flags = v)
.integer("page", "Page (−1 = flat colour)", n -> n.page, (n, v) -> n.page = v)
.integer("page2", "Second page / CLUT", n -> n.page2, (n, v) -> n.page2 = v)
.group("Rectangle")
.integer("u0", "U0 (red)", n -> n.u0, (n, v) -> n.u0 = v)
.integer("u1", "U1 (green, inclusive)", n -> n.u1, (n, v) -> n.u1 = v)
.integer("v0", "V0 (blue)", n -> n.v0, (n, v) -> n.v0 = v)
.integer("v1", "V1 (alpha, inclusive)", n -> n.v1, (n, v) -> n.v1 = v)
.build();
public TextureEntry(String id) {
super(id);
}
@Override
public NodeKind kind() {
return NodeKind.TEXTURE;
}
@Override
public List<Prop> props() {
return PROPS;
}
@Override
public String label() {
Scene s = scene();
String idx = s == null ? id() : "#" + s.textures().indexOf(this);
return (name.isEmpty() ? "texture " : name + " ") + idx;
}
public String name() {
return name;
}
public int flags() {
return flags;
}
public int page() {
return page;
}
public int page2() {
return page2;
}
public int u0() {
return u0;
}
public int u1() {
return u1;
}
public int v0() {
return v0;
}
public int v1() {
return v1;
}
/** Width in texels (u0..u1 inclusive). */
public int width() {
return u1 - u0 + 1;
}
/** Height in texels (v0..v1 inclusive). */
public int height() {
return v1 - v0 + 1;
}
public boolean isFlat() {
return page < 0 || (flags & FLAT) != 0;
}
/** Flat colour as 0xRRGGBB. */
public int flatRgb() {
return u0 << 16 | u1 << 8 | v0;
}
/** Same as {@link com.eliasmueller.llamamod.format.TextChunk.TextureEntry} for format code. */
public com.eliasmueller.llamamod.format.TextChunk.TextureEntry toFormat() {
return new com.eliasmueller.llamamod.format.TextChunk.TextureEntry(flags, page, page2, u0, u1, v0, v1);
}
}
@@ -0,0 +1,101 @@
package com.eliasmueller.llamamod.editor.model;
import com.eliasmueller.llamamod.format.TextChunk;
import java.util.List;
/**
* Texture page (TEXT): w × h pixels (all originals 256 × 256), row-major. flags & 0x80 = stored
* RLE-compressed; flags & 0x7f = format/blend class (1 opaque, 4/7 alpha, 5 blend 3, …); flags 0 =
* 8-bit palettised (pixels are indices). Otherwise pixels are ABGR1555 (bits 0-4 R, 15 = STP;
* 0x0000 transparent), see {@link TextChunk#abgr1555ToRgba}.
*/
public final class TexturePage extends Node {
int flags = 0x81;
int width = 256;
int height = 256;
int[] pixels = new int[256 * 256];
String name = "";
/** Encoded page as read (cache for byte-exact rebuilds), dropped when pixels or flags change. */
byte[] encoded;
private static final List<Prop> PROPS = new Props<TexturePage>()
.group("Page")
.str("name", "Name", n -> n.name, (n, v) -> n.name = v)
.flags("flags", "Flags (0x80 RLE, 0x7f format)", n -> n.flags, (n, v) -> {
n.flags = v;
n.encoded = null;
})
.p("width", "Width", PropType.INT, n -> n.width, (n, v) -> n.width = v, Prop.Builder::readOnly)
.p("height", "Height", PropType.INT, n -> n.height, (n, v) -> n.height = v, Prop.Builder::readOnly)
.ints("pixels", "Pixels (ABGR1555)", n -> n.pixels, (n, v) -> {
n.pixels = v;
n.encoded = null;
})
.build();
public TexturePage(String id) {
super(id);
}
@Override
public NodeKind kind() {
return NodeKind.TEXTURE_PAGE;
}
@Override
public List<Prop> props() {
return PROPS;
}
@Override
public String label() {
return (name.isEmpty() ? "page " + id() : name) + " (" + width + "×" + height + ")";
}
public String name() {
return name;
}
public int flags() {
return flags;
}
public int width() {
return width;
}
public int height() {
return height;
}
public int[] pixels() {
return pixels;
}
public byte[] encoded() {
return encoded;
}
public void setEncodedCache(byte[] e) {
encoded = e;
}
/** Pixel as 0xRRGGBBAA (palettised pages: grey from the index). */
public int rgba(int px, int py) {
int v = pixels[py * width + px];
if (flags == 0) {
return v << 24 | v << 16 | v << 8 | 0xff;
}
return TextChunk.abgr1555ToRgba(v);
}
/** Whole page as 0xAARRGGBB for {@code BufferedImage.TYPE_INT_ARGB}. */
public int[] argb() {
int[] out = new int[pixels.length];
for (int i = 0; i < out.length; i++) {
int c = rgba(i % width, i / width);
out[i] = (c & 0xff) << 24 | c >>> 8;
}
return out;
}
}
@@ -0,0 +1,232 @@
package com.eliasmueller.llamamod.editor.model;
import com.eliasmueller.llamamod.format.Geometry;
import com.eliasmueller.llamamod.format.PyMath;
import java.util.List;
/**
* Placed tile (MAP): a {@link TileModel} at (x, y, z) in export space (cells; z ≤ 0, game z = −z)
* turned by {@code rot}/4096 turns about y. The tile belongs to grid cell (floor(x), floor(−z)); the
* engine tests collision only against the tiles of the player's cell.
*
* <p>Vertex colours: {@code colors} holds frames × vertexCount(model) RGBA words (0x80 = 1.0, the
* engine doubles them); byte 3 of the first word is the frame count (0/1 = one frame). A tile with
* a swap (WFPC 0x10000, e.g. doors: stMapreplace) has {@code swapCell} {cellX, y, cellZ}, the swap
* models and {@code altColors} for {@code swapAltModel}.
*/
public final class Tile extends Node {
public static final int GREY = 0x00808080;
double x;
double y;
double z;
int rot;
TileModel model;
int alt;
int[] fixed;
int[] colors = new int[0];
int[] swapCell;
TileModel swapModel;
TileModel swapAltModel;
int[] altColors;
boolean excluded;
private static final List<Prop> PROPS = new Props<Tile>()
.group("Transform")
.dbl("x", "X (cells)", n -> n.x, (n, v) -> {
n.x = v;
n.fixed = null;
})
.dbl("y", "Y (height)", n -> n.y, (n, v) -> {
n.y = v;
n.fixed = null;
})
.dbl("z", "Z (cells, ≤ 0)", n -> n.z, (n, v) -> {
n.z = v;
n.fixed = null;
})
.angle("rot", "Rotation", n -> n.rot, (n, v) -> {
n.rot = v;
n.fixed = null;
})
.group("Model")
.p("model", "Model", PropType.REF, n -> n.model, (n, v) -> n.model = v, b -> b.ref(NodeKind.TILE_MODEL))
.group("Swap")
.p(
"alt",
"Swap flag",
PropType.INT,
n -> n.alt,
(n, v) -> n.alt = v,
b -> b.desc("≠ 0: the tile has a swap entry (WFPC 0x10000)"))
.p(
"swapCell",
"Swap cell (x, y, z)",
PropType.INT_ARRAY,
n -> n.swapCell,
(n, v) -> n.swapCell = v,
b -> b.nullable())
.p("swapModel", "Swap model", PropType.REF, n -> n.swapModel, (n, v) -> n.swapModel = v, b -> b.ref(
NodeKind.TILE_MODEL)
.nullable())
.p(
"swapAltModel",
"Alternative model",
PropType.REF,
n -> n.swapAltModel,
(n, v) -> n.swapAltModel = v,
b -> b.ref(NodeKind.TILE_MODEL).nullable())
.group("Build")
.p(
"excluded",
"Exclude from build",
PropType.BOOL,
n -> n.excluded,
(n, v) -> n.excluded = v,
b -> b.desc(
"the tile stays in the project but is left out of the built level (Hide only affects the"
+ " editor view)"))
.group("Colours")
.ints("colors", "Vertex colours (frames × vertices)", n -> n.colors, (n, v) -> n.colors = v)
.p(
"altColors",
"Alternative model colours",
PropType.INT_ARRAY,
n -> n.altColors,
(n, v) -> n.altColors = v,
Prop.Builder::nullable)
.group("Raw")
.p(
"fixed",
"Fixed-point placement (rx ry rz x y z)",
PropType.INT_ARRAY,
n -> n.fixed,
(n, v) -> n.fixed = v,
b -> b.nullable()
.readOnly()
.desc("only kept when it differs from the derived value; cleared on every move"))
.build();
public Tile(String id) {
super(id);
}
@Override
public NodeKind kind() {
return NodeKind.TILE;
}
@Override
public List<Prop> props() {
return PROPS;
}
@Override
public String label() {
return "tile " + cellX() + "," + cellZ() + " " + (model == null ? "?" : model.id());
}
/** True when the tile is kept in the project but not written to the built level. */
public boolean excluded() {
return excluded;
}
public double x() {
return x;
}
public double y() {
return y;
}
public double z() {
return z;
}
public int rot() {
return rot;
}
public TileModel model() {
return model;
}
public int alt() {
return alt;
}
public int[] fixed() {
return fixed;
}
public int[] colors() {
return colors;
}
public int[] swapCell() {
return swapCell;
}
public TileModel swapModel() {
return swapModel;
}
public TileModel swapAltModel() {
return swapAltModel;
}
public int[] altColors() {
return altColors;
}
public boolean hasSwap() {
return swapCell != null;
}
/**
* Whether a swap model has collision of this material: door objects (RubyIdolDoor & co., ops
* 0x11a/0x11b) swap the tile when they open, and in many originals only the open model holds
* the exit floor (material 16) of the LevelDoor's cell.
*/
public boolean swapHasMaterial(int material) {
return hasSwap()
&& (swapModel != null && swapModel.hasCollisionMaterial(material)
|| swapAltModel != null && swapAltModel.hasCollisionMaterial(material));
}
/** Grid column, like MAP's cell lists (floor(x)). */
public int cellX() {
return (int) Math.floor(x);
}
/** Grid row (floor(−z)). */
public int cellZ() {
return (int) Math.floor(-z);
}
/** Fixed-point placement the engine uses: stored value or derived from x, y, z, rot. */
public int[] effectiveFixed() {
return fixed != null ? fixed : deriveFixed(x, y, z, rot);
}
public static int[] deriveFixed(double x, double y, double z, int rot) {
return new int[] {
0,
rot,
0,
Math.toIntExact(PyMath.round(x * 4096)),
Math.toIntExact(PyMath.round(y * 4096)),
Math.toIntExact(PyMath.round(-z * 4096))
};
}
/** Collision polygons of the model in world export space (none without a model). */
public List<TileModel.PlacedPolygon> worldCollisionPolygons() {
return model == null ? List.of() : model.collisionPolygons(transform());
}
/** Local (export space) vertex → world (export space). */
public Geometry.TileXform transform() {
return Geometry.TileXform.of(x, y, z, Integer.toUnsignedLong(rot));
}
}
@@ -0,0 +1,209 @@
package com.eliasmueller.llamamod.editor.model;
import com.eliasmueller.llamamod.format.Collision;
import com.eliasmueller.llamamod.format.Geometry;
import java.util.ArrayList;
import java.util.List;
/**
* TRAK tile model in local export space (y up, z mirrored relative to game space; the tile's
* placement adds the cell position). Packed arrays:
*
* <ul>
* <li>{@code vertices}: 6 doubles per vertex (x, y, z, nx, ny, nz), all float32 values
* <li>{@code faces}: 6 ints per triangle (flags, v0, v1, v2, texture index, pad)
* <li>{@code planes}: 4 doubles per triangle (nx, ny, nz, d), used by the engine for culling
* <li>{@code collision0..2}: 32-byte collision records in game space ({@link Collision}); the
* engine always uses all three groups together
* </ul>
*
* {@code header} holds the stored bounding box (0x6c bytes); it is cleared when the vertices change
* and then recomputed by the build.
*/
public final class TileModel extends Node {
public static final int VERTEX_STRIDE = 6;
public static final int FACE_STRIDE = 6;
public static final int PLANE_STRIDE = 4;
public static final int COLLISION_SIZE = 32;
double[] vertices = new double[0];
int[] faces = new int[0];
double[] planes = new double[0];
byte[] collision0 = new byte[0];
byte[] collision1 = new byte[0];
byte[] collision2 = new byte[0];
byte[] header;
String name = "";
private static final List<Prop> PROPS = new Props<TileModel>()
.group("Model")
.str("name", "Name", n -> n.name, (n, v) -> n.name = v)
.group("Geometry")
.doubles("vertices", "Vertices (x y z nx ny nz)", n -> n.vertices, (n, v) -> {
n.vertices = v;
n.header = null;
})
.ints("faces", "Faces (flags v0 v1 v2 tex pad)", n -> n.faces, (n, v) -> n.faces = v)
.doubles("planes", "Face planes (nx ny nz d)", n -> n.planes, (n, v) -> n.planes = v)
.group("Collision")
.bytes("collision0", "Collision group A", n -> n.collision0, (n, v) -> n.collision0 = v)
.bytes("collision1", "Collision group B", n -> n.collision1, (n, v) -> n.collision1 = v)
.bytes("collision2", "Collision group C", n -> n.collision2, (n, v) -> n.collision2 = v)
.group("Raw")
.p("header", "Bounding box header", PropType.BYTES, n -> n.header, (n, v) -> n.header = v, b -> b.nullable()
.readOnly())
.build();
public TileModel(String id) {
super(id);
}
@Override
public NodeKind kind() {
return NodeKind.TILE_MODEL;
}
@Override
public List<Prop> props() {
return PROPS;
}
@Override
public String label() {
String n = name.isEmpty() ? id() : name;
return n + " (" + vertexCount() + " v, " + faceCount() + " f, " + collisionCount() + " c)";
}
public String name() {
return name;
}
public double[] vertices() {
return vertices;
}
public int[] faces() {
return faces;
}
public double[] planes() {
return planes;
}
public byte[] collision(int group) {
return switch (group) {
case 0 -> collision0;
case 1 -> collision1;
case 2 -> collision2;
default -> throw new IndexOutOfBoundsException(group);
};
}
public static String collisionProp(int group) {
return "collision" + group;
}
public byte[] header() {
return header;
}
public int vertexCount() {
return vertices.length / VERTEX_STRIDE;
}
public int faceCount() {
return faces.length / FACE_STRIDE;
}
public int collisionCount(int group) {
return collision(group).length / COLLISION_SIZE;
}
public int collisionCount() {
return collisionCount(0) + collisionCount(1) + collisionCount(2);
}
public double vx(int v) {
return vertices[v * VERTEX_STRIDE];
}
public double vy(int v) {
return vertices[v * VERTEX_STRIDE + 1];
}
public double vz(int v) {
return vertices[v * VERTEX_STRIDE + 2];
}
public int faceFlags(int f) {
return faces[f * FACE_STRIDE];
}
public int faceVertex(int f, int k) {
return faces[f * FACE_STRIDE + 1 + k];
}
public int faceTexture(int f) {
return faces[f * FACE_STRIDE + 4];
}
/** 32-byte record k of a group (copy). */
public byte[] collisionRecord(int group, int k) {
byte[] g = collision(group);
byte[] r = new byte[COLLISION_SIZE];
System.arraycopy(g, k * COLLISION_SIZE, r, 0, COLLISION_SIZE);
return r;
}
/** Decoded collision polygon in local export space (planes; corners via {@link Collision#polygon}). */
public Collision.Decoded collisionPolygon(int group, int k) {
return Collision.decode(collisionRecord(group, k));
}
/** A collision polygon placed by a tile transform: corners and face normal in world export space. */
public record PlacedPolygon(int group, int index, int material, double[][] corners, double[] normal) {}
/** Collision polygons with at least 3 corners, placed by xf (see {@link Tile#worldCollisionPolygons}). */
public List<PlacedPolygon> collisionPolygons(Geometry.TileXform xf) {
List<PlacedPolygon> out = new ArrayList<>();
for (int g = 0; g < 3; g++) {
for (int k = 0; k < collisionCount(g); k++) {
Collision.Decoded d = collisionPolygon(g, k);
double[][] local = Collision.polygon(d);
if (local.length < 3) {
continue;
}
double[][] w = new double[local.length][];
for (int i = 0; i < local.length; i++) {
w[i] = xf.point(local[i][0], local[i][1], local[i][2]);
}
double[] n = xf.normal(d.plane()[0], d.plane()[1], d.plane()[2]);
out.add(new PlacedPolygon(g, k, d.mat(), w, n));
}
}
return out;
}
public int collisionMaterial(int group, int k) {
return collision(group)[k * COLLISION_SIZE + 1] & 0xff;
}
/** Whether any collision polygon has this material. */
public boolean hasCollisionMaterial(int material) {
for (int g = 0; g < 3; g++) {
for (int k = 0; k < collisionCount(g); k++) {
if (collisionMaterial(g, k) == material) {
return true;
}
}
}
return false;
}
/** Copy of a collision group with the material of record k replaced (for {@code SetProp}). */
public byte[] withCollisionMaterial(int group, int k, int material) {
byte[] g = collision(group).clone();
g[k * COLLISION_SIZE + 1] = (byte) material;
return g;
}
}
@@ -0,0 +1,38 @@
package com.eliasmueller.llamamod.editor.model.cmd;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.Scene;
/** Adds a detached node (from {@link Scene#create} / {@link Scene#createBrush}) at an index (-1 = end). */
public final class AddNode implements Command {
private final Node node;
private final int index;
public AddNode(Node node, int index) {
this.node = node;
this.index = index;
}
public AddNode(Node node) {
this(node, -1);
}
public Node node() {
return node;
}
@Override
public String label() {
return "Add " + node.kind().label().toLowerCase(java.util.Locale.ROOT);
}
@Override
public void apply(Scene s) {
s.add(node, index);
}
@Override
public void revert(Scene s) {
s.remove(node);
}
}
@@ -0,0 +1,70 @@
package com.eliasmueller.llamamod.editor.model.cmd;
import com.eliasmueller.llamamod.editor.model.Scene;
import java.util.ArrayList;
import java.util.List;
/** Several commands as one undo step (reverted in reverse order). */
public final class Batch implements Command {
private final String label;
private final List<Command> commands;
private final Object mergeKey;
public Batch(String label, List<Command> commands) {
this(label, commands, null);
}
/** With a merge key, a following batch of the same shape (same targets) merges into this one. */
public Batch(String label, List<Command> commands, Object mergeKey) {
this.label = label;
this.commands = List.copyOf(commands);
this.mergeKey = mergeKey;
}
public List<Command> commands() {
return commands;
}
@Override
public String label() {
return label;
}
@Override
public void apply(Scene s) {
for (Command c : commands) {
c.apply(s);
}
}
@Override
public void revert(Scene s) {
for (int i = commands.size() - 1; i >= 0; i--) {
commands.get(i).revert(s);
}
}
@Override
public Object mergeKey() {
return mergeKey;
}
@Override
public Command mergeWith(Command next) {
if (!(next instanceof Batch b)
|| mergeKey == null
|| !mergeKey.equals(b.mergeKey)
|| b.commands.size() != commands.size()) {
return null;
}
List<Command> out = new ArrayList<>();
for (int i = 0; i < commands.size(); i++) {
Command m = commands.get(i).mergeWith(b.commands.get(i));
if (m == null) {
return null;
}
out.add(m);
}
return new Batch(label, out, mergeKey);
}
}
@@ -0,0 +1,25 @@
package com.eliasmueller.llamamod.editor.model.cmd;
import com.eliasmueller.llamamod.editor.model.Scene;
/** An undoable change of a {@link Scene}. Commands fire the scene's events through {@link Scene}. */
public interface Command {
String label();
void apply(Scene s);
void revert(Scene s);
/**
* Key for merging consecutive commands (e.g. one drag gesture); {@code null} = never merge.
* Commands with equal non-null keys are offered to {@link #mergeWith}.
*/
default Object mergeKey() {
return null;
}
/** This command followed by {@code next} as one command, or {@code null} if not mergeable. */
default Command mergeWith(Command next) {
return null;
}
}
@@ -0,0 +1,234 @@
package com.eliasmueller.llamamod.editor.model.cmd;
import com.eliasmueller.llamamod.editor.model.Brush;
import com.eliasmueller.llamamod.editor.model.BrushKind;
import com.eliasmueller.llamamod.editor.model.EnvZone;
import com.eliasmueller.llamamod.editor.model.Light;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.PathNode;
import com.eliasmueller.llamamod.editor.model.Prop;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.StratCatalog;
import com.eliasmueller.llamamod.editor.model.Tile;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* Ready-made commands for common edits. Positions are in game space cells (x east, y up, z south),
* angles in degrees, unless noted.
*/
public final class Edits {
private Edits() {}
public static int fixed(double cells) {
return (int) Math.round(cells * 4096);
}
public static int angleUnits(double degrees) {
return (int) Math.round(((degrees % 360 + 360) % 360) / 360 * 4096) & 0xfff;
}
/** Several property changes as one step; values map: node → (prop → value). */
public static Command set(String label, Map<Node, Map<String, Object>> values, Object mergeKey) {
List<Command> l = flatten(values, mergeKey);
return l.size() == 1 && mergeKey == null ? l.getFirst() : new Batch(label, l, mergeKey);
}
/**
* Moves nodes by (dx, dy, dz) cells in game space: strats, waypoints, tiles, brushes, zone
* targets, point lights. For a drag, pass the same mergeKey for every step of the gesture.
*/
public static Command translate(
Collection<? extends Node> nodes, double dx, double dy, double dz, Object mergeKey) {
Map<Node, Map<String, Object>> m = new IdentityHashMap<>();
for (Node n : nodes) {
Map<String, Object> v = new LinkedHashMap<>();
switch (n) {
case Strat s -> {
v.put("x", s.x() + fixed(dx));
v.put("y", s.y() + fixed(dy));
v.put("z", s.z() + fixed(dz));
}
case PathNode p -> {
v.put("x", p.x() + fixed(dx));
v.put("y", p.y() + fixed(dy));
v.put("z", p.z() + fixed(dz));
}
case Tile t -> {
v.put("x", (double) (float) (t.x() + dx));
v.put("y", (double) (float) (t.y() + dy));
v.put("z", (double) (float) (t.z() - dz));
}
case Brush b -> v.putAll(b.translated(dx, dy, dz));
case EnvZone z -> {
v.put("x", z.field(3) + fixed(dx));
v.put("y", z.field(4) + fixed(dy));
v.put("z", z.field(5) + fixed(dz));
}
case Light l -> {
if (l.type() != Light.DIRECTIONAL) {
v.put("x", l.x() + dx);
v.put("y", l.y() + dy);
v.put("z", l.z() + dz);
}
}
default -> {}
}
if (!v.isEmpty()) {
m.put(n, v);
}
}
return new Batch("Move", flatten(m, mergeKey), mergeKey);
}
private static List<Command> flatten(Map<Node, Map<String, Object>> m, Object mergeKey) {
List<Command> l = new ArrayList<>();
m.forEach((n, vals) -> vals.forEach((p, v) -> l.add(SetProp.of(n, n.prop(p), v, mergeKey))));
return l;
}
/** Sets the yaw of a strat / waypoint (degrees, 0 = facing +z, 90 = +x) or a tile (quarter turns etc.). */
public static Command rotateTo(Node n, double degrees, Object mergeKey) {
return SetProp.of(n, n.prop(n instanceof Tile ? "rot" : "ry"), angleUnits(degrees), mergeKey);
}
/**
* Places a new object of a catalog type: type fields and default parameters from the
* prototype, its waypoint chain copied relative to the new position ("relative" mode of
* levelbuild, without snapping to the ground).
*/
public static Command placeStrat(Scene s, String type, int variant, double x, double y, double z, Double yawDeg) {
StratCatalog.Type t = s.catalog().type(type);
if (t == null) {
throw new IllegalArgumentException("object type '" + type + "' is not placeable in this level");
}
StratCatalog.Variant v = t.variant(variant);
Strat st = (Strat) s.create(NodeKind.STRAT);
List<Command> cmds = new ArrayList<>();
int fx = fixed(x);
int fy = fixed(y);
int fz = fixed(z);
List<PathNode> chain = new ArrayList<>();
for (int[] c : v.chain()) {
PathNode p = (PathNode) s.create(NodeKind.PATH_NODE);
p.setRaw("x", fx + c[0]);
p.setRaw("y", fy + c[1]);
p.setRaw("z", fz + c[2]);
p.setRaw("rx", c[3]);
p.setRaw("ry", c[4]);
p.setRaw("rz", c[5]);
p.setRaw("unk1", c[6]);
p.setRaw("unk2", c[7]);
p.setRaw("unk3", c[8]);
if (!chain.isEmpty()) {
chain.getLast().setRaw("next", p);
}
chain.add(p);
cmds.add(new AddNode(p));
}
st.setRaw("code", v.code());
st.setRaw("nVars", v.nVars());
st.setRaw("slots", v.slots());
st.setRaw("typeFlags", v.typeFlags());
st.setRaw("extra", v.extra());
st.setRaw("flags", v.flags() & ~Strat.FLAG_HIDDEN);
st.setRaw("rx", v.rx());
st.setRaw("ry", yawDeg == null ? v.ry() : angleUnits(yawDeg));
st.setRaw("rz", v.rz());
st.setRaw("x", fx);
st.setRaw("y", fy);
st.setRaw("z", fz);
st.setRaw("params", v.params().clone());
st.setRaw("waypoint", chain.isEmpty() ? null : chain.getFirst());
st.setTypeName(type);
cmds.add(new AddNode(st));
return new Batch("Add " + type, cmds);
}
/**
* Deletes nodes (and, for strats, the waypoint chains only they use). Tile models still used
* by tiles are refused.
*/
public static Command delete(Scene s, Collection<? extends Node> nodes) {
Set<Node> del = Collections.newSetFromMap(new IdentityHashMap<>());
del.addAll(nodes);
for (Node n : nodes) {
if (n instanceof Strat st && st.waypoint() != null) {
List<PathNode> ch = s.chain(st.waypoint());
boolean shared = s.strats().stream()
.anyMatch(o -> o != st
&& !del.contains(o)
&& o.waypoint() != null
&& ch.contains(o.waypoint()))
|| s.pathNodes().stream().anyMatch(p -> !ch.contains(p) && ch.contains(p.next()));
if (!shared) {
del.addAll(ch);
}
}
}
// users before the nodes they reference
List<Node> order = new ArrayList<>(del);
order.sort(Comparator.comparingInt(n ->
n instanceof Strat ? 0 : n instanceof Tile || n instanceof Brush ? 1 : n instanceof PathNode ? 2 : 3));
return new DeferredRemove(s, order);
}
/** Removes a set of nodes, computing reference unlinks at apply time (so earlier removals count). */
private static final class DeferredRemove implements Command {
private final List<Node> nodes;
private final List<RemoveNode> done = new ArrayList<>();
DeferredRemove(Scene s, List<Node> nodes) {
this.nodes = nodes;
for (Node n : nodes) {
for (Map.Entry<Node, Prop> r : s.referencesTo(n)) {
if (!r.getValue().nullable() && !nodes.contains(r.getKey())) {
throw new IllegalStateException(
n.id() + " is still used by " + r.getKey().id());
}
}
}
}
@Override
public String label() {
return nodes.size() == 1
? "Delete " + nodes.getFirst().kind().label().toLowerCase(Locale.ROOT)
: "Delete " + nodes.size() + " items";
}
@Override
public void apply(Scene s) {
done.clear();
for (Node n : nodes) {
RemoveNode r = new RemoveNode(s, n);
r.apply(s);
done.add(r);
}
}
@Override
public void revert(Scene s) {
for (int i = done.size() - 1; i >= 0; i--) {
done.get(i).revert(s);
}
}
}
/** New brush with parameters (missing ones keep their defaults). */
public static Command addBrush(Scene s, BrushKind kind, Map<String, Object> params) {
Brush b = s.createBrush(kind);
params.forEach(b::setRaw);
return new AddNode(b);
}
}
@@ -0,0 +1,176 @@
package com.eliasmueller.llamamod.editor.model.cmd;
import com.eliasmueller.llamamod.editor.model.Scene;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* Undo/redo stack of a scene. {@link #execute} applies a command; consecutive commands with equal
* {@link Command#mergeKey()} are merged into one step (drags). {@link #begin}/{@link #end} group
* everything executed in between into one {@link Batch}.
*/
public final class History {
private final Scene scene;
private final Deque<Command> undo = new ArrayDeque<>();
private final Deque<Command> redo = new ArrayDeque<>();
private final List<Runnable> listeners = new CopyOnWriteArrayList<>();
private final int limit;
private List<Command> group;
private String groupLabel;
private int groupDepth;
private Command savedAt;
private boolean savedAtEmpty = true;
/** Stands for a saved state that is no longer on the stack (dropped by the limit or by clear). */
private static final Command UNREACHABLE = new Batch("saved state", List.of());
public History(Scene scene) {
this(scene, 500);
}
public History(Scene scene, int limit) {
this.scene = scene;
this.limit = limit;
}
public Scene scene() {
return scene;
}
public void execute(Command c) {
c.apply(scene);
if (group != null) {
group.add(c);
return;
}
push(c);
}
private void push(Command c) {
redo.clear();
Command top = undo.peek();
if (top != null && c.mergeKey() != null && c.mergeKey().equals(top.mergeKey()) && top != savedAt) {
Command m = top.mergeWith(c);
if (m != null) {
undo.pop();
undo.push(m);
changed();
return;
}
}
undo.push(c);
while (undo.size() > limit) {
undo.removeLast();
if (savedAtEmpty) {
savedAtEmpty = false;
savedAt = UNREACHABLE;
}
}
changed();
}
/** Starts a group; nested calls join the outer group. */
public void begin(String label) {
if (groupDepth++ == 0) {
group = new ArrayList<>();
groupLabel = label;
}
}
public void end() {
if (groupDepth == 0) {
throw new IllegalStateException("end() without begin()");
}
if (--groupDepth == 0) {
List<Command> g = group;
group = null;
if (!g.isEmpty()) {
push(g.size() == 1 ? g.getFirst() : new Batch(groupLabel, g));
}
}
}
public boolean canUndo() {
return !undo.isEmpty();
}
public boolean canRedo() {
return !redo.isEmpty();
}
public String undoLabel() {
return undo.isEmpty() ? "" : undo.peek().label();
}
public String redoLabel() {
return redo.isEmpty() ? "" : redo.peek().label();
}
/** Labels of the undo steps, most recent first. */
public List<String> undoLabels() {
return undo.stream().map(Command::label).toList();
}
/** Labels of the redo steps, next first. */
public List<String> redoLabels() {
return redo.stream().map(Command::label).toList();
}
public void undo() {
if (undo.isEmpty()) {
return;
}
Command c = undo.pop();
c.revert(scene);
redo.push(c);
changed();
}
public void redo() {
if (redo.isEmpty()) {
return;
}
Command c = redo.pop();
c.apply(scene);
undo.push(c);
changed();
}
/** Drops all steps. A dirty history stays dirty (the saved state can no longer be reached). */
public void clear() {
boolean dirty = isDirty();
undo.clear();
redo.clear();
savedAtEmpty = !dirty;
savedAt = dirty ? UNREACHABLE : null;
changed();
}
/** Remembers the current state as saved (see {@link #isDirty}). */
public void markSaved() {
savedAt = undo.peek();
savedAtEmpty = undo.isEmpty();
changed();
}
public boolean isDirty() {
return savedAtEmpty ? !undo.isEmpty() : undo.peek() != savedAt;
}
/** Called after every execute/undo/redo/save mark (update menus, title). */
public void addListener(Runnable r) {
listeners.add(r);
}
public void removeListener(Runnable r) {
listeners.remove(r);
}
private void changed() {
for (Runnable r : listeners) {
r.run();
}
}
}
@@ -0,0 +1,55 @@
package com.eliasmueller.llamamod.editor.model.cmd;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.Prop;
import com.eliasmueller.llamamod.editor.model.Scene;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Removes a node. Optional references to it (a strat's waypoint, a node's next, a tile's swap
* model) are cleared and restored on undo; a required reference (a tile's model) refuses the
* removal with {@link IllegalStateException} when the command is created.
*/
public final class RemoveNode implements Command {
private final Node node;
private final List<SetProp> unlink = new ArrayList<>();
private int index = -1;
public RemoveNode(Scene s, Node node) {
this.node = node;
for (Map.Entry<Node, Prop> r : s.referencesTo(node)) {
if (!r.getValue().nullable()) {
throw new IllegalStateException(node.id() + " is still used by "
+ r.getKey().id() + " (" + r.getValue().label() + ")");
}
unlink.add(SetProp.of(r.getKey(), r.getValue(), null, null));
}
}
public Node node() {
return node;
}
@Override
public String label() {
return "Delete " + node.kind().label().toLowerCase(java.util.Locale.ROOT);
}
@Override
public void apply(Scene s) {
for (SetProp p : unlink) {
p.apply(s);
}
index = s.remove(node);
}
@Override
public void revert(Scene s) {
s.add(node, index);
for (int i = unlink.size() - 1; i >= 0; i--) {
unlink.get(i).revert(s);
}
}
}
@@ -0,0 +1,68 @@
package com.eliasmueller.llamamod.editor.model.cmd;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.Prop;
import com.eliasmueller.llamamod.editor.model.Scene;
import java.util.Objects;
/** Sets one property. With a merge key, consecutive changes of the same node/property merge. */
public final class SetProp implements Command {
private final Node node;
private final Prop prop;
private final Object oldValue;
private final Object newValue;
private final Object mergeKey;
private SetProp(Node node, Prop prop, Object oldValue, Object newValue, Object mergeKey) {
this.node = node;
this.prop = prop;
this.oldValue = oldValue;
this.newValue = newValue;
this.mergeKey = mergeKey;
}
public static SetProp of(Node n, String prop, Object value) {
return of(n, n.prop(prop), value, null);
}
/** mergeKey: e.g. a drag gesture object, so all moves of one drag become one undo step. */
public static SetProp of(Node n, Prop p, Object value, Object mergeKey) {
return new SetProp(n, p, Values.copy(p.get(n)), Values.copy(value), mergeKey);
}
public Node node() {
return node;
}
public Prop prop() {
return prop;
}
@Override
public String label() {
return "Change " + prop.label();
}
@Override
public void apply(Scene s) {
s.set(node, prop, Values.copy(newValue));
}
@Override
public void revert(Scene s) {
s.set(node, prop, Values.copy(oldValue));
}
@Override
public Object mergeKey() {
return mergeKey == null ? null : java.util.List.of(mergeKey, node, prop.name());
}
@Override
public Command mergeWith(Command next) {
if (next instanceof SetProp o && o.node == node && o.prop == prop && Objects.equals(mergeKey, o.mergeKey)) {
return new SetProp(node, prop, oldValue, o.newValue, mergeKey);
}
return null;
}
}
@@ -0,0 +1,25 @@
package com.eliasmueller.llamamod.editor.model.cmd;
import java.util.Objects;
/** Copy/compare helpers for property values (arrays are copied so commands own their values). */
public final class Values {
private Values() {}
public static Object copy(Object v) {
if (v instanceof int[] a) {
return a.clone();
}
if (v instanceof double[] a) {
return a.clone();
}
if (v instanceof byte[] a) {
return a.clone();
}
return v;
}
public static boolean same(Object a, Object b) {
return Objects.deepEquals(a, b);
}
}
@@ -0,0 +1,279 @@
package com.eliasmueller.llamamod.editor.render;
import static org.lwjgl.opengl.GL33C.*;
import java.nio.ByteBuffer;
import org.joml.Matrix4f;
import org.joml.Vector3f;
import org.lwjgl.system.MemoryUtil;
/**
* GPU buffers for a group of triangles (sorted into 6 ranges: blend class × double-sided) plus
* overlay lines and translucent overlay triangles. Built on the CPU with {@link Builder}.
*/
final class Batch {
static final int STRIDE = 36;
static final int LINE_STRIDE = 16;
static final int RANGES = 6;
int vao;
int vbo;
int lineVao;
int lineVbo;
final int[] first = new int[RANGES];
final int[] count = new int[RANGES];
int lineVerts;
int fillVerts;
int triangles;
/** Local bounds of a single-instance batch (mesh + overlay), else unused. */
float[] box;
static int range(int blend, boolean doubleSided) {
return blend * 2 + (doubleSided ? 1 : 0);
}
void delete() {
if (vao != 0) {
glDeleteVertexArrays(vao);
glDeleteBuffers(vbo);
}
if (lineVao != 0) {
glDeleteVertexArrays(lineVao);
glDeleteBuffers(lineVbo);
}
vao = vbo = lineVao = lineVbo = 0;
}
boolean empty() {
return triangles == 0 && lineVerts == 0 && fillVerts == 0;
}
void drawRange(int r) {
if (count[r] > 0) {
glBindVertexArray(vao);
glDrawArrays(GL_TRIANGLES, first[r], count[r]);
}
}
void drawLines() {
if (lineVerts > 0) {
glBindVertexArray(lineVao);
glDrawArrays(GL_LINES, 0, lineVerts);
}
}
void drawFill() {
if (fillVerts > 0) {
glBindVertexArray(lineVao);
glDrawArrays(GL_TRIANGLES, lineVerts, fillVerts);
}
}
/** Growable direct buffer. */
static final class Buf {
ByteBuffer b = MemoryUtil.memAlloc(1 << 14);
void ensure(int n) {
if (b.remaining() < n) {
b = MemoryUtil.memRealloc(b, Math.max(b.capacity() * 2, b.position() + n));
}
}
void free() {
MemoryUtil.memFree(b);
}
}
/** Collects instances (optionally transformed) and uploads them as one batch. */
static final class Builder {
private final Buf[] tri = new Buf[RANGES];
private final Buf lines = new Buf();
private final Buf fill = new Buf();
private final int whiteLayer;
private final int[] pageW;
private final float arraySize;
Builder(int whiteLayer, int[] pageW, float arraySize) {
for (int i = 0; i < RANGES; i++) {
tri[i] = new Buf();
}
this.whiteLayer = whiteLayer;
this.pageW = pageW;
this.arraySize = arraySize;
}
/** Adds an instance; xform {@code null} keeps local coordinates. */
void add(RenderInstance inst, Matrix4f xform, boolean withOverlay) {
RenderMesh m = inst.mesh;
int id = inst.id;
int[] vc = inst.vertexColors;
Vector3f p = new Vector3f();
Vector3f n = new Vector3f();
int nt = m.triangleCount();
for (int t = 0; t < nt; t++) {
int fl = m.triFlags[t];
Buf b = tri[range(m.blend[t], (fl & RenderMesh.DOUBLE_SIDED) != 0)];
b.ensure(3 * STRIDE);
int tc = m.triColors[t];
int page = m.pages[t];
int layer = page < 0 || page >= pageW.length ? whiteLayer : page;
float uvScale = page < 0 || page >= pageW.length ? 0 : pageW[page] / arraySize;
for (int k = 0; k < 3; k++) {
int v = m.triangles[3 * t + k];
p.set(m.positions[3 * v], m.positions[3 * v + 1], m.positions[3 * v + 2]);
n.set(m.normals[3 * v], m.normals[3 * v + 1], m.normals[3 * v + 2]);
if (xform != null) {
xform.transformPosition(p);
xform.transformDirection(n);
}
if (n.lengthSquared() > 0) {
n.normalize();
}
ByteBuffer bb = b.b;
bb.putFloat(p.x).putFloat(p.y).putFloat(p.z);
bb.put(snorm(n.x)).put(snorm(n.y)).put(snorm(n.z)).put((byte) 0);
bb.putFloat(m.uvs[6 * t + 2 * k] * uvScale).putFloat(m.uvs[6 * t + 2 * k + 1] * uvScale);
int c = vc != null && (fl & RenderMesh.NO_VERTEX_COLOR) == 0 && v < vc.length
? doubled(vc[v])
: 0xffffffff;
bb.putInt(mul(c, tc));
bb.putInt(id);
bb.putShort((short) layer).putShort((short) fl);
}
}
if (withOverlay && m.overlay != null) {
addOverlay(m.overlay, xform);
}
}
void addOverlay(Overlay o, Matrix4f xform) {
Vector3f p = new Vector3f();
float[] l = o.lines();
int[] lc = o.lineColors();
lines.ensure(o.lineCount() * 2 * LINE_STRIDE);
for (int i = 0; i < o.lineCount(); i++) {
for (int k = 0; k < 2; k++) {
p.set(l[6 * i + 3 * k], l[6 * i + 3 * k + 1], l[6 * i + 3 * k + 2]);
if (xform != null) {
xform.transformPosition(p);
}
lines.b.putFloat(p.x).putFloat(p.y).putFloat(p.z).putInt(rgbaToAbgr(lc[i]));
}
}
float[] tr = o.triangles();
int[] tcol = o.triangleColors();
fill.ensure(o.triangleCount() * 3 * LINE_STRIDE);
for (int i = 0; i < o.triangleCount(); i++) {
for (int k = 0; k < 3; k++) {
p.set(tr[9 * i + 3 * k], tr[9 * i + 3 * k + 1], tr[9 * i + 3 * k + 2]);
if (xform != null) {
xform.transformPosition(p);
}
fill.b.putFloat(p.x).putFloat(p.y).putFloat(p.z).putInt(rgbaToAbgr(tcol[i]));
}
}
}
/** Uploads into target (existing buffers are replaced) and frees the CPU memory. */
Batch upload(Batch target) {
Batch g = target != null ? target : new Batch();
g.delete();
int total = 0;
for (int r = 0; r < RANGES; r++) {
g.first[r] = total;
g.count[r] = tri[r].b.position() / STRIDE;
total += g.count[r];
}
g.triangles = total / 3;
if (total > 0) {
ByteBuffer all = MemoryUtil.memAlloc(total * STRIDE);
for (Buf b : tri) {
b.b.flip();
all.put(b.b);
}
all.flip();
g.vao = glGenVertexArrays();
g.vbo = glGenBuffers();
glBindVertexArray(g.vao);
glBindBuffer(GL_ARRAY_BUFFER, g.vbo);
glBufferData(GL_ARRAY_BUFFER, all, GL_STATIC_DRAW);
MemoryUtil.memFree(all);
meshAttribs();
}
g.lineVerts = lines.b.position() / LINE_STRIDE;
g.fillVerts = fill.b.position() / LINE_STRIDE;
if (g.lineVerts + g.fillVerts > 0) {
ByteBuffer all = MemoryUtil.memAlloc((g.lineVerts + g.fillVerts) * LINE_STRIDE);
all.put(lines.b.flip()).put(fill.b.flip()).flip();
g.lineVao = glGenVertexArrays();
g.lineVbo = glGenBuffers();
glBindVertexArray(g.lineVao);
glBindBuffer(GL_ARRAY_BUFFER, g.lineVbo);
glBufferData(GL_ARRAY_BUFFER, all, GL_STATIC_DRAW);
MemoryUtil.memFree(all);
lineAttribs();
}
glBindVertexArray(0);
free();
return g;
}
void free() {
for (Buf b : tri) {
b.free();
}
lines.free();
fill.free();
}
}
static void meshAttribs() {
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, false, STRIDE, 0);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_BYTE, true, STRIDE, 12);
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 2, GL_FLOAT, false, STRIDE, 16);
glEnableVertexAttribArray(3);
glVertexAttribPointer(3, 4, GL_UNSIGNED_BYTE, true, STRIDE, 24);
glEnableVertexAttribArray(4);
glVertexAttribPointer(4, 4, GL_UNSIGNED_BYTE, true, STRIDE, 28);
glEnableVertexAttribArray(5);
glVertexAttribIPointer(5, 2, GL_SHORT, STRIDE, 32);
}
static void lineAttribs() {
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, false, LINE_STRIDE, 0);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 4, GL_UNSIGNED_BYTE, true, LINE_STRIDE, 12);
}
static byte snorm(float f) {
return (byte) Math.round(Math.max(-1, Math.min(1, f)) * 127);
}
/** Game vertex colour (0x80 = 1.0) to 0..255, doubled with clamp. */
static int doubled(int rgba) {
int r = Math.min(255, ((rgba >>> 24) & 0xff) * 2);
int g = Math.min(255, ((rgba >>> 16) & 0xff) * 2);
int b = Math.min(255, ((rgba >>> 8) & 0xff) * 2);
return r << 24 | g << 16 | b << 8 | (rgba & 0xff);
}
static int mul(int a, int b) {
if (b == 0xffffffff) {
return rgbaToAbgr(a);
}
int r = ((a >>> 24) & 0xff) * ((b >>> 24) & 0xff) / 255;
int g = ((a >>> 16) & 0xff) * ((b >>> 16) & 0xff) / 255;
int bl = ((a >>> 8) & 0xff) * ((b >>> 8) & 0xff) / 255;
int al = (a & 0xff) * (b & 0xff) / 255;
return rgbaToAbgr(r << 24 | g << 16 | bl << 8 | al);
}
/** 0xRRGGBBAA to the little-endian int that stores bytes R, G, B, A in memory order. */
static int rgbaToAbgr(int c) {
return Integer.reverseBytes(c);
}
}
@@ -0,0 +1,183 @@
package com.eliasmueller.llamamod.editor.render;
import org.joml.Matrix4f;
import org.joml.Vector3f;
import org.joml.Vector4f;
/**
* Editor camera: position + yaw/pitch, perspective or orthographic. Units are level cells (export
* space). The view is <b>left-handed like the game's Direct3D renderer</b>: export coordinates are
* drawn mirrored (screen x flipped) compared to a plain OpenGL view, so the picture matches the
* game. Yaw and pitch equal the game camera's angles (0x5790a4/0x5790a0, 4096 = 2&pi;): yaw 0 looks
* along export -z (= game +z), positive yaw turns <b>left</b> (towards +x); pitch positive looks
* up. Screen right = (-cos yaw, 0, -sin yaw) in export space (game: (-cos yaw, 0, sin yaw)).
*/
public final class Camera {
public enum View {
PERSPECTIVE,
TOP,
FRONT,
SIDE
}
public final Vector3f position = new Vector3f(0, 5, 10);
public float yaw;
public float pitch = -0.4f;
public float fovY = (float) Math.toRadians(60);
public float near = 0.03f;
public float far = 600f;
public boolean ortho;
/** Visible half height in orthographic mode. */
public float orthoSize = 10;
/** Distance to the orbit pivot (also used for pan speed and ortho framing). */
public float distance = 10;
public View view = View.PERSPECTIVE;
private int width = 1;
private int height = 1;
public void viewportSize(int w, int h) {
width = Math.max(1, w);
height = Math.max(1, h);
}
public int width() {
return width;
}
public int height() {
return height;
}
public Vector3f forward() {
float cp = (float) Math.cos(pitch);
return new Vector3f((float) Math.sin(yaw) * cp, (float) Math.sin(pitch), -(float) Math.cos(yaw) * cp);
}
/** World direction of screen right (horizontal). */
public Vector3f right() {
return new Vector3f(-(float) Math.cos(yaw), 0, -(float) Math.sin(yaw));
}
/** World direction of screen up. */
public Vector3f up() {
return forward().cross(right()).normalize();
}
public Vector3f pivot() {
return forward().mul(distance).add(position);
}
public Matrix4f viewMatrix() {
Vector3f f = forward();
// straight down: "screen up" is the yaw direction; straight up: its opposite (the same
// limit as with world up, so pitching through the threshold never flips the picture)
float sy = f.y < 0 ? 1 : -1;
Vector3f up = Math.abs(f.y) > 0.999f
? new Vector3f(sy * (float) Math.sin(yaw), 0, -sy * (float) Math.cos(yaw))
: new Vector3f(0, 1, 0);
Vector3f c = new Vector3f(position).add(f);
// scale(-1, 1, 1): left-handed (Direct3D) view, see the class comment; flips triangle winding
return new Matrix4f().scale(-1, 1, 1).lookAt(position, c, up);
}
public Matrix4f projectionMatrix() {
float a = width / (float) height;
if (ortho) {
return new Matrix4f().ortho(-orthoSize * a, orthoSize * a, -orthoSize, orthoSize, -far, far);
}
return new Matrix4f().perspective(fovY, a, near, far);
}
public Matrix4f viewProjection() {
return projectionMatrix().mul(viewMatrix());
}
/** Pick ray through a pixel (origin, direction) in world space; y grows downwards. */
public Vector3f[] ray(double px, double py) {
Matrix4f inv = viewProjection().invert();
float nx = (float) (2 * px / width - 1);
float ny = (float) (1 - 2 * py / height);
Vector4f a = new Vector4f(nx, ny, -1, 1).mul(inv);
Vector4f b = new Vector4f(nx, ny, 1, 1).mul(inv);
Vector3f o = new Vector3f(a.x / a.w, a.y / a.w, a.z / a.w);
Vector3f e = new Vector3f(b.x / b.w, b.y / b.w, b.z / b.w);
return new Vector3f[] {o, e.sub(o).normalize()};
}
/** World point to pixel coordinates {x, y, depth}; depth &gt; 1 or &lt; -1 = clipped. */
public Vector3f project(Vector3f p) {
Vector4f v = new Vector4f(p, 1).mul(viewProjection());
if (v.w <= 1e-6f && !ortho) {
return new Vector3f(Float.NaN, Float.NaN, 2);
}
return new Vector3f((v.x / v.w + 1) * 0.5f * width, (1 - v.y / v.w) * 0.5f * height, v.z / v.w);
}
/** World units per pixel at a point (for constant screen-size handles). */
public float pixelSize(Vector3f p) {
if (ortho) {
return 2 * orthoSize / height;
}
float d = Math.max(near, new Vector3f(p).sub(position).dot(forward()));
return (float) (2 * d * Math.tan(fovY / 2) / height);
}
/** Places the camera so that the box fits the view, keeping the direction. */
public void frame(float[] box) {
Vector3f c = new Vector3f((box[0] + box[3]) / 2, (box[1] + box[4]) / 2, (box[2] + box[5]) / 2);
Vector3f f = forward(), r = right(), u = up();
float tanY = (float) Math.tan(fovY / 2), tanX = tanY * width / (float) height;
float d = 0.5f, half = 0.3f;
for (int k = 0; k < 8; k++) {
Vector3f p = new Vector3f(box[(k & 1) != 0 ? 3 : 0], box[(k & 2) != 0 ? 4 : 1], box[(k & 4) != 0 ? 5 : 2])
.sub(c);
float x = Math.abs(p.dot(r)), y = Math.abs(p.dot(u)), z = p.dot(f);
d = Math.max(d, Math.max(y / tanY, x / tanX) - z);
half = Math.max(half, Math.max(y, x * height / (float) width));
}
distance = d * 1.08f;
orthoSize = half * 1.08f;
position.set(f.mul(-distance).add(c));
}
/** Keeps the pivot and switches to a standard view (orthographic for top/front/side). */
public void setView(View v) {
Vector3f p = pivot();
view = v;
switch (v) {
case TOP -> {
// screen up = export +z (game -z): x to the right, grid rows (game z) downwards
yaw = (float) Math.PI;
pitch = (float) (-Math.PI / 2);
ortho = true;
}
case FRONT -> {
yaw = (float) Math.PI;
pitch = 0;
ortho = true;
}
case SIDE -> {
yaw = (float) (-Math.PI / 2);
pitch = 0;
ortho = true;
}
case PERSPECTIVE -> ortho = false;
}
if (ortho) {
orthoSize = Math.max(orthoSize, 0.5f);
}
position.set(forward().mul(-distance).add(p));
}
public void copyFrom(Camera o) {
position.set(o.position);
yaw = o.yaw;
pitch = o.pitch;
ortho = o.ortho;
orthoSize = o.orthoSize;
distance = o.distance;
view = o.view;
}
}
@@ -0,0 +1,104 @@
package com.eliasmueller.llamamod.editor.render;
import org.joml.FrustumIntersection;
import org.joml.Matrix4f;
import org.joml.Matrix4fc;
import org.joml.Vector3f;
/**
* Conservative view-frustum culling. Boxes are {minX, minY, minZ, maxX, maxY, maxZ}; an instance's
* box covers every triangle vertex <b>and</b> its helper overlay (collision lines/fills), so nothing
* that is drawn with an instance can be culled with it. The planes come from the current
* projection × view (the left-handed view only swaps the left/right planes, the test is
* orientation-independent).
*/
final class Culling {
/** Absolute + relative box growth, so faces exactly on a plane survive float rounding. */
static final float MARGIN = 0.01f;
static final float REL_MARGIN = 0.002f;
/** -Dllamamod.editor.noCull=true draws everything (diagnostics: culled and unculled frames must match). */
static final boolean DISABLED = Boolean.getBoolean("llamamod.editor.noCull");
private final FrustumIntersection fr;
Culling(Matrix4fc viewProjection) {
fr = new FrustumIntersection(viewProjection);
}
/** False only when the box lies completely outside one frustum plane. */
boolean visible(float[] b) {
if (b == null || b[0] > b[3]) {
return false;
}
if (DISABLED) {
return true;
}
float m = MARGIN + REL_MARGIN * Math.max(b[3] - b[0], Math.max(b[4] - b[1], b[5] - b[2]));
return fr.testAab(b[0] - m, b[1] - m, b[2] - m, b[3] + m, b[4] + m, b[5] + m);
}
/** Local bounds of a mesh including its overlay; an empty box (min &gt; max) when there is nothing. */
static float[] localBox(RenderMesh m) {
float[] b = emptyBox();
float[] p = m.positions;
for (int i = 0; i + 2 < p.length; i += 3) {
grow(b, p[i], p[i + 1], p[i + 2]);
}
Overlay o = m.overlay;
if (o != null) {
float[] l = o.lines();
for (int i = 0; i < o.lineCount() * 6; i += 3) {
grow(b, l[i], l[i + 1], l[i + 2]);
}
float[] t = o.triangles();
for (int i = 0; i < o.triangleCount() * 9; i += 3) {
grow(b, t[i], t[i + 1], t[i + 2]);
}
}
return b;
}
/** World bounds of an instance (mesh + overlay, all 8 corners transformed). */
static float[] worldBox(RenderInstance i) {
float[] l = localBox(i.mesh);
return l[0] > l[3] ? l : transform(l, i.transform);
}
static float[] transform(float[] b, Matrix4f m) {
float[] o = emptyBox();
Vector3f p = new Vector3f();
for (int k = 0; k < 8; k++) {
p.set(b[(k & 1) != 0 ? 3 : 0], b[(k & 2) != 0 ? 4 : 1], b[(k & 4) != 0 ? 5 : 2]);
m.transformPosition(p);
grow(o, p.x, p.y, p.z);
}
return o;
}
static float[] emptyBox() {
return new float[] {
Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE, -Float.MAX_VALUE, -Float.MAX_VALUE, -Float.MAX_VALUE
};
}
static void union(float[] b, float[] o) {
if (o[0] > o[3]) {
return;
}
for (int k = 0; k < 3; k++) {
b[k] = Math.min(b[k], o[k]);
b[k + 3] = Math.max(b[k + 3], o[k + 3]);
}
}
static void grow(float[] b, float x, float y, float z) {
b[0] = Math.min(b[0], x);
b[1] = Math.min(b[1], y);
b[2] = Math.min(b[2], z);
b[3] = Math.max(b[3], x);
b[4] = Math.max(b[4], y);
b[5] = Math.max(b[5], z);
}
}
@@ -0,0 +1,584 @@
package com.eliasmueller.llamamod.editor.render;
import org.joml.Matrix4f;
import org.joml.Vector2f;
import org.joml.Vector3f;
/**
* Transform gizmo (translate / rotate / scale with axis and plane constraints). Pure math: the
* viewport draws {@link #build} and feeds mouse input; drags produce {@link GizmoEvent}s with the
* total delta since the drag started. The gizmo never changes the scene itself – listeners apply the
* delta (e.g. as an undoable command) and call {@link #setTarget} to move the gizmo along.
*/
public final class Gizmo {
public enum Mode {
TRANSLATE,
ROTATE,
SCALE
}
public enum Space {
WORLD,
LOCAL
}
public enum Handle {
NONE,
X,
Y,
Z,
XY,
YZ,
XZ,
CENTER;
/** Axis index 0..2 for X/Y/Z, the plane normal for XY/YZ/XZ, -1 otherwise. */
int axis() {
return switch (this) {
case X, YZ -> 0;
case Y, XZ -> 1;
case Z, XY -> 2;
default -> -1;
};
}
boolean plane() {
return this == XY || this == YZ || this == XZ;
}
}
/** Snapping steps; a step ≤ 0 disables that kind. Holding Ctrl while dragging inverts {@link #enabled}. */
public record Snap(boolean enabled, float translate, float rotateDegrees, float scale) {
public static final Snap DEFAULT = new Snap(false, 0.25f, 15f, 0.1f);
}
private static final int[] AXIS_COLORS = {
Legend.rgb(232, 64, 64), Legend.rgb(120, 205, 40), Legend.rgb(60, 120, 245)
};
private static final int ACTIVE = Legend.rgb(255, 222, 60);
private static final float HIT_PX = 9;
private static final Handle[] AXIS_HANDLES = {Handle.X, Handle.Y, Handle.Z};
/** Translate plane handles by normal axis. */
private static final Handle[] PLANE_HANDLES = {Handle.YZ, Handle.XZ, Handle.XY};
private Mode mode = Mode.TRANSLATE;
private Space space = Space.WORLD;
private boolean visible;
private final Matrix4f target = new Matrix4f();
private Snap snap = Snap.DEFAULT;
/** Handle length in pixels. */
public float sizePx = 95;
private Handle hover = Handle.NONE;
private Handle active = Handle.NONE;
private final Vector3f dragPivot = new Vector3f();
private final Vector3f[] dragAxes = {new Vector3f(), new Vector3f(), new Vector3f()};
private final Matrix4f dragStart = new Matrix4f();
private final Vector3f dragHit = new Vector3f();
private float dragT0;
private float lastAngle;
private float totalAngle;
private final Vector2f dragMouse = new Vector2f();
private GizmoEvent last;
public Mode mode() {
return mode;
}
public void setMode(Mode m) {
mode = m;
}
public Space space() {
return space;
}
public void setSpace(Space s) {
space = s;
}
public boolean visible() {
return visible;
}
public void setVisible(boolean v) {
visible = v;
if (!v) {
hover = Handle.NONE;
}
}
public Matrix4f target() {
return new Matrix4f(target);
}
/** Pivot (translation) and orientation (rotation part, used in local space) of the gizmo. */
public void setTarget(Matrix4f m) {
target.set(m);
}
public Snap snap() {
return snap;
}
public void setSnap(Snap s) {
snap = s;
}
public boolean dragging() {
return active != Handle.NONE;
}
public Handle hover() {
return hover;
}
Vector3f pivot() {
return target.getTranslation(new Vector3f());
}
Vector3f[] axes() {
Vector3f[] a = {new Vector3f(1, 0, 0), new Vector3f(0, 1, 0), new Vector3f(0, 0, 1)};
if (space == Space.LOCAL || mode == Mode.SCALE) {
for (int i = 0; i < 3; i++) {
target.getColumn(i, a[i]);
if (a[i].lengthSquared() < 1e-12f) {
a[i].set(i == 0 ? 1 : 0, i == 1 ? 1 : 0, i == 2 ? 1 : 0);
}
a[i].normalize();
}
}
return a;
}
private float length(Camera cam) {
return sizePx * cam.pixelSize(pivot());
}
// ------------------------------------------------------------------ hit testing
/** Handle under the pixel, updating the hover state. */
public Handle hitTest(Camera cam, float mx, float my) {
hover = visible && !dragging() ? pick(cam, mx, my) : hover;
return hover;
}
private Handle pick(Camera cam, float mx, float my) {
Vector3f p = pivot();
Vector3f[] ax = axes();
float len = length(cam);
Vector3f pp = cam.project(p);
if (Float.isNaN(pp.x) || pp.z > 1 || pp.z < -1) {
return Handle.NONE;
}
Vector2f m = new Vector2f(mx, my);
if (mode != Mode.ROTATE && m.distance(pp.x, pp.y) < HIT_PX + 2) {
return Handle.CENTER;
}
if (mode == Mode.TRANSLATE) {
for (int n = 0; n < 3; n++) {
if (inQuad(cam, planeQuad(p, ax, n, len), m)) {
return PLANE_HANDLES[n];
}
}
}
Handle best = Handle.NONE;
float bestD = HIT_PX;
for (int i = 0; i < 3; i++) {
float d;
if (mode == Mode.ROTATE) {
d = ringDistance(cam, p, ax[i], len, m);
} else {
Vector3f a = cam.project(new Vector3f(ax[i]).mul(0.2f * len).add(p));
Vector3f b = cam.project(new Vector3f(ax[i]).mul(len).add(p));
d = segDist(m, a, b);
}
if (d < bestD) {
bestD = d;
best = AXIS_HANDLES[i];
}
}
return best;
}
/** Corners of the translate plane handle with normal axis n. */
private static Vector3f[] planeQuad(Vector3f p, Vector3f[] ax, int n, float len) {
Vector3f a = ax[(n + 1) % 3], b = ax[(n + 2) % 3];
return new Vector3f[] {
corner(p, a, b, 0.25f * len, 0.25f * len),
corner(p, a, b, 0.5f * len, 0.25f * len),
corner(p, a, b, 0.5f * len, 0.5f * len),
corner(p, a, b, 0.25f * len, 0.5f * len)
};
}
private static Vector3f corner(Vector3f p, Vector3f a, Vector3f b, float sa, float sb) {
return new Vector3f(a).mul(sa).add(new Vector3f(b).mul(sb)).add(p);
}
/** Point at angle a on the circle of radius r about p spanned by u and v. */
private static Vector3f circlePoint(Vector3f p, Vector3f u, Vector3f v, float r, double a) {
return new Vector3f(u)
.mul((float) Math.cos(a) * r)
.add(new Vector3f(v).mul((float) Math.sin(a) * r))
.add(p);
}
private static boolean inQuad(Camera cam, Vector3f[] q, Vector2f m) {
Vector3f[] s = new Vector3f[4];
for (int i = 0; i < 4; i++) {
s[i] = cam.project(q[i]);
if (Float.isNaN(s[i].x)) {
return false;
}
}
int sign = 0;
for (int i = 0; i < 4; i++) {
Vector3f a = s[i], b = s[(i + 1) % 4];
float c = (b.x - a.x) * (m.y - a.y) - (b.y - a.y) * (m.x - a.x);
int sg = c > 0 ? 1 : -1;
if (sign == 0) {
sign = sg;
} else if (sg != sign) {
return false;
}
}
return true;
}
private static float segDist(Vector2f m, Vector3f a, Vector3f b) {
if (Float.isNaN(a.x) || Float.isNaN(b.x)) {
return Float.MAX_VALUE;
}
float dx = b.x - a.x, dy = b.y - a.y;
float l2 = dx * dx + dy * dy;
float t = l2 < 1e-6f ? 0 : Math.max(0, Math.min(1, ((m.x - a.x) * dx + (m.y - a.y) * dy) / l2));
return m.distance(a.x + t * dx, a.y + t * dy);
}
private static float ringDistance(Camera cam, Vector3f p, Vector3f axis, float r, Vector2f m) {
Vector3f u = perp(axis), v = new Vector3f(axis).cross(u);
float best = Float.MAX_VALUE;
Vector3f prev = null;
Vector3f toPivot =
cam.ortho ? cam.forward() : new Vector3f(p).sub(cam.position).normalize();
for (int k = 0; k <= 64; k++) {
Vector3f w = circlePoint(p, u, v, r, k * Math.PI * 2 / 64);
boolean front = new Vector3f(w).sub(p).dot(toPivot) <= 0.05f * r;
Vector3f s = cam.project(w);
if (prev != null && front) {
best = Math.min(best, segDist(m, prev, s));
}
prev = s;
}
return best;
}
static Vector3f perp(Vector3f a) {
Vector3f t = Math.abs(a.y) < 0.9f ? new Vector3f(0, 1, 0) : new Vector3f(1, 0, 0);
return t.cross(a).normalize();
}
// ------------------------------------------------------------------ dragging
/** Starts dragging the hovered handle; returns the START event or {@code null}. */
public GizmoEvent begin(Camera cam, float mx, float my) {
Handle h = hitTest(cam, mx, my);
if (h == Handle.NONE) {
return null;
}
active = h;
dragPivot.set(pivot());
Vector3f[] ax = axes();
for (int i = 0; i < 3; i++) {
dragAxes[i].set(ax[i]);
}
dragStart.set(target);
dragMouse.set(mx, my);
totalAngle = 0;
Vector3f[] ray = cam.ray(mx, my);
if (mode == Mode.ROTATE) {
lastAngle = planeAngle(cam, ray, mx, my);
} else if (h.axis() >= 0 && !h.plane()) {
dragT0 = axisParam(ray, dragAxes[h.axis()]);
} else {
Vector3f hit = planeHit(cam, ray, h);
dragHit.set(hit != null ? hit : dragPivot);
}
last = event(GizmoEvent.Phase.START, new Vector3f(), 0, new Vector3f(1, 1, 1));
return last;
}
/** Continues the drag; invertSnap = Ctrl held. */
public GizmoEvent drag(Camera cam, float mx, float my, boolean invertSnap) {
if (!dragging()) {
return null;
}
boolean sn = snap.enabled() != invertSnap;
Vector3f[] ray = cam.ray(mx, my);
Vector3f tr = new Vector3f();
float angle = 0;
Vector3f sc = new Vector3f(1, 1, 1);
int ai = active.axis();
switch (mode) {
case TRANSLATE -> {
if (ai >= 0 && !active.plane()) {
float t = axisParam(ray, dragAxes[ai]) - dragT0;
if (sn && snap.translate() > 0) {
t = Math.round(t / snap.translate()) * snap.translate();
}
tr.set(dragAxes[ai]).mul(t);
} else {
Vector3f hit = planeHit(cam, ray, active);
if (hit != null) {
tr.set(hit).sub(dragHit);
if (sn && snap.translate() > 0) {
float s = snap.translate();
tr.set(Math.round(tr.x / s) * s, Math.round(tr.y / s) * s, Math.round(tr.z / s) * s);
}
}
}
}
case ROTATE -> {
float a = planeAngle(cam, ray, mx, my);
float d = a - lastAngle;
if (d > Math.PI) {
d -= (float) (2 * Math.PI);
} else if (d < -Math.PI) {
d += (float) (2 * Math.PI);
}
totalAngle += d;
lastAngle = a;
angle = totalAngle;
if (sn && snap.rotateDegrees() > 0) {
float st = (float) Math.toRadians(snap.rotateDegrees());
angle = Math.round(angle / st) * st;
}
}
case SCALE -> {
float f;
if (active == Handle.CENTER) {
Vector3f pp = cam.project(dragPivot);
float d0 = Math.max(4, dragMouse.distance(pp.x, pp.y));
f = new Vector2f(mx, my).distance(pp.x, pp.y) / d0;
} else {
float t = axisParam(ray, dragAxes[ai]);
f = Math.abs(dragT0) < 1e-5f ? 1 : t / dragT0;
}
if (sn && snap.scale() > 0) {
f = 1 + Math.round((f - 1) / snap.scale()) * snap.scale();
}
f = Math.max(0.01f, f);
if (active == Handle.CENTER) {
sc.set(f);
} else {
sc.setComponent(ai, f);
}
}
}
last = event(GizmoEvent.Phase.DRAG, tr, angle, sc);
return last;
}
public GizmoEvent end() {
if (!dragging()) {
return null;
}
GizmoEvent e = last.withPhase(GizmoEvent.Phase.END);
active = Handle.NONE;
return e;
}
public GizmoEvent cancel() {
if (!dragging()) {
return null;
}
GizmoEvent e = event(GizmoEvent.Phase.CANCEL, new Vector3f(), 0, new Vector3f(1, 1, 1));
active = Handle.NONE;
return e;
}
private GizmoEvent event(GizmoEvent.Phase ph, Vector3f tr, float angle, Vector3f sc) {
Vector3f axis = active.axis() >= 0 ? new Vector3f(dragAxes[active.axis()]) : new Vector3f(0, 1, 0);
return new GizmoEvent(
ph, mode, active, new Vector3f(dragPivot), tr, axis, angle, sc, new Matrix4f(dragStart), space);
}
/** Parameter t of the point on the axis line through the pivot closest to the ray. */
private float axisParam(Vector3f[] ray, Vector3f a) {
Vector3f o = ray[0], d = ray[1];
Vector3f w = new Vector3f(dragPivot).sub(o);
float b = a.dot(d);
float den = 1 - b * b;
if (Math.abs(den) < 1e-5f) {
return dragT0;
}
return (b * d.dot(w) - a.dot(w)) / den;
}
private Vector3f planeHit(Camera cam, Vector3f[] ray, Handle h) {
Vector3f n = h.plane() ? new Vector3f(dragAxes[h.axis()]) : cam.forward();
if (mode == Mode.TRANSLATE && h.plane() && Math.abs(n.dot(ray[1])) < 0.02f) {
return null;
}
float den = n.dot(ray[1]);
if (Math.abs(den) < 1e-6f) {
return null;
}
float t = n.dot(new Vector3f(dragPivot).sub(ray[0])) / den;
return new Vector3f(ray[1]).mul(t).add(ray[0]);
}
/** Angle of the mouse around the rotation axis (plane hit, or screen angle when edge-on). */
private float planeAngle(Camera cam, Vector3f[] ray, float mx, float my) {
Vector3f axis = dragAxes[active.axis()];
float den = axis.dot(ray[1]);
Vector3f pp = cam.project(dragPivot);
float screen = (float) Math.atan2(my - pp.y, mx - pp.x);
if (Math.abs(den) < 0.2f) {
// edge-on: screen-space angle, sign from which way the axis points at the viewer
// (the left-handed view mirrors screen angles)
return screen * Math.signum(axis.dot(cam.forward()) == 0 ? 1 : -axis.dot(cam.forward()));
}
float t = axis.dot(new Vector3f(dragPivot).sub(ray[0])) / den;
Vector3f hit = new Vector3f(ray[1]).mul(t).add(ray[0]).sub(dragPivot);
Vector3f u = perp(axis), v = new Vector3f(axis).cross(u);
return (float) Math.atan2(hit.dot(v), hit.dot(u));
}
// ------------------------------------------------------------------ geometry
/** Handle geometry in world space for the current camera. */
void build(Camera cam, Overlay lines, Overlay tris) {
if (!visible) {
return;
}
// the listener moves the target along while dragging; the axes stay as at drag start
Vector3f p = pivot();
Vector3f[] ax = dragging() ? dragAxes : axes();
float px = cam.pixelSize(p);
float len = sizePx * px;
Handle cur = dragging() ? active : hover;
for (int i = 0; i < 3; i++) {
int col = cur == AXIS_HANDLES[i] ? ACTIVE : AXIS_COLORS[i];
if (dragging()
&& active != AXIS_HANDLES[i]
&& !(active.plane() && active.axis() != i)
&& active != Handle.CENTER) {
col = Legend.withAlpha(col, 70);
}
Vector3f a = ax[i];
if (mode == Mode.ROTATE) {
ring(cam, tris, p, a, len, 2.2f * px, col);
} else {
Vector3f s0 = new Vector3f(a).mul(0.2f * len).add(p);
Vector3f s1 = new Vector3f(a)
.mul(mode == Mode.SCALE ? 0.92f * len : 0.82f * len)
.add(p);
ribbon(cam, tris, s0, s1, 1.6f * px, col);
if (mode == Mode.TRANSLATE) {
cone(tris, s1, new Vector3f(a).mul(len).add(p), a, 0.065f * len, col);
} else {
cube(tris, new Vector3f(a).mul(len).add(p), 0.055f * len, ax, col);
}
}
}
if (mode == Mode.TRANSLATE) {
for (int n = 0; n < 3; n++) {
int col = cur == PLANE_HANDLES[n] ? ACTIVE : AXIS_COLORS[n];
Vector3f[] q = planeQuad(p, ax, n, len);
int fill = Legend.withAlpha(col, cur == PLANE_HANDLES[n] ? 150 : 80);
tri(tris, q[0], q[1], q[2], fill);
tri(tris, q[0], q[2], q[3], fill);
for (int k = 0; k < 4; k++) {
line(lines, q[k], q[(k + 1) % 4], col);
}
}
}
if (mode != Mode.ROTATE) {
int col = cur == Handle.CENTER ? ACTIVE : Legend.rgb(235, 235, 235);
Vector3f r = cam.right().mul(5 * px), u = cam.up().mul(5 * px);
Vector3f c0 = new Vector3f(p).sub(r).sub(u),
c1 = new Vector3f(p).add(r).sub(u);
Vector3f c2 = new Vector3f(p).add(r).add(u),
c3 = new Vector3f(p).sub(r).add(u);
tri(tris, c0, c1, c2, col);
tri(tris, c0, c2, c3, col);
}
if (dragging() && mode == Mode.TRANSLATE && active.axis() >= 0 && !active.plane()) {
Vector3f a = dragAxes[active.axis()];
line(
lines,
new Vector3f(a).mul(-1000).add(p),
new Vector3f(a).mul(1000).add(p),
Legend.withAlpha(AXIS_COLORS[active.axis()], 160));
}
}
private static void line(Overlay o, Vector3f a, Vector3f b, int col) {
o.line(a.x, a.y, a.z, b.x, b.y, b.z, col);
}
private static void tri(Overlay o, Vector3f a, Vector3f b, Vector3f c, int col) {
o.triangle(new double[] {a.x, a.y, a.z}, new double[] {b.x, b.y, b.z}, new double[] {c.x, c.y, c.z}, col);
}
/** Camera-facing quad strip for a thick segment. */
private static void ribbon(Camera cam, Overlay o, Vector3f a, Vector3f b, float halfW, int col) {
Vector3f dir = new Vector3f(b).sub(a);
Vector3f view = cam.ortho ? cam.forward() : new Vector3f(a).sub(cam.position);
Vector3f side = dir.cross(view, new Vector3f());
if (side.lengthSquared() < 1e-12f) {
side = perp(dir.normalize(new Vector3f()));
}
side.normalize().mul(halfW);
Vector3f a0 = new Vector3f(a).sub(side), a1 = new Vector3f(a).add(side);
Vector3f b0 = new Vector3f(b).sub(side), b1 = new Vector3f(b).add(side);
tri(o, a0, b0, b1, col);
tri(o, a0, b1, a1, col);
}
private static void ring(Camera cam, Overlay o, Vector3f p, Vector3f axis, float r, float halfW, int col) {
Vector3f u = perp(axis), v = new Vector3f(axis).cross(u);
Vector3f prev = null;
Vector3f toPivot =
cam.ortho ? cam.forward() : new Vector3f(p).sub(cam.position).normalize();
for (int k = 0; k <= 72; k++) {
Vector3f w = circlePoint(p, u, v, r, k * Math.PI * 2 / 72);
if (prev != null) {
Vector3f mid = new Vector3f(prev).add(w).mul(0.5f);
boolean front = new Vector3f(mid).sub(p).dot(toPivot) <= 0.05f * r;
ribbon(cam, o, prev, w, halfW, front ? col : Legend.withAlpha(col, 50));
}
prev = w;
}
}
private static void cone(Overlay o, Vector3f base, Vector3f tip, Vector3f axis, float r, int col) {
Vector3f u = perp(axis), v = new Vector3f(axis).cross(u);
int n = 14;
int shade = Legend.dim(col, 0.7);
for (int k = 0; k < n; k++) {
Vector3f q0 = circlePoint(base, u, v, r, k * Math.PI * 2 / n);
Vector3f q1 = circlePoint(base, u, v, r, (k + 1) * Math.PI * 2 / n);
tri(o, q0, q1, tip, k % 2 == 0 ? col : Legend.dim(col, 0.88));
tri(o, q1, q0, base, shade);
}
}
private static void cube(Overlay o, Vector3f c, float s, Vector3f[] ax, int col) {
Vector3f[] p = new Vector3f[8];
for (int k = 0; k < 8; k++) {
p[k] = new Vector3f(c)
.add(new Vector3f(ax[0]).mul((k & 1) != 0 ? s : -s))
.add(new Vector3f(ax[1]).mul((k & 2) != 0 ? s : -s))
.add(new Vector3f(ax[2]).mul((k & 4) != 0 ? s : -s));
}
int[][] f = {{0, 1, 3, 2}, {4, 6, 7, 5}, {0, 4, 5, 1}, {2, 3, 7, 6}, {0, 2, 6, 4}, {1, 5, 7, 3}};
for (int i = 0; i < 6; i++) {
int cc = Legend.dim(col, 0.75 + 0.05 * i);
tri(o, p[f[i][0]], p[f[i][1]], p[f[i][2]], cc);
tri(o, p[f[i][0]], p[f[i][2]], p[f[i][3]], cc);
}
}
}
@@ -0,0 +1,58 @@
package com.eliasmueller.llamamod.editor.render;
import org.joml.Matrix4f;
import org.joml.Vector3f;
/**
* Gizmo drag state. All deltas are totals since {@link Phase#START} (relative to {@link #start}), so
* a listener can show a live preview and emit a single undoable command at {@link Phase#END}; on
* {@link Phase#CANCEL} (Escape) it restores {@link #start}.
*
* @param pivot gizmo position at drag start (world)
* @param translation world-space offset (TRANSLATE)
* @param axis world-space rotation axis (ROTATE) or the dragged axis
* @param angle radians about axis, right-handed (ROTATE)
* @param scale factors along the target's local axes (SCALE)
* @param start target transform at drag start
*/
public record GizmoEvent(
Phase phase,
Gizmo.Mode mode,
Gizmo.Handle handle,
Vector3f pivot,
Vector3f translation,
Vector3f axis,
float angle,
Vector3f scale,
Matrix4f start,
Gizmo.Space space) {
public enum Phase {
START,
DRAG,
END,
CANCEL
}
GizmoEvent withPhase(Phase p) {
return new GizmoEvent(p, mode, handle, pivot, translation, axis, angle, scale, start, space);
}
/** The delta applied to a transform m (any object moved together with the gizmo target). */
public Matrix4f applyTo(Matrix4f m) {
Matrix4f r = new Matrix4f();
switch (mode) {
case TRANSLATE -> r.translation(translation).mul(m);
case ROTATE -> r.translation(pivot)
.rotate(angle, axis)
.translate(-pivot.x, -pivot.y, -pivot.z)
.mul(m);
case SCALE -> r.set(m).scale(scale);
}
return r;
}
/** {@link #applyTo} for the gizmo target itself. */
public Matrix4f result() {
return applyTo(start);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,180 @@
package com.eliasmueller.llamamod.editor.render;
import java.util.Map;
import java.util.Set;
/** Overlay colours (0xRRGGBBAA), same legend as the in-game debug wireframe (notes/debugviz.md). */
public final class Legend {
private Legend() {}
public static final int FLOOR = rgb(40, 255, 40);
public static final int SLOPE = rgb(200, 255, 0);
public static final int WALL = rgb(0, 150, 255);
public static final int CEILING = rgb(255, 130, 0);
public static final int MAT_OTHER = rgb(230, 230, 230);
/** Collision materials with a fixed colour (16 exit floor, 17/18 selective, 30 no floor). */
public static final Map<Integer, Integer> MATERIALS = Map.ofEntries(
Map.entry(30, rgb(255, 0, 255)),
Map.entry(16, rgb(190, 60, 255)),
Map.entry(17, rgb(255, 255, 0)),
Map.entry(18, rgb(255, 200, 120)),
Map.entry(1, rgb(0, 200, 200)),
Map.entry(2, rgb(255, 255, 255)),
Map.entry(3, rgb(255, 120, 170)),
Map.entry(5, rgb(120, 255, 200)),
Map.entry(6, rgb(160, 120, 255)),
Map.entry(7, rgb(255, 170, 80)),
Map.entry(8, rgb(0, 130, 60)),
Map.entry(9, rgb(150, 200, 255)),
Map.entry(10, rgb(150, 150, 0)),
Map.entry(11, rgb(150, 90, 40)),
Map.entry(13, rgb(255, 130, 110)),
Map.entry(15, rgb(255, 255, 150)),
Map.entry(20, rgb(0, 120, 140)),
Map.entry(26, rgb(110, 40, 160)),
Map.entry(27, rgb(190, 190, 190)),
Map.entry(28, rgb(255, 0, 140)),
Map.entry(33, rgb(150, 0, 0)),
Map.entry(255, rgb(20, 20, 20)));
/** Edge colour of a collision polygon from its material and the y component of its normal. */
public static int collision(int material, double ny) {
if (material != 0) {
return MATERIALS.getOrDefault(material, MAT_OTHER);
}
if (ny >= 0.7) {
return FLOOR;
}
if (ny >= 0.2) {
return SLOPE;
}
if (ny > -0.2) {
return WALL;
}
return CEILING;
}
public enum Category {
START(rgb(255, 255, 255)),
EXIT(rgb(190, 60, 255)),
CHECKPOINT(rgb(0, 255, 140)),
CAMERA(rgb(0, 255, 255)),
TRIGGER(rgb(255, 255, 0)),
FORCE(rgb(255, 90, 0)),
SECRET(rgb(255, 200, 0)),
MISC(rgb(170, 170, 170)),
ENVIRONMENT(rgb(255, 100, 180)),
OBJECT(rgb(90, 90, 90));
public final int color;
Category(int color) {
this.color = color;
}
}
private static final Set<String> START_T =
Set.of("Llama", "KuscoAndPachaTogetherOnATree", "RollerCoaster", "LlamaChase");
private static final Set<String> EXIT_T = Set.of("LevelDoor", "VillageEndDoor", "EndGameDoor", "TeleportDoor");
private static final Set<String> FORCE_T = Set.of(
"FallOffNoHurt",
"ObjectDontDropInBox",
"RiverCurrentBox",
"RiverRebound",
"SuckingWPs",
"ObjectSuckPoints",
"RiverTrackChange",
"TrackSwitch",
"BackgroundRotationChanger",
"RiverWhirlpool");
private static final Set<String> MISC_T = Set.of(
"CheatTeleport",
"Wompy",
"WompySimple",
"WompyRollerCoaster",
"Rain",
"DrippingWater",
"WaterfallMist",
"Smoker",
"CoinLine",
"CoinCircle",
"CoinRollerCoaster",
"CoinBat",
"CoinSpawnOracle",
"MusicBox",
"SpitPlatformTarget",
"NewBlockheadKidHelper",
"FlockOfBirds",
"Bats",
"RiverBats",
"Scorpions1",
"Scorpions3",
"Scorpions5",
"GhostCatacombs",
"LabDumbGuards",
"FlyStuck",
"AppearingPlatforms",
"AppearingPlatformsCity",
"AppearingPlatformsJungle",
"AppearingPlatformsLab",
"AppearingPlatformsVillage",
"fat_bird",
"Column2",
"TipoHelp");
private static final Set<String> TRIG_T = Set.of("ChangeToBunny", "ChangeToTurtle", "RiverStartSign");
/** Marker category of a strat type (invisible logic objects), {@code null} for ordinary objects. */
public static Category category(String name) {
String n = name.replace("(spawn)", "");
if (START_T.contains(n)) {
return Category.START;
}
if (EXIT_T.contains(n)) {
return Category.EXIT;
}
if (n.startsWith("CheckPoint")) {
return Category.CHECKPOINT;
}
if (n.contains("Camera")) {
return Category.CAMERA;
}
if (n.equals("Secret")) {
return Category.SECRET;
}
if (FORCE_T.contains(n) || n.startsWith("Force") || n.startsWith("WaterRebound")) {
return Category.FORCE;
}
if (n.contains("Trigger") || n.startsWith("Say") || TRIG_T.contains(n)) {
return Category.TRIGGER;
}
if (MISC_T.contains(n) || n.endsWith("Spawner") || n.contains("Helper")) {
return Category.MISC;
}
return null;
}
public static int rgb(int r, int g, int b) {
return rgba(r, g, b, 0xff);
}
public static int rgba(int r, int g, int b, int a) {
return r << 24 | g << 16 | b << 8 | (a & 0xff);
}
/** Colour 0xRRGGBB of a visibility region (golden-ratio hues). */
public static int regionRgb(int region) {
return java.awt.Color.HSBtoRGB((region * 0.618034f) % 1f, 0.65f, 0.95f) & 0xffffff;
}
public static int withAlpha(int rgba, int a) {
return (rgba & 0xffffff00) | (a & 0xff);
}
public static int dim(int rgba, double f) {
int r = (int) (((rgba >>> 24) & 0xff) * f);
int g = (int) (((rgba >>> 16) & 0xff) * f);
int b = (int) (((rgba >>> 8) & 0xff) * f);
return r << 24 | g << 16 | b << 8 | (rgba & 0xff);
}
}
@@ -0,0 +1,359 @@
package com.eliasmueller.llamamod.editor.render;
import com.eliasmueller.llamamod.format.Collision;
import com.eliasmueller.llamamod.format.Face;
import com.eliasmueller.llamamod.format.Geometry;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.MapChunk;
import com.eliasmueller.llamamod.format.Model;
import com.eliasmueller.llamamod.format.PyMath;
import com.eliasmueller.llamamod.format.Strats;
import com.eliasmueller.llamamod.format.TextChunk;
import com.eliasmueller.llamamod.format.Trak;
import com.eliasmueller.llamamod.format.Vertex;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
/**
* Builds a {@link RenderScene} straight from a level WAD (format API). Pick ids: {@link PickId#TILE}
* + tile index, {@link PickId#OBJECT} + strat index, {@link PickId#WAYPOINT} + path node index,
* {@link PickId#ZONE} + environment zone index.
*/
public final class LevelSceneAdapter {
public static final String LAYER_TILES = "tiles";
public static final String LAYER_OBJECTS = "objects";
public static final String LAYER_MARKERS = "markers";
public static final String LAYER_WAYPOINTS = "waypoints";
public static final String LAYER_ZONES = "zones";
public static final String LAYER_COLLISION = "collision";
/** Converted level plus lookup data for labels. */
public record Result(
RenderScene scene,
Level level,
List<String> stratNames,
List<RenderMesh> tileMeshes,
int gridW,
int gridH) {}
private LevelSceneAdapter() {}
public static Result fromWad(byte[] wadBytes) {
Level lv = Level.parse(wadBytes);
RenderScene sc = new RenderScene();
TextChunk text = TextChunk.parse(lv.wad().require("TEXT"));
List<TextChunk.TextureEntry> tex = text.textureEntries();
int[] pageClass = new int[text.pages.size()];
for (int i = 0; i < text.pages.size(); i++) {
TextChunk.Page p = text.pages.get(i);
pageClass[i] = p.flags & 0x7f;
sc.pages.add(page(p));
}
MapChunk map = lv.map();
List<RenderMesh> tileMeshes = new ArrayList<>();
for (int i = 0; i < lv.models().size(); i++) {
Model m = lv.models().get(i);
RenderMesh rm = mesh("trak" + i, m, tex, text, pageClass, 0);
rm.overlay = collision(m);
tileMeshes.add(rm);
}
addTiles(sc, lv, tileMeshes);
Overlay wp = new Overlay();
List<String> names =
addStrats(sc, lv, wp, (name, model) -> mesh(name, model, tex, text, pageClass, RenderMesh.LIT));
addWaypoints(sc, map, wp);
sc.overlays.put(LAYER_WAYPOINTS, wp);
addZones(sc, map);
return new Result(sc, lv, names, tileMeshes, map.width, map.height);
}
private static void addTiles(RenderScene sc, Level lv, List<RenderMesh> tileMeshes) {
MapChunk map = lv.map();
List<MapChunk.TileColors> cols = map.tileColors(Trak.vertCounts(lv.models()));
for (int i = 0; i < map.tiles.size(); i++) {
MapChunk.Tile t = map.tiles.get(i);
RenderInstance ri =
new RenderInstance(PickId.of(PickId.TILE, i), RenderInstance.Kind.TILE, tileMeshes.get(t.model()));
ri.transform
.translation((float) t.x(), (float) t.y(), (float) t.z())
.rotateY((float) -angle(t.rotUnsigned()));
int[] c = cols.get(i).frames().get(0);
int[] vc = new int[c.length];
for (int k = 0; k < c.length; k++) {
// u32 little endian R, G, B, frame count
vc[k] = (c[k] & 0xff) << 24 | ((c[k] >>> 8) & 0xff) << 16 | ((c[k] >>> 16) & 0xff) << 8 | 0xff;
}
ri.vertexColors = vc;
ri.layer = LAYER_TILES;
sc.instances.add(ri);
}
}
/** Object instances and markers of all strats (+ strat → first waypoint lines); returns the names. */
private static List<String> addStrats(
RenderScene sc, Level lv, Overlay wp, BiFunction<String, Model, RenderMesh> meshes) {
MapChunk map = lv.map();
byte[] stpc = lv.wad().require("STPC");
StratModels sm = new StratModels(stpc, lv.wfpc());
Map<Long, RenderMesh> objMeshes = new HashMap<>();
List<String> names = new ArrayList<>();
for (int i = 0; i < map.strats.size(); i++) {
MapChunk.Strat s = map.strats.get(i);
String name = Strats.nameOr(stpc, s.code(), sm.codeStart(), "?@0x" + Integer.toHexString(s.code()));
names.add(name);
int id = PickId.of(PickId.OBJECT, i);
float x = (float) MapChunk.fx(s.x()), y = (float) MapChunk.fx(s.y()), z = (float) -MapChunk.fx(s.z());
float yaw = (float) angle(s.ry() & 0xfff);
Legend.Category cat = Legend.category(name);
boolean withModel = cat == null || cat == Legend.Category.CHECKPOINT || cat == Legend.Category.START;
int mi = withModel ? sm.modelOf(s.code()) : -1;
Model model = mi >= 0 ? sm.models.get(mi) : null;
long key = mi;
boolean hasModel = model != null;
if (model != null && StratModels.skinned(model)) {
model = sm.posed(s.code(), mi);
key = (long) s.code() << 32 | mi;
}
boolean drawn = model != null && !model.faces.isEmpty();
if (drawn) {
Model mdl = model;
RenderMesh rm = objMeshes.computeIfAbsent(key, k -> meshes.apply("stpc" + mi, mdl));
RenderInstance ri = new RenderInstance(id, RenderInstance.Kind.OBJECT, rm);
ri.transform.translation(x, y, z).rotateY(-yaw);
ri.layer = LAYER_OBJECTS;
sc.instances.add(ri);
}
if (!drawn || cat != null) {
sc.markers.add(stratMarker(sc, id, name, cat, hasModel, x, y, z, yaw));
}
if (s.waypoint() >= 0 && s.waypoint() < map.nodes.size()) {
MapChunk.PathNode nd = map.nodes.get(s.waypoint());
int c = Legend.withAlpha(Legend.dim((cat != null ? cat : Legend.Category.MISC).color, 0.8), 110);
wp.line(x, y + 0.02f, z, nodeX(nd), nodeY(nd), nodeZ(nd), c);
}
}
return names;
}
/** Marker of an invisible strat (category) or of an object whose model cannot be drawn. */
private static Marker stratMarker(
RenderScene sc,
int id,
String name,
Legend.Category cat,
boolean hasModel,
float x,
float y,
float z,
float yaw) {
Marker mk = new Marker(id, x, y, z, (cat != null ? cat : Legend.Category.OBJECT).color);
mk.label = name;
if (cat == null) {
mk.size = 0.05f;
mk.pole = 0.06f;
if (hasModel) {
// model that could not be drawn (unposed character)
mk.shape = Marker.Shape.DIAMOND;
mk.size = 0.12f;
mk.pole = 0.3f;
}
} else if (cat == Legend.Category.START) {
mk.shape = Marker.Shape.ARROW;
mk.size = 0.3f;
mk.yaw = yaw;
} else {
mk.size = 0.06f;
mk.pole = 0.35f;
if (cat == Legend.Category.CAMERA || cat == Legend.Category.CHECKPOINT) {
mk.yaw = yaw;
}
}
if (cat == Legend.Category.EXIT) {
double cx = Math.floor(x), cz = Math.floor(z);
sc.overlays
.computeIfAbsent(LAYER_ZONES, k -> new Overlay())
.box(cx, y - 0.05, cz, cx + 1, y + 1, cz + 1, cat.color);
}
return mk;
}
private static void addWaypoints(RenderScene sc, MapChunk map, Overlay wp) {
for (int i = 0; i < map.nodes.size(); i++) {
MapChunk.PathNode nd = map.nodes.get(i);
float x = nodeX(nd), y = nodeY(nd), z = nodeZ(nd);
Marker mk = new Marker(PickId.of(PickId.WAYPOINT, i), x, y, z, Legend.rgb(255, 230, 120));
mk.size = 0.035f;
mk.shape = Marker.Shape.DIAMOND;
mk.layer = LAYER_WAYPOINTS;
mk.label = "waypoint " + i;
sc.markers.add(mk);
int nx = nd.next();
if (nx != 0 && nx < map.nodes.size()) {
MapChunk.PathNode b = map.nodes.get(nx);
wp.line(x, y, z, nodeX(b), nodeY(b), nodeZ(b), Legend.withAlpha(Legend.rgb(255, 210, 80), 150));
}
}
}
/** Export-space position of a path node (game z negated). */
private static float nodeX(MapChunk.PathNode n) {
return (float) MapChunk.fx(n.x());
}
private static float nodeY(MapChunk.PathNode n) {
return (float) MapChunk.fx(n.y());
}
private static float nodeZ(MapChunk.PathNode n) {
return (float) -MapChunk.fx(n.z());
}
/** Backdrop colour of the default zone, boxes + markers of the other environment zones. */
private static void addZones(RenderScene sc, MapChunk map) {
Overlay zones = sc.overlays.computeIfAbsent(LAYER_ZONES, k -> new Overlay());
for (MapChunk.EnvZone z : map.envZones) {
if (z.isDefault()) {
// backdrop/fog colour (file offset 0x4e, 0x00BBGGRR), notes/mod-fog.md
int c = z.f()[20];
sc.background = (c & 0xff) << 16 | (c & 0xff00) | (c >>> 16) & 0xff;
break;
}
}
int envCol = Legend.Category.ENVIRONMENT.color;
for (int i = 0; i < map.envZones.size(); i++) {
MapChunk.EnvZone z = map.envZones.get(i);
if (z.isDefault()) {
continue;
}
double cx = Integer.toUnsignedLong(z.f()[0]);
double cy = Integer.toUnsignedLong(z.f()[1]);
double cz = -Integer.toUnsignedLong(z.f()[2]);
zones.box(cx + 0.04, cy, cz - 0.04, cx + 0.96, cy + 0.92, cz - 0.96, envCol);
Marker mk = new Marker(
PickId.of(PickId.ZONE, i), (float) cx + 0.5f, (float) cy + 0.46f, (float) cz - 0.5f, envCol);
mk.layer = LAYER_ZONES;
mk.label = "environment zone " + i;
sc.markers.add(mk);
}
}
/** Game angle units (4096 = 360°) to radians. */
static double angle(long units) {
return PyMath.angle(units);
}
public static TexturePage page(TextChunk.Page p) {
int[] out = new int[p.w * p.h];
boolean blended =
switch (p.flags & 0x7f) {
case 4, 5, 7 -> true;
default -> false;
};
for (int i = 0; i < out.length; i++) {
int px = p.pixels[i];
if (p.flags == 0) {
out[i] = px << 24 | px << 16 | px << 8 | 0xff;
continue;
}
int c = TextChunk.abgr1555ToRgba(px);
if (!blended && (c & 0xff) != 0) {
c |= 0xff;
}
out[i] = c;
}
return new TexturePage(p.w, p.h, out);
}
/** Game model to a render mesh: UVs from texture rectangles, flags, blend classes. */
public static RenderMesh mesh(
String name, Model m, List<TextChunk.TextureEntry> tex, TextChunk text, int[] pageClass, int extraFlags) {
RenderMesh rm = new RenderMesh(name, m.verts.size(), m.faces.size());
for (int i = 0; i < m.verts.size(); i++) {
Vertex v = m.verts.get(i);
rm.positions[3 * i] = (float) v.x();
rm.positions[3 * i + 1] = (float) v.y();
rm.positions[3 * i + 2] = (float) v.z();
rm.normals[3 * i] = (float) v.nx();
rm.normals[3 * i + 1] = (float) v.ny();
rm.normals[3 * i + 2] = (float) v.nz();
}
for (int t = 0; t < m.faces.size(); t++) {
Face f = m.faces.get(t);
for (int k = 0; k < 3; k++) {
int vi = f.v(k);
rm.triangles[3 * t + k] = vi < m.verts.size() ? vi : 0;
}
int fl = extraFlags;
if ((f.flags() & 0x400) != 0) {
fl |= RenderMesh.DOUBLE_SIDED;
}
if ((f.flags() & 0x4000) != 0) {
fl |= RenderMesh.NO_VERTEX_COLOR;
}
rm.triFlags[t] = fl;
TextChunk.TextureEntry te = f.tex() < tex.size() ? tex.get(f.tex()) : null;
int mode = te == null ? 0 : (te.flags() >> 12) & 7;
if (te == null || (te.flags() & 1) != 0 || te.page() < 0 || te.page() >= text.pages.size()) {
rm.pages[t] = -1;
int a = te != null && mode != 0 ? te.v1() : 255;
rm.triColors[t] = te == null ? 0xff00ffff : Legend.rgb(te.u0(), te.u1(), te.v0()) & 0xffffff00 | a;
for (int k = 0; k < 6; k++) {
rm.uvs[6 * t + k] = 0;
}
} else {
rm.pages[t] = te.page();
rm.triColors[t] = 0xffffffff;
TextChunk.Page p = text.pages.get(te.page());
double[][] uv = Geometry.faceUvs(f.flags(), Geometry.texUv(te, p.w, p.h));
for (int k = 0; k < 3; k++) {
rm.uvs[6 * t + 2 * k] = (float) uv[k][0];
rm.uvs[6 * t + 2 * k + 1] = (float) uv[k][1];
}
if (mode == 0) {
mode = switch (pageClass[te.page()]) {
case 4, 7 -> 1;
case 5 -> 2;
default -> 0;};
}
}
rm.blend[t] = switch (mode) {
case 0 -> RenderMesh.BLEND_OPAQUE;
case 2, 4 -> RenderMesh.BLEND_ADD;
default -> RenderMesh.BLEND_ALPHA;};
}
return rm;
}
/** Collision polygons of a tile model as coloured outlines (+ fill for special materials). */
public static Overlay collision(Model m) {
Overlay o = new Overlay();
for (List<byte[]> g : m.coll) {
for (byte[] r : g) {
Collision.Decoded c = Collision.decode(r);
double[][] pts = Collision.polygon(c);
if (pts.length < 3) {
continue;
}
double[] n = c.plane();
double off = 0.01;
for (double[] p : pts) {
p[0] += n[0] * off;
p[1] += n[1] * off;
p[2] += n[2] * off;
}
int col = Legend.collision(c.mat(), n[1]);
for (int i = 0; i < pts.length; i++) {
o.line(pts[i], pts[(i + 1) % pts.length], col);
}
int fill = c.mat() == 16 ? 110 : c.mat() == 17 || c.mat() == 18 ? 28 : 0;
for (int i = 1; fill > 0 && i + 1 < pts.length; i++) {
o.triangle(pts[0], pts[i], pts[i + 1], Legend.withAlpha(col, fill));
}
}
}
return o;
}
}
@@ -0,0 +1,39 @@
package com.eliasmueller.llamamod.editor.render;
/**
* Pickable icon in the world: a small coloured shape, optionally on a pole, optionally with a
* direction tick. Used for invisible objects, waypoints, lights, zones. Mutable; call {@link
* RenderScene#changed()} after edits.
*/
public final class Marker {
public enum Shape {
CUBE,
DIAMOND,
ARROW
}
public final int id;
public float x;
public float y;
public float z;
public int color;
/** Half edge length in world units. */
public float size = 0.06f;
/** Height of the shape above the position (a pole is drawn down to it), 0 = none. */
public float pole;
/** Facing angle about y in radians (direction tick), {@link Float#NaN} = none. */
public float yaw = Float.NaN;
public Shape shape = Shape.CUBE;
public String layer = "markers";
public String label;
public boolean visible = true;
public Marker(int id, float x, float y, float z, int color) {
this.id = id;
this.x = x;
this.y = y;
this.z = z;
this.color = color;
}
}
@@ -0,0 +1,98 @@
package com.eliasmueller.llamamod.editor.render;
import java.util.List;
import java.util.Set;
/** Marker shapes as one batch: coloured, slightly shaded solids plus poles and direction ticks. */
final class MarkerMesh {
private MarkerMesh() {}
private static final float[][] CUBE = {
{-1, -1, -1}, {1, -1, -1}, {1, 1, -1}, {-1, 1, -1}, {-1, -1, 1}, {1, -1, 1}, {1, 1, 1}, {-1, 1, 1}
};
private static final int[] CUBE_TRIS = {
0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7, 0, 1, 5, 0, 5, 4, 3, 7, 6, 3, 6, 2, 0, 4, 7, 0, 7, 3, 1, 2, 6, 1, 6, 5
};
private static final float[][] OCTA = {{1, 0, 0}, {-1, 0, 0}, {0, 1.4f, 0}, {0, -1.4f, 0}, {0, 0, 1}, {0, 0, -1}};
private static final int[] OCTA_TRIS = {0, 2, 4, 4, 2, 1, 1, 2, 5, 5, 2, 0, 4, 3, 0, 1, 3, 4, 5, 3, 1, 0, 3, 5};
static Batch build(List<Marker> markers, Set<String> hidden, Batch.Builder b) {
Overlay lines = new Overlay();
for (Marker m : markers) {
if (!m.visible || hidden.contains(m.layer)) {
continue;
}
float cy = m.y + m.pole;
RenderMesh mesh;
if (m.shape == Marker.Shape.ARROW) {
mesh = arrow(m);
} else {
boolean cube = m.shape == Marker.Shape.CUBE;
mesh = solid(cube ? CUBE : OCTA, cube ? CUBE_TRIS : OCTA_TRIS, m.x, cy, m.z, m.size, m.color);
}
b.add(new RenderInstance(m.id, RenderInstance.Kind.OBJECT, mesh), null, false);
if (m.pole > 0) {
lines.line(m.x, m.y, m.z, m.x, cy, m.z, m.color);
}
if (!Float.isNaN(m.yaw) && m.shape != Marker.Shape.ARROW) {
float dx = (float) Math.sin(m.yaw) * 0.3f, dz = -(float) Math.cos(m.yaw) * 0.3f;
lines.line(m.x, cy, m.z, m.x + dx, cy, m.z + dz, m.color);
}
}
b.addOverlay(lines, null);
return b.upload(null);
}
private static RenderMesh solid(float[][] v, int[] tris, float x, float y, float z, float s, int color) {
int nt = tris.length / 3;
RenderMesh m = new RenderMesh("marker", v.length, nt);
for (int i = 0; i < v.length; i++) {
m.positions[3 * i] = x + v[i][0] * s;
m.positions[3 * i + 1] = y + v[i][1] * s;
m.positions[3 * i + 2] = z + v[i][2] * s;
}
System.arraycopy(tris, 0, m.triangles, 0, tris.length);
for (int t = 0; t < nt; t++) {
m.pages[t] = -1;
m.triFlags[t] = RenderMesh.DOUBLE_SIDED;
// fake shading per face: upward faces brighter
float[] a = v[tris[3 * t]], bb = v[tris[3 * t + 1]], c = v[tris[3 * t + 2]];
float ny = ((bb[2] - a[2]) * (c[0] - a[0]) - (bb[0] - a[0]) * (c[2] - a[2]));
float len = (float) Math.sqrt(sq((bb[1] - a[1]) * (c[2] - a[2]) - (bb[2] - a[2]) * (c[1] - a[1]))
+ sq(ny)
+ sq((bb[0] - a[0]) * (c[1] - a[1]) - (bb[1] - a[1]) * (c[0] - a[0])));
float f = 0.7f + 0.3f * (len > 0 ? ny / len : 0);
m.triColors[t] = Legend.dim(color, Math.max(0.45f, f)) | 0xff;
}
return m;
}
/** Flat arrow on the ground pointing along yaw (player start). */
private static RenderMesh arrow(Marker mk) {
float[][] p = {
{-0.12f, -0.3f}, {0.12f, -0.3f}, {0.12f, 0.15f}, {-0.12f, 0.15f}, {-0.3f, 0.15f}, {0.3f, 0.15f}, {0, 0.5f}
};
int[] tris = {0, 1, 2, 0, 2, 3, 4, 5, 6};
RenderMesh m = new RenderMesh("arrow", p.length, 3);
float c = (float) Math.cos(mk.yaw), s = (float) Math.sin(mk.yaw);
float k = mk.size / 0.3f;
for (int i = 0; i < p.length; i++) {
// local x = right, local y = forward
float rx = p[i][0] * k, fw = p[i][1] * k;
m.positions[3 * i] = mk.x + rx * c + fw * s;
m.positions[3 * i + 1] = mk.y + 0.03f;
m.positions[3 * i + 2] = mk.z + rx * s - fw * c;
}
System.arraycopy(tris, 0, m.triangles, 0, tris.length);
for (int t = 0; t < 3; t++) {
m.pages[t] = -1;
m.triFlags[t] = RenderMesh.DOUBLE_SIDED;
m.triColors[t] = mk.color;
}
return m;
}
private static float sq(float f) {
return f * f;
}
}
@@ -0,0 +1,194 @@
package com.eliasmueller.llamamod.editor.render;
import java.awt.event.MouseEvent;
import java.util.ArrayList;
import java.util.List;
import org.joml.Vector3f;
/**
* Viewport navigation: mouse bindings per preset and the camera math (pure, unit-tested in
* {@code NavigationTest}). Zoom is exponential (a constant percentage per wheel notch) towards a
* robust anchor point, orbit keeps the pivot at its screen position with the pitch clamped to
* &plusmn;89&deg;, pan moves the point at the grab depth with the cursor.
*/
public final class Navigation {
private Navigation() {}
public enum Preset {
FUSION("Fusion 360"),
BLENDER("Blender"),
UNITY("Unity / Unreal");
public final String label;
Preset(String label) {
this.label = label;
}
/** Preset by enum name or label; Fusion 360 when unknown. */
public static Preset parse(String s) {
for (Preset p : values()) {
if (p.name().equalsIgnoreCase(s) || p.label.equalsIgnoreCase(s)) {
return p;
}
}
return FUSION;
}
}
public enum Action {
NONE,
ORBIT,
PAN,
ZOOM,
/** Free look in place (+ WASD/QE fly while held). */
LOOK
}
/** Pitch limit of orbit and look (no flip over the poles). */
public static final float PITCH_LIMIT = (float) Math.toRadians(89);
/** Camera action of a mouse button press (left without Alt is never navigation). */
public static Action drag(Preset p, int button, boolean shift, boolean ctrl, boolean alt) {
boolean l = button == MouseEvent.BUTTON1, m = button == MouseEvent.BUTTON2, r = button == MouseEvent.BUTTON3;
if (l) {
return alt ? Action.ORBIT : Action.NONE;
}
return switch (p) {
case FUSION -> m
? (shift ? Action.ORBIT : ctrl ? Action.ZOOM : Action.PAN)
: r && alt ? Action.ZOOM : r ? Action.LOOK : Action.NONE;
case BLENDER -> m
? (shift ? Action.PAN : ctrl ? Action.ZOOM : alt ? Action.PAN : Action.ORBIT)
: r && alt ? Action.ZOOM : r ? Action.LOOK : Action.NONE;
case UNITY -> m
? (ctrl ? Action.ZOOM : Action.PAN)
: r && alt ? Action.ZOOM : r ? Action.LOOK : Action.NONE;
};
}
/** Rows {keys, action} for the help table and the status bar. */
public static List<String[]> cheatSheet(Preset p) {
List<String[]> rows = new ArrayList<>();
switch (p) {
case FUSION -> {
rows.add(new String[] {"Middle drag", "Pan"});
rows.add(new String[] {"Shift+middle drag", "Orbit around the point under the cursor"});
rows.add(new String[] {"Double-click middle", "Fit all"});
}
case BLENDER -> {
rows.add(new String[] {"Middle drag", "Orbit around the point under the cursor"});
rows.add(new String[] {"Shift+middle drag", "Pan"});
rows.add(new String[] {"Ctrl+middle drag", "Zoom"});
}
case UNITY -> {
rows.add(new String[] {"Alt+left drag", "Orbit around the point under the cursor"});
rows.add(new String[] {"Middle drag", "Pan"});
rows.add(new String[] {"Alt+right drag", "Zoom"});
}
}
rows.add(new String[] {"Mouse wheel", "Zoom towards the cursor (smooth, constant steps)"});
rows.add(
new String[] {"Right drag", "Look around; W A S D move, Q / E down / up, Shift faster, wheel = speed"});
if (p != Preset.UNITY) {
rows.add(new String[] {"Alt+left drag", "Orbit (touchpads, no middle button)"});
}
rows.add(new String[] {"View cube", "Click a face, edge or corner to snap; drag to orbit; house = home view"});
rows.add(new String[] {"F / Home", "Frame the selection / the whole level"});
rows.add(new String[] {"Numpad 7 / 1 / 3 / 5", "Top / front / side / perspective-ortho toggle"});
return rows;
}
/** One-line summary for the status bar. */
public static String statusLine(Preset p) {
return switch (p) {
case FUSION -> "Fusion 360: MMB pan · Shift+MMB orbit · wheel zoom · RMB look+WASD";
case BLENDER -> "Blender: MMB orbit · Shift+MMB pan · Ctrl+MMB zoom · RMB look+WASD";
case UNITY -> "Unity/Unreal: RMB look+WASD · Alt+LMB orbit · MMB pan · wheel zoom";
};
}
// ------------------------------------------------------------------ math
/** Distance factor for wheel notches (&gt; 0 = towards the user = out); percent per notch, e.g. 0.12. */
public static float zoomFactor(double notches, float percent, boolean invert) {
double p = Math.max(0.01, Math.min(0.6, percent));
return (float) Math.pow(1 - p, -(invert ? -notches : notches));
}
/**
* Distance along the cursor ray to zoom towards: the surface hit when it is in a sane range
* (in front of the near plane, not beyond 3&times; the pivot depth or the far plane), else the
* pivot's depth along the ray, so pointing at the sky or a far mountain never makes a huge step.
*/
public static float anchorDistance(float hit, float pivotDepth, float near, float far) {
float pd = Math.max(pivotDepth, 4 * near);
if (Float.isFinite(hit) && hit > 2 * near && hit < 3 * pd && hit < 0.9f * far) {
return hit;
}
return pd;
}
/** Moves the camera towards/away from the point at {@code anchor} along {@code dir} by factor. */
public static void zoom(Camera cam, Vector3f dir, float anchor, float factor) {
float min = 2 * cam.near;
float nd = Math.max(min, anchor * factor);
cam.position.add(new Vector3f(dir).mul(anchor - nd));
float cos = Math.max(0.05f, dir.dot(cam.forward()));
// the anchor becomes the orbit pivot depth
cam.distance = Math.max(min, nd * cos);
}
/** Orthographic zoom keeping the world point under the cursor fixed. */
public static void zoomOrtho(Camera cam, double px, double py, float factor) {
Vector3f before = cam.ray(px, py)[0];
cam.orthoSize = Math.max(0.05f, Math.min(5000, cam.orthoSize * factor));
Vector3f after = cam.ray(px, py)[0];
cam.position.add(before.sub(after));
}
/**
* Orbits about a world pivot: the camera keeps its position relative to the pivot in camera
* space, so the pivot stays at the same screen position. Pitch is clamped to &plusmn;89&deg;.
*/
public static void orbit(Camera cam, Vector3f pivot, float dYaw, float dPitch) {
Vector3f rel = new Vector3f(cam.position).sub(pivot);
Vector3f r0 = cam.right(), u0 = cam.up(), f0 = cam.forward();
float rr = rel.dot(r0), ru = rel.dot(u0), rf = rel.dot(f0);
cam.yaw = wrap(cam.yaw + dYaw);
cam.pitch = clampPitch(cam.pitch + dPitch);
Vector3f r1 = cam.right(), u1 = cam.up(), f1 = cam.forward();
cam.position.set(pivot).add(r1.mul(rr)).add(u1.mul(ru)).add(f1.mul(rf));
if (rf < 0) {
cam.distance = Math.max(2 * cam.near, -rf);
}
}
/** Free look in place. */
public static void look(Camera cam, float dYaw, float dPitch) {
cam.yaw = wrap(cam.yaw + dYaw);
cam.pitch = clampPitch(cam.pitch + dPitch);
}
/** World units per pixel at a view depth. */
public static float pixelSizeAt(Camera cam, float depth) {
if (cam.ortho) {
return 2 * cam.orthoSize / cam.height();
}
return (float) (2 * Math.max(cam.near, depth) * Math.tan(cam.fovY / 2) / cam.height());
}
/** Pans so that a point at {@code depth} follows the cursor by (dx, dy) pixels. */
public static void pan(Camera cam, float depth, float dx, float dy) {
float px = pixelSizeAt(cam, depth);
cam.position.add(cam.right().mul(-dx * px)).add(cam.up().mul(dy * px));
}
public static float clampPitch(float p) {
return Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, p));
}
private static float wrap(float a) {
return (float) Math.IEEEremainder(a, 2 * Math.PI);
}
}
@@ -0,0 +1,121 @@
package com.eliasmueller.llamamod.editor.render;
import java.util.Arrays;
/** Unlit helper geometry: coloured line segments and (translucent) triangles. Colours 0xRRGGBBAA. */
public final class Overlay {
private float[] lines = new float[0];
private int[] lineColors = new int[0];
private int nLines;
private float[] tris = new float[0];
private int[] triColors = new int[0];
private int nTris;
public void line(float ax, float ay, float az, float bx, float by, float bz, int rgba) {
if (nLines == lineColors.length) {
int n = Math.max(16, nLines * 2);
lines = Arrays.copyOf(lines, n * 6);
lineColors = Arrays.copyOf(lineColors, n);
}
int o = nLines * 6;
lines[o] = ax;
lines[o + 1] = ay;
lines[o + 2] = az;
lines[o + 3] = bx;
lines[o + 4] = by;
lines[o + 5] = bz;
lineColors[nLines++] = rgba;
}
public void line(double[] a, double[] b, int rgba) {
line((float) a[0], (float) a[1], (float) a[2], (float) b[0], (float) b[1], (float) b[2], rgba);
}
public void triangle(double[] a, double[] b, double[] c, int rgba) {
growTris();
int o = nTris * 9;
double[][] p = {a, b, c};
for (int k = 0; k < 3; k++) {
tris[o + 3 * k] = (float) p[k][0];
tris[o + 3 * k + 1] = (float) p[k][1];
tris[o + 3 * k + 2] = (float) p[k][2];
}
triColors[nTris++] = rgba;
}
private void growTris() {
if (nTris == triColors.length) {
int n = Math.max(16, nTris * 2);
tris = Arrays.copyOf(tris, n * 9);
triColors = Arrays.copyOf(triColors, n);
}
}
/** Twelve edges of an axis-aligned box. */
public void box(double x0, double y0, double z0, double x1, double y1, double z1, int rgba) {
double[][] p = {
{x0, y0, z0},
{x1, y0, z0},
{x1, y0, z1},
{x0, y0, z1},
{x0, y1, z0},
{x1, y1, z0},
{x1, y1, z1},
{x0, y1, z1}
};
int[][] e = {{0, 1}, {1, 2}, {2, 3}, {3, 0}, {4, 5}, {5, 6}, {6, 7}, {7, 4}, {0, 4}, {1, 5}, {2, 6}, {3, 7}};
for (int[] k : e) {
line(p[k[0]], p[k[1]], rgba);
}
}
/** Appends all lines and triangles of another overlay. */
public void append(Overlay o) {
for (int i = 0; i < o.nLines; i++) {
int k = 6 * i;
line(
o.lines[k],
o.lines[k + 1],
o.lines[k + 2],
o.lines[k + 3],
o.lines[k + 4],
o.lines[k + 5],
o.lineColors[i]);
}
for (int i = 0; i < o.nTris; i++) {
growTris();
System.arraycopy(o.tris, 9 * i, tris, 9 * nTris, 9);
triColors[nTris++] = o.triColors[i];
}
}
public int lineCount() {
return nLines;
}
public int triangleCount() {
return nTris;
}
/** Segment endpoints, 6 floats per line (valid up to {@link #lineCount()}). */
public float[] lines() {
return lines;
}
public int[] lineColors() {
return lineColors;
}
public float[] triangles() {
return tris;
}
public int[] triangleColors() {
return triColors;
}
public void clear() {
nLines = 0;
nTris = 0;
}
}
@@ -0,0 +1,44 @@
package com.eliasmueller.llamamod.editor.render;
/**
* 32-bit pick ids: kind in the top 8 bits, index in the low 24 bits. 0 means "nothing". The ids are
* opaque to the renderer; this encoding is only a convention used by {@link LevelSceneAdapter}.
*/
public final class PickId {
public static final int TILE = 1;
public static final int OBJECT = 2;
public static final int WAYPOINT = 3;
public static final int ZONE = 4;
public static final int LIGHT = 5;
public static final int USER = 16;
private PickId() {}
public static int of(int kind, int index) {
return kind << 24 | (index & 0xffffff);
}
public static int kind(int id) {
return id >>> 24;
}
public static int index(int id) {
return id & 0xffffff;
}
public static String toString(int id) {
if (id == 0) {
return "none";
}
String k =
switch (kind(id)) {
case TILE -> "tile";
case OBJECT -> "object";
case WAYPOINT -> "waypoint";
case ZONE -> "zone";
case LIGHT -> "light";
default -> "kind" + kind(id);
};
return k + " " + index(id);
}
}
@@ -0,0 +1,38 @@
package com.eliasmueller.llamamod.editor.render;
import org.joml.Matrix4f;
/**
* Placed mesh. Instances of kind {@link Kind#TILE} are baked into static world batches (fast, many);
* all others are drawn one by one with their transform. After changing a field call {@link
* RenderScene#changed(RenderInstance)}.
*/
public final class RenderInstance {
public enum Kind {
TILE,
OBJECT,
PREVIEW
}
/** Stable pick id ({@link PickId}), 0 = not pickable. */
public final int id;
public final Kind kind;
public RenderMesh mesh;
/** Local → world (export space: y up, z ≤ 0 for the level grid). */
public final Matrix4f transform = new Matrix4f();
/** 0xRRGGBBAA per mesh vertex with 0x80 = 1.0 (game convention), or {@code null} = white. */
public int[] vertexColors;
/** Multiplied with the final colour (0xRRGGBBAA, 0xffffffff = none). */
public int tint = 0xffffffff;
public boolean visible = true;
/** Free-form group for visibility filters (e.g. "tiles", "objects"). */
public String layer = "";
public RenderInstance(int id, Kind kind, RenderMesh mesh) {
this.id = id;
this.kind = kind;
this.mesh = mesh;
}
}
@@ -0,0 +1,71 @@
package com.eliasmueller.llamamod.editor.render;
/**
* Triangle mesh in local space, shared by any number of {@link RenderInstance}s. Positions and
* normals are per vertex; texture coordinates, page and flags are per triangle corner / triangle,
* because the game assigns UVs per face (texture rectangles), not per vertex.
*/
public final class RenderMesh {
/** Triangle is drawn from both sides (face flag 0x400). */
public static final int DOUBLE_SIDED = 1;
/** Ignore vertex colours (face flag 0x4000). */
public static final int NO_VERTEX_COLOR = 2;
/** Shade with the vertex normals instead of baked vertex colours (objects). */
public static final int LIT = 4;
public static final int BLEND_OPAQUE = 0;
public static final int BLEND_ALPHA = 1;
public static final int BLEND_ADD = 2;
public final String name;
/** x, y, z per vertex. */
public final float[] positions;
/** nx, ny, nz per vertex (may be zero). */
public final float[] normals;
/** Three vertex indices per triangle. */
public final int[] triangles;
/** u, v per triangle corner (6 per triangle), normalised to the page. */
public final float[] uvs;
/** Texture page per triangle, -1 = untextured (flat colour only). */
public final int[] pages;
/** 0xRRGGBBAA per triangle, multiplied with the texel (flat colours, translucency). */
public final int[] triColors;
/** Bit set of {@link #DOUBLE_SIDED}, {@link #NO_VERTEX_COLOR}, {@link #LIT} per triangle. */
public final int[] triFlags;
/** {@link #BLEND_OPAQUE}, {@link #BLEND_ALPHA} or {@link #BLEND_ADD} per triangle. */
public final int[] blend;
/** Optional local-space helper geometry that follows every instance (collision wireframe). */
public Overlay overlay;
public RenderMesh(String name, int vertexCount, int triangleCount) {
this.name = name;
positions = new float[vertexCount * 3];
normals = new float[vertexCount * 3];
triangles = new int[triangleCount * 3];
uvs = new float[triangleCount * 6];
pages = new int[triangleCount];
triColors = new int[triangleCount];
triFlags = new int[triangleCount];
blend = new int[triangleCount];
}
public int vertexCount() {
return positions.length / 3;
}
public int triangleCount() {
return pages.length;
}
/** Local bounding box {minX, minY, minZ, maxX, maxY, maxZ}; zero box when empty. */
public float[] bounds() {
if (positions.length == 0) {
return new float[6];
}
float[] b = Culling.emptyBox();
for (int i = 0; i < positions.length; i += 3) {
Culling.grow(b, positions[i], positions[i + 1], positions[i + 2]);
}
return b;
}
}
@@ -0,0 +1,85 @@
package com.eliasmueller.llamamod.editor.render;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Everything the viewport draws. Plain mutable data; the renderer caches GPU buffers and rebuilds
* them after {@link #changed()} (everything), {@link #changed(RenderInstance)} (one instance) or
* {@link #texturesChanged()}. Not thread-safe: modify on the Swing thread.
*/
public final class RenderScene {
public final List<TexturePage> pages = new ArrayList<>();
public final List<RenderInstance> instances = new ArrayList<>();
public final List<Marker> markers = new ArrayList<>();
/** World-space overlays by layer name (e.g. "waypoints", "zones"). */
public final Map<String, Overlay> overlays = new LinkedHashMap<>();
/** Background colour 0xRRGGBB. */
public int background = 0x5d7186;
private int version;
private int textureVersion;
private final Set<RenderInstance> dirty = new HashSet<>();
private boolean allDirty = true;
public void changed() {
allDirty = true;
version++;
}
public void changed(RenderInstance inst) {
dirty.add(inst);
version++;
}
/** Only markers and overlays changed (cheap: no chunk is rebuilt). */
public void overlaysChanged() {
version++;
}
public void texturesChanged() {
textureVersion++;
}
public int version() {
return version;
}
public int textureVersion() {
return textureVersion;
}
/** Taken by the renderer: {@code null} means rebuild everything. */
Set<RenderInstance> takeDirty() {
if (allDirty) {
allDirty = false;
dirty.clear();
return null;
}
Set<RenderInstance> d = new HashSet<>(dirty);
dirty.clear();
return d;
}
public RenderInstance instance(int id) {
for (RenderInstance i : instances) {
if (i.id == id) {
return i;
}
}
return null;
}
public Marker marker(int id) {
for (Marker m : markers) {
if (m.id == id) {
return m;
}
}
return null;
}
}
@@ -0,0 +1,134 @@
package com.eliasmueller.llamamod.editor.render;
/** GLSL 3.30 core sources. */
final class Shaders {
private Shaders() {}
static final String MESH_VS =
"""
#version 330 core
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aUv;
layout(location = 3) in vec4 aColor;
layout(location = 4) in vec4 aId;
layout(location = 5) in ivec2 aLayerFlags;
uniform mat4 uViewProj;
uniform mat4 uModel;
out vec2 vUv;
out vec4 vColor;
out vec3 vNormal;
out vec3 vWorld;
flat out vec4 vId;
flat out int vLayer;
flat out int vFlags;
void main() {
vec4 w = uModel * vec4(aPos, 1.0);
vWorld = w.xyz;
gl_Position = uViewProj * w;
vUv = aUv;
vColor = aColor;
vNormal = mat3(uModel) * aNormal;
vId = aId;
vLayer = aLayerFlags.x;
vFlags = aLayerFlags.y;
}
""";
static final String MESH_FS =
"""
#version 330 core
in vec2 vUv;
in vec4 vColor;
in vec3 vNormal;
in vec3 vWorld;
flat in vec4 vId;
flat in int vLayer;
flat in int vFlags;
uniform sampler2DArray uTex;
uniform int uPass; // 0 opaque, 1 blended, 2 pick, 3 back sides of one-sided faces
uniform vec4 uTint;
uniform vec4 uPickId;
uniform int uUsePickId;
uniform vec3 uFogColor;
uniform vec2 uFog; // start, end (end <= start: off)
uniform vec4 uHighlight; // rgb, amount
uniform vec4 uFogMode; // view direction (0 = radial distance), max amount
uniform vec3 uFogOrigin; // distances are measured from here (normally the eye)
uniform float uFarClip; // > 0: discard beyond this view distance
uniform int uClipGhost; // 1: beyond uFarClip draw a faded ghost instead of discarding
uniform float uBackAlpha; // opacity of back sides (pass 3)
uniform int uShade; // 0 texture × colour, 1 vertex colours only
out vec4 fragColor;
void main() {
vec4 t = texture(uTex, vec3(vUv, float(vLayer)));
if (uPass != 1) {
if (t.a < 0.5) discard;
t.a = 1.0;
}
bool back = !gl_FrontFacing && (vFlags & 1) == 0;
if (uPass == 2) {
fragColor = uUsePickId != 0 ? uPickId : vId;
// bit 31 of the id (unused by pick ids) marks the back side of a one-sided face
if (back) fragColor.a = min(fragColor.a + 128.0 / 255.0, 1.0);
return;
}
float dist = dot(uFogMode.xyz, uFogMode.xyz) > 0.0
? dot(vWorld - uFogOrigin, uFogMode.xyz) : distance(vWorld, uFogOrigin);
bool beyond = uFarClip > 0.0 && dist > uFarClip;
if (beyond && uClipGhost == 0) discard;
if (uShade == 1) t.rgb = vec3(1.0);
vec4 c = t * vColor * uTint;
if ((vFlags & 4) != 0) {
vec3 n = normalize(vNormal);
float l = 0.62 + 0.38 * max(dot(n, normalize(vec3(0.35, 0.85, 0.4))), 0.0)
+ 0.12 * max(dot(n, normalize(vec3(-0.5, 0.2, -0.6))), 0.0);
c.rgb *= l;
}
if (uPass == 1 && c.a < 0.004) discard;
c.rgb = mix(c.rgb, uHighlight.rgb, uHighlight.a);
if (beyond) {
// beyond the previewed far clip: a desaturated ghost instead of full fog colour
float g = dot(c.rgb, vec3(0.3, 0.59, 0.11));
c.rgb = mix(mix(c.rgb, vec3(g), 0.6), uFogColor, 0.45);
c.a *= 0.55;
} else if (uFog.y > uFog.x) {
float f = clamp((dist - uFog.x) / (uFog.y - uFog.x), 0.0, 1.0);
c.rgb = mix(c.rgb, uFogColor, f * uFogMode.w);
}
if (uPass == 3) {
// back side: darkened, desaturated, translucent, fine diagonal hatch (screen space)
float g = dot(c.rgb, vec3(0.3, 0.59, 0.11));
vec3 d = mix(vec3(g), c.rgb, 0.3) * 0.5;
float stripe = 1.0 - step(1.5, mod(gl_FragCoord.x + gl_FragCoord.y, 6.0));
d = mix(d, min(d * 1.9 + 0.1, vec3(1.0)), stripe * 0.6);
c = vec4(d, clamp(uBackAlpha * (1.0 + 0.5 * stripe), 0.0, 1.0));
}
fragColor = c;
}
""";
static final String LINE_VS =
"""
#version 330 core
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec4 aColor;
uniform mat4 uViewProj;
uniform mat4 uModel;
out vec4 vColor;
void main() {
gl_Position = uViewProj * uModel * vec4(aPos, 1.0);
vColor = aColor;
}
""";
static final String LINE_FS =
"""
#version 330 core
in vec4 vColor;
out vec4 fragColor;
void main() {
fragColor = vColor;
}
""";
}
@@ -0,0 +1,221 @@
package com.eliasmueller.llamamod.editor.render;
import com.eliasmueller.llamamod.format.Bytes;
import com.eliasmueller.llamamod.format.Model;
import com.eliasmueller.llamamod.format.Stpc;
import com.eliasmueller.llamamod.format.Strats;
import com.eliasmueller.llamamod.format.Trak;
import com.eliasmueller.llamamod.format.Vertex;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* STPC object models and which one a strat type shows. The strat bytecode sets its model with
* {@code 0xb2 <abs STPC offset of the model header>; 0x54} (push address, set model); the first such
* pair inside the strat's code is taken. Spawn stubs ({@code b2 <code> e0}) are followed to their
* target. Characters set skinned models through other opcodes (0x41 with animation sets) and are
* not resolved.
*/
public final class StratModels {
private static final int OP_PUSH_ADDR = 0xb2;
private static final int OP_SET_MODEL = 0x54;
private static final int OP_SPAWN = 0xe0;
private static final int SCAN_LIMIT = 2048;
public final List<Model> models = new ArrayList<>();
private final byte[] stpc;
private final int codeStart;
private final int[] nameSites;
private final Map<Integer, Integer> byHeader = new HashMap<>();
private final Map<Integer, Integer> cache = new HashMap<>();
/** Animation set header offsets (FUN_0041f8b0 layout, see {@link Stpc}). */
private final List<Integer> animSets = new ArrayList<>();
public StratModels(byte[] stpc, int wfpc) {
this.stpc = stpc;
Stpc.Layout lay = Stpc.walk(stpc, wfpc);
codeStart = lay.codeStart();
for (Stpc.ModelSpan s : lay.models()) {
add(
s.header(),
Trak.parseModels(stpc, s.header(), 1, Trak.STPC_HEADER)
.models()
.get(0));
}
for (Stpc.Extra e : lay.extra()) {
if (e.models().isEmpty()) {
continue;
}
List<Model> mm = Trak.parseModels(
stpc, e.models().get(0).header(), e.models().size(), Trak.STPC_HEADER)
.models();
for (int i = 0; i < mm.size(); i++) {
add(e.models().get(i).header(), mm.get(i));
}
}
int o = lay.models().isEmpty()
? 4
: lay.models().get(lay.models().size() - 1).end();
int na = Bytes.u32(stpc, o);
o += 4;
for (int i = 0; i < na && o + 0x20 <= stpc.length; i++) {
animSets.add(o);
o = skipAnimSet(o);
}
nameSites = Strats.allNamed(stpc, codeStart).stream()
.mapToInt(Strats.Named::offset)
.sorted()
.toArray();
}
private void add(int header, Model m) {
byHeader.put(header, models.size());
models.add(m);
}
private int skipAnimSet(int h) {
int nfr = Bytes.u32(stpc, h + 8);
int a4 = Bytes.u32(stpc, h + 16);
int a6 = Bytes.u32(stpc, h + 24);
int o = firstMatrices(h);
if (a4 != 0) {
o += nfr * 4;
}
for (int f = 0; f < nfr; f++) {
if (a4 != 0) {
o += f == 0 ? a4 * 8 : a4 * 2 + ((a4 & 1) != 0 ? 2 : 0);
}
o += a6 * 0x40;
}
return o;
}
/** Offset of the first bone matrix of frame 0 of an animation set (only sets without vertex deltas). */
private int firstMatrices(int h) {
int a0 = Bytes.u32(stpc, h);
int nfr = Bytes.u32(stpc, h + 8);
int a3 = Bytes.i32(stpc, h + 12);
int o = h + 0x20 + a0 * 4;
if (a3 == 0) {
o += nfr * 8;
}
return o + nfr * 4;
}
/**
* A skinned model posed with frame 0 of the first animation set referenced by the strat code that
* has the model's bone count (else any such set), or {@code null}. Bone matrices are 4×4 f32,
* row vectors (D3D): p' = p·M.
*/
public Model posed(int entry, int modelIndex) {
Model m = models.get(modelIndex);
int bones = m.extra.length / 2;
if (!skinned(m) || bones == 0) {
return null;
}
int set = -1;
int end = Math.min(stpc.length - 12, entry + SCAN_LIMIT * 2);
for (int o = entry; o >= codeStart && o < end && set < 0; o += 4) {
if ((Bytes.u32L(stpc, o) & 0xffff) == OP_PUSH_ADDR) {
int a = Bytes.i32(stpc, o + 4);
if (animSets.contains(a) && Bytes.u32(stpc, a + 24) == bones && Bytes.u32(stpc, a + 16) == 0) {
set = a;
}
}
}
if (set < 0) {
for (int a : animSets) {
if (Bytes.u32(stpc, a + 24) == bones && Bytes.u32(stpc, a + 16) == 0 && Bytes.u32(stpc, a + 8) > 0) {
set = a;
break;
}
}
}
if (set < 0) {
return null;
}
int mo = firstMatrices(set);
Model out = new Model();
out.faces.addAll(m.faces);
int v = 0;
for (int b = 0; b < bones; b++) {
int cnt = Bytes.u16(m.extra, 2 * b);
float[] r = new float[16];
for (int k = 0; k < 16; k++) {
r[k] = (float) Bytes.f32(stpc, mo + b * 0x40 + 4 * k);
}
for (int k = 0; k < cnt && v < m.verts.size(); k++, v++) {
Vertex p = m.verts.get(v);
double x = p.x(), y = p.y(), z = p.z();
double nx = p.nx(), ny = p.ny(), nz = p.nz();
out.verts.add(new Vertex(
x * r[0] + y * r[4] + z * r[8] + r[12],
x * r[1] + y * r[5] + z * r[9] + r[13],
x * r[2] + y * r[6] + z * r[10] + r[14],
nx * r[0] + ny * r[4] + nz * r[8],
nx * r[1] + ny * r[5] + nz * r[9],
nx * r[2] + ny * r[6] + nz * r[10]));
}
}
while (out.verts.size() < m.verts.size()) {
out.verts.add(m.verts.get(out.verts.size()));
}
return out;
}
public int codeStart() {
return codeStart;
}
/** Skinned (boned) models store bone-relative vertices; they are not drawn as rigid meshes. */
public static boolean skinned(Model m) {
return m.extra.length > 0 && m.x84 == 0;
}
/** Model index shown by the strat whose code starts at entry, or -1. */
public int modelOf(int entry) {
Integer m = cache.get(entry);
if (m == null) {
cache.put(entry, -1);
m = resolve(entry);
cache.put(entry, m);
}
return m;
}
private int resolve(int entry) {
if (entry < codeStart || entry + 32 > stpc.length) {
return -1;
}
for (int k = 0; k < 5; k++) {
int o = entry + 4 * k;
if (Bytes.u32L(stpc, o) == OP_PUSH_ADDR && Bytes.u32L(stpc, o + 8) == OP_SPAWN) {
int target = Bytes.i32(stpc, o + 4);
return target != entry ? modelOf(target) : -1;
}
}
int end = Math.min(stpc.length - 12, entry + SCAN_LIMIT);
// stop before the next strat: its name site lies a few words behind its first opcode
int own = -1;
for (int s : nameSites) {
if (own < 0 && s >= entry) {
own = s;
} else if (own >= 0 && s > own) {
end = Math.min(end, s - 16);
break;
}
}
for (int o = entry; o < end; o += 4) {
if ((Bytes.u32L(stpc, o) & 0xffff) == OP_PUSH_ADDR && (Bytes.u32L(stpc, o + 8) & 0xffff) == OP_SET_MODEL) {
Integer m = byHeader.get(Bytes.i32(stpc, o + 4));
if (m != null) {
return m;
}
}
}
return -1;
}
}
@@ -0,0 +1,7 @@
package com.eliasmueller.llamamod.editor.render;
/**
* Texture page: w·h pixels 0xRRGGBBAA, row-major from the top. Alpha 0 is transparent (cut out),
* alpha below 255 is translucent on blended triangles.
*/
public record TexturePage(int width, int height, int[] rgba) {}
@@ -0,0 +1,284 @@
package com.eliasmueller.llamamod.editor.render;
import org.joml.Matrix4f;
import org.joml.Vector3f;
import org.joml.Vector4f;
/**
* Orientation cube in the top-right viewport corner (Fusion 360 style). Faces are named after the
* game views: TOP = +y, FRONT = export &minus;z (the front view looks along game &minus;z), RIGHT =
* +x. Clicking a face, edge or corner region snaps the view to look from that direction at the
* pivot; the house button above it goes home. Pure geometry/hit testing; the viewport draws it.
*/
public final class ViewCube {
private ViewCube() {}
/** Geometry in cube space ([-1, 1]³), its matrix and the GL viewport rect (framebuffer pixels). */
public record Frame(
Overlay lines, Overlay tris, Matrix4f matrix, int x, int y, int size, Overlay hud, Matrix4f hudMatrix) {}
/** A region: direction from the cube centre (components -1/0/1), or the home button. */
public record Hit(int dx, int dy, int dz, boolean home) {
static final Hit HOME = new Hit(0, 0, 0, true);
public Vector3f direction() {
return new Vector3f(dx, dy, dz).normalize();
}
}
public static final int SIZE = 108;
public static final int MARGIN = 10;
static final int HOME = 22;
/** Face cells: |c| beyond this belongs to an edge/corner region. */
static final float SPLIT = 0.56f;
/** Cube rect in component pixels {x, y, size}. */
public static int[] rect(int width) {
return new int[] {width - MARGIN - SIZE, MARGIN + HOME, SIZE};
}
/** Home button rect {x, y, w, h} (component pixels), left above the cube. */
public static int[] homeRect(int width) {
int[] r = rect(width);
return new int[] {r[0] + 4, MARGIN - 4, HOME, HOME};
}
/** Rotation-only view (same handedness as the main view) with a fixed orthographic lens. */
public static Matrix4f matrix(Camera cam) {
Matrix4f v = cam.viewMatrix();
v.setTranslation(0, 0, 0);
return new Matrix4f().ortho(-1.8f, 1.8f, -1.8f, 1.8f, -10, 10).mul(v);
}
/** Region under a component pixel, or null. */
public static Hit hit(Camera cam, int width, int mx, int my) {
int[] h = homeRect(width);
if (mx >= h[0] && mx < h[0] + h[2] && my >= h[1] && my < h[1] + h[3]) {
return Hit.HOME;
}
int[] r = rect(width);
if (mx < r[0] || my < r[1] || mx >= r[0] + r[2] || my >= r[1] + r[2]) {
return null;
}
float nx = 2f * (mx - r[0] + 0.5f) / r[2] - 1, ny = 1 - 2f * (my - r[1] + 0.5f) / r[2];
Matrix4f inv = matrix(cam).invert();
Vector4f a = new Vector4f(nx, ny, -1, 1).mul(inv);
Vector4f b = new Vector4f(nx, ny, 1, 1).mul(inv);
Vector3f o = new Vector3f(a.x, a.y, a.z).div(a.w);
Vector3f d = new Vector3f(b.x, b.y, b.z).div(b.w).sub(o);
// slab test against [-1, 1]^3, nearest entry
float t0 = -Float.MAX_VALUE, t1 = Float.MAX_VALUE;
int axis = -1;
for (int k = 0; k < 3; k++) {
float ok = o.get(k), dk = d.get(k);
if (Math.abs(dk) < 1e-9f) {
if (ok < -1 || ok > 1) {
return null;
}
continue;
}
float ta = (-1 - ok) / dk, tb = (1 - ok) / dk;
float lo = Math.min(ta, tb), hi = Math.max(ta, tb);
if (lo > t0) {
t0 = lo;
axis = k;
}
t1 = Math.min(t1, hi);
}
if (axis < 0 || t0 > t1) {
return null;
}
Vector3f p = new Vector3f(d).mul(t0).add(o);
int[] c = new int[3];
for (int k = 0; k < 3; k++) {
float v = p.get(k);
c[k] = k == axis ? (v > 0 ? 1 : -1) : v > SPLIT ? 1 : v < -SPLIT ? -1 : 0;
}
return new Hit(c[0], c[1], c[2], false);
}
/**
* Camera angles looking from direction (dx, dy, dz) towards the pivot: {yaw, pitch}. Straight
* down/up keeps the top view's orientation (x right, game z downwards).
*/
public static float[] angles(Hit h) {
Vector3f f = h.direction().negate();
float pitch = (float) Math.asin(Math.max(-1, Math.min(1, f.y)));
float yaw = Math.abs(f.x) + Math.abs(f.z) < 1e-6 ? (float) Math.PI : (float) Math.atan2(f.x, -f.z);
if (Math.abs(pitch) > Math.PI / 2 - 1e-3) {
pitch = (float) Math.copySign(Math.PI / 2, pitch);
}
return new float[] {yaw, pitch};
}
private static final int FACE = Legend.withAlpha(Legend.rgb(222, 226, 233), 238);
private static final int FACE_HOVER = Legend.withAlpha(Legend.rgb(120, 170, 245), 245);
private static final int EDGE = Legend.withAlpha(Legend.rgb(70, 76, 88), 255);
private static final int GRID = Legend.withAlpha(Legend.rgb(150, 156, 168), 140);
private static final int TEXT = Legend.withAlpha(Legend.rgb(40, 44, 54), 255);
/** Face table: normal axis, sign, text right axis/sign, text up axis/sign, label. */
private static final Object[][] FACES = {
{1, 1, 0, 1, 2, 1, "TOP"},
{1, -1, 0, 1, 2, -1, "BOTTOM"},
{2, -1, 0, 1, 1, 1, "FRONT"},
{2, 1, 0, -1, 1, 1, "BACK"},
{0, 1, 2, 1, 1, 1, "RIGHT"},
{0, -1, 2, -1, 1, 1, "LEFT"},
};
/** Builds the frame (framebuffer pixels: scale = framebuffer / component). */
public static Frame build(Camera cam, int compWidth, int compHeight, float scale, Hit hover) {
Overlay lines = new Overlay(), tris = new Overlay();
float[] cuts = {-1, -SPLIT, SPLIT, 1};
for (Object[] f : FACES) {
int n = (int) f[0], ns = (int) f[1], ra = (int) f[2], rs = (int) f[3], ua = (int) f[4], us = (int) f[5];
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
int[] reg = new int[3];
reg[n] = ns;
reg[ra] = rs * (i - 1);
reg[ua] = us * (j - 1);
boolean hl = hover != null
&& !hover.home()
&& hover.dx() == reg[0]
&& hover.dy() == reg[1]
&& hover.dz() == reg[2];
double[][] q = new double[4][];
for (int k = 0; k < 4; k++) {
double[] p = new double[3];
p[n] = ns;
p[ra] = rs * cuts[i + ((k == 1 || k == 2) ? 1 : 0)];
p[ua] = us * cuts[j + (k >= 2 ? 1 : 0)];
q[k] = p;
}
int col = hl ? FACE_HOVER : FACE;
tris.triangle(q[0], q[1], q[2], col);
tris.triangle(q[0], q[2], q[3], col);
}
}
for (int k = 1; k <= 2; k++) {
double[] a = new double[3], b = new double[3], c = new double[3], d = new double[3];
a[n] = b[n] = c[n] = d[n] = ns * 1.001;
a[ra] = b[ra] = cuts[k];
a[ua] = -1;
b[ua] = 1;
c[ua] = d[ua] = cuts[k];
c[ra] = -1;
d[ra] = 1;
lines.line(a, b, GRID);
lines.line(c, d, GRID);
}
text(lines, (String) f[6], n, ns, ra, rs, ua, us);
}
for (int a = 0; a < 3; a++) {
for (int s1 = -1; s1 <= 1; s1 += 2) {
for (int s2 = -1; s2 <= 1; s2 += 2) {
double[] p = new double[3], q = new double[3];
int b = (a + 1) % 3, c = (a + 2) % 3;
p[a] = -1;
q[a] = 1;
p[b] = q[b] = s1 * 1.002;
p[c] = q[c] = s2 * 1.002;
lines.line(p, q, EDGE);
}
}
}
int[] r = rect(compWidth);
int size = Math.round(r[2] * scale);
int x = Math.round(r[0] * scale);
int y = Math.round((compHeight - r[1] - r[2]) * scale);
Overlay hud = homeButton(compWidth, hover != null && hover.home());
Matrix4f hm = new Matrix4f().ortho(0, compWidth, compHeight, 0, -1, 1);
return new Frame(lines, tris, matrix(cam), x, y, size, hud, hm);
}
/** Home button (house glyph) in component pixels; pixel centres so axis-aligned lines rasterise. */
private static Overlay homeButton(int width, boolean hl) {
Overlay o = new Overlay();
int[] r = homeRect(width);
double x0 = r[0], y0 = r[1], x1 = r[0] + r[2], y1 = r[1] + r[3];
int bg = hl ? FACE_HOVER : Legend.withAlpha(Legend.rgb(222, 226, 233), 215);
o.triangle(new double[] {x0, y0, 0}, new double[] {x1, y0, 0}, new double[] {x1, y1, 0}, bg);
o.triangle(new double[] {x0, y0, 0}, new double[] {x1, y1, 0}, new double[] {x0, y1, 0}, bg);
float cx = (float) (x0 + x1) / 2 + 0.5f, s = r[2] / 22f, hy = (float) y0 + 0.5f;
float[][] p = {
{cx - 7 * s, hy + 11 * s}, {cx, hy + 4 * s}, {cx + 7 * s, hy + 11 * s},
{cx + 5 * s, hy + 9.5f * s}, {cx + 5 * s, hy + 18 * s}, {cx - 5 * s, hy + 18 * s},
{cx - 5 * s, hy + 9.5f * s}
};
int[][] seg = {{0, 1}, {1, 2}, {3, 4}, {4, 5}, {5, 6}};
for (int[] g : seg) {
o.line(p[g[0]][0], p[g[0]][1], 0, p[g[1]][0], p[g[1]][1], 0, TEXT);
}
return o;
}
// ------------------------------------------------------------------ stroke font
/** Polylines per letter in a 0..1 × 0..1.4 box, pen-up between strokes (NaN). */
private static final String[] GLYPHS = {
"T 0,1.4 1,1.4 N .5,1.4 .5,0",
"O 0,0 1,0 1,1.4 0,1.4 0,0",
"P 0,0 0,1.4 1,1.4 1,.7 0,.7",
"B 0,0 0,1.4 .8,1.4 .8,.7 0,.7 N .8,.7 1,.6 1,0 0,0",
"M 0,0 0,1.4 .5,.7 1,1.4 1,0",
"F 0,0 0,1.4 1,1.4 N 0,.7 .75,.7",
"R 0,0 0,1.4 1,1.4 1,.7 0,.7 1,0",
"N 0,0 0,1.4 1,0 1,1.4",
"A 0,0 0,1.4 1,1.4 1,0 N 0,.7 1,.7",
"C 1,0 0,0 0,1.4 1,1.4",
"K 0,0 0,1.4 N 1,1.4 0,.7 1,0",
"L 0,1.4 0,0 1,0",
"E 1,0 0,0 0,1.4 1,1.4 N 0,.7 .75,.7",
"I .5,0 .5,1.4",
"G 1,1.4 0,1.4 0,0 1,0 1,.7 .5,.7",
"H 0,0 0,1.4 N 1,0 1,1.4 N 0,.7 1,.7",
};
private static void text(Overlay o, String s, int n, int ns, int ra, int rs, int ua, int us) {
float h = 0.32f, w = 0.18f, gap = 0.07f;
float total = s.length() * w + (s.length() - 1) * gap;
float x0 = -total / 2, y0 = -h / 2;
for (int i = 0; i < s.length(); i++) {
String g = glyph(s.charAt(i));
if (g == null) {
continue;
}
double[] prev = null;
for (String tok : g.substring(2).split(" ")) {
if (tok.equals("N")) {
prev = null;
continue;
}
String[] xy = tok.split(",");
float gx = x0 + i * (w + gap) + Float.parseFloat(xy[0]) * w;
float gy = y0 + Float.parseFloat(xy[1]) / 1.4f * h;
double[] p = new double[3];
p[n] = ns * 1.004;
p[ra] = rs * gx;
p[ua] = us * gy;
if (prev != null) {
// twice, half a pixel apart: bolder, and never lost on a pixel edge
o.line(prev, p, TEXT);
double[] a = prev.clone(), b = p.clone();
a[ua] += 0.016;
b[ua] += 0.016;
a[ra] += 0.016;
b[ra] += 0.016;
o.line(a, b, TEXT);
}
prev = p;
}
}
}
private static String glyph(char c) {
for (String g : GLYPHS) {
if (g.charAt(0) == c) {
return g;
}
}
return null;
}
}
@@ -0,0 +1,343 @@
package com.eliasmueller.llamamod.editor.render;
import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
import com.eliasmueller.llamamod.mods.testworld.TestWorld;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.event.MouseEvent;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.imageio.ImageIO;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import org.joml.Matrix4f;
import org.joml.Vector3f;
/**
* Standalone level viewer for testing the viewport.
*
* <pre>
* ViewerDemo &lt;level.wad | testworld&gt; [--cd DIR] [--size WxH] [--collision] [--no-markers]
* [--view top|front|side] [--cam x,y,z,yawDeg,pitchDeg] [--fov vDeg] [--select ID[,ID]] [--frame]
* [--gizmo translate|rotate|scale] [--shot out.png] [--bench]
* </pre>
*
* IDs: {@code o12} = strat 12, {@code t5} = tile 5, {@code w3} = waypoint 3. With --shot the
* window renders once, saves and exits; --bench prints frame times while orbiting.
*/
public final class ViewerDemo {
private ViewerDemo() {}
public static void main(String[] args) throws Exception {
if (args.length == 0) {
System.err.println("usage: ViewerDemo <level.wad|testworld> [options]");
System.exit(2);
}
Map<String, String> o = new HashMap<>();
for (int i = 1; i < args.length; i++) {
String a = args[i];
if (Set.of("--collision", "--no-markers", "--frame", "--bench", "--no-grid", "--no-fog", "--list")
.contains(a)) {
o.put(a, "1");
} else if (i + 1 < args.length) {
o.put(a, args[++i]);
}
}
Path cd = Path.of(o.getOrDefault("--cd", System.getProperty("user.home") + "/.wine-lama/iso-dk/Setup/Eng/Cd"));
byte[] wad;
if (args[0].equals("testworld")) {
byte[] base = Files.readAllBytes(cd.resolve(LevelBuilder.BASE_WAD));
wad = new TestWorld()
.buildWad(
base,
rel -> {
try {
Path p = cd.resolve(rel);
return Files.exists(p) ? Files.readAllBytes(p) : null;
} catch (IOException e) {
throw new UncheckedIOException(e);
}
},
Map.of());
} else {
wad = Files.readAllBytes(Path.of(args[0]));
}
long t0 = System.nanoTime();
LevelSceneAdapter.Result res = LevelSceneAdapter.fromWad(wad);
System.out.printf(
"scene: %d instances, %d markers, %d pages, converted in %.0f ms%n",
res.scene().instances.size(),
res.scene().markers.size(),
res.scene().pages.size(),
(System.nanoTime() - t0) / 1e6);
if (o.containsKey("--list")) {
for (int i = 0; i < res.stratNames().size(); i++) {
var s = res.level().map().strats.get(i);
System.out.printf(
"o%d %s %.2f %.2f %.2f%n",
i, res.stratNames().get(i), s.x() / 4096.0, s.y() / 4096.0, -s.z() / 4096.0);
}
return;
}
SwingUtilities.invokeLater(() -> show(res, o));
}
static int parseId(String s) {
int k =
switch (s.charAt(0)) {
case 't' -> PickId.TILE;
case 'o' -> PickId.OBJECT;
case 'w' -> PickId.WAYPOINT;
case 'z' -> PickId.ZONE;
default -> throw new IllegalArgumentException("id " + s);
};
return PickId.of(k, Integer.parseInt(s.substring(1)));
}
private static void show(LevelSceneAdapter.Result res, Map<String, String> o) {
RenderScene scene = res.scene();
JFrame f = new JFrame("Llama Level Viewer – " + res.level().wad().levelName());
Viewport vp = new Viewport();
String[] size = o.getOrDefault("--size", "1280x720").split("x");
vp.setPreferredSize(new Dimension(Integer.parseInt(size[0]), Integer.parseInt(size[1])));
vp.setScene(scene);
vp.setGrid(0, new float[] {0, -res.gridH(), res.gridW(), 0});
vp.setLayerVisible(LevelSceneAdapter.LAYER_COLLISION, o.containsKey("--collision"));
if (o.containsKey("--no-markers")) {
for (String l : List.of(
LevelSceneAdapter.LAYER_MARKERS,
LevelSceneAdapter.LAYER_WAYPOINTS,
LevelSceneAdapter.LAYER_ZONES)) {
vp.setLayerVisible(l, false);
}
}
if (o.containsKey("--hide")) {
for (String l : o.get("--hide").split(",")) {
vp.setLayerVisible(l, false);
}
}
vp.setGridVisible(!o.containsKey("--no-grid"));
vp.setFog(!o.containsKey("--no-fog"));
JLabel status = new JLabel(" ");
JPanel bar = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 2));
for (String l : List.of(
LevelSceneAdapter.LAYER_TILES,
LevelSceneAdapter.LAYER_OBJECTS,
LevelSceneAdapter.LAYER_MARKERS,
LevelSceneAdapter.LAYER_WAYPOINTS,
LevelSceneAdapter.LAYER_ZONES,
LevelSceneAdapter.LAYER_COLLISION)) {
JCheckBox cb = new JCheckBox(l, vp.isLayerVisible(l));
cb.setFocusable(false);
cb.addActionListener(e -> vp.setLayerVisible(l, cb.isSelected()));
bar.add(cb);
}
JComboBox<Camera.View> views = new JComboBox<>(Camera.View.values());
views.setFocusable(false);
views.addActionListener(e -> vp.setView((Camera.View) views.getSelectedItem()));
bar.add(views);
JComboBox<Gizmo.Mode> modes = new JComboBox<>(Gizmo.Mode.values());
modes.setFocusable(false);
modes.addActionListener(e -> {
vp.gizmo().setMode((Gizmo.Mode) modes.getSelectedItem());
vp.requestRender();
});
bar.add(modes);
JCheckBox snap = new JCheckBox("snap", false);
snap.setFocusable(false);
snap.addActionListener(e -> vp.gizmo().setSnap(new Gizmo.Snap(snap.isSelected(), 0.25f, 15f, 0.1f)));
bar.add(snap);
vp.addViewportListener(new ViewportListener() {
@Override
public void picked(PickEvent e) {
if (e.button() != MouseEvent.BUTTON1) {
return;
}
select(vp, scene, e.id());
status.setText(describe(res, e.id())
+ (e.world() != null
? String.format(" @ %.2f %.2f %.2f", e.world().x, e.world().y, e.world().z)
: ""));
}
@Override
public void hovered(int id) {
if (id != 0) {
status.setText("hover: " + describe(res, id));
}
}
@Override
public void gizmo(GizmoEvent e) {
// demo: apply the delta directly (the editor turns END into an undoable command)
Matrix4f m = e.phase() == GizmoEvent.Phase.CANCEL ? e.start() : e.result();
for (int id : vp.selection()) {
RenderInstance ri = scene.instance(id);
if (ri != null) {
ri.transform.set(m);
scene.changed(ri);
}
Marker mk = scene.marker(id);
if (mk != null) {
Vector3f t = m.getTranslation(new Vector3f());
mk.x = t.x;
mk.y = t.y;
mk.z = t.z;
scene.changed();
}
}
if (e.phase() != GizmoEvent.Phase.START) {
vp.gizmo().setTarget(m);
}
vp.requestRender();
}
});
f.getContentPane().add(bar, BorderLayout.NORTH);
f.getContentPane().add(vp, BorderLayout.CENTER);
f.getContentPane().add(status, BorderLayout.SOUTH);
f.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
f.pack();
f.setVisible(true);
Gizmo.Mode gm =
o.containsKey("--gizmo") ? Gizmo.Mode.valueOf(o.get("--gizmo").toUpperCase()) : null;
Timer start = new Timer(600, e -> {
if (gm != null) {
vp.gizmo().setMode(gm);
}
List<Integer> sel = new ArrayList<>();
if (o.containsKey("--select")) {
for (String s : o.get("--select").split(",")) {
sel.add(parseId(s));
}
select(vp, scene, sel.get(0));
vp.setSelection(sel);
}
vp.frameAll();
if (o.containsKey("--view")) {
vp.setView(Camera.View.valueOf(o.get("--view").toUpperCase()));
vp.frameAll();
}
if (o.containsKey("--frame") && !sel.isEmpty()) {
vp.frameSelection();
}
if (o.containsKey("--cam")) {
vp.stopAnimation();
String[] c = o.get("--cam").split(",");
Camera cam = vp.camera();
cam.position.set(Float.parseFloat(c[0]), Float.parseFloat(c[1]), Float.parseFloat(c[2]));
cam.yaw = (float) Math.toRadians(Double.parseDouble(c[3]));
cam.pitch = (float) Math.toRadians(Double.parseDouble(c[4]));
if (c.length > 5) {
cam.distance = Float.parseFloat(c[5]);
}
}
if (o.containsKey("--fov")) {
vp.camera().fovY = (float) Math.toRadians(Double.parseDouble(o.get("--fov")));
}
System.out.println("GL: " + vp.glInfo() + (vp.glError() != null ? " ERROR " + vp.glError() : ""));
if (o.containsKey("--bench")) {
bench(vp);
if (!o.containsKey("--shot")) {
System.exit(0);
}
}
if (o.containsKey("--shot")) {
// let the camera animation finish
Timer t = new Timer(400, ev -> {
try {
vp.renderNow();
File out = new File(o.get("--shot"));
ImageIO.write(vp.screenshot(), out.getName().endsWith(".jpg") ? "jpg" : "png", out);
Viewport.GlRendererStats st = vp.stats();
System.out.printf(
"shot %s: %d draw calls, %d triangles, chunks %d/%d, cpu %.1f ms%n",
out, st.drawCalls(), st.triangles(), st.chunksDrawn(), st.chunksTotal(), st.cpuMs());
} catch (IOException ex) {
ex.printStackTrace();
}
System.exit(0);
});
t.setRepeats(false);
t.start();
}
});
start.setRepeats(false);
start.start();
}
private static void select(Viewport vp, RenderScene scene, int id) {
vp.setSelection(id == 0 ? List.of() : List.of(id));
RenderInstance ri = scene.instance(id);
Marker mk = scene.marker(id);
Matrix4f m = null;
if (ri != null) {
m = new Matrix4f(ri.transform);
} else if (mk != null) {
m = new Matrix4f().translation(mk.x, mk.y, mk.z);
}
vp.gizmo().setVisible(m != null);
if (m != null) {
vp.gizmo().setTarget(m);
}
}
private static String describe(LevelSceneAdapter.Result res, int id) {
int k = PickId.kind(id), i = PickId.index(id);
if (k == PickId.OBJECT && i < res.stratNames().size()) {
return "object " + i + " " + res.stratNames().get(i);
}
if (k == PickId.TILE && i < res.level().map().tiles.size()) {
return "tile " + i + " (model " + res.level().map().tiles.get(i).model() + ")";
}
return PickId.toString(id);
}
/** Orbits once around the level and prints frame statistics. */
private static void bench(Viewport vp) {
Camera c = vp.camera();
Vector3f piv = c.pivot();
int frames = 120;
double total = 0, worst = 0, cpu = 0;
Viewport.GlRendererStats st = null;
for (int i = 0; i < frames; i++) {
c.yaw += (float) (Math.PI * 2 / frames);
c.position.set(c.forward().mul(-c.distance).add(piv));
long t0 = System.nanoTime();
vp.renderNow();
double ms = (System.nanoTime() - t0) / 1e6;
total += ms;
worst = Math.max(worst, ms);
st = vp.stats();
cpu += st.cpuMs();
}
System.out.printf(
"bench: %d frames, avg %.1f ms (%.0f fps), CPU submit %.1f ms, worst %.1f ms, last: %d draw calls, %d triangles, chunks %d/%d%n",
frames,
total / frames,
1000 / (total / frames),
cpu / frames,
worst,
st.drawCalls(),
st.triangles(),
st.chunksDrawn(),
st.chunksTotal());
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,33 @@
package com.eliasmueller.llamamod.editor.render;
import java.awt.event.KeyEvent;
import java.awt.event.MouseEvent;
/**
* Input hook for editor tools ({@link Viewport#setInput}). Gets left-button presses that do not hit
* the gizmo (and are not Alt-orbit), the following drag/release, every mouse move and key events
* before the viewport's own handling. Returning true consumes the event. Swing thread.
*/
public interface ViewportInput {
/** Left button pressed; true = this tool owns the following drag and release (no pick event). */
default boolean mousePressed(MouseEvent e) {
return false;
}
default void mouseDragged(MouseEvent e) {}
default void mouseReleased(MouseEvent e) {}
/** Mouse moved without a button (also while the gizmo is hovered). */
default void mouseMoved(MouseEvent e) {}
default void mouseExited(MouseEvent e) {}
default boolean keyPressed(KeyEvent e) {
return false;
}
default boolean keyReleased(KeyEvent e) {
return false;
}
}
@@ -0,0 +1,25 @@
package com.eliasmueller.llamamod.editor.render;
import org.joml.Vector3f;
/** Viewport callbacks (Swing thread). All methods have empty defaults. */
public interface ViewportListener {
/**
* Click without drag (left button unless noted).
*
* @param id picked id or 0
* @param world hit point on geometry (or marker), {@code null} if nothing was hit
* @param button {@link java.awt.event.MouseEvent} button
* @param modifiers {@link java.awt.event.InputEvent} extended modifiers (shift/ctrl for multi-select)
*/
record PickEvent(int id, Vector3f world, int x, int y, int button, int modifiers, int clickCount) {}
default void picked(PickEvent e) {}
/** Id under the mouse changed (0 = none). */
default void hovered(int id) {}
default void gizmo(GizmoEvent e) {}
default void cameraChanged(Camera c) {}
}
@@ -0,0 +1,321 @@
package com.eliasmueller.llamamod.editor.tools.catalog;
import com.eliasmueller.llamamod.editor.model.SceneEvent;
import com.eliasmueller.llamamod.editor.ui.AssetDrop;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.Icons;
import com.eliasmueller.llamamod.editor.ui.NodeIcons;
import com.eliasmueller.llamamod.save.Worlds;
import com.eliasmueller.llamamod.util.SwingListeners;
import java.awt.AlphaComposite;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.image.BufferedImage;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import javax.imageio.ImageIO;
import javax.swing.BorderFactory;
import javax.swing.DefaultListModel;
import javax.swing.Icon;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JMenuItem;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JScrollPane;
import javax.swing.JTextField;
import javax.swing.ListCellRenderer;
import javax.swing.SwingConstants;
import javax.swing.UIManager;
import org.joml.Vector3f;
/**
* Asset browser tab "All worlds": every object type of every level on the CD with thumbnails,
* search and world/category filters. Drag a type into the viewport to import and place it,
* double-click places it at the view centre.
*/
final class AllWorldsTab extends JPanel {
static final String DRAG_KIND = "xobj";
private static final int THUMB = 84;
private final EditorContext ctx;
private final DefaultListModel<ObjectCatalog.Entry> model = new DefaultListModel<>();
private final JList<ObjectCatalog.Entry> list = new JList<>(model);
private final JTextField search = new JTextField(14);
private final JComboBox<String> world = new JComboBox<>();
private final JComboBox<String> category = new JComboBox<>();
private final JCheckBox importable = new JCheckBox("Importable only", true);
private final JLabel count = new JLabel();
private final JLabel state = new JLabel(" ", SwingConstants.CENTER);
private final Map<String, Icon> thumbs = new HashMap<>();
AllWorldsTab(EditorContext ctx) {
super(new BorderLayout());
this.ctx = ctx;
world.addItem("All worlds");
for (int w = 1; w <= 8; w++) {
world.addItem(Worlds.name(w));
}
world.addItem("Other levels");
category.addItem("All categories");
for (String c : ObjectCatalog.CATEGORIES) {
category.addItem(ObjectCatalog.categoryLabel(c));
}
search.putClientProperty("JTextField.placeholderText", "Search all worlds…");
search.putClientProperty("JTextField.leadingIcon", Icons.get("search", 13));
search.putClientProperty("JTextField.showClearButton", true);
SwingListeners.onTextChange(search, () -> apply());
world.addActionListener(e -> apply());
category.addActionListener(e -> apply());
importable.addActionListener(e -> apply());
JButton bulk = new JButton("Import world…", Icons.get("import", 14));
bulk.setToolTipText("Import many object types of one world or level at once (with a size preview)");
bulk.addActionListener(e -> ImportWorldDialog.show(ctx, world.getSelectedIndex()));
JPanel top = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 3));
top.add(search);
top.add(world);
top.add(category);
top.add(importable);
top.add(bulk);
top.add(count);
add(top, BorderLayout.NORTH);
list.setLayoutOrientation(JList.HORIZONTAL_WRAP);
list.setVisibleRowCount(-1);
list.setFixedCellWidth(THUMB + 46);
list.setFixedCellHeight(THUMB + 40);
list.setCellRenderer(new Cell());
javax.swing.ToolTipManager.sharedInstance().registerComponent(list);
AssetDrop.dragSource(list, e -> AssetDrop.payload(DRAG_KIND, e.key()));
list.addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
ObjectCatalog.Entry it = list.getSelectedValue();
if (it != null && e.getClickCount() == 2 && e.getButton() == MouseEvent.BUTTON1) {
placeAtCentre(it);
}
}
@Override
public void mousePressed(MouseEvent e) {
popup(e);
}
@Override
public void mouseReleased(MouseEvent e) {
popup(e);
}
});
JPanel centre = new JPanel(new BorderLayout());
centre.add(new JScrollPane(list), BorderLayout.CENTER);
state.setForeground(UIManager.getColor("Label.disabledForeground"));
centre.add(state, BorderLayout.SOUTH);
add(centre, BorderLayout.CENTER);
CatalogStore.addListener(this::reload);
ctx.addDocumentListener(d -> list.repaint());
ctx.addSceneListener(e -> {
if (e.type() == SceneEvent.Type.RELOADED) {
list.repaint();
}
});
reload();
}
private void popup(MouseEvent e) {
if (!e.isPopupTrigger()) {
return;
}
int i = list.locationToIndex(e.getPoint());
if (i < 0) {
return;
}
list.setSelectedIndex(i);
ObjectCatalog.Entry it = model.get(i);
JPopupMenu m = new JPopupMenu();
JMenuItem place = new JMenuItem("Place at view centre");
place.addActionListener(a -> placeAtCentre(it));
place.setEnabled(ctx.document() != null && it.importable());
JMenuItem imp = new JMenuItem("Import into the project");
imp.addActionListener(a -> Importer.importTypes(ctx, List.of(it), o -> list.repaint()));
imp.setEnabled(ctx.document() != null && it.importable());
JMenuItem lvl = new JMenuItem("Import all of " + it.levelName() + "…");
lvl.addActionListener(a -> ImportWorldDialog.show(ctx, it.level()));
lvl.setEnabled(ctx.document() != null);
m.add(place);
m.add(imp);
m.addSeparator();
m.add(lvl);
m.show(list, e.getX(), e.getY());
}
private void placeAtCentre(ObjectCatalog.Entry it) {
if (ctx.document() == null) {
return;
}
Vector3f p = ctx.viewCentre();
Importer.importAndPlace(ctx, it, p);
}
void reload() {
ObjectCatalog c = CatalogStore.get();
if (c == null) {
model.clear();
state.setText(CatalogStore.building() ? "Building the catalogue of all worlds…" : "");
if (!CatalogStore.building()) {
CatalogStore.ensure(ctx);
}
return;
}
state.setText(c.entries().size() + " object types from "
+ c.entries().stream()
.map(ObjectCatalog.Entry::level)
.distinct()
.count() + " levels · drag into the viewport to import and place");
apply();
}
private void apply() {
ObjectCatalog c = CatalogStore.get();
model.clear();
if (c == null) {
return;
}
int w = world.getSelectedIndex();
int wf = w == 0 ? 0 : w <= 8 ? w : -1;
String cat =
category.getSelectedIndex() <= 0 ? null : ObjectCatalog.CATEGORIES[category.getSelectedIndex() - 1];
List<ObjectCatalog.Entry> l = c.filter(search.getText(), Math.max(wf, 0), cat, importable.isSelected());
int n = 0;
for (ObjectCatalog.Entry e : l) {
if (wf < 0 && e.world() >= 1 && e.world() <= 8) {
continue;
}
model.addElement(e);
n++;
}
count.setText(n + " of " + c.entries().size());
}
private Icon thumb(ObjectCatalog.Entry e) {
return thumbs.computeIfAbsent(e.key(), k -> {
ObjectCatalog c = CatalogStore.get();
Path f = c == null ? null : c.thumbFile(e);
try {
if (f != null && Files.isRegularFile(f)) {
BufferedImage img = ImageIO.read(f.toFile());
if (img != null) {
BufferedImage s = new BufferedImage(THUMB, THUMB, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = s.createGraphics();
g.setRenderingHint(
RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
if (!e.importable()) {
g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.35f));
}
g.drawImage(img, 0, 0, THUMB, THUMB, null);
g.dispose();
return new ImageIcon(s);
}
}
} catch (java.io.IOException ex) {
// fall back to the category icon
}
return NodeIcons.category(categoryForIcon(e.category()), 40);
});
}
static String categoryForIcon(String c) {
return switch (c) {
case "enemy", "mechanism", "character", "creature", "effect" -> "other";
default -> c;
};
}
static String tooltip(ObjectCatalog.Entry e) {
StringBuilder sb = new StringBuilder("<html><b>")
.append(e.type())
.append("</b><br>")
.append(e.levelName())
.append(" (")
.append(e.level())
.append(")<br>")
.append(ObjectCatalog.categoryLabel(e.category()))
.append(" · ")
.append(e.instances())
.append(e.instances() == 1 ? " instance" : " instances")
.append(" · ")
.append(e.params())
.append(" parameters<br>")
.append(String.format(
Locale.ROOT,
"needs %d code units, %d models, %d animations, %d sounds, %d textures (%.0f KB)",
e.units(),
e.models(),
e.anims(),
e.sounds(),
e.textures(),
e.bytes() / 1024.0));
for (String r : e.reasons()) {
sb.append("<br><font color='#d93f3f'>✗ ").append(r).append("</font>");
}
for (String r : e.notes()) {
sb.append("<br>• ").append(r);
}
return sb.append("</html>").toString();
}
private final class Cell extends JPanel implements ListCellRenderer<ObjectCatalog.Entry> {
private final JLabel img = new JLabel();
private final JLabel title = new JLabel();
private final JLabel sub = new JLabel();
Cell() {
super(new BorderLayout());
setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4));
img.setHorizontalAlignment(SwingConstants.CENTER);
img.setPreferredSize(new Dimension(THUMB, THUMB));
add(img, BorderLayout.CENTER);
JPanel t = new JPanel(new BorderLayout());
t.setOpaque(false);
title.setHorizontalAlignment(SwingConstants.CENTER);
title.setFont(title.getFont().deriveFont(Font.BOLD, 11f));
sub.setHorizontalAlignment(SwingConstants.CENTER);
sub.setFont(sub.getFont().deriveFont(10f));
t.add(title, BorderLayout.NORTH);
t.add(sub, BorderLayout.SOUTH);
add(t, BorderLayout.SOUTH);
}
@Override
public Component getListCellRendererComponent(
JList<? extends ObjectCatalog.Entry> l, ObjectCatalog.Entry e, int index, boolean sel, boolean focus) {
img.setIcon(thumb(e));
boolean done = Importer.importedCode(ctx, Importer.ref(e)) >= 0;
title.setText((done ? "✓ " : "") + e.type());
String w = e.world() >= 1 ? Worlds.name(e.world()) + " " + e.chapter() : e.level();
sub.setText(w + " · " + (e.bytes() < 1024 ? e.bytes() + " B" : e.bytes() / 1024 + " KB"));
Color fg = sel ? UIManager.getColor("List.selectionForeground") : UIManager.getColor("List.foreground");
title.setForeground(
!e.importable() ? UIManager.getColor("Label.disabledForeground") : done ? Icons.GREEN : fg);
sub.setForeground(sel ? fg : UIManager.getColor("Label.disabledForeground"));
setBackground(sel ? UIManager.getColor("List.selectionBackground") : l.getBackground());
setToolTipText(tooltip(e));
return this;
}
}
}
@@ -0,0 +1,125 @@
package com.eliasmueller.llamamod.editor.tools.catalog;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.ui.ActionSpec;
import com.eliasmueller.llamamod.editor.ui.AssetDrop;
import com.eliasmueller.llamamod.editor.ui.AssetTab;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.EditorExtensions;
import com.eliasmueller.llamamod.editor.ui.EditorPlugin;
import com.eliasmueller.llamamod.editor.ui.Icons;
import com.eliasmueller.llamamod.editor.ui.InspectorSection;
import com.eliasmueller.llamamod.editor.ui.Problem;
import com.eliasmueller.llamamod.save.Worlds;
import java.awt.BorderLayout;
import java.util.List;
import java.util.Locale;
import javax.swing.BorderFactory;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JPanel;
/**
* Cross-level object import: asset tab "All worlds" (catalogue of every object type on the CD),
* drag &amp; drop import + placement, "Import world…" bulk import, inspector note "imported from",
* limit checks. Merge logic: {@link CrossImport}, format: {@code format.stpcmerge}.
*/
public final class CatalogEditorPlugin implements EditorPlugin {
@Override
public void install(EditorExtensions x) {
CrossImport.init();
x.assetTab(new AssetTab("allworlds", "All worlds", "world", 15, AllWorldsTab::new));
AssetDrop.register(AllWorldsTab.DRAG_KIND, (ctx, data, ground) -> {
ObjectCatalog.Entry e = CatalogStore.find(data);
if (e == null) {
ctx.status("Unknown object type " + data);
return;
}
Importer.importAndPlace(ctx, e, ground);
});
x.action(ActionSpec.of(
"xobj.importWorld",
"Level",
40,
10,
"Import objects of a world…",
"import",
"ctrl shift I",
c -> ImportWorldDialog.show(c, 0))
.enabledWhen(c -> c.document() != null)
.tip("Copy object types (enemies, pickups, platforms …) of another world into this level"));
x.action(ActionSpec.of("xobj.rebuildCatalog", "Level", 40, 20, "Rebuild object catalogue", "world", null, c -> {
CatalogStore.rebuild(c);
})
.tip("Scan all levels of the CD again (thumbnails, sizes)"));
x.inspector(new ImportedSection());
x.check((scene, out) -> {
if (CrossImport.codes(scene).isEmpty()) {
return;
}
CrossImport.Limits l = CrossImport.limits(scene);
for (String p : l.problems()) {
out.add(Problem.error("Imported objects: " + p, null));
}
for (String w : l.warnings()) {
out.add(Problem.warning("Imported objects: " + w, null));
}
});
x.onOpen(CatalogStore::ensure);
}
/** Inspector: where an imported object type comes from. */
private static final class ImportedSection implements InspectorSection {
@Override
public String title() {
return "Imported object";
}
@Override
public int order() {
return 20;
}
@Override
public boolean appliesTo(List<Node> sel) {
return sel.size() == 1
&& sel.get(0) instanceof Strat s
&& s.scene() != null
&& CrossImport.codes(s.scene()).containsKey(s.code());
}
@Override
public JComponent create(EditorContext ctx, List<Node> sel) {
Strat s = (Strat) sel.get(0);
CrossImport.TypeRef t = CrossImport.codes(s.scene()).get(s.code());
ObjectCatalog.Entry e = CatalogStore.find(t.level() + ":" + t.type() + "@" + Integer.toHexString(t.code()));
String where = e != null ? e.levelName() : levelLabel(t.level());
JLabel l = new JLabel(
"<html>Imported from <b>" + where + "</b> (" + t.level() + ")"
+ (e == null
? ""
: String.format(
Locale.ROOT,
"<br>%s · %d models, %d animations, %d sounds, %.0f KB",
ObjectCatalog.categoryLabel(e.category()),
e.models(),
e.anims(),
e.sounds(),
e.bytes() / 1024.0))
+ "<br><font color='gray'>Code, models, textures and sounds are copied into this level when it is built.</font></html>",
Icons.get("import", 16),
JLabel.LEADING);
l.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
JPanel p = new JPanel(new BorderLayout());
p.add(l, BorderLayout.CENTER);
return p;
}
}
static String levelLabel(String level) {
java.util.regex.Matcher m =
java.util.regex.Pattern.compile("t1l(\\d)m0*(\\d+)").matcher(level);
return m.matches() ? Worlds.name(Integer.parseInt(m.group(1))) + " – Chapter " + m.group(2) : level;
}
}
@@ -0,0 +1,99 @@
package com.eliasmueller.llamamod.editor.tools.catalog;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import javax.swing.SwingUtilities;
/** The global object catalogue of this session: loaded from the cache or built once in the background. */
final class CatalogStore {
private CatalogStore() {}
private static volatile ObjectCatalog catalog;
private static volatile boolean building;
private static final List<Runnable> LISTENERS = new CopyOnWriteArrayList<>();
static ObjectCatalog get() {
return catalog;
}
static boolean building() {
return building;
}
static void addListener(Runnable r) {
LISTENERS.add(r);
}
static Path dir(EditorContext ctx) {
return ctx.env().editorDir().resolve("catalog");
}
/** Loads or builds the catalogue (needs the CD); listeners run on the Swing thread when it is ready. */
static void ensure(EditorContext ctx) {
if (catalog != null || building || !ctx.env().hasCd()) {
return;
}
Path cd = ctx.env().cd();
Path dir = dir(ctx);
building = true;
Thread t = new Thread(
() -> {
ObjectCatalog c = ObjectCatalog.cached(dir, cd);
try {
if (c == null) {
c = ObjectCatalog.build(
cd,
dir,
(a, b) -> SwingUtilities.invokeLater(
() -> ctx.status("Building the object catalogue of all worlds… " + a + "/"
+ b + " levels")));
}
} catch (Exception e) {
SwingUtilities.invokeLater(() -> ctx.error("Object catalogue", e));
}
ObjectCatalog done = c;
SwingUtilities.invokeLater(() -> {
catalog = done;
building = false;
if (done != null) {
ctx.status("Object catalogue: " + done.entries().size() + " types of all worlds");
}
LISTENERS.forEach(Runnable::run);
});
},
"object-catalogue");
t.setDaemon(true);
t.start();
}
/** Drops the cache and builds the catalogue again. */
static void rebuild(EditorContext ctx) {
if (building) {
return;
}
try {
java.nio.file.Files.deleteIfExists(dir(ctx).resolve("catalog.json"));
} catch (java.io.IOException e) {
// rebuilt anyway
}
catalog = null;
LISTENERS.forEach(Runnable::run);
ensure(ctx);
}
/** Entry by key (level:type@code), or null. */
static ObjectCatalog.Entry find(String key) {
ObjectCatalog c = catalog;
if (c == null) {
return null;
}
for (ObjectCatalog.Entry e : c.entries()) {
if (e.key().equals(key)) {
return e;
}
}
return null;
}
}
@@ -0,0 +1,634 @@
package com.eliasmueller.llamamod.editor.tools.catalog;
import com.eliasmueller.llamamod.editor.io.Bases;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.io.ProjectIO;
import com.eliasmueller.llamamod.editor.model.Brush;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.Prop;
import com.eliasmueller.llamamod.editor.model.PropType;
import com.eliasmueller.llamamod.editor.model.RawChunk;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.StratCatalog;
import com.eliasmueller.llamamod.editor.model.TextureAnim;
import com.eliasmueller.llamamod.editor.model.TextureEntry;
import com.eliasmueller.llamamod.editor.model.TexturePage;
import com.eliasmueller.llamamod.editor.model.TileModel;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.MapChunk;
import com.eliasmueller.llamamod.format.TextChunk;
import com.eliasmueller.llamamod.format.stpcmerge.DialogMerge;
import com.eliasmueller.llamamod.format.stpcmerge.SoundMerge;
import com.eliasmueller.llamamod.format.stpcmerge.StpcImage;
import com.eliasmueller.llamamod.format.stpcmerge.StpcMerge;
import com.eliasmueller.llamamod.format.stpcmerge.TextMerge;
import java.io.IOException;
import java.lang.ref.SoftReference;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Object types imported from other levels into a project (notes/editor-cross-import.md). The
* project stores only the list of imports (level.json key {@value #KEY}: batches of {level, type,
* code}); on load every batch is merged, in order, into the reference and the current scene alike
* (STPC code/models/animations, TEXT pages/entries, SMPC samples, object catalogue, strat code
* offsets), so the stored diff keeps describing only the user's edits and builds are deterministic.
*/
public final class CrossImport {
public static final String KEY = "importedTypes";
private CrossImport() {}
static {
ProjectIO.addLoadHook(CrossImport::onLoad);
}
/** Makes sure the load hook is registered. */
public static void init() {}
/** One imported type: source level, type name and its code entry in the source STPC. */
public record TypeRef(String level, String type, int code) {
Map<String, Object> json() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("level", level);
m.put("type", type);
m.put("code", (long) code);
return m;
}
static TypeRef of(Object o) {
@SuppressWarnings("unchecked")
Map<String, Object> m = (Map<String, Object>) o;
return new TypeRef(
((String) m.get("level")).toLowerCase(Locale.ROOT),
(String) m.getOrDefault("type", ""),
((Number) m.get("code")).intValue());
}
}
/** Result of merging one batch into a scene. */
public record Outcome(
byte[] stpc,
byte[] smpc,
byte[] lgpc,
byte[] srpc,
StpcMerge.OffsetMap target,
List<TextChunk.Page> pages,
List<TextChunk.TextureEntry> entries,
List<TextMerge.Anim> anims,
Map<TypeRef, Integer> codes,
Map<TypeRef, StratCatalog.Variant> variants,
Map<Integer, String> names,
StpcMerge.Stats stats,
List<String> warnings) {}
// ------------------------------------------------------------------ project records
/** The import batches stored in a project (in order). */
public static List<List<TypeRef>> records(Map<String, Object> extra) {
List<List<TypeRef>> out = new ArrayList<>();
Object v = extra.get(KEY);
if (v instanceof List<?> l) {
for (Object b : l) {
if (b instanceof Map<?, ?> m && m.get("types") instanceof List<?> ts) {
List<TypeRef> batch = new ArrayList<>();
for (Object t : ts) {
batch.add(TypeRef.of(t));
}
out.add(batch);
}
}
}
return out;
}
/** All imported types of a project. */
public static List<TypeRef> imported(Project p) {
List<TypeRef> out = new ArrayList<>();
records(p.extra()).forEach(out::addAll);
return out;
}
/** Imported entries of a scene by their current code offset. */
public static Map<Integer, TypeRef> codes(Scene s) {
Map<Integer, TypeRef> m = CODES.get(s);
return m == null ? Map.of() : Map.copyOf(m);
}
/** Current code offsets of the imported entries per scene (rebuilt on every merge). */
private static final Map<Scene, Map<Integer, TypeRef>> CODES =
java.util.Collections.synchronizedMap(new java.util.WeakHashMap<>());
private static void onLoad(Map<String, Object> json, Bases cd, Scene ref, Scene cur, List<String> warnings)
throws IOException {
List<List<TypeRef>> recs = records(json);
for (List<TypeRef> batch : recs) {
try {
Outcome o = compute(ref, batch, cd);
apply(o, ref, false, -1, -1, -1);
apply(o, cur, false, -1, -1, -1);
warnings.addAll(o.warnings);
} catch (RuntimeException e) {
warnings.add("imported object types " + names(batch) + " could not be merged: " + e.getMessage());
}
}
}
static String names(List<TypeRef> batch) {
List<String> n = new ArrayList<>();
for (TypeRef t : batch) {
n.add(t.type + " (" + t.level + ")");
}
return n.size() > 4 ? n.subList(0, 4) + " +" + (n.size() - 4) : n.toString();
}
/**
* Imports a batch into an open project: merges into its reference and its scene (events fire on
* the scene) and records the batch. Strat code offsets shift, so the caller should clear the
* undo history. Types already imported or present in the base with the same code are skipped.
*/
public static Outcome importInto(Project p, List<TypeRef> batch, Bases cd) throws IOException {
Outcome o = compute(p.reference(), batch, cd);
commit(p, batch, o);
return o;
}
/**
* Second half of {@link #importInto}: writes an outcome computed from the project's reference
* (possibly on another thread) into reference and scene and records the batch. Swing thread.
*/
public static void commit(Project p, List<TypeRef> batch, Outcome o) {
Scene ref = p.reference(), cur = p.scene();
int pages = ref.pages().size(),
tex = ref.textures().size(),
anims = ref.textureAnims().size();
apply(o, ref, false, -1, -1, -1);
apply(o, cur, true, pages, tex, anims);
@SuppressWarnings("unchecked")
List<Object> l = p.extra().get(KEY) instanceof List<?> x ? (List<Object>) x : new ArrayList<>();
List<Object> nl = new ArrayList<>(l);
Map<String, Object> rec = new LinkedHashMap<>();
List<Object> ts = new ArrayList<>();
for (TypeRef t : batch) {
ts.add(t.json());
}
rec.put("types", ts);
nl.add(rec);
p.extra().put(KEY, nl);
}
/** Engine limits of a level (notes/editor-cross-import.md). */
public record Limits(int stpcBytes, int pages, int textures, int samples, int speech, int texts, long heapBytes) {
public static final long HEAP = 0x1000000;
public List<String> problems() {
List<String> out = new ArrayList<>();
if (samples > SoundMerge.MAX_SAMPLES) {
out.add("too many sound samples: " + samples + " (max " + SoundMerge.MAX_SAMPLES + ")");
}
if (pages > 127) {
out.add("too many texture pages: " + pages + " (max 127)");
}
if (heapBytes > HEAP) {
out.add(String.format(
Locale.ROOT,
"level data ≈ %.1f MB exceeds the game's 16 MB level memory",
heapBytes / 1048576.0));
}
return out;
}
public List<String> warnings() {
List<String> out = new ArrayList<>();
if (heapBytes > HEAP * 3 / 4 && heapBytes <= HEAP) {
out.add(String.format(
Locale.ROOT, "level data ≈ %.1f MB of 16 MB level memory", heapBytes / 1048576.0));
}
if (pages > 100 && pages <= 127) {
out.add(pages + " of 127 texture pages used");
}
if (samples > 110 && samples <= SoundMerge.MAX_SAMPLES) {
out.add(samples + " of " + SoundMerge.MAX_SAMPLES + " sound samples used");
}
return out;
}
public String summary() {
return String.format(
Locale.ROOT,
"code+models %.1f MB · %d/127 texture pages · %d textures · %d/%d sounds · %d texts · level memory ≈ %.1f/16 MB",
stpcBytes / 1048576.0,
pages,
textures,
samples,
SoundMerge.MAX_SAMPLES,
texts,
heapBytes / 1048576.0);
}
}
/**
* Current limits of a scene (after its imports). Level memory: the loader keeps STPC, TEXT
* (compressed pages + 20 bytes per entry), TRAK, MAP, LGPC and the strat pool in one 16 MB block
* (FUN_0041ef00); this sums those chunk sizes (an estimate, sound banks are not in it).
*/
public static Limits limits(Scene s) {
long heap = 0;
int stpc = 0;
for (RawChunk c : s.chunks()) {
switch (c.tag()) {
case "STPC" -> {
stpc = c.data().length;
heap += stpc;
}
case "SMPC", "AMPC", "SRPC", "END ", "TEXT", "TRAK", "MAP " -> {}
default -> heap += c.data() == null ? 0 : c.data().length;
}
}
for (TexturePage p : s.pages()) {
heap += p.encoded() != null ? p.encoded().length : (long) p.width() * p.height();
}
heap += 20L * s.textures().size();
for (TileModel m : s.tileModels()) {
heap += 0x84
+ m.vertexCount() * 24L
+ m.faceCount() * 28L
+ 32L * (m.collisionCount(0) + m.collisionCount(1) + m.collisionCount(2));
}
heap += (long) s.tiles().size() * 64
+ (long) s.settings().width() * s.settings().height() * 8;
heap += (s.strats().size() + 30L + Math.max(0, s.settings().poolReserve())) * 0x154;
RawChunk sm = chunk(s, "SMPC"), lg = chunk(s, "LGPC"), sr = chunk(s, "SRPC");
int samples = sm == null ? 0 : SoundMerge.parse(sm.data()).size();
int texts = lg == null ? 0 : DialogMerge.Texts.parse(lg.data()).texts.size();
int speech =
sr == null ? 0 : DialogMerge.Speech.parse(sr.data()).entries.size();
return new Limits(stpc, s.pages().size(), s.textures().size(), samples, speech, texts, heap);
}
// ------------------------------------------------------------------ merge
record SourceLevel(StpcMerge.Source source, Level level) {}
private static final Map<String, SoftReference<SourceLevel>> SOURCES = new ConcurrentHashMap<>();
static SourceLevel source(Bases cd, String level) throws IOException {
String k = level.toLowerCase(Locale.ROOT);
SoftReference<SourceLevel> r = SOURCES.get(k);
SourceLevel s = r == null ? null : r.get();
if (s != null) {
return s;
}
Level lv = Level.parse(cd.wad(k));
byte[] st = lv.wad().require("STPC");
Set<Integer> roots = new TreeSet<>();
for (MapChunk.Strat x : lv.map().strats) {
roots.add(x.code());
}
StpcMerge.Analysis a = new StpcMerge.Analysis(new StpcImage(st, lv.wfpc()), roots);
s = new SourceLevel(
new StpcMerge.Source(
k,
a,
TextMerge.Textures.of(lv.wad().require("TEXT")),
SoundMerge.parse(lv.wad().get("SMPC")),
lv.wad().get("LGPC") == null
? null
: DialogMerge.Texts.parse(lv.wad().get("LGPC")),
DialogMerge.Speech.parse(lv.wad().get("SRPC"))),
lv);
SOURCES.put(k, new SoftReference<>(s));
return s;
}
static RawChunk chunk(Scene s, String tag) {
for (RawChunk c : s.chunks()) {
if (c.tag().equals(tag)) {
return c;
}
}
return null;
}
/** Analysis of a scene's (possibly already merged) STPC. */
static StpcMerge.Analysis analysis(Scene s) {
RawChunk c = chunk(s, "STPC");
if (c == null) {
throw new IllegalStateException("the level has no STPC chunk");
}
Set<Integer> roots = new TreeSet<>();
for (Strat x : s.strats()) {
roots.add(x.code());
}
roots.addAll(s.catalog().names().keySet());
StpcImage img = new StpcImage(c.data(), s.settings().wfpc());
roots.removeIf(o -> o < img.codeStart || img.unitOf(o) < 0);
return new StpcMerge.Analysis(img, roots);
}
static TextMerge.Textures textures(Scene s) {
List<TextChunk.Page> pages = new ArrayList<>();
for (TexturePage p : s.pages()) {
pages.add(new TextChunk.Page(p.flags(), p.width(), p.height(), p.pixels()));
}
List<TextChunk.TextureEntry> entries = new ArrayList<>();
for (TextureEntry e : s.textures()) {
entries.add(new TextChunk.TextureEntry(
(int) e.get("flags"),
(int) e.get("page"),
(int) e.get("page2"),
(int) e.get("u0"),
(int) e.get("u1"),
(int) e.get("v0"),
(int) e.get("v1")));
}
List<TextMerge.Anim> anims = new ArrayList<>();
for (TextureAnim a : s.textureAnims()) {
anims.add(new TextMerge.Anim(a.texture(), a.type(), a.data()));
}
return new TextMerge.Textures(pages, entries, anims);
}
/** Merges a batch into (a copy of) the scene's level data; the scene is not changed. */
public static Outcome compute(Scene s, List<TypeRef> batch, Bases cd) throws IOException {
StpcMerge.Analysis tgt = analysis(s);
TextMerge.Textures tex = textures(s);
int p0 = tex.pages.size(), e0 = tex.entries.size(), a0 = tex.anims.size();
RawChunk sm = chunk(s, "SMPC");
List<SoundMerge.Sample> snd = SoundMerge.parse(sm == null ? null : sm.data());
RawChunk lg = chunk(s, "LGPC"), sr = chunk(s, "SRPC");
DialogMerge.Texts texts = lg == null ? null : DialogMerge.Texts.parse(lg.data());
DialogMerge.Speech speech = DialogMerge.Speech.parse(sr == null ? null : sr.data());
List<StpcMerge.Request> reqs = new ArrayList<>();
Map<TypeRef, StpcMerge.Request> byRef = new LinkedHashMap<>();
for (TypeRef t : batch) {
SourceLevel src = source(cd, t.level);
StpcMerge.Request r = new StpcMerge.Request(src.source(), t.code);
reqs.add(r);
byRef.put(t, r);
}
StpcMerge.Result res = StpcMerge.merge(tgt, reqs, new StpcMerge.Target(tex, snd, texts, speech));
Map<TypeRef, Integer> codes = new LinkedHashMap<>();
Map<TypeRef, StratCatalog.Variant> variants = new LinkedHashMap<>();
for (var e : byRef.entrySet()) {
int code = res.entries().get(e.getValue());
codes.put(e.getKey(), code);
variants.put(e.getKey(), variant(source(cd, e.getKey().level).level(), e.getKey(), code));
}
List<String> warn = new ArrayList<>(res.warnings());
return new Outcome(
res.stpc(),
sm == null ? null : SoundMerge.build(snd),
lg == null || res.stats().addedTexts() == 0 ? null : texts.build(),
sr == null || res.stats().addedTexts() == 0 ? null : speech.build(),
res.target(),
new ArrayList<>(tex.pages.subList(p0, tex.pages.size())),
new ArrayList<>(tex.entries.subList(e0, tex.entries.size())),
new ArrayList<>(tex.anims.subList(a0, tex.anims.size())),
codes,
variants,
res.names(),
res.stats(),
warn);
}
/** Catalog prototype of the source level's first instance of the entry (type fields, params, waypoints). */
static StratCatalog.Variant variant(Level lv, TypeRef t, int newCode) {
MapChunk map = lv.map();
for (MapChunk.Strat s : map.strats) {
if (s.code() != t.code) {
continue;
}
int[] prm = s.nParams() > 0 && s.paramIndex() >= 0 && s.paramIndex() + s.nParams() <= map.params.length
? Arrays.copyOfRange(map.params, s.paramIndex(), s.paramIndex() + s.nParams())
: new int[0];
List<int[]> chain = new ArrayList<>();
int k = s.waypoint();
while (k >= 0 && k < map.nodes.size() && chain.size() <= 200) {
MapChunk.PathNode n = map.nodes.get(k);
chain.add(new int[] {
n.x() - s.x(), n.y() - s.y(), n.z() - s.z(), n.rx(), n.ry(), n.rz(), n.unk1(), n.unk2(), n.unk3()
});
if (n.next() == 0) {
break;
}
k = n.next();
}
return new StratCatalog.Variant(
newCode,
s.nVars(),
s.slots(),
s.typeFlags(),
s.extra(),
s.flags() & 0xffff,
s.rx(),
s.ry(),
s.rz(),
prm,
List.copyOf(chain),
t.level);
}
throw new IllegalArgumentException(t.type + " has no instance in " + t.level);
}
/**
* Writes an outcome into a scene. pageAt/texAt/animAt: where the new pages/entries/animations go
* (−1 = end); items behind them (user textures) are renumbered.
*/
static void apply(Outcome o, Scene s, boolean events, int pageAt, int texAt, int animAt) {
Runnable r = () -> {
StratCatalog old = s.catalog();
StratCatalog c = new StratCatalog();
for (StratCatalog.Type t : old.types()) {
for (StratCatalog.Variant v : t.variants()) {
c.add(t.name(), withCode(v, o.target.map(v.code())));
}
}
for (var e : o.variants.entrySet()) {
String name = e.getKey().type.isEmpty() ? "?" : e.getKey().type;
c.add(name, e.getValue());
}
for (var e : old.names().entrySet()) {
int m = o.target.map(e.getKey());
if (old.codeOnly().contains(e.getValue()) && !c.has(e.getValue())) {
c.addCodeOnly(e.getValue(), m);
} else {
c.addName(m, e.getValue());
}
}
o.names.forEach((code, name) -> {
if (!c.has(name)) {
c.addCodeOnly(name, code);
} else {
c.addName(code, name);
}
});
s.setCatalog(c);
chunk(s, "STPC").setRaw("data", o.stpc);
if (o.smpc != null) {
chunk(s, "SMPC").setRaw("data", o.smpc);
}
if (o.lgpc != null) {
chunk(s, "LGPC").setRaw("data", o.lgpc);
}
if (o.srpc != null && chunk(s, "SRPC") != null) {
chunk(s, "SRPC").setRaw("data", o.srpc);
}
for (Strat x : s.strats()) {
int m = o.target.map(x.code());
if (m >= 0 && m != x.code()) {
x.setRaw("code", m);
x.setTypeName(s.catalog().nameOf(m));
}
}
insertTextures(o, s, pageAt, texAt, animAt);
Map<Integer, TypeRef> codes = new java.util.HashMap<>();
Map<Integer, TypeRef> prev = CODES.get(s);
if (prev != null) {
prev.forEach((k, v) -> codes.put(o.target.map(k), v));
}
o.codes.forEach((t, k) -> codes.put(k, t));
CODES.put(s, codes);
};
if (events) {
s.bulk(r);
} else {
r.run();
}
}
static StratCatalog.Variant withCode(StratCatalog.Variant v, int code) {
return new StratCatalog.Variant(
code,
v.nVars(),
v.slots(),
v.typeFlags(),
v.extra(),
v.flags(),
v.rx(),
v.ry(),
v.rz(),
v.params(),
v.chain(),
v.sourceLevel());
}
private static final Pattern TEX_REF = Pattern.compile("#(\\d+)");
private static void insertTextures(Outcome o, Scene s, int pageAt, int texAt, int animAt) {
int np = o.pages.size(), ne = o.entries.size(), na = o.anims.size();
int pAt = pageAt < 0 ? s.pages().size() : pageAt;
int tAt = texAt < 0 ? s.textures().size() : texAt;
int aAt = animAt < 0 ? s.textureAnims().size() : animAt;
boolean shiftPages = pAt < s.pages().size(),
shiftTex = tAt < s.textures().size();
if (shiftPages) {
for (TextureEntry e : s.textures()) {
int pg = (int) e.get("page");
if (pg >= pAt) {
e.setRaw("page", pg + np);
}
}
}
if (shiftTex && ne > 0) {
for (TileModel m : s.tileModels()) {
int[] f = m.faces().clone();
boolean ch = false;
for (int i = 4; i < f.length; i += 6) {
if (f[i] >= tAt) {
f[i] += ne;
ch = true;
}
}
if (ch) {
m.setRaw("faces", f);
}
}
for (TextureAnim a : s.textureAnims()) {
if (a.texture() >= tAt) {
a.setRaw("texture", a.texture() + ne);
}
byte[] d = a.data().clone();
boolean ch = false;
int[][] fr = new TextMerge.Anim(a.texture(), a.type(), d).frames();
for (int[] f : fr) {
if (f[0] >= tAt) {
int v = f[0] + ne;
d[f[1]] = (byte) v;
d[f[1] + 1] = (byte) (v >> 8);
ch = true;
}
}
if (ch) {
a.setRaw("data", d);
}
}
for (Brush b : s.brushes()) {
for (Prop p : b.props()) {
if (p.type() == PropType.STRING && p.get(b) instanceof String str && str.startsWith("#")) {
Matcher m = TEX_REF.matcher(str);
if (m.matches() && Integer.parseInt(m.group(1)) >= tAt) {
p.setRaw(b, "#" + (Integer.parseInt(m.group(1)) + ne));
}
}
}
}
}
int base = countImported(s, "page:x");
for (int i = 0; i < np; i++) {
TextChunk.Page p = o.pages.get(i);
TexturePage n = new TexturePage("page:x" + (base + i));
n.setRaw("flags", p.flags);
n.setRaw("width", p.w);
n.setRaw("height", p.h);
n.setRaw("pixels", p.pixels);
s.add(n, pAt + i);
}
int tb = countImported(s, "tex:x");
for (int i = 0; i < ne; i++) {
TextChunk.TextureEntry e = o.entries.get(i);
TextureEntry n = new TextureEntry("tex:x" + (tb + i));
n.setRaw("flags", e.flags());
n.setRaw("page", e.page());
n.setRaw("page2", e.page2());
n.setRaw("u0", e.u0());
n.setRaw("u1", e.u1());
n.setRaw("v0", e.v0());
n.setRaw("v1", e.v1());
s.add(n, tAt + i);
}
int ab = countImported(s, "anim:x");
for (int i = 0; i < na; i++) {
TextMerge.Anim a = o.anims.get(i);
TextureAnim n = new TextureAnim("anim:x" + (ab + i));
n.setRaw("texture", a.texture());
n.setRaw("type", a.type());
n.setRaw("data", a.data());
s.add(n, aAt + i);
}
}
private static int countImported(Scene s, String prefix) {
int n = 0;
for (Node x : s.pages()) {
n += x.id().startsWith(prefix) ? 1 : 0;
}
for (Node x : s.textures()) {
n += x.id().startsWith(prefix) ? 1 : 0;
}
for (Node x : s.textureAnims()) {
n += x.id().startsWith(prefix) ? 1 : 0;
}
return n;
}
}
@@ -0,0 +1,351 @@
package com.eliasmueller.llamamod.editor.tools.catalog;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.ui.Document;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.Icons;
import com.eliasmueller.llamamod.save.Worlds;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.SwingWorker;
import javax.swing.table.AbstractTableModel;
/**
* "Import world…": many object types of one world (all its chapters) or one level at once, for
* fresh maps. Shows the dependency sizes and, after "Preview", the exact merge result against the
* engine limits.
*/
final class ImportWorldDialog extends JDialog {
private final EditorContext ctx;
private final JComboBox<String> source = new JComboBox<>();
private final List<Object> sourceKeys = new ArrayList<>();
private final JCheckBox skipKnown = new JCheckBox("Skip types the level already has", true);
private final Rows rows = new Rows();
private final JTable table = new JTable(rows);
private final JLabel preview = new JLabel(" ");
private final JButton importBtn = new JButton("Import", Icons.get("import", 14));
private SwingWorker<?, ?> worker;
static void show(EditorContext ctx, Object initial) {
if (ctx.document() == null) {
JOptionPane.showMessageDialog(
ctx.frame(), "Open a level first.", "Import world", JOptionPane.INFORMATION_MESSAGE);
return;
}
if (CatalogStore.get() == null) {
CatalogStore.ensure(ctx);
JOptionPane.showMessageDialog(
ctx.frame(),
"The object catalogue is still being built – try again in a moment.",
"Import world",
JOptionPane.INFORMATION_MESSAGE);
return;
}
ImportWorldDialog d = new ImportWorldDialog(ctx, initial);
d.setVisible(true);
}
private ImportWorldDialog(EditorContext ctx, Object initial) {
super(ctx.frame(), "Import objects of a world", true);
this.ctx = ctx;
ObjectCatalog c = CatalogStore.get();
for (int w = 1; w <= 8; w++) {
source.addItem(Worlds.name(w) + " – all chapters");
sourceKeys.add(w);
}
Map<String, String> levels = new LinkedHashMap<>();
for (ObjectCatalog.Entry e : c.entries()) {
levels.putIfAbsent(e.level(), e.levelName() + " (" + e.level() + ")");
}
levels.forEach((k, v) -> {
source.addItem(v);
sourceKeys.add(k);
});
if (initial instanceof Integer w && w >= 1 && w <= 8) {
source.setSelectedIndex(w - 1);
} else if (initial instanceof String l && sourceKeys.contains(l)) {
source.setSelectedIndex(sourceKeys.indexOf(l));
}
source.addActionListener(e -> fill());
skipKnown.addActionListener(e -> fill());
JPanel top = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 6));
top.add(new JLabel("Source:"));
top.add(source);
top.add(skipKnown);
JButton all = new JButton("All");
all.addActionListener(e -> rows.setAll(true));
JButton none = new JButton("None");
none.addActionListener(e -> rows.setAll(false));
top.add(all);
top.add(none);
table.setRowHeight(22);
table.getColumnModel().getColumn(0).setMaxWidth(36);
table.getColumnModel().getColumn(1).setPreferredWidth(200);
table.getColumnModel().getColumn(2).setPreferredWidth(170);
table.getColumnModel().getColumn(3).setPreferredWidth(170);
table.getColumnModel().getColumn(4).setPreferredWidth(70);
table.getColumnModel().getColumn(5).setPreferredWidth(320);
rows.addTableModelListener(e -> preview.setText(rows.summary()));
JButton prev = new JButton("Preview");
prev.setToolTipText("Merge in the background and show the exact sizes against the engine limits");
prev.addActionListener(e -> preview());
importBtn.addActionListener(e -> doImport());
JButton cancel = new JButton("Cancel");
cancel.addActionListener(e -> dispose());
JPanel bottom = new JPanel(new BorderLayout());
preview.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
bottom.add(preview, BorderLayout.CENTER);
JPanel btns = new JPanel(new FlowLayout(FlowLayout.RIGHT));
btns.add(prev);
btns.add(importBtn);
btns.add(cancel);
bottom.add(btns, BorderLayout.SOUTH);
JPanel root = new JPanel(new BorderLayout());
root.add(top, BorderLayout.NORTH);
root.add(new JScrollPane(table), BorderLayout.CENTER);
root.add(bottom, BorderLayout.SOUTH);
setContentPane(root);
setSize(new Dimension(1060, 640));
setLocationRelativeTo(ctx.frame());
fill();
}
private void fill() {
ObjectCatalog c = CatalogStore.get();
Object key = sourceKeys.get(Math.max(0, source.getSelectedIndex()));
Map<String, ObjectCatalog.Entry> byName = new LinkedHashMap<>();
for (ObjectCatalog.Entry e : c.entries()) {
boolean match =
key instanceof Integer w ? e.world() == w : e.level().equals(key);
if (!match) {
continue;
}
if (skipKnown.isSelected()
&& ctx.scene() != null
&& ctx.scene().catalog().has(e.type())) {
continue;
}
byName.putIfAbsent(e.type(), e);
}
rows.set(new ArrayList<>(byName.values()));
preview.setText(rows.summary());
}
private List<ObjectCatalog.Entry> chosen() {
List<ObjectCatalog.Entry> out = new ArrayList<>();
for (int i = 0; i < rows.list.size(); i++) {
if (rows.on.get(i) && rows.list.get(i).importable()) {
out.add(rows.list.get(i));
}
}
return out;
}
private void preview() {
Document d = ctx.document();
List<ObjectCatalog.Entry> ch = chosen();
if (d == null || ch.isEmpty()) {
return;
}
List<CrossImport.TypeRef> batch = new ArrayList<>();
for (ObjectCatalog.Entry e : ch) {
if (Importer.importedCode(ctx, Importer.ref(e)) < 0) {
batch.add(Importer.ref(e));
}
}
Project p = d.project();
CrossImport.Limits before = CrossImport.limits(p.scene());
preview.setText("Merging " + batch.size() + " types…");
if (worker != null) {
worker.cancel(true);
}
worker = new SwingWorker<CrossImport.Outcome, Void>() {
@Override
protected CrossImport.Outcome doInBackground() throws Exception {
return CrossImport.compute(p.reference(), batch, d.bases());
}
@Override
protected void done() {
try {
CrossImport.Outcome o = get();
preview.setText(describe(before, o));
} catch (Exception ex) {
if (!isCancelled()) {
Throwable t = ex.getCause() != null ? ex.getCause() : ex;
preview.setText(
"<html><font color='#d93f3f'>Cannot import: " + t.getMessage() + "</font></html>");
}
}
}
};
worker.execute();
}
static String describe(CrossImport.Limits b, CrossImport.Outcome o) {
StpcMergeStats s = new StpcMergeStats(o);
CrossImport.Limits a = new CrossImport.Limits(
o.stpc().length,
b.pages() + o.pages().size(),
b.textures() + o.entries().size(),
o.smpc() == null
? b.samples()
: com.eliasmueller.llamamod.format.stpcmerge.SoundMerge.parse(o.smpc())
.size(),
b.speech(),
b.texts() + o.stats().addedTexts(),
b.heapBytes()
+ (o.stpc().length - b.stpcBytes())
+ 65536L * o.pages().size()
+ 20L * o.entries().size());
StringBuilder sb = new StringBuilder("<html>");
sb.append(String.format(
Locale.ROOT,
"Adds %d code units (%d shared with the level), %d models, %d animations, %d texture pages, %d textures, %d sounds, %d texts: +%.0f KB<br>",
s.units,
s.shared,
o.stats().models(),
o.stats().anims(),
o.pages().size(),
o.entries().size(),
o.stats().addedSamples(),
o.stats().addedTexts(),
o.stats().bytesAdded() / 1024.0));
sb.append("After: ").append(a.summary());
for (String p : a.problems()) {
sb.append("<br><font color='#d93f3f'>✗ ").append(p).append("</font>");
}
for (String w : a.warnings()) {
sb.append("<br><font color='#e0a820'>! ").append(w).append("</font>");
}
for (String w : o.warnings()) {
sb.append("<br>• ").append(w);
}
return sb.append("</html>").toString();
}
private record StpcMergeStats(int units, int shared) {
StpcMergeStats(CrossImport.Outcome o) {
this(o.stats().units(), o.stats().sharedUnits());
}
}
private void doImport() {
List<ObjectCatalog.Entry> ch = chosen();
if (ch.isEmpty()) {
return;
}
dispose();
Importer.importTypes(ctx, ch, o -> {
if (o != null) {
ctx.status("Imported " + o.codes().size() + " object types – find them in Assets → Objects. "
+ CrossImport.limits(ctx.scene()).summary());
}
});
}
private static final class Rows extends AbstractTableModel {
final List<ObjectCatalog.Entry> list = new ArrayList<>();
final List<Boolean> on = new ArrayList<>();
void set(List<ObjectCatalog.Entry> l) {
list.clear();
on.clear();
list.addAll(l);
for (ObjectCatalog.Entry e : l) {
on.add(e.importable()
&& !e.category().equals("trigger")
&& !e.category().equals("force"));
}
fireTableDataChanged();
}
void setAll(boolean v) {
for (int i = 0; i < on.size(); i++) {
on.set(i, v && list.get(i).importable());
}
fireTableDataChanged();
}
String summary() {
int n = 0;
long b = 0;
for (int i = 0; i < list.size(); i++) {
if (on.get(i)) {
n++;
b += list.get(i).bytes();
}
}
return String.format(
Locale.ROOT,
"%d of %d types selected, ≤ %.0f KB before sharing with the level (Preview for exact numbers)",
n,
list.size(),
b / 1024.0);
}
@Override
public int getRowCount() {
return list.size();
}
@Override
public int getColumnCount() {
return 6;
}
@Override
public String getColumnName(int c) {
return new String[] {"", "Type", "Category", "Level", "Size", "Notes"}[c];
}
@Override
public Class<?> getColumnClass(int c) {
return c == 0 ? Boolean.class : String.class;
}
@Override
public boolean isCellEditable(int r, int c) {
return c == 0 && list.get(r).importable();
}
@Override
public Object getValueAt(int r, int c) {
ObjectCatalog.Entry e = list.get(r);
return switch (c) {
case 0 -> on.get(r);
case 1 -> e.type();
case 2 -> ObjectCatalog.categoryLabel(e.category());
case 3 -> e.levelName();
case 4 -> e.bytes() / 1024 + " KB";
default -> e.importable() ? String.join("; ", e.notes()) : "✗ " + String.join("; ", e.reasons());
};
}
@Override
public void setValueAt(Object v, int r, int c) {
if (c == 0) {
on.set(r, Boolean.TRUE.equals(v));
fireTableRowsUpdated(r, r);
}
}
}
}
@@ -0,0 +1,136 @@
package com.eliasmueller.llamamod.editor.tools.catalog;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.StratCatalog;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.ui.Document;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.tools.EditOps;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.function.Consumer;
import org.joml.Vector3f;
/** Editor side of cross-level imports: background merge, commit, placement. */
final class Importer {
private Importer() {}
static CrossImport.TypeRef ref(ObjectCatalog.Entry e) {
return new CrossImport.TypeRef(e.level(), e.type(), e.code());
}
/** Variant index of the catalog type whose code equals the imported entry, or −1. */
static int variant(Scene s, String type, int code) {
StratCatalog.Type t = s.catalog().type(type);
if (t == null) {
return -1;
}
for (int i = 0; i < t.variants().size(); i++) {
if (t.variants().get(i).code() == code) {
return i;
}
}
return -1;
}
/** Code of an already imported type in this project, or −1. */
static int importedCode(EditorContext ctx, CrossImport.TypeRef t) {
Document d = ctx.document();
if (d == null) {
return -1;
}
java.util.Map<Integer, CrossImport.TypeRef> m =
CrossImport.codes(d.project().scene());
for (var e : m.entrySet()) {
if (e.getValue().equals(t)) {
return e.getKey();
}
}
return -1;
}
/**
* Imports the entries not yet imported (in the background), then calls done on the Swing thread
* with the outcome (null if nothing had to be imported).
*/
static void importTypes(EditorContext ctx, List<ObjectCatalog.Entry> entries, Consumer<CrossImport.Outcome> done) {
Document d = ctx.document();
if (d == null) {
return;
}
List<CrossImport.TypeRef> batch = new ArrayList<>();
for (ObjectCatalog.Entry e : entries) {
CrossImport.TypeRef t = ref(e);
if (e.importable() && importedCode(ctx, t) < 0 && !batch.contains(t)) {
batch.add(t);
}
}
if (batch.isEmpty()) {
done.accept(null);
return;
}
Project p = d.project();
String what = batch.size() == 1 ? batch.get(0).type() : batch.size() + " object types";
ctx.runTask(
"Importing " + what,
pr -> {
pr.update(0.1, "Merging " + what + " into the level");
return CrossImport.compute(p.reference(), batch, d.bases());
},
res -> {
if (!(res instanceof CrossImport.Outcome o) || ctx.document() != d) {
return;
}
CrossImport.commit(p, batch, o);
d.history().clear();
ctx.execute(new ImportMark(CrossImport.names(batch)));
CrossImport.Limits lim = CrossImport.limits(p.scene());
ctx.status(String.format(
Locale.ROOT,
"Imported %s: +%d KB code/models, +%d textures, +%d sounds, +%d texts · %s",
what,
o.stats().bytesAdded() / 1024,
o.stats().addedTextures(),
o.stats().addedSamples(),
o.stats().addedTexts(),
lim.summary()));
done.accept(o);
});
}
/** Imports (if needed) and places one instance at ground (export space). */
static void importAndPlace(EditorContext ctx, ObjectCatalog.Entry e, Vector3f ground) {
if (!e.importable()) {
ctx.status(e.type() + " cannot be imported: " + String.join("; ", e.reasons()));
return;
}
importTypes(ctx, List.of(e), o -> {
int code = importedCode(ctx, ref(e));
int v = code < 0 ? -1 : variant(ctx.scene(), e.type(), code);
if (v < 0) {
ctx.status(e.type() + ": imported, but no placeable variant found");
return;
}
EditOps.placeObject(ctx, e.type(), v, ground);
});
}
/**
* Undo-history marker for an import. Imports move code offsets and cannot be undone, so the
* history is cleared before; undoing this entry does nothing.
*/
record ImportMark(String what) implements Command {
@Override
public String label() {
return "Import " + what + " (permanent)";
}
@Override
public void apply(Scene s) {}
@Override
public void revert(Scene s) {}
}
}
@@ -0,0 +1,491 @@
package com.eliasmueller.llamamod.editor.tools.catalog;
import com.eliasmueller.llamamod.editor.io.Bases;
import com.eliasmueller.llamamod.editor.model.StratCatalog;
import com.eliasmueller.llamamod.editor.render.LevelSceneAdapter;
import com.eliasmueller.llamamod.editor.render.RenderMesh;
import com.eliasmueller.llamamod.editor.render.StratModels;
import com.eliasmueller.llamamod.editor.render.TexturePage;
import com.eliasmueller.llamamod.editor.ui.OriginalLevels;
import com.eliasmueller.llamamod.editor.ui.view.Thumbnails;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.MapChunk;
import com.eliasmueller.llamamod.format.Model;
import com.eliasmueller.llamamod.format.Strats;
import com.eliasmueller.llamamod.format.TextChunk;
import com.eliasmueller.llamamod.format.stpcmerge.StpcImage;
import com.eliasmueller.llamamod.format.stpcmerge.StpcMerge;
import com.eliasmueller.llamamod.platform.Json;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeSet;
import java.util.function.BiConsumer;
import javax.imageio.ImageIO;
/**
* Global catalogue of the placeable object types of all levels on the CD: name, source levels,
* category, dependency size, thumbnail and whether it can be imported (with reasons). Built once in
* the background and cached in {@code <data>/editor/catalog/} (catalog.json + thumbs/*.png).
*/
public final class ObjectCatalog {
static final int VERSION = 2;
public static final int THUMB = 96;
/** One type of one level (the first instance's code entry). */
public record Entry(
String level,
int world,
int chapter,
String levelName,
String type,
int code,
String category,
int instances,
int params,
boolean waypoints,
int units,
int models,
int anims,
int sounds,
int textures,
int bytes,
boolean importable,
List<String> reasons,
List<String> notes,
String thumb) {
public String key() {
return level + ":" + type + "@" + Integer.toHexString(code);
}
Map<String, Object> json() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("level", level);
m.put("world", (long) world);
m.put("chapter", (long) chapter);
m.put("levelName", levelName);
m.put("type", type);
m.put("code", (long) code);
m.put("category", category);
m.put("instances", (long) instances);
m.put("params", (long) params);
m.put("waypoints", waypoints);
m.put("units", (long) units);
m.put("models", (long) models);
m.put("anims", (long) anims);
m.put("sounds", (long) sounds);
m.put("textures", (long) textures);
m.put("bytes", (long) bytes);
m.put("importable", importable);
m.put("reasons", new ArrayList<Object>(reasons));
m.put("notes", new ArrayList<Object>(notes));
m.put("thumb", thumb == null ? "" : thumb);
return m;
}
@SuppressWarnings("unchecked")
static Entry of(Map<String, Object> m) {
return new Entry(
(String) m.get("level"),
i(m, "world"),
i(m, "chapter"),
(String) m.get("levelName"),
(String) m.get("type"),
i(m, "code"),
(String) m.get("category"),
i(m, "instances"),
i(m, "params"),
Boolean.TRUE.equals(m.get("waypoints")),
i(m, "units"),
i(m, "models"),
i(m, "anims"),
i(m, "sounds"),
i(m, "textures"),
i(m, "bytes"),
Boolean.TRUE.equals(m.get("importable")),
(List<String>) (List<?>) m.getOrDefault("reasons", List.of()),
(List<String>) (List<?>) m.getOrDefault("notes", List.of()),
(String) m.get("thumb"));
}
private static int i(Map<String, Object> m, String k) {
Object v = m.get(k);
return v instanceof Number n ? n.intValue() : 0;
}
}
private final List<Entry> entries;
private final Path dir;
private ObjectCatalog(List<Entry> entries, Path dir) {
this.entries = entries;
this.dir = dir;
}
public List<Entry> entries() {
return entries;
}
public Path thumbFile(Entry e) {
return e.thumb == null || e.thumb.isEmpty() || dir == null
? null
: dir.resolve("thumbs").resolve(e.thumb);
}
/** Cached catalogue if it matches the CD, else null. */
@SuppressWarnings("unchecked")
public static ObjectCatalog cached(Path dir, Path cd) {
try {
Path f = dir.resolve("catalog.json");
if (!Files.isRegularFile(f)) {
return null;
}
Map<String, Object> j = (Map<String, Object>) Json.parse(Files.readString(f, StandardCharsets.UTF_8));
if (((Number) j.getOrDefault("version", 0L)).intValue() != VERSION
|| !fingerprint(cd).equals(j.get("cd"))) {
return null;
}
List<Entry> out = new ArrayList<>();
for (Object o : (List<Object>) j.get("entries")) {
out.add(Entry.of((Map<String, Object>) o));
}
return new ObjectCatalog(out, dir);
} catch (IOException | RuntimeException e) {
return null;
}
}
/** Names and sizes of the CD's level WADs (cache key). */
static String fingerprint(Path cd) throws IOException {
StringBuilder sb = new StringBuilder();
for (OriginalLevels.Level l : OriginalLevels.list(cd)) {
Path p = Bases.resolveIgnoreCase(cd, "wads/" + l.base() + ".wad");
sb.append(l.base())
.append(':')
.append(p == null ? 0 : Files.size(p))
.append(';');
}
return Integer.toHexString(sb.toString().hashCode()) + ":" + sb.length();
}
/** Builds the catalogue (all levels), writes the cache; progress(done, total). */
public static ObjectCatalog build(Path cd, Path dir, BiConsumer<Integer, Integer> progress) throws IOException {
Bases bases = Bases.fromCd(cd);
List<OriginalLevels.Level> levels = new ArrayList<>(OriginalLevels.list(cd));
Files.createDirectories(dir.resolve("thumbs"));
List<Entry> out = new ArrayList<>();
int done = 0;
for (OriginalLevels.Level l : levels) {
progress.accept(done++, levels.size());
byte[] wad = bases.fileOrNull("wads/" + l.base() + ".wad");
if (wad == null) {
continue;
}
try {
scanLevel(l, wad, dir, out);
} catch (RuntimeException e) {
// front-end WADs without MAP and the like
}
}
progress.accept(levels.size(), levels.size());
Map<String, Object> j = new LinkedHashMap<>();
j.put("version", (long) VERSION);
j.put("cd", fingerprint(cd));
List<Object> es = new ArrayList<>();
out.forEach(e -> es.add(e.json()));
j.put("entries", es);
Files.writeString(dir.resolve("catalog.json"), Json.write(j), StandardCharsets.UTF_8);
return new ObjectCatalog(out, dir);
}
static void scanLevel(OriginalLevels.Level l, byte[] wad, Path dir, List<Entry> out) throws IOException {
Level lv = Level.parse(wad);
byte[] st = lv.wad().require("STPC");
StpcImage img = new StpcImage(st, lv.wfpc());
TreeSet<Integer> roots = new TreeSet<>();
Map<Integer, Integer> count = new LinkedHashMap<>();
Map<Integer, MapChunk.Strat> first = new LinkedHashMap<>();
for (MapChunk.Strat s : lv.map().strats) {
roots.add(s.code());
count.merge(s.code(), 1, Integer::sum);
first.putIfAbsent(s.code(), s);
}
StpcMerge.Analysis a = new StpcMerge.Analysis(img, roots);
byte[] text = lv.wad().require("TEXT");
TextChunk tc = TextChunk.parse(text);
List<TextChunk.TextureEntry> tex = TextChunk.textureEntries(text);
int[] pageClass = new int[tc.pages.size()];
List<TexturePage> pages = new ArrayList<>();
for (int i = 0; i < tc.pages.size(); i++) {
pageClass[i] = tc.pages.get(i).flags & 0x7f;
pages.add(LevelSceneAdapter.page(tc.pages.get(i)));
}
StratModels sm = new StratModels(st, lv.wfpc());
Map<String, Integer> seenName = new LinkedHashMap<>();
for (var e : first.entrySet()) {
int code = e.getKey();
MapChunk.Strat s = e.getValue();
String name = Strats.nameOr(st, code, img.codeStart, "?").replace("(spawn)", "");
if (seenName.containsKey(name)) {
continue; // further variants (spawn stubs) of a type: the first one represents it
}
seenName.put(name, code);
StpcMerge.Closure c = StpcMerge.closure(a, code, null);
List<String> reasons = new ArrayList<>(c.problems());
List<String> notes = new ArrayList<>();
String cat = category(name);
if (cat.equals("player")) {
reasons.add("player start: every level has its own");
}
if (cat.equals("camera") || name.startsWith("CutScene")) {
reasons.add("camera / cut scene of its level");
}
if (c.text()) {
notes.add("speaks/shows " + c.texts().size() + " texts of its level (copied with speech)");
}
if (c.tileSwap()) {
notes.add("swaps tiles of its cell (needs alternative tile models there)");
}
if (s.waypoint() >= 0) {
notes.add("uses a waypoint chain (copied relative to the object)");
}
String thumb = null;
try {
BufferedImage img2 = thumbnail(sm, code, name, tex, tc, pageClass, pages);
if (img2 != null) {
thumb = l.base() + "_" + Integer.toHexString(code) + ".png";
ByteArrayOutputStream bo = new ByteArrayOutputStream();
ImageIO.write(img2, "png", bo);
Files.write(dir.resolve("thumbs").resolve(thumb), bo.toByteArray());
}
} catch (RuntimeException ex) {
thumb = null;
}
out.add(new Entry(
l.base(),
l.world(),
l.chapter(),
l.name(),
name,
code,
cat,
count.get(code),
s.nParams(),
s.waypoint() >= 0,
c.units().size(),
c.models().size(),
c.anims().size(),
c.sounds().size(),
c.textures().size(),
c.bytes(),
reasons.isEmpty(),
reasons,
notes,
thumb));
}
}
/** Catalogue categories: the editor's ones, with "other" split by name. */
public static final String[] CATEGORIES = {
"enemy",
"collectible",
"mechanism",
"character",
"creature",
"effect",
"checkpoint",
"exit",
"force",
"trigger",
"camera",
"player",
"other"
};
public static String categoryLabel(String c) {
return switch (c) {
case "enemy" -> "Enemies";
case "collectible" -> "Collectibles & pickups";
case "mechanism" -> "Platforms, doors & mechanisms";
case "character" -> "Characters";
case "creature" -> "Animals";
case "effect" -> "Effects & ambience";
case "checkpoint" -> "Checkpoints";
case "exit" -> "Exits";
case "force" -> "Forces & currents";
case "trigger" -> "Triggers & dialogue";
case "camera" -> "Cameras & cut scenes";
case "player" -> "Player & start";
default -> "Other";
};
}
private static final String[][] WORDS = {
{
"enemy",
"Guard",
"Spider",
"Beetle",
"Scorpion",
"Bat",
"Monkey",
"Croc",
"Snake",
"Wasp",
"Bee",
"Dart",
"Blade",
"Boulder",
"Gorilla",
"Warthog",
"Lizard",
"Jaguar",
"Piranha",
"Needle",
"Cannon",
"Hammer"
},
{"collectible", "Grapes", "Banana", "Fruit", "Potion", "Idol", "Booster", "Pot", "Jar", "Box", "Pinata"},
{
"mechanism",
"Platform",
"Door",
"Lift",
"Bridge",
"Switch",
"Pad",
"Column",
"Statue",
"Log",
"Gate",
"Lever",
"Wall",
"Block",
"Treadmill",
"MerryGoRound",
"Rope",
"Pole",
"Tree",
"Rock",
"Wheel",
"Puzzle",
"Sinking",
"Falling",
"Rising",
"Mouth",
"Timer",
"Pump",
"Seesaw",
"Spring",
"Trampoline",
"Elevator"
},
{
"character",
"Pacha",
"Tipo",
"Chaca",
"Kid",
"Yzma",
"Kronk",
"Bucky",
"Villager",
"Folk",
"Kusco",
"Kuzco",
"Man",
"Girl",
"Naked",
"General",
"Herder",
"Condor",
"Blockhead"
},
{"creature", "Bird", "Bunny", "Butterfly", "Moth", "Ladybird", "Fly", "Frog", "Turtle", "Squirrel", "Wompy"},
{
"effect",
"Mist",
"Rain",
"Water",
"Firework",
"Sparkle",
"Smoke",
"Dripping",
"Fire",
"Sound",
"Steam",
"Light"
}
};
public static String category(String name) {
String c = StratCatalog.category(name);
if (!c.equals("other")) {
return c;
}
for (String[] w : WORDS) {
for (int i = 1; i < w.length; i++) {
if (name.contains(w[i])) {
return w[0];
}
}
}
return "other";
}
static BufferedImage thumbnail(
StratModels sm,
int code,
String name,
List<TextChunk.TextureEntry> tex,
TextChunk tc,
int[] pageClass,
List<TexturePage> pages) {
int mi = sm.modelOf(code);
if (mi < 0) {
return null;
}
Model m = sm.models.get(mi);
if (StratModels.skinned(m)) {
m = sm.posed(code, mi);
}
if (m == null || m.faces.isEmpty()) {
return null;
}
RenderMesh rm = LevelSceneAdapter.mesh(name, m, tex, tc, pageClass, RenderMesh.LIT);
return Thumbnails.render(rm, pages, THUMB);
}
/** Entries matching a query (name or level, case-insensitive), world (0 = all) and category (null = all). */
public List<Entry> filter(String q, int world, String category, boolean importableOnly) {
String ql = q == null ? "" : q.trim().toLowerCase(Locale.ROOT);
List<Entry> out = new ArrayList<>();
for (Entry e : entries) {
if (world > 0 && e.world != world) {
continue;
}
if (category != null && !category.equals(e.category)) {
continue;
}
if (importableOnly && !e.importable) {
continue;
}
if (!ql.isEmpty()
&& !e.type.toLowerCase(Locale.ROOT).contains(ql)
&& !e.level.contains(ql)
&& !e.levelName.toLowerCase(Locale.ROOT).contains(ql)) {
continue;
}
out.add(e);
}
return out;
}
}
@@ -0,0 +1,101 @@
package com.eliasmueller.llamamod.editor.tools.environment;
import com.eliasmueller.llamamod.editor.model.LevelSettings;
import java.util.List;
/** Lighting presets for the bake tool, plus the fog/ambient colours that go with them. */
public final class BakePresets {
private BakePresets() {}
/**
* @param fogBgr zone fog/backdrop colour (0x00BBGGRR)
* @param ambientBgr zone ambient colour for objects (0x00BBGGRR)
*/
public record Preset(String name, VertexBake.Rig rig, int fogBgr, int ambientBgr) {
@Override
public String toString() {
return name;
}
}
private static double[] c(double r, double g, double b) {
return new double[] {r, g, b};
}
private static int bgr(int r, int g, int b) {
return r | g << 8 | b << 16;
}
public static final List<Preset> ALL = List.of(
new Preset(
"Day",
new VertexBake.Rig(
215,
50,
0.62,
c(1.0, 0.93, 0.80),
c(0.50, 0.55, 0.66),
c(0.30, 0.28, 0.24),
0.55,
1.3,
0.35,
false),
bgr(0xa8, 0xc8, 0xe8),
bgr(0x40, 0x40, 0x40)),
new Preset(
"Dusk",
new VertexBake.Rig(
255,
14,
0.70,
c(1.0, 0.58, 0.32),
c(0.36, 0.30, 0.44),
c(0.22, 0.16, 0.15),
0.55,
1.3,
0.30,
true),
bgr(0xc0, 0x70, 0x50),
bgr(0x40, 0x30, 0x30)),
new Preset(
"Night",
new VertexBake.Rig(
140,
45,
0.28,
c(0.55, 0.65, 1.0),
c(0.13, 0.16, 0.30),
c(0.06, 0.06, 0.10),
0.6,
1.3,
0.25,
true),
bgr(0x08, 0x0c, 0x24),
bgr(0x18, 0x1c, 0x30)),
new Preset(
"Cave",
new VertexBake.Rig(
0, 80, 0.0, c(1, 1, 1), c(0.34, 0.30, 0.25), c(0.20, 0.17, 0.14), 0.85, 2.0, 0.0, true),
bgr(0x10, 0x0c, 0x08),
bgr(0x30, 0x2c, 0x28)),
new Preset(
"Flat (no lighting)",
new VertexBake.Rig(0, 90, 0.0, c(1, 1, 1), c(1, 1, 1), c(1, 1, 1), 0.0, 1.0, 1.0, false),
bgr(0xa8, 0xc8, 0xe8),
bgr(0x40, 0x40, 0x40)));
/** The level's own sun (LevelSettings "Lighting" group, used for brushes). */
public static VertexBake.Rig fromSettings(LevelSettings s, boolean points) {
return new VertexBake.Rig(
s.sunAzimuth(),
s.sunElevation(),
s.sunStrength(),
s.sunColor(),
s.sky(),
s.ground(),
s.ao(),
s.aoRadius(),
s.shadow(),
points);
}
}
@@ -0,0 +1,168 @@
package com.eliasmueller.llamamod.editor.tools.environment;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.SceneEvent;
import com.eliasmueller.llamamod.editor.model.SceneListener;
import com.eliasmueller.llamamod.editor.model.cmd.SetProp;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import java.awt.Color;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.DoubleFunction;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.function.ToDoubleFunction;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JSpinner;
import javax.swing.SwingUtilities;
/**
* Binds form widgets to node properties: edits go through undoable {@link SetProp}s (one merge key
* per widget, so repeated spinner clicks are one undo step) and the widgets follow scene changes
* (undo, gizmo drags) while the form is shown.
*/
final class Binder {
private final EditorContext ctx;
private final Node node;
private final List<Runnable> refreshers = new ArrayList<>();
private boolean updating;
private final SceneListener listener;
private boolean alive = true;
Binder(EditorContext ctx, Node node) {
this.ctx = ctx;
this.node = node;
listener = this::changed;
ctx.addSceneListener(listener);
}
private void changed(SceneEvent e) {
if (!alive || e.node() != node || updating) {
return;
}
SwingUtilities.invokeLater(this::refresh);
}
/** Stops following the scene (the shell rebuilds sections on every selection change). */
void attach(JComponent root) {
boolean[] shown = {false};
root.addHierarchyListener(e -> {
if (root.isDisplayable()) {
shown[0] = true;
} else if (shown[0]) {
alive = false;
}
});
}
void refresh() {
updating = true;
try {
for (Runnable r : refreshers) {
r.run();
}
} finally {
updating = false;
}
}
void onRefresh(Runnable r) {
refreshers.add(r);
}
/** Runs r now and on every refresh. */
void live(Runnable r) {
r.run();
onRefresh(r);
}
/** Label whose text follows the node. */
JLabel label(Supplier<String> text) {
JLabel l = new JLabel();
live(() -> l.setText(text.get()));
return l;
}
void set(String prop, Object value, Object mergeKey) {
if (updating) {
return;
}
Object old = node.get(prop);
if (java.util.Objects.equals(old, value)) {
return;
}
updating = true;
try {
ctx.execute(SetProp.of(node, node.prop(prop), value, mergeKey));
} finally {
updating = false;
}
SwingUtilities.invokeLater(this::refresh);
}
/** Spinner showing f(node) in display units; writes toProp(display value). */
JSpinner number(
double min,
double max,
double step,
String pattern,
ToDoubleFunction<Node> get,
DoubleFunction<Object> toProp,
String prop) {
Object key = new Object();
JSpinner[] sp = new JSpinner[1];
sp[0] = Ui.number(get.applyAsDouble(node), min, max, step, pattern, v -> {
Object pv = toProp.apply(v);
if (!updating && pv != null) {
set(prop, pv, key);
}
});
onRefresh(() -> sp[0].setValue(Ui.clamp(get.applyAsDouble(node), min, max)));
return sp[0];
}
/** Generic custom edit: run with this binder's echo guard. */
void edit(Runnable r) {
if (!updating) {
r.run();
}
}
JCheckBox check(String label, Function<Node, Boolean> get, Consumer<Boolean> set) {
JCheckBox c = new JCheckBox(label, get.apply(node));
c.addActionListener(e -> {
if (!updating) {
set.accept(c.isSelected());
}
});
onRefresh(() -> c.setSelected(get.apply(node)));
return c;
}
<T> JComboBox<T> combo(T[] items, Function<Node, Integer> index, Consumer<Integer> set) {
JComboBox<T> c = new JComboBox<>(items);
c.setMaximumRowCount(20);
int i = index.apply(node);
c.setSelectedIndex(i >= 0 && i < items.length ? i : -1);
c.addActionListener(e -> {
if (!updating && c.getSelectedIndex() >= 0) {
set.accept(c.getSelectedIndex());
}
});
onRefresh(() -> {
int k = index.apply(node);
c.setSelectedIndex(k >= 0 && k < items.length ? k : -1);
});
return c;
}
Ui.ColorButton color(String title, Function<Node, Color> get, java.util.function.BiConsumer<Color, Object> set) {
Ui.ColorButton b = new Ui.ColorButton(title, () -> get.apply(node), set);
onRefresh(b::refresh);
return b;
}
}
@@ -0,0 +1,242 @@
package com.eliasmueller.llamamod.editor.tools.environment;
import com.eliasmueller.llamamod.editor.model.EnvZone;
import com.eliasmueller.llamamod.editor.model.Light;
import com.eliasmueller.llamamod.editor.model.Regions;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.model.TileModel;
import com.eliasmueller.llamamod.editor.ui.LevelCheck;
import com.eliasmueller.llamamod.editor.ui.Problem;
import java.util.List;
import java.util.Locale;
/** Problems checks for zones/fog, visibility regions, vertex lighting and lights. */
final class EnvChecks implements LevelCheck {
/** Fog end below this (cells, detail Maximum) makes the level a fog wall. */
static final double FOG_NEAR = 3;
/** Above this the engine draws and wakes up a lot (the originals stay ≤ 23). */
static final double FOG_FAR = 30;
@Override
public void check(Scene s, List<Problem> out) {
zones(s, out);
regions(s, out);
unlit(s, out);
lights(s, out);
}
static void zones(Scene s, List<Problem> out) {
int w = s.settings().width(), h = s.settings().height();
int starts = 0;
for (EnvZone z : s.zones()) {
if (EnvMath.isStart(z)) {
starts++;
}
}
if (!s.zones().isEmpty() && starts == 0) {
out.add(Problem.warning(
"No start zone (zone flag 8): the level starts without its fog, colours and music settings",
s.zones().getFirst()));
} else if (starts > 1) {
out.add(Problem.warning(starts + " zones are marked as start zone; the last one wins", null));
}
for (EnvZone z : s.zones()) {
String name = EnvMath.isStart(z) ? "Start zone" : "Zone " + z.cellX() + "," + z.cellZ();
double[] hi = EnvMath.fog(z, EnvMath.DETAIL_MAX);
double[] lo = EnvMath.fog(z, EnvMath.DETAIL_MIN);
if (z.fogMode() != 0 || EnvMath.isStart(z)) {
if (hi[1] < FOG_NEAR) {
out.add(Problem.warning(
String.format(
Locale.ROOT,
"%s: fog ends after %.1f cells even at detail Maximum (%.1f at Minimum) – a wall of"
+ " fog, objects pop in right in front of the player",
name,
hi[1],
lo[1]),
z));
} else if (hi[1] > FOG_FAR) {
out.add(Problem.warning(
String.format(
Locale.ROOT,
"%s: draw distance %.1f cells at detail Maximum – slow, and enemies wake up from"
+ " far away (the original levels use 7–23)",
name,
hi[1]),
z));
}
if (hi[0] >= hi[1] || lo[0] >= lo[1]) {
out.add(Problem.warning(name + ": fog start is not before fog end", z));
}
}
if (EnvMath.isStart(z)) {
continue;
}
boolean cellIn = z.cellX() >= 0 && z.cellZ() >= 0 && z.cellX() < w && z.cellZ() < h;
if (!cellIn) {
out.add(Problem.warning(
name + ": trigger cell outside the " + w + "×" + h + " grid, never entered", z));
}
if (EnvMath.teleports(z)) {
if (z.targetX() < 0 || z.targetZ() < 0 || z.targetX() >= w || z.targetZ() >= h) {
out.add(Problem.error(
String.format(
Locale.ROOT,
"%s: teleport target %.1f, %.1f is outside the grid",
name,
z.targetX(),
z.targetZ()),
z));
}
if (cellIn && !hasExitFloor(s, z.cellX(), z.cellZ())) {
out.add(Problem.info(
name + ": no exit floor (collision material 16) or door object in the trigger cell – the"
+ " teleport only fires when the player steps on an exit floor",
z));
}
}
}
}
static boolean hasExitFloor(Scene s, int cx, int cz) {
for (Tile t : s.tiles()) {
if (t.cellX() != cx || t.cellZ() != cz) {
continue;
}
if (t.swapHasMaterial(16)) {
return true;
}
TileModel m = t.model();
if (m != null) {
for (int g = 0; g < 3; g++) {
for (int k = 0; k < m.collisionCount(g); k++) {
if (m.collisionMaterial(g, k) == 16) {
return true;
}
}
}
}
}
for (Strat st : s.strats()) {
if ((int) Math.floor(st.gx()) == cx && (int) Math.floor(st.gz()) == cz) {
return true;
}
}
return !s.brushes().isEmpty();
}
static void regions(Scene s, List<Problem> out) {
Regions r = s.regions();
int w = s.settings().width(), h = s.settings().height();
if (r.grid().length != w * h || r.table().length < 2 * Regions.COUNT) {
return;
}
Strat p = s.playerStart();
if (p != null) {
int cx = (int) Math.floor(p.gx()), cz = (int) Math.floor(p.gz());
int reg = r.regionAt(cx, cz, w);
if (reg >= 0 && r.mask(reg) != 0 && (r.mask(reg) & (1 << reg)) == 0) {
out.add(Problem.error(
String.format(
Locale.ROOT,
"The player start (cell %d, %d) is in visibility region %d whose mask does not include"
+ " region %d itself – the ground and the llama would be invisible",
cx,
cz,
reg,
reg),
p));
}
}
boolean[] used = RegionGen.used(r.grid());
long[] tris = RegionGen.trianglesPerRegion(s, r.grid());
int seen = 0;
for (int a = 0; a < Regions.COUNT; a++) {
if (used[a]) {
seen |= r.mask(a) == 0 ? -1 : r.mask(a);
}
}
for (int b = 0; b < Regions.COUNT; b++) {
if (used[b] && tris[b] > 0 && (seen & (1 << b)) == 0) {
out.add(Problem.warning(
"Visibility region " + b + " (" + tris[b] + " triangles) is not drawn from any region – its"
+ " geometry is never visible",
r));
}
}
long[] vis = RegionGen.visibleTriangles(s, r.grid(), r.table());
for (int a = 0; a < Regions.COUNT; a++) {
if (used[a] && vis[a] > RegionGen.HEAVY) {
out.add(Problem.info(
String.format(
Locale.ROOT,
"Visibility region %d draws %,d triangles (heavy for the original engine; the fog"
+ " distance limits it further)",
a,
vis[a]),
r));
break;
}
}
}
static void unlit(Scene s, List<Problem> out) {
int grey = 0, black = 0;
Tile first = null;
for (Tile t : s.tiles()) {
TileModel m = t.model();
if (m == null || m.faceCount() == 0) {
continue;
}
int nv = m.vertexCount();
int[] c = t.colors();
if (c.length < nv || nv == 0) {
continue;
}
boolean allGrey = true, allBlack = true;
for (int i = 0; i < nv; i++) {
int v = c[i] & 0xffffff;
allGrey &= v == 0x808080;
allBlack &= v == 0;
}
if (allGrey || allBlack) {
if (first == null) {
first = t;
}
if (allGrey) {
grey++;
} else {
black++;
}
}
}
if (grey + black > 0) {
out.add(Problem.warning(
(grey > 0 ? grey + " tiles have flat grey (unlit) vertex colours" : "")
+ (grey > 0 && black > 0 ? ", " : "")
+ (black > 0 ? black + " tiles are completely black" : "")
+ " – use Environment → Lighting → Bake lighting",
first));
}
}
static void lights(Scene s, List<Problem> out) {
for (Light l : s.lights()) {
if (l.type() == Light.POINT || l.type() == Light.POINT_BRIGHT) {
if (l.rangeB() <= 0) {
out.add(Problem.warning(l.label() + ": falloff radius 0, the light has no effect", l));
} else if (l.rangeB() < l.a()) {
out.add(Problem.info(l.label() + ": falloff radius smaller than the full radius", l));
}
} else if (l.type() != Light.DIRECTIONAL) {
out.add(Problem.info(l.label() + ": type " + l.type() + " is ignored by the engine", l));
}
if (l.r() == 0 && l.g() == 0 && l.b() == 0) {
out.add(Problem.info(l.label() + ": colour is black (no effect)", l));
}
}
}
}
@@ -0,0 +1,325 @@
package com.eliasmueller.llamamod.editor.tools.environment;
import com.eliasmueller.llamamod.editor.model.EnvZone;
import com.eliasmueller.llamamod.editor.model.Light;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.render.Legend;
import java.awt.Color;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
/**
* Engine rules for zones, fog and lights (groove.exe), shared by the panels, the previews and the
* checks. Zone colours are stored as 0x00BBGGRR, tile vertex colours as R | G<<8 | B<<16 | frames<<24
* with 0x80 = 1.0.
*/
public final class EnvMath {
private EnvMath() {}
// ------------------------------------------------------------ zone fields
public static final int F_CELL_X = 0;
public static final int F_CELL_Y = 1;
public static final int F_CELL_Z = 2;
public static final int F_X = 3;
public static final int F_Y = 4;
public static final int F_Z = 5;
public static final int F_ANGLE = 7;
public static final int F_FLAGS = 8;
public static final int F_SKY = 10;
public static final int F_SKY_YAW = 11;
public static final int F_MODE = 13;
public static final int F_PUSH = 14;
public static final int F_NEAR_LO = 15;
public static final int F_FAR_LO = 16;
public static final int F_NEAR_HI = 17;
public static final int F_FAR_HI = 18;
public static final int F_AMBIENT = 19;
public static final int F_FOG = 20;
public static final int F_MUSIC = 23;
/** flags (+0x20) bit 2: only switch environment, do not move the player (FUN_0054de00). */
public static final int FLAG_NO_TELEPORT = 4;
/** flags bit 3: zone active at level start (loader FUN_0042ac50 → DAT_00584644). */
public static final int FLAG_START = 8;
/** angleFlags bit 0: trigger also one cell below; bit 1: one cell above + no forward step. */
public static final int ANGLE_Y_BELOW = 1;
public static final int ANGLE_Y_ABOVE = 2;
/** Fog modes (zone +0x34 → DAT_005865cc, FUN_00438180). */
public static final String[] FOG_MODES = {
"0 – no fog",
"1 – darkness, light around the player (radius = fog start…end)",
"2 – fog (table)",
"3 – fog (vertex, most levels)"
};
public static boolean isStart(EnvZone z) {
return (z.field(F_FLAGS) & FLAG_START) != 0;
}
public static boolean teleports(EnvZone z) {
return (z.field(F_FLAGS) & FLAG_NO_TELEPORT) == 0 && !isStart(z);
}
/** The zone the level starts with (flag 8; else the (1,1,1) default entry, else the first). */
public static EnvZone startZone(Scene s) {
for (EnvZone z : s.zones()) {
if (isStart(z)) {
return z;
}
}
for (EnvZone z : s.zones()) {
if (z.isDefault()) {
return z;
}
}
return s.zones().isEmpty() ? null : s.zones().getFirst();
}
public static String describe(EnvZone z) {
if (isStart(z)) {
return "Start zone (level default)";
}
String cell = z.cellX() + "," + z.cellZ();
if (!teleports(z)) {
return "Environment " + cell;
}
return String.format(Locale.ROOT, "Teleport %s → %.1f, %.1f", cell, z.targetX(), z.targetZ());
}
/** Facing at the target in degrees (0 = +z, 90 = +x), from bits 4..11 of the angle field. */
public static double facingDegrees(EnvZone z) {
return (z.field(F_ANGLE) & 0xff0) * 360.0 / 4096;
}
public static int withFacing(int angleField, double degrees) {
int a = (int) Math.round(((degrees % 360 + 360) % 360) * 4096 / 360.0) & 0xff0;
return (angleField & ~0xff0) | a;
}
// ------------------------------------------------------------ fog
/** Detail level value t of the game's detail setting (0x5848cc). */
public static final double DETAIL_MIN = 0;
public static final double DETAIL_MED = 0.5;
public static final double DETAIL_MAX = 1;
/**
* Fog start and end (cells) at detail t, as FUN_0041a480 computes them: the low pair belongs to
* detail Minimum, the high pair to Maximum (if high start ≤ low start the loader uses 1.5 × the
* low pair), interpolated and then scaled by t + (1 − t)·0.5.
*/
public static double[] fog(EnvZone z, double t) {
double ns = z.field(F_NEAR_LO), fs = z.field(F_FAR_LO);
double nh = z.field(F_NEAR_HI), fh = z.field(F_FAR_HI);
if (nh <= ns) {
nh = ns + Math.floor(ns / 2);
fh = fs + Math.floor(fs / 2);
}
double k = t + (1 - t) * 0.5;
double start = (ns + (nh - ns) * t) * k / 4096;
double end = (fs + (fh - fs) * t) * k / 4096;
return new double[] {start, end};
}
public static Color zoneColor(int bgr) {
return new Color(bgr & 0xff, (bgr >> 8) & 0xff, (bgr >> 16) & 0xff);
}
public static int zoneBgr(Color c) {
return c.getRed() | c.getGreen() << 8 | c.getBlue() << 16;
}
/** 0x00BBGGRR → 0xRRGGBB. */
public static int bgrToRgb(int c) {
return (c & 0xff) << 16 | (c & 0xff00) | (c >>> 16) & 0xff;
}
// ------------------------------------------------------------ music
/**
* Music ids: table at 0x578960 in groove.exe (20 bytes per id, name pointer + 8), played from
* {@code music/<name>.ASF} (FUN_00546b70). 0 stops the music.
*/
public static final String[] MUSIC = {
"(none)",
"VILLAGE",
"JUNGLE1",
"RIVER",
"JUNGLE2",
"MOUNTAIN",
"CITY",
"CATACOMB",
"LAB",
"TITLE",
"GAMEOVER",
"TALLY",
"TURRACE",
"PACHASNK",
"MOUNT2",
"BALLOON",
"JUNGSPID",
"JAGCHASE",
"RIVER",
"RIVRACE",
"SNKJUNG",
"FROG",
"KRONK",
"KIDLLAMA",
"TUNE8",
"ROLLER",
"BUNNY",
"BLOCK",
"TUNE5",
"CITYPUZZ",
"TUNE1",
"CHACHA",
"JJUNGLE2",
"TUNE9",
"TUNE6",
"VICTORY",
"PUZZLE2",
"PLATFORM",
"(none)",
"ENDGAME"
};
public static String musicLabel(int id) {
return id >= 0 && id < MUSIC.length ? id + " – " + MUSIC[id] : id + " – (unknown)";
}
// ------------------------------------------------------------ lights
/** Light colour as shown (engine factor ×2/255, clamped for display). */
public static Color lightColor(Light l) {
return new Color(Math.min(255, 2 * l.r()), Math.min(255, 2 * l.g()), Math.min(255, 2 * l.b()));
}
/** Engine colour factor of a light (type 4 negative). */
public static double[] lightFactor(Light l) {
double[] c = {2.0 * l.r() / 255, 2.0 * l.g() / 255, 2.0 * l.b() / 255};
if (l.type() == Light.POINT_BRIGHT) {
for (int k = 0; k < 3; k++) {
c[k] = -(c[k] + 1) * 0.5;
}
}
return c;
}
/** Direction of a directional light (game space, towards the light), normalised. */
public static double[] direction(Light l) {
double x = l.x(), y = l.y(), z = l.z();
double len = Math.sqrt(x * x + y * y + z * z);
return len < 1e-9 ? new double[] {0, 1, 0} : new double[] {x / len, y / len, z / len};
}
/** Lights the engine applies to an object at p (game space): all directional, nearest points, max 6. */
public static List<Light> lightsFor(Scene s, double px, double py, double pz) {
List<Light> out = new ArrayList<>();
List<Light> points = new ArrayList<>();
for (Light l : s.lights()) {
if (l.type() == Light.DIRECTIONAL) {
if (out.size() < 6) {
out.add(l);
}
} else if (l.type() == Light.POINT || l.type() == Light.POINT_BRIGHT) {
points.add(l);
}
}
points.sort(Comparator.comparingDouble(l -> sq(l.x() - px) + sq(l.y() - py) + sq(l.z() - pz)));
for (Light l : points) {
if (out.size() >= 6) {
break;
}
out.add(l);
}
return out;
}
/**
* Light at a vertex (FUN_0041ba10): ambient + Σ colour·max(0, n·L), point lights with full
* strength up to radius a and a linear falloff to b. Game space; result 1.0 = texture colour.
*/
public static double[] shade(double[] ambient, List<Light> lights, double[] p, double[] n) {
double[] c = ambient.clone();
for (Light l : lights) {
double f;
if (l.type() == Light.DIRECTIONAL) {
double[] d = direction(l);
f = d[0] * n[0] + d[1] * n[1] + d[2] * n[2];
} else {
double dx = l.x() - p[0], dy = l.y() - p[1], dz = l.z() - p[2];
double d2 = dx * dx + dy * dy + dz * dz;
if (d2 > l.rangeB() * l.rangeB() || d2 < 1e-12) {
continue;
}
double d = Math.sqrt(d2);
double cos = (dx * n[0] + dy * n[1] + dz * n[2]) / d;
if (cos < 0) {
continue;
}
f = cos;
if (d2 > l.a() * l.a() && l.rangeB() > l.a()) {
f *= (l.rangeB() - d) / (l.rangeB() - l.a());
}
}
if (f <= 0) {
continue;
}
double[] lc = lightFactor(l);
for (int k = 0; k < 3; k++) {
c[k] += f * lc[k];
}
}
return c;
}
/** Ambient of a zone for objects (FUN_0041bcf0: byte / 255). */
public static double[] ambient(EnvZone z) {
int a = z == null ? 0x404040 : z.field(F_AMBIENT);
return new double[] {(a & 0xff) / 255.0, ((a >> 8) & 0xff) / 255.0, ((a >> 16) & 0xff) / 255.0};
}
// ------------------------------------------------------------ misc
/** Cells → 1/4096 fixed point (zone fields). */
static int fixed(double cells) {
return (int) Math.round(cells * 4096);
}
/** Unit vector towards a light at azimuth/elevation (degrees; 0 = +z, 90 = +x). */
static double[] directionFrom(double azDeg, double elDeg) {
double az = Math.toRadians(azDeg), el = Math.toRadians(elDeg);
return new double[] {Math.cos(el) * Math.sin(az), Math.sin(el), Math.cos(el) * Math.cos(az)};
}
static double sq(double v) {
return v * v;
}
/** Colour of a visibility region (as in the shell's region layer). */
public static Color regionColor(int r) {
return new Color(Legend.regionRgb(r));
}
/** Tile colour word → RGB (0x80 = 1.0 kept as stored, i.e. half brightness). */
public static int[] unpack(int w) {
return new int[] {w & 0xff, (w >> 8) & 0xff, (w >> 16) & 0xff};
}
/** RGB 0..255 (0x80 = 1.0) into a colour word, keeping byte 3 (frame count) of {@code keep}. */
public static int pack(int r, int g, int b, int keep) {
return clamp(r) | clamp(g) << 8 | clamp(b) << 16 | (keep & 0xff000000);
}
static int clamp(int v) {
return Math.max(0, Math.min(255, v));
}
}
@@ -0,0 +1,218 @@
package com.eliasmueller.llamamod.editor.tools.environment;
import com.eliasmueller.llamamod.editor.model.EnvZone;
import com.eliasmueller.llamamod.editor.model.Light;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.SceneEvent;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.render.Marker;
import com.eliasmueller.llamamod.editor.render.Overlay;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.RenderContributor;
import java.awt.Color;
/** Viewport helpers: zone targets with facing arrows, light direction arrows and radius spheres. */
final class EnvOverlays {
private EnvOverlays() {}
static final String LAYER_ZONES = "envZoneHandles";
static final String LAYER_LIGHTS = "envLightGizmos";
static int rgba(Color c, int a) {
return c.getRed() << 24 | c.getGreen() << 16 | c.getBlue() << 8 | (a & 0xff);
}
static final Color ZONE = new Color(0xE0, 0x5A, 0xA0);
static final Color TARGET = new Color(0x30, 0xD0, 0xFF);
/** Circle in the plane spanned by unit vectors u, v around c (export space). */
static void circle(Overlay o, double[] c, double r, double[] u, double[] v, int rgba) {
int n = 40;
double[] prev = null;
for (int i = 0; i <= n; i++) {
double a = 2 * Math.PI * i / n;
double[] p = {
c[0] + r * (Math.cos(a) * u[0] + Math.sin(a) * v[0]),
c[1] + r * (Math.cos(a) * u[1] + Math.sin(a) * v[1]),
c[2] + r * (Math.cos(a) * u[2] + Math.sin(a) * v[2])
};
if (prev != null) {
o.line(prev, p, rgba);
}
prev = p;
}
}
/** Horizontal circle (export space). */
static void ring(Overlay o, double[] c, double r, int rgba) {
circle(o, c, r, new double[] {1, 0, 0}, new double[] {0, 0, 1}, rgba);
}
static void sphere(Overlay o, double[] c, double r, int rgba) {
ring(o, c, r, rgba);
circle(o, c, r, new double[] {1, 0, 0}, new double[] {0, 1, 0}, rgba);
circle(o, c, r, new double[] {0, 0, 1}, new double[] {0, 1, 0}, rgba);
}
/** Arrow from a to b with a head at b (export space). */
static void arrow(Overlay o, double[] a, double[] b, double head, int rgba) {
o.line(a, b, rgba);
double dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
double len = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (len < 1e-6) {
return;
}
dx /= len;
dy /= len;
dz /= len;
double[] side = Math.abs(dy) > 0.9 ? new double[] {1, 0, 0} : new double[] {-dz, 0, dx};
double sl = Math.sqrt(side[0] * side[0] + side[1] * side[1] + side[2] * side[2]);
for (int s = -1; s <= 1; s += 2) {
double[] h = {
b[0] - dx * head + s * side[0] / sl * head * 0.5,
b[1] - dy * head + s * side[1] / sl * head * 0.5,
b[2] - dz * head + s * side[2] / sl * head * 0.5
};
o.line(b, h, rgba);
}
}
/** Anchor of directional light arrows: above the level centre (the shell's light markers). */
static double[] anchor(Scene s) {
return new double[] {s.settings().width() / 2.0, 8, -s.settings().height() / 2.0};
}
static final RenderContributor ZONES = new RenderContributor() {
@Override
public String layer() {
return LAYER_ZONES;
}
@Override
public boolean affectedBy(SceneEvent e) {
return e.node() instanceof EnvZone || e.node() instanceof Tile && e.type() != SceneEvent.Type.CHANGED;
}
@Override
public boolean followsSelection() {
return true;
}
@Override
public void build(EditorContext ctx, Builder b) {
Scene s = ctx.scene();
Overlay o = b.overlay();
for (EnvZone z : s.zones()) {
if (EnvMath.isStart(z) || ctx.isHidden(z)) {
continue;
}
boolean sel = ctx.selection().contains(z);
int a = sel ? 255 : 120;
double cx = z.cellX(), cy = z.cellY(), cz = -z.cellZ();
if (sel) {
int fill = rgba(ZONE, 70);
double y0 = cy + 0.02;
o.triangle(
new double[] {cx, y0, cz},
new double[] {cx + 1, y0, cz},
new double[] {cx + 1, y0, cz - 1},
fill);
o.triangle(
new double[] {cx, y0, cz},
new double[] {cx + 1, y0, cz - 1},
new double[] {cx, y0, cz - 1},
fill);
o.box(cx, cy, cz - 1, cx + 1, cy + 1, cz, rgba(ZONE, 255));
}
if (!EnvMath.teleports(z)) {
continue;
}
double tx = z.targetX(), ty = z.targetY(), tz = -z.targetZ();
double f = Math.toRadians(EnvMath.facingDegrees(z));
double[] t0 = {tx, ty + 0.1, tz};
double[] t1 = {tx + Math.sin(f) * 0.8, ty + 0.1, tz - Math.cos(f) * 0.8};
arrow(o, t0, t1, 0.25, rgba(TARGET, a));
ring(o, new double[] {tx, ty + 0.02, tz}, 0.3, rgba(TARGET, a));
if (sel) {
double[] from = {cx + 0.5, cy + 0.5, cz - 0.5};
double[] mid = {(from[0] + tx) / 2, Math.max(from[1], ty) + 1.5, (from[2] + tz) / 2};
double[] prev = from;
for (int i = 1; i <= 16; i++) {
double u = i / 16.0;
double[] p = {
(1 - u) * (1 - u) * from[0] + 2 * u * (1 - u) * mid[0] + u * u * tx,
(1 - u) * (1 - u) * from[1] + 2 * u * (1 - u) * mid[1] + u * u * (ty + 0.1),
(1 - u) * (1 - u) * from[2] + 2 * u * (1 - u) * mid[2] + u * u * tz
};
o.line(prev, p, rgba(TARGET, 230));
prev = p;
}
}
Marker m = b.marker(z, (float) tx, (float) ty + 0.1f, (float) tz, rgba(TARGET, 255));
m.shape = Marker.Shape.DIAMOND;
m.size = 0.09f;
m.label = "target of " + z.label();
}
}
};
static final RenderContributor LIGHTS = new RenderContributor() {
@Override
public String layer() {
return LAYER_LIGHTS;
}
@Override
public boolean affectedBy(SceneEvent e) {
return e.node() instanceof Light;
}
@Override
public boolean followsSelection() {
return true;
}
@Override
public void build(EditorContext ctx, Builder b) {
Scene s = ctx.scene();
Overlay o = b.overlay();
double[] an = anchor(s);
for (Light l : s.lights()) {
if (ctx.isHidden(l)) {
continue;
}
boolean sel = ctx.selection().contains(l);
Color c = EnvMath.lightColor(l);
if (c.getRed() + c.getGreen() + c.getBlue() < 90) {
c = new Color(200, 200, 200);
}
if (l.type() == Light.DIRECTIONAL) {
double[] d = EnvMath.direction(l);
double len = sel ? 4 : 2.5;
double[] from = {an[0] + d[0] * len, an[1] + d[1] * len, an[2] - d[2] * len};
arrow(o, from, an, sel ? 0.5 : 0.3, rgba(c, sel ? 255 : 170));
if (sel) {
arrow(
o,
new double[] {from[0] + 0.3, from[1], from[2]},
new double[] {an[0] + 0.3, an[1], an[2]},
0.4,
rgba(c, 200));
}
} else if (l.type() == Light.POINT || l.type() == Light.POINT_BRIGHT) {
double[] p = {l.x(), l.y(), -l.z()};
if (sel) {
sphere(o, p, l.a(), rgba(c, 230));
sphere(o, p, l.rangeB(), rgba(c, 110));
o.line(p, new double[] {p[0], p[1] - 3, p[2]}, rgba(c, 120));
} else {
ring(o, p, l.rangeB(), rgba(c, 60));
}
if (l.type() == Light.POINT_BRIGHT) {
circle(o, p, 0.25, new double[] {1, 0, 0}, new double[] {0, 1, 0}, rgba(Color.BLACK, 200));
}
}
}
}
};
}
@@ -0,0 +1,411 @@
package com.eliasmueller.llamamod.editor.tools.environment;
import com.eliasmueller.llamamod.editor.model.EnvZone;
import com.eliasmueller.llamamod.editor.model.Light;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.Regions;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.SceneEvent;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.render.Camera;
import com.eliasmueller.llamamod.editor.render.RenderInstance;
import com.eliasmueller.llamamod.editor.render.RenderScene;
import com.eliasmueller.llamamod.editor.render.Viewport;
import com.eliasmueller.llamamod.editor.render.ViewportListener;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.ToolEvent;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
/**
* Per-window state of the environment tools: the live fog preview (fog of the zone the camera is
* in), the region preview (only what the game draws for a camera in a region), the object lighting
* preview and the viewport shading mode. Panels listen with {@link #addListener}.
*/
public final class EnvSession {
private static final Map<EditorContext, EnvSession> SESSIONS = new WeakHashMap<>();
public enum FogSource {
AUTO("Camera (nearest zone area)"),
START("Start zone"),
SELECTED("Selected zone only");
final String label;
FogSource(String l) {
label = l;
}
@Override
public String toString() {
return label;
}
}
final EditorContext ctx;
private final List<Runnable> listeners = new CopyOnWriteArrayList<>();
boolean fogPreview;
FogSource fogSource = FogSource.AUTO;
double detail = EnvMath.DETAIL_MAX;
EnvZone previewZone;
EnvZone pinnedZone;
double[] fogRange = {0, 0};
/** Camera too far from its pivot: the fog preview is paused (overview). */
boolean fogOverview;
/** Fog of the previewed zone; null = no preview. */
private Viewport.FogOverride fogBase;
/** -1 = off, -2 = follow the camera, else the region. */
int regionPreview = -1;
int regionPreviewActive = -1;
private final Set<Node> regionHidden = new HashSet<>();
boolean objectLighting;
private final Map<RenderInstance, int[]> litInstances = new WeakHashMap<>();
private boolean lightingQueued;
private final Timer cameraTimer;
private EnvSession(EditorContext ctx) {
this.ctx = ctx;
fogPreview = ctx.prefs().getBool("env.fogPreview", true);
detail = ctx.prefs().getDouble("env.fogDetail", EnvMath.DETAIL_MAX);
cameraTimer = new Timer(120, e -> cameraMoved());
cameraTimer.setRepeats(false);
ctx.viewport().addViewportListener(new ViewportListener() {
@Override
public void cameraChanged(Camera c) {
if (fogPreview && applyFog()) {
fire();
}
cameraTimer.restart();
}
});
ctx.addDocumentListener(d -> {
pinnedZone = null;
regionHidden.clear();
regionPreview = -1;
regionPreviewActive = -1;
litInstances.clear();
SwingUtilities.invokeLater(() -> {
updateFog();
queueLighting();
fire();
});
});
ctx.addSceneListener(this::sceneChanged);
ctx.selection().addListener(ch -> {
if (ctx.selection().primary() instanceof EnvZone z) {
pinnedZone = z;
updateFog();
}
});
}
public static EnvSession of(EditorContext ctx) {
synchronized (SESSIONS) {
return SESSIONS.computeIfAbsent(ctx, EnvSession::new);
}
}
public void addListener(Runnable r) {
listeners.add(r);
}
void fire() {
for (Runnable r : listeners) {
r.run();
}
}
private void sceneChanged(SceneEvent e) {
Object n = e.node();
if (n instanceof EnvZone || e.type() == SceneEvent.Type.RELOADED) {
if (pinnedZone != null && ctx.scene() != null && !ctx.scene().contains(pinnedZone)) {
pinnedZone = null;
}
SwingUtilities.invokeLater(this::updateFog);
}
if (n instanceof Regions && regionPreviewActive >= 0) {
int r = regionPreviewActive;
regionPreviewActive = -1;
SwingUtilities.invokeLater(() -> applyRegion(r));
}
if (objectLighting && (n instanceof Light || n instanceof Strat || n instanceof EnvZone)) {
queueLighting();
}
SwingUtilities.invokeLater(this::fire);
}
private void cameraMoved() {
if (fogPreview) {
updateFog();
}
if (regionPreview == -2) {
applyRegion(cameraRegion());
}
}
// ------------------------------------------------------------ fog preview
public void setFogPreview(boolean on) {
fogPreview = on;
ctx.prefs().put("env.fogPreview", on);
updateFog();
fire();
}
public void setFogSource(FogSource s) {
fogSource = s;
updateFog();
fire();
}
public void setDetail(double t) {
detail = t;
ctx.prefs().put("env.fogDetail", t);
updateFog();
fire();
}
/**
* Where the "player" is for the previews: the orbit pivot of the camera (the game camera sits a
* few cells behind the llama), game space cells.
*/
double[] cameraGame() {
return ToolEvent.toGame(ctx.viewport().camera().pivot());
}
/** Zone whose environment the preview shows. */
EnvZone zoneForPreview() {
Scene s = ctx.scene();
if (s == null || s.zones().isEmpty()) {
return null;
}
EnvZone start = EnvMath.startZone(s);
if (fogSource == FogSource.START) {
return start;
}
if (pinnedZone != null && s.contains(pinnedZone) && ctx.selection().contains(pinnedZone)) {
return pinnedZone;
}
if (fogSource == FogSource.SELECTED) {
return pinnedZone != null && s.contains(pinnedZone) ? pinnedZone : start;
}
return nearestArea(s, cameraGame());
}
/**
* The zone whose area contains p: the game switches the environment when the player teleports
* through a zone, so the area of a zone is approximated by its teleport target (the start zone's
* by the player start); the nearest one (xz) wins.
*/
static EnvZone nearestArea(Scene s, double[] p) {
EnvZone best = EnvMath.startZone(s);
Strat player = s.playerStart();
double bd = player == null ? Double.MAX_VALUE : EnvMath.sq(player.gx() - p[0]) + EnvMath.sq(player.gz() - p[2]);
for (EnvZone z : s.zones()) {
if (!EnvMath.teleports(z)) {
continue;
}
double d = EnvMath.sq(z.targetX() - p[0]) + EnvMath.sq(z.targetZ() - p[2]);
if (d < bd) {
bd = d;
best = z;
}
}
return best;
}
void updateFog() {
Viewport vp = ctx.viewport();
EnvZone z = fogPreview && ctx.scene() != null ? zoneForPreview() : null;
previewZone = z;
if (z == null) {
fogBase = null;
vp.setFogOverride(null);
fogRange = new double[] {0, 0};
fire();
return;
}
double[] f = EnvMath.fog(z, detail);
fogRange = f;
int mode = z.fogMode();
int rgb = mode == 1 ? 0x000000 : EnvMath.bgrToRgb(z.fogColor());
float amount = mode == 0 ? 0f : 1f;
float end = (float) Math.max(f[1], 0.5);
fogBase = new Viewport.FogOverride((float) Math.min(f[0], end - 0.01), end, rgb, amount, true, true);
applyFog();
fire();
}
/**
* Fog override for the current camera distance: beyond max(10, 1.6 × fog end) from the pivot
* the fog fades out over another half of that distance (radius grows, amount drops) instead of
* switching off at once; the overview (no override) starts when the fade is complete and ends
* again at 90 % of it (hysteresis). Returns whether the overview state changed.
*/
private boolean applyFog() {
if (fogBase == null) {
return false;
}
Viewport vp = ctx.viewport();
Camera cam = vp.camera();
double t = Math.max(10, 1.6 * fogBase.end());
double u = cam.ortho ? 0 : Math.clamp((cam.position.distance(cam.pivot()) - t) / (0.5 * t), 0, 1);
boolean was = fogOverview;
fogOverview = was ? u > 0.9 : u >= 1;
if (fogOverview) {
vp.setFogOverride(null);
} else {
float grow = (float) (1 + 30 * u * u);
vp.setFogOverride(new Viewport.FogOverride(
fogBase.start() * grow,
fogBase.end() * grow,
fogBase.rgb(),
(float) (fogBase.amount() * (1 - u)),
true,
true));
}
return was != fogOverview;
}
// ------------------------------------------------------------ region preview
int cameraRegion() {
Scene s = ctx.scene();
if (s == null) {
return -1;
}
double[] p = cameraGame();
return s.regions()
.regionAt(
(int) Math.floor(p[0]),
(int) Math.floor(p[2]),
s.settings().width());
}
/** -1 off, -2 follow camera, 0..31 fixed region. */
public void setRegionPreview(int r) {
regionPreview = r;
applyRegion(r == -2 ? cameraRegion() : r);
fire();
}
void applyRegion(int region) {
Scene s = ctx.scene();
if (s == null || region == regionPreviewActive) {
return;
}
regionPreviewActive = region;
Set<Node> hide = new HashSet<>();
if (region >= 0 && regionPreview != -1) {
int w = s.settings().width(), h = s.settings().height();
int mask = s.regions().mask(region);
if (mask != 0) {
for (Tile t : s.tiles()) {
if (!visible(s.regions(), mask, t.cellX(), t.cellZ(), w, h)) {
hide.add(t);
}
}
for (Strat st : s.strats()) {
int cx = (int) Math.floor(st.gx()), cz = (int) Math.floor(st.gz());
if (!visible(s.regions(), mask, cx, cz, w, h)) {
hide.add(st);
}
}
}
}
List<Node> show = new ArrayList<>();
for (Node n : regionHidden) {
if (!hide.contains(n) && s.contains(n)) {
show.add(n);
}
}
List<Node> add = new ArrayList<>();
for (Node n : hide) {
if (!regionHidden.contains(n) && !ctx.isHidden(n)) {
add.add(n);
}
}
if (!show.isEmpty()) {
ctx.setHidden(show, false);
}
if (!add.isEmpty()) {
ctx.setHidden(add, true);
}
regionHidden.removeAll(show);
regionHidden.addAll(add);
if (regionPreview == -1) {
regionHidden.clear();
}
}
/** Engine rule (FUN_0042bf40 / FUN_0041e020): cells outside the grid are drawn always. */
static boolean visible(Regions r, int mask, int cx, int cz, int w, int h) {
if (cx < 0 || cz < 0 || cx >= w || cz >= h) {
return true;
}
int reg = r.regionAt(cx, cz, w);
return reg < 0 || (mask & (1 << (reg & 31))) != 0;
}
// ------------------------------------------------------------ object lighting preview
public void setObjectLighting(boolean on) {
objectLighting = on;
if (!on) {
RenderScene rs = ctx.viewport().scene();
for (Map.Entry<RenderInstance, int[]> e : litInstances.entrySet()) {
if (rs != null && rs.instances.contains(e.getKey())) {
e.getKey().vertexColors = e.getValue();
rs.changed(e.getKey());
}
}
litInstances.clear();
ctx.viewport().requestRender();
} else {
queueLighting();
}
fire();
}
void queueLighting() {
if (!objectLighting || lightingQueued) {
return;
}
lightingQueued = true;
SwingUtilities.invokeLater(() -> SwingUtilities.invokeLater(() -> {
lightingQueued = false;
applyObjectLighting();
}));
}
/** Colours every object model with the engine's per-vertex lighting (zone ambient + ≤ 6 lights). */
void applyObjectLighting() {
Scene s = ctx.scene();
RenderScene rs = ctx.viewport().scene();
if (!objectLighting || s == null || rs == null) {
return;
}
double[] amb = EnvMath.ambient(previewZone != null ? previewZone : EnvMath.startZone(s));
for (RenderInstance ri : List.copyOf(rs.instances)) {
if (ri.kind != RenderInstance.Kind.OBJECT || !(ctx.nodeOf(ri.id) instanceof Strat st)) {
continue;
}
litInstances.putIfAbsent(ri, ri.vertexColors);
ri.vertexColors = ObjectLighting.colors(s, st, ri, amb);
rs.changed(ri);
}
ctx.viewport().requestRender();
}
}
@@ -0,0 +1,96 @@
package com.eliasmueller.llamamod.editor.tools.environment;
import com.eliasmueller.llamamod.editor.render.Viewport;
import com.eliasmueller.llamamod.editor.ui.ActionSpec;
import com.eliasmueller.llamamod.editor.ui.Dock;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.EditorExtensions;
import com.eliasmueller.llamamod.editor.ui.EditorPlugin;
import com.eliasmueller.llamamod.editor.ui.LayerSpec;
import com.eliasmueller.llamamod.editor.ui.PanelSpec;
import java.util.function.Predicate;
/**
* Environment, lighting and visibility tools: zone / teleport editing with a live fog preview,
* lights with gizmos and an object lighting preview, the vertex lighting bake and colour brush,
* visibility regions (paint, mask matrix, preview, auto-generate), level settings and checks.
*/
public final class EnvironmentEditorPlugin implements EditorPlugin {
private static final Predicate<EditorContext> DOC = c -> c.document() != null;
@Override
public void install(EditorExtensions x) {
Ui.init();
x.panel(new PanelSpec("environment", "Environment", "sun", Dock.LEFT, 30, true, EnvironmentPanel::new));
x.panel(new PanelSpec("visibility", "Visibility", "region", Dock.RIGHT, 30, true, VisibilityPanel::new));
x.tool(new ZoneTool());
x.tool(new LightTool());
x.tool(new VertexPaintTool());
x.tool(new RegionPaintTool());
x.inspector(new ZoneSection());
x.inspector(new LightSection());
x.layer(new LayerSpec(EnvOverlays.LAYER_ZONES, "Zone targets & facing", "zone", true, 61));
x.layer(new LayerSpec(EnvOverlays.LAYER_LIGHTS, "Light gizmos", "light", true, 71));
x.renderContributor(EnvOverlays.ZONES);
x.renderContributor(EnvOverlays.LIGHTS);
x.check(new EnvChecks());
x.onOpen(EnvSession::of);
x.action(ActionSpec.of(
"level.settings",
"Level",
10,
10,
"Level settings…",
"settings",
null,
LevelSettingsDialog::show)
.enabledWhen(DOC)
.tip("Name, chapter, grid size, death plane, feature flags"));
x.action(ActionSpec.of("env.bake", "Level", 30, 10, "Bake lighting…", "sun", null, c -> {
c.showPanel("environment");
c.status("Environment → Lighting: choose a preset and press Bake lighting");
})
.enabledWhen(DOC));
x.action(ActionSpec.of(
"env.zones",
"Level",
30,
20,
"Environment zones",
"zone",
null,
c -> c.showPanel("environment"))
.enabledWhen(DOC));
x.action(ActionSpec.of(
"env.visibility",
"Level",
30,
30,
"Visibility regions",
"region",
null,
c -> c.showPanel("visibility"))
.enabledWhen(DOC));
x.action(ActionSpec.of("env.fogPreview", "View", 50, 10, "Fog preview", "fog", null, c -> {
EnvSession s = EnvSession.of(c);
s.setFogPreview(!s.fogPreview);
})
.checked(c -> EnvSession.of(c).fogPreview)
.tip("Game fog of the zone the camera is in"));
x.action(ActionSpec.of(
"env.objectLighting", "View", 50, 20, "Light objects (LGHT preview)", "light", null, c -> {
EnvSession s = EnvSession.of(c);
s.setObjectLighting(!s.objectLighting);
})
.checked(c -> EnvSession.of(c).objectLighting));
x.action(ActionSpec.of(
"env.shadeVertex", "View", 50, 30, "Vertex colours only", "paint", "shift V", c -> c.viewport()
.setShading(
c.viewport().shading() == Viewport.SHADE_VERTEX_COLORS
? Viewport.SHADE_NORMAL
: Viewport.SHADE_VERTEX_COLORS))
.checked(c -> c.viewport().shading() == Viewport.SHADE_VERTEX_COLORS)
.tip("Show the baked/painted vertex colours without textures"));
}
}
@@ -0,0 +1,494 @@
package com.eliasmueller.llamamod.editor.tools.environment;
import com.eliasmueller.llamamod.editor.model.EnvZone;
import com.eliasmueller.llamamod.editor.model.LevelSettings;
import com.eliasmueller.llamamod.editor.model.Light;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.SceneEvent;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.model.cmd.AddNode;
import com.eliasmueller.llamamod.editor.model.cmd.Batch;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.model.cmd.SetProp;
import com.eliasmueller.llamamod.editor.render.Viewport;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.Icons;
import com.eliasmueller.llamamod.editor.ui.Widgets;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import javax.swing.BorderFactory;
import javax.swing.ButtonGroup;
import javax.swing.DefaultListCellRenderer;
import javax.swing.DefaultListModel;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JScrollPane;
import javax.swing.JTabbedPane;
import javax.swing.ListSelectionModel;
import javax.swing.Timer;
import org.joml.Vector3f;
/** Left dock panel: zones (with the live fog preview), lights and the vertex lighting bake. */
final class EnvironmentPanel extends JPanel {
private final EditorContext ctx;
private final EnvSession env;
private final DefaultListModel<EnvZone> zoneModel = new DefaultListModel<>();
private final JList<EnvZone> zoneList = new JList<>(zoneModel);
private final DefaultListModel<Light> lightModel = new DefaultListModel<>();
private final JList<Light> lightList = new JList<>(lightModel);
private final JLabel fogInfo = new JLabel(" ");
private boolean syncing;
private final Timer rebuild;
EnvironmentPanel(EditorContext ctx) {
super(new BorderLayout());
this.ctx = ctx;
this.env = EnvSession.of(ctx);
JTabbedPane tabs = new JTabbedPane(JTabbedPane.TOP, JTabbedPane.SCROLL_TAB_LAYOUT);
tabs.addTab("Zones", Icons.get("zone", 14), zonesTab());
tabs.addTab("Lights", Icons.get("light", 14), lightsTab());
tabs.addTab("Lighting", Icons.get("sun", 14), new BakePanel(ctx));
add(tabs);
rebuild = new Timer(60, e -> reload());
rebuild.setRepeats(false);
ctx.addDocumentListener(d -> rebuild.restart());
ctx.addSceneListener(this::sceneChanged);
ctx.selection().addListener(ch -> {
if (ch.source() != this) {
syncSelection();
}
});
env.addListener(this::updateFogInfo);
reload();
}
private void sceneChanged(SceneEvent e) {
boolean env = e.node() instanceof EnvZone || e.node() instanceof Light;
if (env || (e.type() != SceneEvent.Type.CHANGED && e.node() == null)) {
rebuild.restart();
}
}
private void reload() {
syncing = true;
try {
zoneModel.clear();
lightModel.clear();
Scene s = ctx.scene();
if (s != null) {
EnvZone st = EnvMath.startZone(s);
if (st != null) {
zoneModel.addElement(st);
}
for (EnvZone z : s.zones()) {
if (z != st) {
zoneModel.addElement(z);
}
}
for (Light l : s.lights()) {
lightModel.addElement(l);
}
}
} finally {
syncing = false;
}
syncSelection();
zoneList.repaint();
lightList.repaint();
updateFogInfo();
}
private void syncSelection() {
syncing = true;
try {
select(zoneList, zoneModel);
select(lightList, lightModel);
} finally {
syncing = false;
}
}
private <T extends Node> void select(JList<T> list, DefaultListModel<T> model) {
List<Integer> idx = new ArrayList<>();
for (int i = 0; i < model.size(); i++) {
if (ctx.selection().contains(model.get(i))) {
idx.add(i);
}
}
list.setSelectedIndices(idx.stream().mapToInt(Integer::intValue).toArray());
if (!idx.isEmpty()) {
list.ensureIndexIsVisible(idx.getFirst());
}
}
/** List selection → editor selection; double-click frames it. */
private <T extends Node> void bindList(JList<T> list) {
list.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
list.addListSelectionListener(e -> {
List<T> sel = list.getSelectedValuesList();
if (!e.getValueIsAdjusting() && !syncing && !sel.isEmpty()) {
ctx.selection().set(sel, this);
}
});
list.addMouseListener(new java.awt.event.MouseAdapter() {
@Override
public void mouseClicked(java.awt.event.MouseEvent e) {
if (e.getClickCount() == 2) {
ctx.focusSelection();
}
}
});
}
// ------------------------------------------------------------ zones
private JComponent zonesTab() {
JPanel p = new JPanel(new BorderLayout());
Ui.Form f = new Ui.Form();
f.header("Fog preview");
JCheckBox live = new JCheckBox("Live fog in the viewport", env.fogPreview);
live.addActionListener(a -> env.setFogPreview(live.isSelected()));
env.addListener(() -> live.setSelected(env.fogPreview));
f.full(live);
JComboBox<EnvSession.FogSource> src = new JComboBox<>(EnvSession.FogSource.values());
src.setSelectedItem(env.fogSource);
src.addActionListener(a -> env.setFogSource((EnvSession.FogSource) src.getSelectedItem()));
f.row("Zone", src, "Which zone's fog is shown; a selected zone is always previewed");
String[] names = {"Minimum", "Medium", "Maximum"};
double[] vals = {EnvMath.DETAIL_MIN, EnvMath.DETAIL_MED, EnvMath.DETAIL_MAX};
JComboBox<String> det = new JComboBox<>(names);
det.setSelectedIndex(env.detail <= 0.25 ? 0 : env.detail < 0.75 ? 1 : 2);
det.addActionListener(a -> env.setDetail(vals[det.getSelectedIndex()]));
f.row("Detail", det, "The game's detail setting: fog distances are interpolated between the Min and Max pairs");
f.full(fogInfo);
p.add(f, BorderLayout.NORTH);
bindList(zoneList);
zoneList.setCellRenderer(new DefaultListCellRenderer() {
@Override
public Component getListCellRendererComponent(JList<?> l, Object v, int i, boolean s, boolean fo) {
EnvZone z = (EnvZone) v;
JLabel lab = (JLabel) super.getListCellRendererComponent(l, "", i, s, fo);
String music = EnvMath.MUSIC[Math.max(0, Math.min(EnvMath.MUSIC.length - 1, z.music()))];
double far = EnvMath.fog(z, 1)[1];
lab.setText(String.format(
Locale.ROOT,
"<html><b>%s</b><br><span style='font-size:90%%'>fog %.1f cells · %s</span></html>",
EnvMath.describe(z),
far,
music));
lab.setIcon(Icons.swatch(EnvMath.zoneColor(z.fogColor()), 18, 18));
lab.setIconTextGap(8);
lab.setBorder(BorderFactory.createEmptyBorder(3, 4, 3, 4));
return lab;
}
});
p.add(new JScrollPane(zoneList), BorderLayout.CENTER);
JButton tele = Widgets.button(
"Teleport", "plus", "Add a teleport zone: click a trigger cell, then the target (Zone tool)", () -> {
ctx.selection().clear(this);
ctx.setTool("env.zones");
ctx.status("Click the cell that triggers the teleport (it needs an exit floor, material 16)");
});
JButton envz = Widgets.button(
"Environment",
"plus",
"Add a zone that only switches fog, colours and music (at the camera focus)",
this::addEnvZone);
JButton del = Widgets.button("", "delete", "Delete the selected zones", () -> ctx.run("edit.delete"));
JButton frame = Widgets.button("", "focus", "Frame the selection", ctx::focusSelection);
JPanel b = Ui.line(tele, envz, del, frame);
b.setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4));
p.add(b, BorderLayout.SOUTH);
return p;
}
private void addEnvZone() {
Scene s = ctx.scene();
if (s == null) {
return;
}
Vector3f c = ctx.viewport().camera().pivot();
EnvZone z = ZoneTool.create(s, (int) Math.floor(c.x), (int) Math.round(c.y), (int) Math.floor(-c.z));
z.setRaw("flags", EnvMath.FLAG_NO_TELEPORT);
ctx.execute(new AddNode(z));
ctx.selection().set(z, this);
}
private void updateFogInfo() {
EnvZone z = env.previewZone;
if (!env.fogPreview) {
fogInfo.setText("<html><i>Fog preview off</i></html>");
} else if (z == null) {
fogInfo.setText("<html><i>No zone</i></html>");
} else {
fogInfo.setText(String.format(
Locale.ROOT,
"<html>%s <b>%s</b><br>fog %.1f → %.1f cells, %s</html>",
env.fogOverview ? "Zoom in to see the fog of" : "Showing",
EnvMath.describe(z),
env.fogRange[0],
env.fogRange[1],
EnvMath.FOG_MODES[Math.max(0, Math.min(3, z.fogMode()))].substring(4)));
}
}
// ------------------------------------------------------------ lights
private JComponent lightsTab() {
JPanel p = new JPanel(new BorderLayout());
Ui.Form f = new Ui.Form();
f.header("Object lighting");
JCheckBox prev = new JCheckBox("Preview lights on objects", env.objectLighting);
prev.addActionListener(a -> env.setObjectLighting(prev.isSelected()));
env.addListener(() -> prev.setSelected(env.objectLighting));
f.full(prev);
f.note("The engine lights object models per vertex: zone ambient colour + all directional lights +"
+ " the nearest point lights (6 at most). Tiles keep their baked vertex colours.");
p.add(f, BorderLayout.NORTH);
bindList(lightList);
lightList.setCellRenderer(new DefaultListCellRenderer() {
@Override
public Component getListCellRendererComponent(JList<?> l, Object v, int i, boolean s, boolean fo) {
Light li = (Light) v;
JLabel lab = (JLabel) super.getListCellRendererComponent(l, "", i, s, fo);
String what = li.type() == Light.DIRECTIONAL
? String.format(
Locale.ROOT,
"Directional · az %.0f°, el %.0f°",
LightSection.azimuth(li),
LightSection.elevation(li))
: String.format(
Locale.ROOT,
"%s · %.1f, %.1f, %.1f · r %.1f",
li.type() == Light.POINT_BRIGHT ? "Negative" : "Point",
li.x(),
li.y(),
li.z(),
li.rangeB());
lab.setText(what);
lab.setIcon(Icons.swatch(EnvMath.lightColor(li), 16, 16));
lab.setIconTextGap(8);
return lab;
}
});
p.add(new JScrollPane(lightList), BorderLayout.CENTER);
JButton point = Widgets.button(
"Point", "plus", "Place point lights in the viewport (Lights tool)", () -> ctx.setTool("env.lights"));
JButton dir = Widgets.button("Directional", "plus", "Add a directional light (sun)", () -> {
if (ctx.scene() != null) {
Light l = LightTool.directional(ctx.scene(), 215, 50, 110, 100, 80);
ctx.execute(new AddNode(l));
ctx.selection().set(l, this);
}
});
JButton del = Widgets.button("", "delete", "Delete the selected lights", () -> ctx.run("edit.delete"));
JPanel b = Ui.line(point, dir, del);
b.setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4));
p.add(b, BorderLayout.SOUTH);
return p;
}
// ------------------------------------------------------------ lighting bake
/** Bake settings, presets and the vertex colour display mode. */
static final class BakePanel extends JPanel {
private final EditorContext ctx;
double az = 215, el = 50, strength = 0.62, ao = 0.55, aoRadius = 1.3, shadow = 0.35;
Color sun = new Color(255, 237, 204), sky = new Color(128, 140, 168), ground = new Color(77, 71, 61);
int fogBgr = -1, ambBgr = -1;
private final List<Runnable> refresh = new ArrayList<>();
private final JRadioButton all = new JRadioButton("All tiles", true);
private final JRadioButton sel = new JRadioButton("Selected tiles");
private final JCheckBox points = new JCheckBox("Add point lights (LGHT) to tiles", false);
private final JCheckBox brushes = new JCheckBox("Also light brushes (level setting)", true);
private final JCheckBox zone = new JCheckBox("Set start zone fog & ambient colour", false);
private final JLabel info = new JLabel(" ");
BakePanel(EditorContext ctx) {
super(new BorderLayout());
this.ctx = ctx;
Ui.Form f = new Ui.Form();
f.header("Preset");
JComboBox<BakePresets.Preset> pre = new JComboBox<>(BakePresets.ALL.toArray(new BakePresets.Preset[0]));
pre.setSelectedIndex(-1);
pre.addActionListener(a -> {
if (pre.getSelectedItem() instanceof BakePresets.Preset p) {
load(p.rig());
fogBgr = p.fogBgr();
ambBgr = p.ambientBgr();
points.setSelected(p.rig().pointLights());
zone.setSelected(true);
}
});
f.row("Preset", pre, "Fills in the values below (nothing changes until you bake)");
f.full(Ui.line(Widgets.button(
"Level's current", null, "Load the level's lighting settings (used for brushes)", () -> {
if (ctx.scene() != null) {
load(BakePresets.fromSettings(ctx.scene().settings(), points.isSelected()));
}
})));
f.header("Sun");
f.row("Azimuth (°)", num(0, 360, 5, () -> az, v -> az = v), "0 = from +z (south), 90 = from +x (east)");
f.row("Elevation (°)", num(-10, 90, 5, () -> el, v -> el = v));
f.row("Strength", num(0, 2, 0.05, () -> strength, v -> strength = v));
f.row("Sun colour", color("Sun colour", () -> sun, c -> sun = c));
f.header("Sky");
f.row("Sky colour", color("Sky light", () -> sky, c -> sky = c), "Light from above (hemisphere)");
f.row("Ground colour", color("Ground light", () -> ground, c -> ground = c), "Light from below");
f.row("Ambient occlusion", num(0, 1, 0.05, () -> ao, v -> ao = v), "Darkening of corners and crevices");
f.row("AO radius", num(0.2, 6, 0.1, () -> aoRadius, v -> aoRadius = v), "cells");
f.row("Shadow light", num(0, 1, 0.05, () -> shadow, v -> shadow = v), "0 = black shadows, 1 = no shadows");
f.header("Bake");
ButtonGroup g = new ButtonGroup();
g.add(all);
g.add(sel);
f.full(Ui.line(all, sel));
f.full(points);
f.full(brushes);
f.full(zone);
JButton bake = Widgets.button("Bake lighting", "sun", "Compute vertex colours (one undo step)", this::bake);
bake.putClientProperty("JButton.buttonType", "default");
f.full(Ui.line(bake));
f.full(info);
f.header("Display");
JRadioButton tex = new JRadioButton("Textured", ctx.viewport().shading() == Viewport.SHADE_NORMAL);
JRadioButton vc =
new JRadioButton("Vertex colours only", ctx.viewport().shading() == Viewport.SHADE_VERTEX_COLORS);
ButtonGroup dg = new ButtonGroup();
dg.add(tex);
dg.add(vc);
tex.addActionListener(a -> ctx.viewport().setShading(Viewport.SHADE_NORMAL));
vc.addActionListener(a -> ctx.viewport().setShading(Viewport.SHADE_VERTEX_COLORS));
f.full(Ui.line(tex, vc));
f.full(Ui.line(Widgets.button(
"Paint vertex colours", "paint", "Vertex colour brush (V)", () -> ctx.setTool("env.vpaint"))));
f.end();
JScrollPane sp = new JScrollPane(f);
sp.setBorder(null);
sp.getVerticalScrollBar().setUnitIncrement(16);
add(sp);
setPreferredSize(new Dimension(260, 400));
}
private JComponent num(
double min,
double max,
double step,
java.util.function.DoubleSupplier get,
java.util.function.DoubleConsumer set) {
javax.swing.JSpinner s = Ui.number(get.getAsDouble(), min, max, step, "0.00", set);
refresh.add(() -> s.setValue(Ui.clamp(get.getAsDouble(), min, max)));
return s;
}
private JComponent color(
String title, java.util.function.Supplier<Color> get, java.util.function.Consumer<Color> set) {
Ui.ColorButton b = new Ui.ColorButton(title, get, (c, k) -> set.accept(c));
refresh.add(b::refresh);
return b;
}
void load(VertexBake.Rig r) {
az = r.azimuth();
el = r.elevation();
strength = r.strength();
sun = col(r.sun());
sky = col(r.sky());
ground = col(r.ground());
ao = r.ao();
aoRadius = r.aoRadius();
shadow = r.shadow();
refresh.forEach(Runnable::run);
}
static Color col(double[] c) {
return new Color(unit(c[0]), unit(c[1]), unit(c[2]));
}
private static int unit(double v) {
return (int) Math.round(Ui.clamp(v, 0, 1) * 255);
}
static double[] vec(Color c) {
return new double[] {c.getRed() / 255.0, c.getGreen() / 255.0, c.getBlue() / 255.0};
}
VertexBake.Rig rig() {
return new VertexBake.Rig(
az, el, strength, vec(sun), vec(sky), vec(ground), ao, aoRadius, shadow, points.isSelected());
}
private void bake() {
Scene s = ctx.scene();
if (s == null) {
return;
}
List<Tile> tiles = new ArrayList<>();
if (sel.isSelected()) {
for (Node n : ctx.selection().nodes()) {
if (n instanceof Tile t) {
tiles.add(t);
}
}
} else {
tiles.addAll(s.tiles());
}
VertexBake.Rig rig = rig();
boolean doBrushes = brushes.isSelected(), doZone = zone.isSelected();
int fog = fogBgr, amb = ambBgr;
long t0 = System.nanoTime();
info.setText("Baking " + tiles.size() + " tiles…");
ctx.runTask(
"Baking lighting",
p -> {
VertexBake vb = new VertexBake(s);
return vb.bake(s, tiles, rig, f -> p.update(f, "Baking lighting"));
},
res -> {
@SuppressWarnings("unchecked")
Map<Tile, int[]> out = (Map<Tile, int[]>) res;
List<Command> cmds = new ArrayList<>();
if (!out.isEmpty()) {
cmds.add(new PropMapCommand("Bake lighting", "colors", out, null));
}
if (doBrushes && !s.brushes().isEmpty()) {
LevelSettings st = s.settings();
cmds.add(SetProp.of(st, "sunAzimuth", rig.azimuth()));
cmds.add(SetProp.of(st, "sunElevation", rig.elevation()));
cmds.add(SetProp.of(st, "sunStrength", rig.strength()));
cmds.add(SetProp.of(st, "sunColor", rig.sun()));
cmds.add(SetProp.of(st, "sky", rig.sky()));
cmds.add(SetProp.of(st, "ground", rig.ground()));
cmds.add(SetProp.of(st, "ao", rig.ao()));
cmds.add(SetProp.of(st, "aoRadius", rig.aoRadius()));
cmds.add(SetProp.of(st, "shadow", rig.shadow()));
}
EnvZone z = EnvMath.startZone(s);
if (doZone && z != null && fog >= 0) {
cmds.add(SetProp.of(z, "fogColor", fog));
cmds.add(SetProp.of(z, "ambientColor", amb));
}
if (!cmds.isEmpty()) {
ctx.execute(new Batch("Bake lighting", cmds));
}
info.setText(String.format(
Locale.ROOT, "Baked %d tiles in %.1f s", out.size(), (System.nanoTime() - t0) / 1e9));
ctx.status(info.getText());
});
}
}
}
@@ -0,0 +1,358 @@
package com.eliasmueller.llamamod.editor.tools.environment;
import com.eliasmueller.llamamod.editor.io.ProjectIO;
import com.eliasmueller.llamamod.editor.io.ProjectInfo;
import com.eliasmueller.llamamod.editor.model.Brush;
import com.eliasmueller.llamamod.editor.model.BrushKind;
import com.eliasmueller.llamamod.editor.model.LevelSettings;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.Regions;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.model.TileModel;
import com.eliasmueller.llamamod.editor.model.cmd.AddNode;
import com.eliasmueller.llamamod.editor.model.cmd.Batch;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.model.cmd.SetProp;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.mods.levels.LevelProjects;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.GridLayout;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSpinner;
import javax.swing.JTextArea;
import javax.swing.JTextField;
/** Level → Settings: name, chapter, grid size, death plane, WFPC flags, object pool (one undo step). */
final class LevelSettingsDialog {
private LevelSettingsDialog() {}
/** WFPC bits: mask, label, changes the file layout (not editable). */
record Flag(int mask, String label, boolean layout) {}
static final List<Flag> FLAGS = List.of(
new Flag(0x10, "MAP has a path buffer size (block 14)", true),
new Flag(0x100, "STPC has an extra model block", true),
new Flag(0x800, "Fog flag of the renderer (FUN_00424b90, face flag 0x4000 handling)", false),
new Flag(0x10000, "Tile swaps (doors / bridges, stMapreplace)", true),
new Flag(0x80000, "Script flag (strat opcode 0x8e reads it)", false),
new Flag(0x100000, "TRPS chunk has extra entries", true),
new Flag(0x200000, "MAP has a path block", true),
new Flag(0x4000000, "Path entries with an extra word", true),
new Flag(0x8000000, "Collision materials 17/18 are ignored", false),
new Flag(0x10000000, "Two vertex colour pointer tables (light states)", true));
static void show(EditorContext ctx) {
Scene s = ctx.scene();
if (s == null) {
return;
}
LevelSettings st = s.settings();
JDialog d = new JDialog(ctx.frame(), "Level settings", true);
Ui.Form f = new Ui.Form();
f.header("Level");
JTextField name = new JTextField(st.levelName(), 24);
f.row("Name", name, "Shown in the level list / loading screen (NAME chunk)");
JTextField author = new JTextField(st.author());
f.row("Author", author);
JTextArea desc = new JTextArea(st.description(), 3, 24);
desc.setLineWrap(true);
desc.setWrapStyleWord(true);
f.row("Description", new JScrollPane(desc));
f.row("Project", new JLabel(st.isNewLevel() ? "new level" : "changes to an original level"));
f.row(
"Base level",
new JLabel(st.base() + " (object code, textures, sounds)"),
"Fixed when the project is created (the project stores only the differences to it)");
JSpinner chapter = Ui.number(Math.max(1, st.targetChapter()), 1, 49, 1, "0", v -> {});
JLabel used = new JLabel(usedChapters(ctx, st.targetChapter()));
f.row("TestLevel chapter", chapter, "New levels replace this chapter of world 9 (TestLevel)");
f.full(used);
chapter.setEnabled(st.isNewLevel());
f.header("Grid");
int w0 = st.width(), h0 = st.height();
int[] need = extent(s);
JSpinner gw = Ui.number(w0, Math.max(1, need[0]), 1024, 1, "0", v -> {});
JSpinner gh = Ui.number(h0, Math.max(1, need[1]), 1024, 1, "0", v -> {});
f.row("Width (cells, x)", gw, "Columns; growing adds cells in the east");
f.row("Height (cells, z)", gh, "Rows; growing adds cells at the far end (higher z)");
f.note(String.format(
Locale.ROOT,
"Current %d × %d. Content needs at least %d × %d. New cells get region 0.",
w0,
h0,
need[0],
need[1]));
f.header("Death plane");
List<Node> kill = killPlanes(s);
double ky = killHeight(s, kill);
JSpinner killY = Ui.number(Double.isNaN(ky) ? -1.5 : ky, -64, 256, 0.25, "0.00", v -> {});
killY.setEnabled(!kill.isEmpty());
f.row("Height", killY, "Falling below / touching it kills the player (collision material 30)");
JCheckBox addKill = new JCheckBox("Add a death plane over the whole grid", false);
addKill.addActionListener(e -> killY.setEnabled(addKill.isSelected()));
if (kill.isEmpty()) {
f.full(addKill);
f.note("No death plane in this level.");
} else {
f.note(kill.size() + " death plane(s): " + summary(kill));
}
f.header("Feature flags (WFPC 0x" + Integer.toHexString(st.wfpc()) + ")");
Map<Integer, JCheckBox> boxes = new LinkedHashMap<>();
JPanel grid = new JPanel(new GridLayout(0, 1));
int known = 0;
for (Flag fl : FLAGS) {
known |= fl.mask();
JCheckBox cb = new JCheckBox(
String.format(Locale.ROOT, "0x%08X %s", fl.mask(), fl.label()), (st.wfpc() & fl.mask()) != 0);
cb.setEnabled(!fl.layout());
if (fl.layout()) {
cb.setToolTipText("Changes the layout of the level data – fixed by the base level");
}
boxes.put(fl.mask(), cb);
grid.add(cb);
}
for (int b = 0; b < 32; b++) {
int m = 1 << b;
if ((known & m) == 0 && (st.wfpc() & m) != 0) {
JCheckBox cb = new JCheckBox(String.format(Locale.ROOT, "0x%08X (unknown)", m), true);
cb.setToolTipText("Meaning unknown; keep it unless you know better");
boxes.put(m, cb);
grid.add(cb);
}
}
f.full(grid);
f.header("Objects");
JSpinner pool = Ui.number(st.poolExtra(), 0, 4096, 1, "0", v -> {});
f.row("Strat pool extra", pool, "Slots for objects = this + 30 (also for spawned objects)");
f.end();
JButton ok = new JButton("OK");
JButton cancel = new JButton("Cancel");
ok.putClientProperty("JButton.buttonType", "default");
d.getRootPane().setDefaultButton(ok);
cancel.addActionListener(a -> d.dispose());
ok.addActionListener(a -> {
List<Command> c = new ArrayList<>();
put(c, st, "name", name.getText().trim());
put(c, st, "author", author.getText().trim());
put(c, st, "description", desc.getText().trim());
if (st.isNewLevel()) {
put(c, st, "targetChapter", ((Number) chapter.getValue()).intValue());
}
int wfpc = st.wfpc();
for (Map.Entry<Integer, JCheckBox> e : boxes.entrySet()) {
wfpc = e.getValue().isSelected() ? wfpc | e.getKey() : wfpc & ~e.getKey();
}
put(c, st, "wfpc", wfpc);
put(c, st, "poolExtra", ((Number) pool.getValue()).intValue());
int nw = ((Number) gw.getValue()).intValue(), nh = ((Number) gh.getValue()).intValue();
if (nw != w0 || nh != h0) {
c.addAll(resize(s, nw, nh));
}
try {
killY.commitEdit();
} catch (java.text.ParseException ignored) {
// keep the last valid value
}
double y = ((Number) killY.getValue()).doubleValue();
if (!kill.isEmpty() && Math.abs(y - ky) > 1e-9) {
c.addAll(moveKill(kill, y - ky));
}
if (kill.isEmpty() && addKill.isSelected()) {
Brush b = (Brush) s.createBrush(BrushKind.KILL_PLANE);
b.setRaw("x1", (double) nw);
b.setRaw("z1", (double) nh);
b.setRaw("y", y);
c.add(new AddNode(b));
}
if (!c.isEmpty()) {
ctx.execute(new Batch("Level settings", c));
}
d.dispose();
});
JPanel buttons = Ui.line(ok, cancel);
buttons.setLayout(new java.awt.FlowLayout(java.awt.FlowLayout.RIGHT));
JScrollPane sp = new JScrollPane(f);
sp.setBorder(BorderFactory.createEmptyBorder());
sp.getVerticalScrollBar().setUnitIncrement(16);
JPanel root = new JPanel(new BorderLayout());
root.add(sp);
root.add(buttons, BorderLayout.SOUTH);
d.setContentPane(root);
d.setSize(new Dimension(560, 720));
d.setLocationRelativeTo(ctx.frame());
d.setVisible(true);
}
private static void put(List<Command> c, Node n, String prop, Object v) {
if (!java.util.Objects.equals(n.get(prop), v)) {
c.add(SetProp.of(n, prop, v));
}
}
/** Minimum grid size {w, h} for the tiles and objects of the level. */
static int[] extent(Scene s) {
int w = 1, h = 1;
for (Tile t : s.tiles()) {
w = Math.max(w, t.cellX() + 1);
h = Math.max(h, t.cellZ() + 1);
}
for (Strat st : s.strats()) {
w = Math.max(w, (int) Math.floor(st.gx()) + 1);
h = Math.max(h, (int) Math.floor(st.gz()) + 1);
}
for (Brush b : s.brushes()) {
double[] bb = b.bounds();
if (bb != null && b.brushKind() != BrushKind.KILL_PLANE) {
w = Math.max(w, (int) Math.ceil(bb[3]));
h = Math.max(h, (int) Math.ceil(bb[5]));
}
}
return new int[] {w, h};
}
/** Commands that change the grid size (region grid rows copied, new cells region 0). */
static List<Command> resize(Scene s, int nw, int nh) {
LevelSettings st = s.settings();
int w = st.width(), h = st.height();
int[] old = s.regions().grid();
int[] g = new int[nw * nh];
for (int z = 0; z < Math.min(h, nh); z++) {
for (int x = 0; x < Math.min(w, nw); x++) {
if (x + z * w < old.length) {
g[x + z * nw] = old[x + z * w];
}
}
}
Regions rg = s.regions();
return List.of(SetProp.of(st, "width", nw), SetProp.of(st, "height", nh), SetProp.of(rg, "grid", g));
}
/** Death plane brushes (KILL_PLANE, SEA with a death plane) and tiles whose model only has material 30. */
static List<Node> killPlanes(Scene s) {
List<Node> out = new ArrayList<>();
for (Brush b : s.brushes()) {
if (b.brushKind() == BrushKind.KILL_PLANE
|| b.brushKind() == BrushKind.SEA && !"none".equals(b.s("kill"))) {
out.add(b);
}
}
for (Tile t : s.tiles()) {
if (isKillModel(t.model())) {
out.add(t);
}
}
return out;
}
static boolean isKillModel(TileModel m) {
if (m == null || m.faceCount() > 0 || m.collisionCount() == 0) {
return false;
}
for (int g = 0; g < 3; g++) {
for (int k = 0; k < m.collisionCount(g); k++) {
if (m.collisionMaterial(g, k) != 30) {
return false;
}
}
}
return true;
}
/** Height of the first death plane (tiles: tile y + the model's collision height). */
static double killHeight(Scene s, List<Node> kill) {
for (Node n : kill) {
if (n instanceof Brush b) {
return b.d("y");
}
if (n instanceof Tile t) {
return t.y() + collisionY(t.model());
}
}
return Double.NaN;
}
static double collisionY(TileModel m) {
double[] v = m.vertices();
return m.vertexCount() > 0 ? v[1] : 0;
}
static List<Command> moveKill(List<Node> kill, double dy) {
List<Command> c = new ArrayList<>();
for (Node n : kill) {
if (n instanceof Brush b) {
c.add(SetProp.of(b, "y", b.d("y") + dy));
} else if (n instanceof Tile t) {
c.add(SetProp.of(t, "y", t.y() + dy));
}
}
return c;
}
private static String summary(List<Node> kill) {
int b = 0, t = 0;
for (Node n : kill) {
if (n instanceof Brush) {
b++;
} else {
t++;
}
}
return (b > 0 ? b + " brush" + (b > 1 ? "es" : "") : "") + (b > 0 && t > 0 ? ", " : "")
+ (t > 0 ? t + " no-floor tile" + (t > 1 ? "s" : "") : "") + " (all move together)";
}
private static String usedChapters(EditorContext ctx, int own) {
TreeMap<Integer, String> used = new TreeMap<>();
try {
for (Path p : LevelProjects.list(ctx.env().levelsDir())) {
try {
ProjectInfo i = ProjectIO.info(Files.readAllBytes(p));
if (i.isNewLevel()
&& (ctx.document() == null
|| !p.equals(ctx.document().file()))
&& i.targetChapter() > 0) {
used.put(i.targetChapter(), i.name());
}
} catch (Exception e) {
// skip broken files
}
}
} catch (RuntimeException e) {
return " ";
}
if (used.isEmpty()) {
return "<html><i>No other level project uses a chapter.</i></html>";
}
StringBuilder sb = new StringBuilder("<html>Used by other projects: ");
used.forEach((k, v) -> sb.append(k == own ? "<b><font color='#d9603f'>" : "")
.append(k)
.append(" (")
.append(v)
.append(")")
.append(k == own ? "</font></b>" : "")
.append(" "));
return sb.append("</html>").toString();
}
}
@@ -0,0 +1,153 @@
package com.eliasmueller.llamamod.editor.tools.environment;
import com.eliasmueller.llamamod.editor.model.Light;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.cmd.Batch;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.model.cmd.SetProp;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.InspectorSection;
import com.eliasmueller.llamamod.editor.ui.Widgets;
import java.awt.Color;
import java.util.ArrayList;
import java.util.List;
import javax.swing.JComponent;
/** Inspector content for a light: type, colour + intensity, direction or position and radii. */
final class LightSection implements InspectorSection {
static final String[] TYPES = {"Directional (sun)", "Point light", "Negative point light (darkens)"};
@Override
public String title() {
return "Light";
}
@Override
public int order() {
return 20;
}
@Override
public boolean appliesTo(List<Node> selection) {
return selection.size() == 1 && selection.getFirst() instanceof Light;
}
@Override
public JComponent create(EditorContext ctx, List<Node> selection) {
return build(ctx, (Light) selection.getFirst());
}
static int typeIndex(Light l) {
return l.type() == Light.DIRECTIONAL ? 0 : l.type() == Light.POINT_BRIGHT ? 2 : 1;
}
/** Intensity = brightest channel (128 = 1.0 in the engine, i.e. ×2/255). */
static double intensity(Light l) {
return Math.max(l.r(), Math.max(l.g(), l.b())) / 127.5;
}
static Color hue(Light l) {
int m = Math.max(1, Math.max(l.r(), Math.max(l.g(), l.b())));
return new Color(l.r() * 255 / m, l.g() * 255 / m, l.b() * 255 / m);
}
static void setColor(EditorContext ctx, Light l, Color hue, double intensity, Object key) {
double k = intensity * 127.5 / 255;
int r = EnvMath.clamp((int) Math.round(hue.getRed() * k));
int g = EnvMath.clamp((int) Math.round(hue.getGreen() * k));
int b = EnvMath.clamp((int) Math.round(hue.getBlue() * k));
List<Command> c = List.of(
SetProp.of(l, l.prop("r"), r, key),
SetProp.of(l, l.prop("g"), g, key),
SetProp.of(l, l.prop("b"), b, key));
ctx.execute(new Batch("Light colour", c, key));
}
static JComponent build(EditorContext ctx, Light l) {
Binder b = new Binder(ctx, l);
Ui.Form f = new Ui.Form();
b.attach(f);
f.full(b.label(l::label));
f.full(Ui.line(
Widgets.button("Frame", "focus", "Show the light", ctx::focusSelection),
Widgets.button("Duplicate", "duplicate", null, () -> ctx.run("edit.duplicate"))));
f.header("Light");
f.row("Type", b.combo(TYPES, n -> typeIndex(l), i -> {
int t = i == 0 ? Light.DIRECTIONAL : i == 2 ? Light.POINT_BRIGHT : Light.POINT;
List<Command> c = new ArrayList<>();
c.add(SetProp.of(l, "type", t));
if (t != Light.DIRECTIONAL && l.rangeB() <= 0) {
c.add(SetProp.of(l, "a", 1.0));
c.add(SetProp.of(l, "rangeB", 3.0));
}
if (t == Light.DIRECTIONAL && l.y() == 0 && l.x() == 0 && l.z() == 0) {
c.add(SetProp.of(l, "y", 1.0));
}
ctx.execute(new Batch("Light type", c));
javax.swing.SwingUtilities.invokeLater(() -> {
ctx.selection().clear(LightSection.class);
ctx.selection().set(l, LightSection.class);
});
}));
Object ck = new Object();
f.row("Colour", b.color("Light colour", n -> hue(l), (c, key) -> setColor(ctx, l, c, intensity(l), key)));
javax.swing.JSpinner inten = b.number(0, 2, 0.05, "0.00", n -> intensity(l), v -> null, "r");
inten.addChangeListener(
e -> b.edit(() -> setColor(ctx, l, hue(l), ((Number) inten.getValue()).doubleValue(), ck)));
f.row("Intensity", inten, "1.0 = colour value 128 (engine factor ×2/255); 2.0 = maximum");
f.row("Red / green / blue", b.label(() -> l.r() + " / " + l.g() + " / " + l.b()));
if (l.type() == Light.DIRECTIONAL) {
f.header("Direction");
f.note("Lights object models from this direction (towards the light). Tiles are not lit by"
+ " lights – bake their vertex colours in the Environment panel.");
javax.swing.JSpinner az = b.number(-360, 360, 5, "0.#", n -> azimuth(l), v -> null, "x");
javax.swing.JSpinner el = b.number(-90, 90, 5, "0.#", n -> elevation(l), v -> null, "y");
f.row("Azimuth (°)", az, "0 = from +z, 90 = from +x (east)");
f.row("Elevation (°)", el);
Object dk = new Object();
javax.swing.event.ChangeListener dir = e -> b.edit(() -> setDirection(
ctx, l, ((Number) az.getValue()).doubleValue(), ((Number) el.getValue()).doubleValue(), dk));
az.addChangeListener(dir);
el.addChangeListener(dir);
} else {
f.header("Position & range");
f.row("X", b.number(-64, 4096, 0.25, "0.00", n -> l.x(), v -> v, "x"));
f.row("Y (height)", b.number(-64, 256, 0.25, "0.00", n -> l.y(), v -> v, "y"));
f.row("Z (row)", b.number(-64, 4096, 0.25, "0.00", n -> l.z(), v -> v, "z"));
f.row(
"Full radius",
b.number(0, 64, 0.1, "0.00", n -> l.a(), v -> v, "a"),
"Full strength up to this distance (cells)");
f.row(
"Falloff radius",
b.number(0, 64, 0.1, "0.00", n -> l.rangeB(), v -> v, "rangeB"),
"Linear falloff to zero at this distance; nothing beyond");
f.row(
"",
b.check("Flicker", n -> l.c() == 1, on -> b.set("c", on ? 1 : 0, null)),
"c = 1: the engine randomly scales the light ×1.5 / ×0.5 every frame (DAT_005865d4)");
f.note("Each object is lit by all directional lights and its nearest point lights, 6 at most.");
}
f.end();
return f;
}
static double azimuth(Light l) {
double[] d = EnvMath.direction(l);
return Math.toDegrees(Math.atan2(d[0], d[2]));
}
static double elevation(Light l) {
double[] d = EnvMath.direction(l);
return Math.toDegrees(Math.asin(Math.max(-1, Math.min(1, d[1]))));
}
static void setDirection(EditorContext ctx, Light l, double azDeg, double elDeg, Object key) {
double[] d = EnvMath.directionFrom(azDeg, elDeg);
List<Command> c = List.of(
SetProp.of(l, l.prop("x"), d[0], key),
SetProp.of(l, l.prop("y"), d[1], key),
SetProp.of(l, l.prop("z"), d[2], key));
ctx.execute(new Batch("Light direction", c, key));
}
}
@@ -0,0 +1,203 @@
package com.eliasmueller.llamamod.editor.tools.environment;
import com.eliasmueller.llamamod.editor.model.EnvZone;
import com.eliasmueller.llamamod.editor.model.Light;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.cmd.AddNode;
import com.eliasmueller.llamamod.editor.model.cmd.SetProp;
import com.eliasmueller.llamamod.editor.render.Overlay;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.Tool;
import com.eliasmueller.llamamod.editor.ui.ToolEvent;
import java.awt.Color;
import java.awt.Cursor;
import java.util.Locale;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JSpinner;
import org.joml.Vector3f;
/** Light tool: click to place a point light (1 cell above the surface), drag to set its radius. */
final class LightTool implements Tool {
record Preset(String name, int r, int g, int b) {
@Override
public String toString() {
return name;
}
}
static final Preset[] PRESETS = {
new Preset("Torch (orange)", 123, 86, 0),
new Preset("Fire (red)", 160, 40, 0),
new Preset("Lamp (warm white)", 128, 112, 80),
new Preset("Magic (cyan)", 0, 120, 110),
new Preset("Moon (blue)", 40, 60, 120),
new Preset("Glow (green)", 20, 128, 40)
};
private Preset preset = PRESETS[0];
private double height = 1.0;
private double radius = 3.0;
private Light dragging;
private Object key;
private Vector3f press;
private Vector3f hover;
@Override
public String id() {
return "env.lights";
}
@Override
public String name() {
return "Lights";
}
@Override
public String icon() {
return "light";
}
@Override
public javax.swing.KeyStroke shortcut() {
return javax.swing.KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_U, 0);
}
@Override
public String tooltip() {
return "Place point lights: click to place, drag to set the radius";
}
@Override
public String group() {
return "env";
}
@Override
public int order() {
return 310;
}
@Override
public void activate(EditorContext ctx) {
ctx.setLayerVisible("lights", true);
ctx.setLayerVisible(EnvOverlays.LAYER_LIGHTS, true);
}
@Override
public boolean mousePressed(ToolEvent e) {
EditorContext ctx = e.ctx();
if (ctx.scene() == null || e.pick() instanceof Light || e.pick() instanceof EnvZone) {
return false;
}
Vector3f g = e.ground();
if (g == null) {
return false;
}
Light l = point(ctx.scene(), g.x, g.y + height, -g.z, preset, Math.min(1, radius / 3), radius);
ctx.execute(new AddNode(l));
ctx.selection().set(l, this);
dragging = l;
key = new Object();
press = g;
return true;
}
static Light point(Scene s, double x, double y, double z, Preset p, double a, double b) {
Light l = (Light) s.create(NodeKind.LIGHT);
l.setRaw("type", Light.POINT);
l.setRaw("r", p.r());
l.setRaw("g", p.g());
l.setRaw("b", p.b());
l.setRaw("x", x);
l.setRaw("y", y);
l.setRaw("z", z);
l.setRaw("a", a);
l.setRaw("rangeB", b);
return l;
}
static Light directional(Scene s, double azDeg, double elDeg, int r, int g, int b) {
Light l = (Light) s.create(NodeKind.LIGHT);
double[] d = EnvMath.directionFrom(azDeg, elDeg);
l.setRaw("type", Light.DIRECTIONAL);
l.setRaw("r", r);
l.setRaw("g", g);
l.setRaw("b", b);
l.setRaw("x", d[0]);
l.setRaw("y", d[1]);
l.setRaw("z", d[2]);
return l;
}
@Override
public void mouseDragged(ToolEvent e) {
if (dragging == null || !e.ctx().scene().contains(dragging)) {
return;
}
Vector3f g = e.onPlane(press.y);
if (g == null) {
return;
}
double r = Math.max(0.25, Math.hypot(g.x - press.x, g.z - press.z));
e.ctx()
.execute(new com.eliasmueller.llamamod.editor.model.cmd.Batch(
"Light radius",
java.util.List.of(
SetProp.of(dragging, dragging.prop("rangeB"), r, key),
SetProp.of(dragging, dragging.prop("a"), Math.min(1.0, r * 0.4), key)),
key));
}
@Override
public void mouseReleased(ToolEvent e) {
if (dragging != null) {
e.ctx()
.status(String.format(
Locale.ROOT,
"Point light at %.1f, %.1f (radius %.1f)",
dragging.x(),
dragging.z(),
dragging.rangeB()));
}
dragging = null;
}
@Override
public void mouseMoved(ToolEvent e) {
hover = e.ctx().scene() == null ? null : e.ground();
e.ctx().repaintTool();
}
@Override
public void drawOverlay(Overlay depthTested, Overlay onTop) {
if (hover == null || dragging != null) {
return;
}
Color c =
new Color(Math.min(255, preset.r() * 2), Math.min(255, preset.g() * 2), Math.min(255, preset.b() * 2));
double[] p = {hover.x, hover.y + height, hover.z};
EnvOverlays.sphere(onTop, p, 0.15, EnvOverlays.rgba(c, 255));
EnvOverlays.ring(
depthTested, new double[] {hover.x, hover.y + 0.03, hover.z}, radius, EnvOverlays.rgba(c, 200));
onTop.line(p, new double[] {hover.x, hover.y, hover.z}, EnvOverlays.rgba(c, 160));
}
@Override
public JComponent optionsBar(EditorContext ctx) {
JComboBox<Preset> p = new JComboBox<>(PRESETS);
p.setSelectedItem(preset);
p.addActionListener(a -> preset = (Preset) p.getSelectedItem());
p.setFocusable(false);
JSpinner h = Ui.number(height, 0, 10, 0.25, "0.00", v -> height = v);
JSpinner r = Ui.number(radius, 0.25, 32, 0.25, "0.00", v -> radius = v);
return Ui.line(new JLabel("Colour"), p, new JLabel("Height"), h, new JLabel("Radius"), r);
}
@Override
public Cursor cursor() {
return Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR);
}
}
@@ -0,0 +1,38 @@
package com.eliasmueller.llamamod.editor.tools.environment;
import com.eliasmueller.llamamod.editor.model.Light;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.render.RenderInstance;
import com.eliasmueller.llamamod.editor.render.RenderMesh;
import java.util.List;
import org.joml.Vector3f;
/** Per-vertex object lighting like the engine (preview of LGHT lights on object models). */
final class ObjectLighting {
private ObjectLighting() {}
/** Vertex colours (0xRRGGBBAA, 0x80 = 1.0) of an object instance lit by the level's lights. */
static int[] colors(Scene s, Strat st, RenderInstance ri, double[] ambient) {
RenderMesh m = ri.mesh;
List<Light> lights = EnvMath.lightsFor(s, st.gx(), st.gy(), st.gz());
int nv = m.vertexCount();
int[] out = new int[nv];
Vector3f p = new Vector3f(), n = new Vector3f();
for (int v = 0; v < nv; v++) {
p.set(m.positions[3 * v], m.positions[3 * v + 1], m.positions[3 * v + 2]);
n.set(m.normals[3 * v], m.normals[3 * v + 1], m.normals[3 * v + 2]);
ri.transform.transformPosition(p);
ri.transform.transformDirection(n);
if (n.lengthSquared() > 0) {
n.normalize();
}
double[] c = EnvMath.shade(ambient, lights, new double[] {p.x, p.y, -p.z}, new double[] {n.x, n.y, -n.z});
int r = EnvMath.clamp((int) Math.round(c[0] * 128));
int g = EnvMath.clamp((int) Math.round(c[1] * 128));
int b = EnvMath.clamp((int) Math.round(c[2] * 128));
out[v] = r << 24 | g << 16 | b << 8 | 0xff;
}
return out;
}
}
@@ -0,0 +1,88 @@
package com.eliasmueller.llamamod.editor.tools.environment;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/**
* Sets one property on many nodes (e.g. vertex colours of the tiles under a paint brush). Commands
* with the same merge key merge even if they touch different nodes (one undo step per stroke): the
* first old value and the last new value of every node are kept.
*/
public final class PropMapCommand implements Command {
private final String label;
private final String prop;
private final Map<Node, Object[]> values;
private final Object mergeKey;
/** newValues: node → value; old values are read now. */
public PropMapCommand(String label, String prop, Map<? extends Node, ?> newValues, Object mergeKey) {
this.label = label;
this.prop = prop;
this.mergeKey = mergeKey;
this.values = new LinkedHashMap<>();
for (Map.Entry<? extends Node, ?> e : newValues.entrySet()) {
values.put(e.getKey(), new Object[] {copy(e.getKey().get(prop)), copy(e.getValue())});
}
}
private PropMapCommand(String label, String prop, Map<Node, Object[]> values, Object mergeKey, boolean raw) {
this.label = label;
this.prop = prop;
this.values = values;
this.mergeKey = mergeKey;
}
private static Object copy(Object v) {
if (v instanceof int[] a) {
return a.clone();
}
if (v instanceof double[] a) {
return a.clone();
}
return v;
}
@Override
public String label() {
return label;
}
@Override
public void apply(Scene s) {
for (Map.Entry<Node, Object[]> e : values.entrySet()) {
s.set(e.getKey(), prop, copy(e.getValue()[1]));
}
}
@Override
public void revert(Scene s) {
for (Map.Entry<Node, Object[]> e : values.entrySet()) {
s.set(e.getKey(), prop, copy(e.getValue()[0]));
}
}
@Override
public Object mergeKey() {
return mergeKey;
}
@Override
public Command mergeWith(Command next) {
if (!(next instanceof PropMapCommand o) || mergeKey == null || !Objects.equals(mergeKey, o.mergeKey)) {
return null;
}
if (!prop.equals(o.prop)) {
return null;
}
Map<Node, Object[]> m = new LinkedHashMap<>(values);
for (Map.Entry<Node, Object[]> e : o.values.entrySet()) {
Object[] mine = m.get(e.getKey());
m.put(e.getKey(), new Object[] {mine != null ? mine[0] : e.getValue()[0], e.getValue()[1]});
}
return new PropMapCommand(label, prop, m, mergeKey, true);
}
}
@@ -0,0 +1,288 @@
package com.eliasmueller.llamamod.editor.tools.environment;
import com.eliasmueller.llamamod.editor.model.Regions;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Tile;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* Visibility region helpers: automatic layouts (all visible, rectangular blocks, connected areas)
* and the cost of a layout (triangles the engine draws for a camera in each region; the tile
* renderer FUN_0042bf40 skips cells whose region bit is not in the camera region's mask, a mask of
* 0 means everything).
*/
public final class RegionGen {
private RegionGen() {}
public enum Mode {
ALL_VISIBLE("One region, everything visible"),
BLOCKS("Rectangular blocks, visible within a distance"),
CONNECTED("Connected areas (islands / rooms), visible within a distance");
public final String label;
Mode(String label) {
this.label = label;
}
@Override
public String toString() {
return label;
}
}
/** Region grid (W·H) and table (mask, value) × 32. */
public record Layout(int[] grid, int[] table) {}
public static Layout generate(Scene s, Mode mode, double distance) {
int w = s.settings().width(), h = s.settings().height();
int[] table = s.regions().table().length == 2 * Regions.COUNT
? s.regions().table().clone()
: new int[2 * Regions.COUNT];
int[] grid = new int[w * h];
int n;
switch (mode) {
case ALL_VISIBLE -> {
for (int r = 0; r < Regions.COUNT; r++) {
table[2 * r] = r == 0 ? -1 : 0;
}
return new Layout(grid, table);
}
case BLOCKS -> n = blocks(w, h, grid);
default -> n = connected(s, w, h, grid);
}
int[][] box = new int[n][];
for (int i = 0; i < n; i++) {
box[i] = new int[] {Integer.MAX_VALUE, Integer.MAX_VALUE, -1, -1};
}
for (int cz = 0; cz < h; cz++) {
for (int cx = 0; cx < w; cx++) {
int r = grid[cx + cz * w];
int[] b = box[r];
b[0] = Math.min(b[0], cx);
b[1] = Math.min(b[1], cz);
b[2] = Math.max(b[2], cx);
b[3] = Math.max(b[3], cz);
}
}
for (int r = 0; r < Regions.COUNT; r++) {
table[2 * r] = 0;
}
for (int a = 0; a < n; a++) {
int m = 0;
for (int b = 0; b < n; b++) {
if (a == b || boxDistance(box[a], box[b]) <= distance) {
m |= 1 << b;
}
}
table[2 * a] = m;
}
return new Layout(grid, table);
}
private static double boxDistance(int[] a, int[] b) {
double dx = Math.max(0, Math.max(a[0] - b[2], b[0] - a[2]) - 1);
double dz = Math.max(0, Math.max(a[1] - b[3], b[1] - a[3]) - 1);
return Math.sqrt(dx * dx + dz * dz);
}
private static int blocks(int w, int h, int[] grid) {
int bx = 1, bz = 1;
while (bx * bz < Regions.COUNT) {
if ((double) w / bx >= (double) h / bz && (bx + 1) * bz <= Regions.COUNT) {
bx++;
} else if (bx * (bz + 1) <= Regions.COUNT) {
bz++;
} else {
break;
}
}
for (int cz = 0; cz < h; cz++) {
for (int cx = 0; cx < w; cx++) {
grid[cx + cz * w] = Math.min(bx - 1, cx * bx / w) + Math.min(bz - 1, cz * bz / h) * bx;
}
}
return bx * bz;
}
/** Flood fill over cells with tiles (neighbours within 2.5 height), ≤ 32 areas, empty cells to the nearest. */
private static int connected(Scene s, int w, int h, int[] grid) {
double[] top = topHeights(s, w, h);
int[] lab = new int[w * h];
Arrays.fill(lab, -1);
List<List<Integer>> areas = new ArrayList<>();
int[] dx = {1, -1, 0, 0}, dz = {0, 0, 1, -1};
for (int i = 0; i < w * h; i++) {
if (Double.isNaN(top[i]) || lab[i] >= 0) {
continue;
}
List<Integer> area = new ArrayList<>();
ArrayDeque<Integer> q = new ArrayDeque<>();
q.add(i);
lab[i] = areas.size();
while (!q.isEmpty()) {
int c = q.poll();
area.add(c);
int cx = c % w, cz = c / w;
for (int k = 0; k < 4; k++) {
int nx = cx + dx[k], nz = cz + dz[k];
if (nx < 0 || nz < 0 || nx >= w || nz >= h) {
continue;
}
int nc = nx + nz * w;
if (lab[nc] < 0 && !Double.isNaN(top[nc]) && Math.abs(top[nc] - top[c]) < 2.5) {
lab[nc] = lab[i];
q.add(nc);
}
}
}
areas.add(area);
}
if (areas.isEmpty()) {
return 1;
}
// merge the smallest areas into their nearest neighbour until 32 remain
int[] owner = new int[areas.size()];
for (int i = 0; i < owner.length; i++) {
owner[i] = i;
}
int alive = areas.size();
int[] size = areas.stream().mapToInt(List::size).toArray();
double[][] centre = new double[areas.size()][2];
for (int a = 0; a < areas.size(); a++) {
for (int c : areas.get(a)) {
centre[a][0] += c % w;
centre[a][1] += c / w;
}
centre[a][0] /= size[a];
centre[a][1] /= size[a];
}
while (alive > Regions.COUNT) {
int small = -1;
for (int a = 0; a < owner.length; a++) {
if (owner[a] == a && (small < 0 || size[a] < size[small])) {
small = a;
}
}
int best = -1;
double bd = Double.MAX_VALUE;
for (int a = 0; a < owner.length; a++) {
if (owner[a] == a && a != small) {
double d =
EnvMath.sq(centre[a][0] - centre[small][0]) + EnvMath.sq(centre[a][1] - centre[small][1]);
if (d < bd) {
bd = d;
best = a;
}
}
}
for (int a = 0; a < owner.length; a++) {
if (owner[a] == small) {
owner[a] = best;
}
}
centre[best][0] =
(centre[best][0] * size[best] + centre[small][0] * size[small]) / (size[best] + size[small]);
centre[best][1] =
(centre[best][1] * size[best] + centre[small][1] * size[small]) / (size[best] + size[small]);
size[best] += size[small];
alive--;
}
int[] index = new int[owner.length];
Arrays.fill(index, -1);
int n = 0;
for (int a = 0; a < owner.length; a++) {
if (owner[a] == a) {
index[a] = n++;
}
}
// multi-source BFS: every cell gets the region of the nearest occupied cell
Arrays.fill(grid, -1);
ArrayDeque<Integer> q = new ArrayDeque<>();
for (int i = 0; i < w * h; i++) {
if (lab[i] >= 0) {
grid[i] = index[owner[lab[i]]];
q.add(i);
}
}
while (!q.isEmpty()) {
int c = q.poll();
int cx = c % w, cz = c / w;
for (int k = 0; k < 4; k++) {
int nx = cx + dx[k], nz = cz + dz[k];
if (nx >= 0 && nz >= 0 && nx < w && nz < h && grid[nx + nz * w] < 0) {
grid[nx + nz * w] = grid[c];
q.add(nx + nz * w);
}
}
}
return n;
}
/** Highest tile y per cell (NaN = no tile). */
static double[] topHeights(Scene s, int w, int h) {
double[] top = new double[w * h];
Arrays.fill(top, Double.NaN);
for (Tile t : s.tiles()) {
int cx = t.cellX(), cz = t.cellZ();
if (cx >= 0
&& cz >= 0
&& cx < w
&& cz < h
&& t.model() != null
&& t.model().faceCount() > 0) {
int i = cx + cz * w;
top[i] = Double.isNaN(top[i]) ? t.y() : Math.max(top[i], t.y());
}
}
return top;
}
/** Triangles per region (cells of the region), index = region. */
public static long[] trianglesPerRegion(Scene s, int[] grid) {
int w = s.settings().width(), h = s.settings().height();
long[] tris = new long[Regions.COUNT];
for (Tile t : s.tiles()) {
int cx = t.cellX(), cz = t.cellZ();
if (cx >= 0 && cz >= 0 && cx < w && cz < h && t.model() != null && cx + cz * w < grid.length) {
tris[grid[cx + cz * w] & 31] += t.model().faceCount();
}
}
return tris;
}
/** Triangles drawn for a camera in each region (all regions in its mask; mask 0 = all). */
public static long[] visibleTriangles(Scene s, int[] grid, int[] table) {
long[] per = trianglesPerRegion(s, grid);
long all = Arrays.stream(per).sum();
long[] out = new long[Regions.COUNT];
for (int r = 0; r < Regions.COUNT; r++) {
int m = table[2 * r];
if (m == 0) {
out[r] = all;
continue;
}
for (int b = 0; b < Regions.COUNT; b++) {
if ((m & (1 << b)) != 0) {
out[r] += per[b];
}
}
}
return out;
}
/** Regions that occur in the grid. */
public static boolean[] used(int[] grid) {
boolean[] u = new boolean[Regions.COUNT];
for (int g : grid) {
u[g & 31] = true;
}
return u;
}
/** Above this many triangles visible from one region the game gets slow on old hardware. */
public static final long HEAVY = 30000;
}
@@ -0,0 +1,200 @@
package com.eliasmueller.llamamod.editor.tools.environment;
import com.eliasmueller.llamamod.editor.model.Regions;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.cmd.SetProp;
import com.eliasmueller.llamamod.editor.render.Overlay;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.Icons;
import com.eliasmueller.llamamod.editor.ui.Tool;
import com.eliasmueller.llamamod.editor.ui.ToolEvent;
import java.awt.Component;
import java.awt.Cursor;
import javax.swing.DefaultListCellRenderer;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JList;
import org.joml.Vector3f;
/** Paints visibility regions onto grid cells (square brush); Ctrl+click picks the region of a cell. */
final class RegionPaintTool implements Tool {
int region = 1;
int size = 1;
private Object stroke;
private Vector3f hover;
private JComboBox<Integer> regionBox;
@Override
public String id() {
return "env.regions";
}
@Override
public String name() {
return "Paint regions";
}
@Override
public String icon() {
return "region";
}
@Override
public javax.swing.KeyStroke shortcut() {
return javax.swing.KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_Y, 0);
}
@Override
public String tooltip() {
return "Paint visibility regions on grid cells (Ctrl+click picks a region)";
}
@Override
public String group() {
return "env";
}
@Override
public int order() {
return 330;
}
@Override
public void activate(EditorContext ctx) {
ctx.setLayerVisible("regions", true);
}
void setRegion(int r) {
region = r & 31;
if (regionBox != null && !Integer.valueOf(region).equals(regionBox.getSelectedItem())) {
regionBox.setSelectedItem(region);
}
}
private int[] cell(Vector3f p) {
return new int[] {(int) Math.floor(p.x), (int) Math.floor(-p.z)};
}
@Override
public boolean mousePressed(ToolEvent e) {
Scene s = e.ctx().scene();
Vector3f p = e.ground();
if (s == null || p == null) {
return s != null;
}
int[] c = cell(p);
if (e.ctrl()) {
int r = s.regions().regionAt(c[0], c[1], s.settings().width());
if (r >= 0) {
setRegion(r);
e.ctx().status("Region " + r);
}
return true;
}
stroke = new Object();
paint(e.ctx(), c);
return true;
}
@Override
public void mouseDragged(ToolEvent e) {
Vector3f p = e.ground();
hover = p;
if (stroke != null && p != null) {
paint(e.ctx(), cell(p));
}
e.ctx().repaintTool();
}
@Override
public void mouseReleased(ToolEvent e) {
stroke = null;
}
@Override
public void mouseMoved(ToolEvent e) {
hover = e.ctx().scene() == null ? null : e.ground();
e.ctx().repaintTool();
}
private void paint(EditorContext ctx, int[] c) {
Scene s = ctx.scene();
Regions rg = s.regions();
int w = s.settings().width(), h = s.settings().height();
int[] grid = rg.grid();
if (grid.length != w * h) {
return;
}
int[] g = null;
int half = size / 2;
for (int z = c[1] - half; z <= c[1] + half; z++) {
for (int x = c[0] - half; x <= c[0] + half; x++) {
if (x < 0 || z < 0 || x >= w || z >= h || grid[x + z * w] == region) {
continue;
}
if (g == null) {
g = grid.clone();
}
g[x + z * w] = region;
}
}
if (g != null) {
ctx.execute(SetProp.of(rg, rg.prop("grid"), g, stroke));
}
}
@Override
public void drawOverlay(Overlay depthTested, Overlay onTop) {
if (hover == null) {
return;
}
int[] c = cell(hover);
int half = size / 2;
double y = hover.y + 0.05;
int col = EnvOverlays.rgba(EnvMath.regionColor(region), 255);
onTop.box(c[0] - half, y, -(c[1] + half) - 1, c[0] + half + 1, y + 0.2, -(c[1] - half), col);
}
@Override
public JComponent optionsBar(EditorContext ctx) {
Integer[] items = new Integer[Regions.COUNT];
for (int i = 0; i < items.length; i++) {
items[i] = i;
}
regionBox = new JComboBox<>(items);
regionBox.setMaximumRowCount(16);
regionBox.setRenderer(new DefaultListCellRenderer() {
@Override
public Component getListCellRendererComponent(JList<?> l, Object v, int i, boolean s, boolean f) {
JLabel lab = (JLabel) super.getListCellRendererComponent(l, "Region " + v, i, s, f);
lab.setIcon(Icons.swatch(EnvMath.regionColor((Integer) v), 14, 12));
return lab;
}
});
regionBox.setSelectedItem(region);
regionBox.addActionListener(a -> {
if (regionBox.getSelectedItem() instanceof Integer r && r != region) {
setRegion(r);
}
});
regionBox.setFocusable(false);
JComboBox<String> sz = new JComboBox<>(new String[] {"1 × 1", "3 × 3", "5 × 5", "9 × 9", "17 × 17"});
int[] sizes = {1, 3, 5, 9, 17};
for (int i = 0; i < sizes.length; i++) {
if (sizes[i] == size) {
sz.setSelectedIndex(i);
}
}
sz.addActionListener(a -> size = sizes[sz.getSelectedIndex()]);
sz.setFocusable(false);
JLabel hint = new JLabel("Ctrl+click: pick · masks in the Visibility panel");
hint.setEnabled(false);
return Ui.line(new JLabel("Region"), regionBox, new JLabel("Brush"), sz, hint);
}
@Override
public Cursor cursor() {
return Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR);
}
}
Loaded 100 of 826 files, more files were not shown because too many files have changed in this diff. Show more