testlevel chapters 2-31 overwrite game memory (coin counter and flag rows end after world 8: progress, language, options): level projects, play-tests and the arena use chapters 1 and 32-49, old settings migrate; level tally counts the level's own coins and secrets

This commit is contained in:
MineTec committed 2026-11-07 15:27:08 +01:00
1 parent 987b0104fa
commit d6b852a1b6
35 files changed
+1015 -175

No files matched your search

@@ -17,6 +17,7 @@ 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 com.eliasmueller.llamamod.save.TestLevelChapters;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.GridLayout;
@@ -38,6 +39,7 @@ import javax.swing.JScrollPane;
import javax.swing.JSpinner;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.SpinnerListModel;
/** Level → Settings: name, chapter, grid size, death plane, WFPC flags, object pool (one undo step). */
final class LevelSettingsDialog {
@@ -80,9 +82,13 @@ final class LevelSettingsDialog {
"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 -> {});
JSpinner chapter = new JSpinner(new SpinnerListModel(TestLevelChapters.all()));
chapter.setValue(TestLevelChapters.migrate(st.targetChapter()));
JLabel used = new JLabel(usedChapters(ctx, st.targetChapter()));
f.row("TestLevel chapter", chapter, "New levels replace this chapter of world 9 (TestLevel)");
f.row(
"TestLevel chapter",
chapter,
"New levels replace this chapter of world 9 (TestLevel): 32–49, or 1 (the Test World's)");
f.full(used);
chapter.setEnabled(st.isNewLevel());
@@ -17,6 +17,7 @@ import com.eliasmueller.llamamod.platform.Config;
import com.eliasmueller.llamamod.platform.GameLauncher;
import com.eliasmueller.llamamod.platform.Json;
import com.eliasmueller.llamamod.platform.Progress;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import com.eliasmueller.llamamod.save.Worlds;
import java.io.IOException;
import java.nio.file.Files;
@@ -50,7 +51,7 @@ public final class PlaytestRunner {
/** World/chapter of a project: TestLevel chapter, or the original's place; null if not on the map. */
public static int[] target(Document d) {
if (d.isNewLevel()) {
return new int[] {9, Math.max(1, d.scene().settings().targetChapter())};
return new int[] {9, TestLevelChapters.migrate(d.scene().settings().targetChapter())};
}
Matcher m = Pattern.compile("t1l(\\d)m(\\d{3})").matcher(d.base().toLowerCase(Locale.ROOT));
if (m.matches()) {
@@ -4,6 +4,7 @@ import com.eliasmueller.llamamod.editor.io.ProjectIO;
import com.eliasmueller.llamamod.editor.io.ProjectInfo;
import com.eliasmueller.llamamod.editor.io.Templates;
import com.eliasmueller.llamamod.mods.levels.LevelProjects;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
@@ -28,7 +29,7 @@ import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JSpinner;
import javax.swing.JTextField;
import javax.swing.SpinnerNumberModel;
import javax.swing.SpinnerListModel;
import javax.swing.UIManager;
/** New level: name, base level (object types, textures, sky, music), template, TestLevel chapter. */
@@ -51,7 +52,8 @@ final class NewLevelDialog extends JDialog {
base.setSelectedItem(l);
}
}
JSpinner chapter = new JSpinner(new SpinnerNumberModel(nextFreeChapter(w), 1, 49, 1));
JSpinner chapter = new JSpinner(new SpinnerListModel(TestLevelChapters.all()));
chapter.setValue(nextFreeChapter(w));
PicturePanel pic = new PicturePanel(300, 170);
Runnable updatePic = () -> {
OriginalLevels.Level l = (OriginalLevels.Level) base.getSelectedItem();
@@ -75,7 +77,8 @@ final class NewLevelDialog extends JDialog {
row,
"TestLevel chapter",
chapter,
"The level replaces this chapter of world 9 (TestLevel) when it is enabled");
"The level replaces this chapter of world 9 (TestLevel) when it is enabled (32–49; 1 is the Test"
+ " World's)");
JPanel templates = new JPanel();
templates.setLayout(new BoxLayout(templates, BoxLayout.Y_AXIS));
ButtonGroup g = new ButtonGroup();
@@ -161,7 +164,7 @@ final class NewLevelDialog extends JDialog {
return row + 1;
}
/** First TestLevel chapter from 2 that no project in the levels folder uses. */
/** First usable TestLevel chapter from 32 that no project in the levels folder uses. */
static int nextFreeChapter(EditorWindow w) {
Set<Integer> used = new HashSet<>();
for (Path p : LevelProjects.list(w.env().levelsDir())) {
@@ -174,11 +177,7 @@ final class NewLevelDialog extends JDialog {
// skip broken files
}
}
int c = 2;
while (used.contains(c) && c < 49) {
c++;
}
return c;
return TestLevelChapters.pick(TestLevelChapters.FIRST, used);
}
Result result() {
@@ -15,6 +15,7 @@ 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.platform.Json;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.File;
@@ -592,7 +593,7 @@ public final class ProjectController {
? String.format(
Locale.ROOT,
"T1L9M%03d",
Math.max(1, d.scene().settings().targetChapter()))
TestLevelChapters.migrate(d.scene().settings().targetChapter()))
: d.base();
fc.setSelectedFile(new File(fc.getCurrentDirectory(), stem + ".wad"));
if (fc.showSaveDialog(w) != JFileChooser.APPROVE_OPTION) {
@@ -1,6 +1,8 @@
package com.eliasmueller.llamamod.format.stpcmerge;
import com.eliasmueller.llamamod.format.TextChunk;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
@@ -87,6 +89,41 @@ public final class TextMerge {
}
}
/**
* TEXT chunk {@code orig} with the pages, entries and animations of {@code tex} (usually
* {@code Textures.of(orig)} extended by {@link #merge}); palettes and the rest are kept.
*/
public static byte[] rebuild(byte[] orig, Textures tex) {
Textures old = Textures.of(orig);
int head = 8 + 8 * old.entries.size();
for (Anim a : old.anims) {
head += 3 + a.data().length;
}
TextChunk tc = TextChunk.parse(orig);
byte[] rest = Arrays.copyOfRange(tc.tail, head, tc.tail.length);
ByteBuffer b = ByteBuffer.allocate(8 + 8 * tex.entries.size() + tex.anims.size() * 64 + rest.length + 1024)
.order(ByteOrder.LITTLE_ENDIAN);
b.putInt(tex.entries.size());
for (TextChunk.TextureEntry e : tex.entries) {
b.putShort((short) e.flags())
.put((byte) e.page())
.put((byte) e.page2())
.put((byte) e.u0())
.put((byte) e.u1())
.put((byte) e.v0())
.put((byte) e.v1());
}
b.putInt(tex.anims.size());
for (Anim a : tex.anims) {
b.putShort((short) a.texture()).put((byte) a.type()).put(a.data());
}
b.put(rest);
tc.pages.clear();
tc.pages.addAll(tex.pages);
tc.tail = Arrays.copyOf(b.array(), b.position());
return tc.build();
}
/** Result: map[srcIndex] = target index (−1 = not needed), counts of added items. */
public record Result(int[] map, int addedPages, int addedEntries, int addedAnims, int reusedEntries) {}
@@ -3,7 +3,6 @@ package com.eliasmueller.llamamod.mods.arena;
import com.eliasmueller.llamamod.format.Bytes;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.MapChunk;
import com.eliasmueller.llamamod.format.TextChunk;
import com.eliasmueller.llamamod.format.Wad;
import com.eliasmueller.llamamod.format.stpcmerge.DialogMerge;
import com.eliasmueller.llamamod.format.stpcmerge.SoundMerge;
@@ -106,10 +105,6 @@ final class ArenaImport {
StpcMerge.Analysis tgt = new StpcMerge.Analysis(new StpcImage(wad.require("STPC"), lv.wfpc()), troots);
byte[] text = wad.require("TEXT");
TextMerge.Textures tex = TextMerge.Textures.of(text);
int origTail = 8 + 8 * tex.entries.size();
for (TextMerge.Anim a : tex.anims) {
origTail += 3 + a.data().length;
}
List<SoundMerge.Sample> snd = SoundMerge.parse(wad.get("SMPC"));
DialogMerge.Texts texts = wad.get("LGPC") == null ? null : DialogMerge.Texts.parse(wad.get("LGPC"));
DialogMerge.Speech speech = DialogMerge.Speech.parse(wad.get("SRPC"));
@@ -143,40 +138,13 @@ final class ArenaImport {
Wad out = new Wad(wad.chunks());
out.set("STPC", res.stpc());
out.set("MAP", map.build());
out.set("TEXT", text(text, tex, origTail));
out.set("TEXT", TextMerge.rebuild(text, tex));
if (wad.get("SMPC") != null) {
out.set("SMPC", SoundMerge.build(snd));
}
return new Merged(out.toBytes(), codes, res.stats());
}
/** TEXT chunk with the (extended) pages, entries and animations of tex; the rest is kept. */
private static byte[] text(byte[] orig, TextMerge.Textures tex, int origTailHead) {
TextChunk tc = TextChunk.parse(orig);
byte[] rest = Arrays.copyOfRange(tc.tail, origTailHead, tc.tail.length);
ByteBuffer b = ByteBuffer.allocate(8 + 8 * tex.entries.size() + tex.anims.size() * 64 + rest.length + 1024)
.order(ByteOrder.LITTLE_ENDIAN);
b.putInt(tex.entries.size());
for (TextChunk.TextureEntry e : tex.entries) {
b.putShort((short) e.flags())
.put((byte) e.page())
.put((byte) e.page2())
.put((byte) e.u0())
.put((byte) e.u1())
.put((byte) e.v0())
.put((byte) e.v1());
}
b.putInt(tex.anims.size());
for (TextMerge.Anim a : tex.anims) {
b.putShort((short) a.texture()).put((byte) a.type()).put(a.data());
}
b.put(rest);
tc.pages.clear();
tc.pages.addAll(tex.pages);
tc.tail = Arrays.copyOf(b.array(), b.position());
return tc.build();
}
private static void putI32(byte[] d, int o, int v) {
ByteBuffer.wrap(d, o, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(v);
}
@@ -5,6 +5,8 @@ import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModContext;
import com.eliasmueller.llamamod.mods.ModOption;
import com.eliasmueller.llamamod.mods.levels.LevelTally;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
@@ -53,7 +55,29 @@ public final class ArenaMod implements Mod {
ModOption.integer(
"energy", "Llama energy", "Energy cap of the llama in the arena (5 = normal).", 4, 1, 5),
ModOption.integer("coins", "Coins per hit", "Coins that pop out per hit (5 more at the end).", 1, 0, 5),
ModOption.integer("chapter", "TestLevel chapter", "Chapter of world 9 the arena replaces.", 8, 2, 8));
chapterOption());
}
static final int DEFAULT_CHAPTER = TestLevelChapters.LAST;
/** TestLevel chapters 32–49 ({@link TestLevelChapters}); a stored 2–8 falls back to 49. */
static ModOption chapterOption() {
ModOption.Choice[] cs = new ModOption.Choice[TestLevelChapters.LAST - TestLevelChapters.FIRST + 1];
for (int c = TestLevelChapters.FIRST; c <= TestLevelChapters.LAST; c++) {
cs[c - TestLevelChapters.FIRST] = ModOption.of(Integer.toString(c), Integer.toString(c));
}
return ModOption.choice(
"chapter",
"TestLevel chapter",
"Chapter of world 9 the arena replaces (32–49; chapter 1 is the Test World's).",
Integer.toString(DEFAULT_CHAPTER),
cs);
}
/** Coins the arena can give beyond its placed ones: hit and death coins of every alligator, exit bonus. */
static int dynamicCoins(ArenaScripts.Settings s) {
int perGator = (s.lives() - 1) * s.coinsPerHit() + 5;
return ArenaScripts.totalAlligators(s.waves()) * perGator + 5;
}
/** After Test World (50) and level projects (60), so a clash with them is reported here. */
@@ -95,7 +119,8 @@ public final class ArenaMod implements Mod {
}
ctx.progress(0.0, "building alligator arena");
byte[] base = ctx.read(LevelBuilder.BASE_WAD);
byte[] wad = new Arena(settings(ctx))
ArenaScripts.Settings settings = settings(ctx);
byte[] wad = new Arena(settings)
.buildWad(
base,
rel -> {
@@ -106,7 +131,7 @@ public final class ArenaMod implements Mod {
}
},
scriptOpts);
ctx.write(target, wad);
ctx.write(target, LevelTally.counted(wad, dynamicCoins(settings)));
ctx.progress(0.9, "loading picture");
ctx.write("T19" + chapter + ".COR", loadingPicture(chapter));
ctx.progress(1.0, "arena done");
@@ -10,6 +10,7 @@ import com.eliasmueller.llamamod.mods.ModContext;
import com.eliasmueller.llamamod.mods.ModOption;
import com.eliasmueller.llamamod.mods.boottolevel.BootToLevelMod;
import com.eliasmueller.llamamod.mods.boottolevel.StartOverride;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -129,14 +130,22 @@ public final class LevelProjectMod implements Mod {
if (!info.isNewLevel()) {
return List.of();
}
return List.of(ModOption.integer(
"chapter",
"TestLevel chapter",
"Chapter of world 9 (TestLevel) the level replaces. Chapter 1 is also used by the Test World mod;"
+ " each level needs its own chapter. Unlock it on the Save games tab.",
defaultChapter,
1,
49));
return List.of(chapterOption(
"Chapter of world 9 (TestLevel) the level replaces: 32–49, or 1 (the Test World's chapter). Each"
+ " level needs its own chapter. Unlock it on the Save games tab.",
defaultChapter));
}
/**
* TestLevel chapter choice: the usable chapters ({@link TestLevelChapters}); a stored value
* outside them (2–31 from older versions) falls back to {@code def}.
*/
private static ModOption chapterOption(String description, int def) {
ModOption.Choice[] cs = TestLevelChapters.all().stream()
.map(c -> ModOption.of(
Integer.toString(c), c == TestLevelChapters.TEST_WORLD ? "1 (Test World)" : "" + c))
.toArray(ModOption.Choice[]::new);
return ModOption.choice("chapter", "TestLevel chapter", description, Integer.toString(def), cs);
}
/** CD path of the WAD this mod writes. */
@@ -160,7 +169,8 @@ public final class LevelProjectMod implements Mod {
throw new IOException("Level \"" + name() + "\": " + target + " is already changed by another mod ("
+ (info.isNewLevel()
? "Test World or another level on TestLevel chapter " + chapter
+ "). Choose a free chapter in the options of \"" + name() + "\""
+ "). Choose a free chapter in the options of \"" + name()
+ "\" or turn one of them off"
: "two mods change the same level). Turn one of them off")
+ ".");
}
@@ -174,6 +184,9 @@ public final class LevelProjectMod implements Mod {
throw new IOException("Level \"" + name() + "\": " + bad + ". Move the Llama onto walkable ground in"
+ " the level editor.");
}
if (info.isNewLevel()) {
wad = LevelTally.counted(wad);
}
ctx.write(target, wad);
if (info.isNewLevel()) {
ctx.progress(0.9, "loading picture");
@@ -5,18 +5,21 @@ import com.eliasmueller.llamamod.editor.io.ProjectInfo;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModSource;
import com.eliasmueller.llamamod.platform.AppPaths;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* One mod per {@code .llamalevel} file in {@code <data>/levels/} (category "levels"). New levels
* get a default TestLevel chapter: their stored target, or the next free one if another project
* already uses it. Temporary play-test copies in {@code levels/.playtest/} are listed too, with
* get a default TestLevel chapter: their stored target, or the next free usable one if another
* project already uses it or it is not usable ({@link TestLevelChapters}; old projects on 2–31). Temporary play-test copies in {@code levels/.playtest/} are listed too, with
* ids {@code playtest-…}.
*/
public final class LevelProjectSource implements ModSource {
@@ -32,57 +35,57 @@ public final class LevelProjectSource implements ModSource {
@Override
public List<Mod> mods() {
List<Mod> out = new ArrayList<>();
Set<Integer> used = new HashSet<>();
List<Path> levels = LevelProjects.list(dir);
List<Path> tests = LevelProjects.list(LevelProjects.playtestDir(dir));
Set<String> bases = new HashSet<>();
levels.forEach(p -> bases.add(LevelProjectMod.baseId(p, false)));
tests.forEach(p -> bases.add(LevelProjectMod.baseId(p, true)));
Set<String> ids = new HashSet<>();
for (Path p : levels) {
add(p, false, used, bases, ids, out);
List<Entry> all = new ArrayList<>();
levels.forEach(p -> read(p, false, all));
tests.forEach(p -> read(p, true, all));
// usable stored chapters first (first file wins), so a moved old project never displaces them
Map<Entry, Integer> chapters = new HashMap<>();
Set<Integer> used = new HashSet<>();
for (Entry e : all) {
int c = e.info.targetChapter();
if (e.info.isNewLevel() && !e.playtest && TestLevelChapters.usable(c) && used.add(c)) {
chapters.put(e, c);
}
}
for (Path p : tests) {
add(p, true, used, bases, ids, out);
List<Mod> out = new ArrayList<>();
Set<String> ids = new HashSet<>();
for (Entry e : all) {
int chapter = 0;
if (e.info.isNewLevel() && e.playtest) {
chapter = TestLevelChapters.migrate(e.info.targetChapter());
} else if (e.info.isNewLevel()) {
chapter = chapters.computeIfAbsent(e, k -> TestLevelChapters.pick(k.info.targetChapter(), used));
used.add(chapter);
}
// names differing only in case or punctuation share an id: later ones get a suffix no other file has
String base = LevelProjectMod.baseId(e.file, e.playtest);
String id = base;
if (!ids.add(id)) {
int i = 2;
while (bases.contains(base + "-" + i) || ids.contains(base + "-" + i)) {
i++;
}
id = base + "-" + i;
ids.add(id);
}
out.add(new LevelProjectMod(e.file, e.data, e.info, chapter, id, e.playtest));
}
return out;
}
private static void add(
Path p, boolean playtest, Set<Integer> used, Set<String> bases, Set<String> ids, List<Mod> out) {
byte[] d;
ProjectInfo info;
private record Entry(Path file, boolean playtest, byte[] data, ProjectInfo info) {}
private static void read(Path p, boolean playtest, List<Entry> out) {
try {
d = Files.readAllBytes(p);
info = ProjectIO.info(d);
byte[] d = Files.readAllBytes(p);
out.add(new Entry(p, playtest, d, ProjectIO.info(d)));
} catch (IOException | RuntimeException e) {
return;
// unreadable or broken: not listed
}
int chapter = 0;
if (info.isNewLevel() && playtest) {
chapter = Math.max(1, Math.min(49, info.targetChapter()));
} else if (info.isNewLevel()) {
chapter = info.targetChapter();
if (chapter < 1 || chapter > 49 || used.contains(chapter)) {
chapter = 2;
while (used.contains(chapter) && chapter < 49) {
chapter++;
}
}
used.add(chapter);
}
// names differing only in case or punctuation share an id: later ones get a suffix no other file has
String base = LevelProjectMod.baseId(p, playtest);
String id = base;
if (!ids.add(id)) {
int i = 2;
while (bases.contains(base + "-" + i) || ids.contains(base + "-" + i)) {
i++;
}
id = base + "-" + i;
ids.add(id);
}
out.add(new LevelProjectMod(p, d, info, chapter, id, playtest));
}
}
@@ -0,0 +1,58 @@
package com.eliasmueller.llamamod.mods.levels;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.MapChunk;
import com.eliasmueller.llamamod.format.Strats;
import com.eliasmueller.llamamod.format.Wad;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.List;
import java.util.Map;
/**
* The level tally's totals ("Münzen gesammelt n / total", "Geheimnisse entdeckt n / total"): chunk
* LNFO = u32 coins, u32 secrets (→ 0x5ff070 / 0x5ff060, FUN_0042ab10). Levels built on a base keep
* the base's numbers (Village: 55, 2) unless they are recounted here.
*/
public final class LevelTally {
private LevelTally() {}
/**
* Placed coins (Coin = 1, Coin5 = 5) and Secret strats of a level WAD. Other coin sources
* (CoinLine, CoinCircle, enemies' EnemyCoins …) are not counted.
*/
public static int[] count(byte[] wad) {
Level lv = Level.parse(wad);
Map<String, List<MapChunk.Strat>> t = Strats.levelTypes(lv.wad().require("STPC"), lv.wfpc(), lv.map());
int coins = n(t, "Coin") + 5 * n(t, "Coin5");
return new int[] {coins, n(t, "Secret")};
}
private static int n(Map<String, List<MapChunk.Strat>> t, String type) {
List<MapChunk.Strat> l = t.get(type);
return l == null ? 0 : l.size();
}
/** {@code wad} with LNFO set to {@link #count} plus {@code extraCoins}; unchanged without LNFO. */
public static byte[] counted(byte[] wad, int extraCoins) {
int[] c = count(wad);
return withTotals(wad, c[0] + extraCoins, c[1]);
}
public static byte[] counted(byte[] wad) {
return counted(wad, 0);
}
/** {@code wad} with LNFO = (coins, secrets); unchanged if it has no LNFO chunk. */
public static byte[] withTotals(byte[] wad, int coins, int secrets) {
Wad w = Wad.parse(wad);
byte[] old = w.get("LNFO");
if (old == null || old.length < 8) {
return wad;
}
byte[] ln = old.clone();
ByteBuffer.wrap(ln).order(ByteOrder.LITTLE_ENDIAN).putInt(0, coins).putInt(4, secrets);
w.set("LNFO", ln);
return w.toBytes();
}
}
@@ -412,10 +412,6 @@ public final class Gallery {
StpcMerge.Analysis tgt = new StpcMerge.Analysis(ti, roots);
byte[] text = wad.require("TEXT");
TextMerge.Textures tex = TextMerge.Textures.of(text);
int origTail = 8 + 8 * tex.entries.size();
for (TextMerge.Anim a : tex.anims) {
origTail += 3 + a.data().length;
}
List<SoundMerge.Sample> snd = SoundMerge.parse(wad.get("SMPC"));
DialogMerge.Texts texts = wad.get("LGPC") == null ? null : DialogMerge.Texts.parse(wad.get("LGPC"));
DialogMerge.Speech speech = DialogMerge.Speech.parse(wad.get("SRPC"));
@@ -463,34 +459,7 @@ public final class Gallery {
Wad out = new Wad(wad.chunks());
out.set("STPC", res.stpc());
out.set("MAP", map.build());
out.set("TEXT", text(text, tex, origTail));
out.set("TEXT", TextMerge.rebuild(text, tex));
return new Merged(out.toBytes(), codes, res.stats());
}
/** TEXT chunk with the (extended) pages, entries and animations of tex; the rest is kept. */
private static byte[] text(byte[] orig, TextMerge.Textures tex, int origTailHead) {
TextChunk tc = TextChunk.parse(orig);
byte[] rest = Arrays.copyOfRange(tc.tail, origTailHead, tc.tail.length);
ByteBuffer b = ByteBuffer.allocate(8 + 8 * tex.entries.size() + tex.anims.size() * 64 + rest.length + 1024)
.order(java.nio.ByteOrder.LITTLE_ENDIAN);
b.putInt(tex.entries.size());
for (TextChunk.TextureEntry e : tex.entries) {
b.putShort((short) e.flags())
.put((byte) e.page())
.put((byte) e.page2())
.put((byte) e.u0())
.put((byte) e.u1())
.put((byte) e.v0())
.put((byte) e.v1());
}
b.putInt(tex.anims.size());
for (TextMerge.Anim a : tex.anims) {
b.putShort((short) a.texture()).put((byte) a.type()).put(a.data());
}
b.put(rest);
tc.pages.clear();
tc.pages.addAll(tex.pages);
tc.tail = Arrays.copyOf(b.array(), b.position());
return tc.build();
}
}
@@ -4,6 +4,7 @@ import com.eliasmueller.llamamod.format.CorEncoder;
import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModContext;
import com.eliasmueller.llamamod.mods.levels.LevelTally;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
@@ -59,7 +60,7 @@ public final class TestWorldMod implements Mod {
}
},
Map.of());
ctx.write(OUT_WAD, wad);
ctx.write(OUT_WAD, LevelTally.counted(wad));
ctx.progress(0.9, "loading picture");
ctx.write(LOADING_PICTURE, loadingPicture());
ctx.progress(1.0, "test world done");
@@ -0,0 +1,76 @@
package com.eliasmueller.llamamod.save;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* TestLevel (world 9) chapters a mod level may occupy. The game has per-chapter arrays only for
* worlds 1–8: the coin counter u32 0x584F1C[world·6 + chapter] and the flag byte
* 0x584EC8[world·6 + chapter]. For world 9 both run into other globals, which the game zeroes on
* level start and overwrites with coins or flags:
*
* <pre>
* coin slot 0x584FF4 + 4c: c=0 counter var 0xC0, 1 unused, 2 counter var 0xBF, 3 HUD flag 0x585000,
* 4 var 0x80, 5..8 last played tribe/world/chapter/map (0x585008..14), 9 furthest progress
* (0x585018: zeroed on level start, coins make it garbage), 10..21 unused, 22..31 byte table
* 0x585024 (strat var 0x93, tribe 1), 32..49 the same table for tribes 2/3 (never used).
* flag byte 0x584EFE + c: 0..5 unused, 6..9 language index 0x584F04 (|=2 → wrong texts/crash),
* 10..13 option 0x584F08, 14..21 0x584F0C/0x584F10, 22..25 unused, 26..29 option 0x584F18,
* 30..49 coin slots of world 0 (never read).
* </pre>
*
* Safe: chapter 1 (Test World) and 32–49.
*/
public final class TestLevelChapters {
private TestLevelChapters() {}
/** The Test World's chapter. */
public static final int TEST_WORLD = 1;
/** First and last chapter for other mod levels. */
public static final int FIRST = 32;
public static final int LAST = 49;
public static boolean usable(int chapter) {
return chapter == TEST_WORLD || chapter >= FIRST && chapter <= LAST;
}
/** All usable chapters: 1, 32..49. */
public static List<Integer> all() {
List<Integer> out = new ArrayList<>();
out.add(TEST_WORLD);
for (int c = FIRST; c <= LAST; c++) {
out.add(c);
}
return out;
}
/**
* A usable chapter for a stored one: unchanged if usable, else 2..19 → 32..49, 20..31 → 32..43,
* anything else → 32 (deterministic, for play-tests and exports of old projects).
*/
public static int migrate(int chapter) {
if (usable(chapter)) {
return chapter;
}
return chapter >= 2 && chapter < FIRST ? FIRST + (chapter - 2) % (LAST - FIRST + 1) : FIRST;
}
/**
* {@code wanted} if usable and not in {@code used}, else the first free chapter of 32..49; when
* all are taken, {@link #migrate}({@code wanted}) (shared: only one of those levels can be on).
*/
public static int pick(int wanted, Collection<Integer> used) {
if (usable(wanted) && !used.contains(wanted)) {
return wanted;
}
for (int c = FIRST; c <= LAST; c++) {
if (!used.contains(c)) {
return c;
}
}
return migrate(wanted);
}
}
@@ -76,7 +76,10 @@ class PlaytestLifecycleTest {
assertNotNull(t);
assertTrue(t.playtest());
assertTrue(t.name().endsWith("(play-test)"));
assertEquals("4", t.options().getFirst().defaultValue(), "own chapter, not shifted by the saved level");
assertEquals(
"34",
t.options().getFirst().defaultValue(),
"own chapter (old 4 → 34), not shifted by the saved level");
assertFalse(mod("level-mine").playtest());
cfg.enabledMods = new ArrayList<>(List.of("fog", "playtest-mine"));
@@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.eliasmueller.llamamod.format.Bytes;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.MapChunk;
import com.eliasmueller.llamamod.format.Strats;
@@ -13,8 +14,11 @@ import com.eliasmueller.llamamod.format.stpcmerge.StratCode;
import com.eliasmueller.llamamod.format.strat.StratCompiler;
import com.eliasmueller.llamamod.format.strat.StratListing;
import com.eliasmueller.llamamod.mods.GameData;
import com.eliasmueller.llamamod.mods.ModOption;
import com.eliasmueller.llamamod.mods.ModOptions;
import com.eliasmueller.llamamod.mods.ModRegistry;
import com.eliasmueller.llamamod.mods.TestModContext;
import com.eliasmueller.llamamod.platform.Config;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -89,11 +93,11 @@ class ArenaTest {
void builtLevel() throws IOException {
Path cd = GameData.cd();
ArenaMod mod = new ArenaMod();
TestModContext ctx = new TestModContext(cd, mod, Map.of("lives", "7", "chapter", "5"), null);
TestModContext ctx = new TestModContext(cd, mod, Map.of("lives", "7", "chapter", "40"), null);
mod.apply(ctx);
byte[] wad = ctx.written.get("wads/T1L9M005.wad");
byte[] wad = ctx.written.get("wads/T1L9M040.wad");
assertNotNull(wad, "written: " + ctx.written.keySet());
assertNotNull(ctx.written.get("T195.COR"));
assertNotNull(ctx.written.get("T1940.COR"));
Level lv = Level.parse(wad);
Map<String, List<MapChunk.Strat>> t = types(lv);
@@ -101,6 +105,9 @@ class ArenaTest {
assertEquals(1, t.get("ArenaMaster").size());
assertEquals(1, t.get("CheckPointPost").size());
assertEquals(2, t.get("Coin").size());
// tally total: 2 placed + 5 alligators · (6 hit coins + 5 death coins) + 5 exit bonus
assertEquals(2 + 5 * (6 + 5) + 5, Bytes.u32(lv.wad().require("LNFO"), 0));
assertEquals(0, Bytes.u32(lv.wad().require("LNFO"), 4));
assertTrue(
!t.containsKey("LevelDoor") && !t.containsKey("ArenaGator"),
t.keySet().toString());
@@ -140,9 +147,9 @@ class ArenaTest {
Path cd = GameData.cd();
ArenaMod mod = new ArenaMod();
TestModContext ctx = new TestModContext(cd, mod, Map.of(), null);
ctx.write("wads/T1L9M008.wad", new byte[] {1, 2, 3});
ctx.write("wads/T1L9M049.wad", new byte[] {1, 2, 3});
IOException e = assertThrows(IOException.class, () -> mod.apply(ctx));
assertTrue(e.getMessage().contains("chapter 8"), e.getMessage());
assertTrue(e.getMessage().contains("chapter 49"), e.getMessage());
}
@Test
@@ -151,9 +158,27 @@ class ArenaTest {
ArenaMod mod = new ArenaMod();
TestModContext ctx = new TestModContext(cd, mod, Map.of(), null);
ArenaMod.apply(ctx, Map.of("probe", "true", "master", "false"));
Level lv = Level.parse(ctx.written.get("wads/T1L9M008.wad"));
Level lv = Level.parse(ctx.written.get("wads/T1L9M049.wad"));
Map<String, List<MapChunk.Strat>> t = types(lv);
assertEquals(1, t.get("ArenaProbe").size());
assertTrue(!t.containsKey("ArenaMaster"), new ArrayList<>(t.keySet()).toString());
}
@Test
void chapterIsUsableOnly() {
ModOption o = new ArenaMod()
.options().stream()
.filter(x -> x.key().equals("chapter"))
.findFirst()
.orElseThrow();
assertEquals("49", o.defaultValue());
assertEquals("32", o.normalize("32"));
for (String bad : List.of("1", "8", "9", "31", "50")) {
assertThrows(IllegalArgumentException.class, () -> o.normalize(bad), bad);
}
// a stored chapter from the old range 2..8 falls back to the default
Config cfg = new Config();
cfg.modOptions.put("arena", Map.of("chapter", "8"));
assertEquals("49", ModOptions.resolve(new ArenaMod(), cfg).get("chapter"));
}
}
@@ -12,7 +12,7 @@ import java.util.Map;
/**
* The arena with level script options for in-game tests (not registered): {@code
* APPTEST_CP=build/classes/java/test APPTEST_JAVA_OPTS=-Dllamamod.extraMods=<this class>}, option
* {@code script} = {@code key=value;key=value} (probe=true, start=x,z,rot).
* {@code script} = {@code key=value;key=value} (probe=true, start=x,z,rot); {@code chapter} 1–49.
*/
public final class ArenaTestMod implements Mod {
@Override
@@ -32,7 +32,14 @@ public final class ArenaTestMod implements Mod {
@Override
public List<ModOption> options() {
List<ModOption> o = new ArrayList<>(new ArenaMod().options());
List<ModOption> o = new ArrayList<>();
for (ModOption x : new ArenaMod().options()) {
// any chapter, to reproduce the per-chapter memory clashes of unusable ones
o.add(
x.key().equals("chapter")
? ModOption.integer("chapter", x.label(), x.description(), ArenaMod.DEFAULT_CHAPTER, 1, 49)
: x);
}
o.add(ModOption.string("script", "Script options", "key=value;…", ""));
return o;
}
@@ -10,10 +10,13 @@ import com.eliasmueller.llamamod.editor.io.ProjectIO;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.editor.model.cmd.History;
import com.eliasmueller.llamamod.format.Bytes;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.mods.GameData;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModOptions;
import com.eliasmueller.llamamod.mods.TestModContext;
import com.eliasmueller.llamamod.platform.Config;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -38,10 +41,10 @@ class LevelProjectModTest {
ProjectIO.save(edit, dir.resolve("village edit.llamalevel"), cd);
Project a = Project.create(cd, "new", "t1l1m001");
a.scene().settings().prop("name").setRaw(a.scene().settings(), "Level A");
a.scene().settings().prop("targetChapter").setRaw(a.scene().settings(), 3);
a.scene().settings().prop("targetChapter").setRaw(a.scene().settings(), 40);
ProjectIO.save(a, dir.resolve("a.llamalevel"), cd);
Project b = Project.create(cd, "new", "t1l1m001");
b.scene().settings().prop("targetChapter").setRaw(b.scene().settings(), 3);
b.scene().settings().prop("targetChapter").setRaw(b.scene().settings(), 40);
ProjectIO.save(b, dir.resolve("b.llamalevel"), cd);
Files.writeString(dir.resolve("broken.llamalevel"), "no zip");
@@ -52,17 +55,20 @@ class LevelProjectModTest {
LevelProjectMod ma = (LevelProjectMod) mods.get(0);
LevelProjectMod mb = (LevelProjectMod) mods.get(1);
LevelProjectMod me = (LevelProjectMod) mods.get(2);
assertEquals("3", ma.options().getFirst().defaultValue());
assertEquals("2", mb.options().getFirst().defaultValue(), "chapter 3 is taken: next free");
assertEquals("40", ma.options().getFirst().defaultValue());
assertEquals("32", mb.options().getFirst().defaultValue(), "chapter 40 is taken: first free usable");
assertTrue(me.options().isEmpty());
assertEquals(Mod.LEVELS, me.category());
TestModContext ctx = new TestModContext(cdDir, ma, Map.of(), null);
ma.apply(ctx);
assertTrue(ctx.written.containsKey("wads/T1L9M003.wad"));
assertTrue(ctx.written.containsKey("T193.COR"));
Level lv = Level.parse(ctx.written.get("wads/T1L9M003.wad"));
assertTrue(ctx.written.containsKey("wads/T1L9M040.wad"));
assertTrue(ctx.written.containsKey("T1940.COR"));
Level lv = Level.parse(ctx.written.get("wads/T1L9M040.wad"));
assertEquals(0, lv.map().strats.size());
byte[] lnfo = lv.wad().get("LNFO");
assertEquals(0, Bytes.u32(lnfo, 0), "tally: no coins (not the base's 55)");
assertEquals(0, Bytes.u32(lnfo, 4), "tally: no secrets");
TestModContext ec = new TestModContext(cdDir, me, Map.of(), null);
me.apply(ec);
@@ -78,7 +84,7 @@ class LevelProjectModTest {
IOException e = assertThrows(IOException.class, () -> me.apply(twice));
assertTrue(e.getMessage().contains("already changed"), e.getMessage());
TestModContext both = new TestModContext(cdDir, ma, Map.of(), null);
both.written.put("wads/T1L9M003.wad", new byte[] {1});
both.written.put("wads/T1L9M040.wad", new byte[] {1});
assertThrows(IOException.class, () -> ma.apply(both));
}
@@ -100,4 +106,40 @@ class LevelProjectModTest {
assertTrue(!v1.equals(v2), "cache key must change with the project file");
assertEquals(new LevelProjectSource(dir).mods().getFirst().version(), v2);
}
@Test
void oldChaptersMoveToUsableOnes(@TempDir Path dir) throws IOException {
Bases cd = Bases.fromCd(GameData.cd());
for (String[] f : new String[][] {{"a", "5"}, {"b", "9"}, {"c", "1"}, {"d", "33"}}) {
Project p = Project.create(cd, "new", "t1l1m001");
p.scene().settings().prop("targetChapter").setRaw(p.scene().settings(), Integer.parseInt(f[1]));
ProjectIO.save(p, dir.resolve(f[0] + ".llamalevel"), cd);
}
List<Mod> mods = new LevelProjectSource(dir).mods();
assertEquals(
List.of("32", "34", "1", "33"),
mods.stream().map(m -> m.options().getFirst().defaultValue()).toList(),
"2..31 → first free of 32..49; 1 and 33 stay");
// a chapter stored in the launcher config by an older version falls back to the default
Config cfg = new Config();
cfg.modOptions.put("level-a", Map.of("chapter", "5"));
assertEquals("32", ModOptions.resolve(mods.getFirst(), cfg).get("chapter"));
cfg.modOptions.put("level-a", Map.of("chapter", "45"));
assertEquals("45", ModOptions.resolve(mods.getFirst(), cfg).get("chapter"));
}
@Test
void moreProjectsThanChaptersShareThem(@TempDir Path dir) throws IOException {
Bases cd = Bases.fromCd(GameData.cd());
Project p = Project.create(cd, "new", "t1l1m001");
p.scene().settings().prop("targetChapter").setRaw(p.scene().settings(), 40);
for (int i = 0; i < 20; i++) {
ProjectIO.save(p, dir.resolve(String.format("p%02d.llamalevel", i)), cd);
}
List<String> ch = new LevelProjectSource(dir)
.mods().stream().map(m -> m.options().getFirst().defaultValue()).toList();
assertEquals("40", ch.get(0));
assertEquals(18, ch.stream().distinct().count(), ch.toString());
assertTrue(ch.stream().allMatch(c -> Integer.parseInt(c) >= 32), ch.toString());
}
}
@@ -3,10 +3,13 @@ package com.eliasmueller.llamamod.mods.testworld;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.fail;
import com.eliasmueller.llamamod.format.Bytes;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
import com.eliasmueller.llamamod.levelbuild.LevelScript;
import com.eliasmueller.llamamod.mods.GameData;
import com.eliasmueller.llamamod.mods.ModContext;
import com.eliasmueller.llamamod.mods.levels.LevelTally;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -135,7 +138,10 @@ class TestWorldGoldenTest {
public void progress(double fraction, String message) {}
});
assertEquals(List.of("T191.COR", "wads/T1L9M001.wad"), List.copyOf(written.keySet()));
assertEquals(TEST_WORLD_SHA, sha256(written.get("wads/T1L9M001.wad")));
// the mod only recounts the tally (LNFO: 20 coins, no secrets instead of the Village's 55, 2)
byte[] wad = written.get("wads/T1L9M001.wad");
assertEquals(20, Bytes.u32(Level.parse(wad).wad().require("LNFO"), 0));
assertEquals(TEST_WORLD_SHA, sha256(LevelTally.withTotals(wad, 55, 2)));
assertEquals(
"c55e3babf4855540f14daa60b979a2534e3732037854d9ad785f69f370d095d0", sha256(written.get("T191.COR")));
}
@@ -0,0 +1,35 @@
package com.eliasmueller.llamamod.mods.testworld;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.eliasmueller.llamamod.format.Bytes;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.MapChunk;
import com.eliasmueller.llamamod.format.Strats;
import com.eliasmueller.llamamod.mods.GameData;
import com.eliasmueller.llamamod.mods.TestModContext;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
/** The tally of the Test World counts its own coins, not the Village base's 55. */
class TestWorldTallyTest {
@Test
void lnfoCountsPlacedCoins() throws IOException {
TestWorldMod mod = new TestWorldMod();
TestModContext ctx = new TestModContext(GameData.cd(), mod, Map.of(), null);
mod.apply(ctx);
Level lv = Level.parse(ctx.written.get(TestWorldMod.OUT_WAD));
Map<String, List<MapChunk.Strat>> t = Strats.levelTypes(lv.wad().require("STPC"), lv.wfpc(), lv.map());
int coins = t.getOrDefault("Coin", List.of()).size()
+ 5 * t.getOrDefault("Coin5", List.of()).size();
byte[] lnfo = lv.wad().require("LNFO");
System.out.println(
"TALLY test world " + Bytes.u32(lnfo, 0) + "/" + Bytes.u32(lnfo, 4) + " types " + t.keySet());
assertTrue(coins > 0);
assertEquals(coins, Bytes.u32(lnfo, 0));
assertEquals(t.getOrDefault("Secret", List.of()).size(), Bytes.u32(lnfo, 4));
}
}
@@ -0,0 +1,57 @@
package com.eliasmueller.llamamod.save;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
class TestLevelChaptersTest {
/** Coin slot 0x584F1C + 4·(54 + c) and flag byte 0x584EC8 + 54 + c of world 9 (see the class comment). */
@Test
void usableChaptersAvoidTheGlobalsBehindTheArrays() {
for (int c = 0; c <= 50; c++) {
int coin = 0x584F1C + 4 * (9 * 6 + c);
int flag = 0x584EC8 + 9 * 6 + c;
boolean coinFree = c == 1
|| coin >= 0x58501C && coin < 0x585024
|| coin >= 0x585028 && coin < 0x58504C
|| coin >= 0x585074 && coin <= 0x5850BB;
boolean flagFree = flag < 0x584F04 || flag >= 0x584F14 && flag < 0x584F18 || flag >= 0x584F1C;
assertEquals(coinFree && flagFree && c >= 1 && c <= 49, TestLevelChapters.usable(c), "chapter " + c);
}
assertEquals(19, TestLevelChapters.all().size());
assertEquals(0x585018, 0x584F1C + 4 * (9 * 6 + 9), "chapter 9 = furthest progress");
}
@Test
void migrate() {
assertEquals(1, TestLevelChapters.migrate(1));
assertEquals(40, TestLevelChapters.migrate(40));
assertEquals(32, TestLevelChapters.migrate(2));
assertEquals(38, TestLevelChapters.migrate(8));
assertEquals(49, TestLevelChapters.migrate(19));
assertEquals(32, TestLevelChapters.migrate(20));
assertEquals(43, TestLevelChapters.migrate(31));
assertEquals(32, TestLevelChapters.migrate(0));
assertEquals(32, TestLevelChapters.migrate(60));
for (int c = -1; c < 60; c++) {
assertTrue(TestLevelChapters.usable(TestLevelChapters.migrate(c)));
}
}
@Test
void pick() {
assertEquals(40, TestLevelChapters.pick(40, Set.of()));
assertEquals(1, TestLevelChapters.pick(1, Set.of(32)));
assertEquals(33, TestLevelChapters.pick(40, Set.of(32, 40)));
assertEquals(32, TestLevelChapters.pick(5, Set.of()));
Set<Integer> full = new HashSet<>(TestLevelChapters.all());
assertEquals(38, TestLevelChapters.pick(8, full), "all taken: shared");
assertEquals(45, TestLevelChapters.pick(45, full));
assertFalse(List.of(1).contains(TestLevelChapters.pick(1, Set.of(1))));
}
}