base exe patch moves the per-chapter coin and flag arrays into free .data space (128 entries), so every testlevel chapter is safe (revision 2, caches rebuilt once); new projects start on a free chapter from 32, manual shows number ranges, texture swatch caches follow texture edits, stray levelbau.md.orig removed

This commit is contained in:
MineTec committed 2026-11-07 15:52:00 +01:00
1 parent d6b852a1b6
commit 00b2880d67
24 files changed
+376 -364

No files matched your search

@@ -6,6 +6,7 @@ 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 com.eliasmueller.llamamod.save.TestLevelChapters;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
@@ -45,7 +46,7 @@ public final class Templates {
LevelSettings st = s.settings();
st.setRaw("kind", LevelSettings.NEW);
st.setRaw("name", "New level");
st.setRaw("targetChapter", 1);
st.setRaw("targetChapter", TestLevelChapters.FIRST);
st.setRaw("poolReserve", NEW_LEVEL_RESERVE);
int[] table = s.regions().table().clone();
table[0] = 0xffffffff;
@@ -1,6 +1,8 @@
package com.eliasmueller.llamamod.editor.tools.geometry;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.SceneEvent;
import com.eliasmueller.llamamod.editor.model.TextureEntry;
import com.eliasmueller.llamamod.editor.model.TexturePage;
import com.eliasmueller.llamamod.editor.render.Legend;
@@ -21,10 +23,12 @@ import java.awt.Image;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;
import java.util.function.Consumer;
import java.util.function.IntConsumer;
@@ -148,16 +152,32 @@ final class GeoWidgets {
private static final Map<Scene, Map<String, Integer>> NAMES = new WeakHashMap<>();
/** Scenes whose texture/page edits clear their cache entries (listener added on first use). */
private static final Set<Scene> WATCHED = Collections.newSetFromMap(new WeakHashMap<>());
static void clearCache(Scene s) {
CACHE.remove(s);
NAMES.remove(s);
}
private static void watch(Scene s) {
if (WATCHED.add(s)) {
s.addListener(e -> {
if (e.type() == SceneEvent.Type.RELOADED
|| e.node().kind() == NodeKind.TEXTURE
|| e.node().kind() == NodeKind.TEXTURE_PAGE) {
clearCache(s);
}
});
}
}
/** Village texture names the scene's textures show (name → index), see {@link BaseTextures}. */
static Map<String, Integer> names(Scene s) {
if (s == null) {
return LevelBuilder.TEX;
}
watch(s);
return NAMES.computeIfAbsent(s, k -> {
List<TextChunk.TextureEntry> e = new ArrayList<>();
for (TextureEntry t : k.textures()) {
@@ -203,6 +223,7 @@ final class GeoWidgets {
if (s == null || tex == null || tex.isEmpty()) {
return null;
}
watch(s);
Map<String, BufferedImage> m = CACHE.computeIfAbsent(s, k -> new LinkedHashMap<>());
if (m.containsKey(tex)) {
return m.get(tex);
@@ -337,21 +337,36 @@ public final class ManualContent {
return switch (o.type()) {
case BOOL -> "on / off";
case INT -> o.min() + " – " + o.max();
case CHOICE -> {
List<String> c = new ArrayList<>();
o.choices().forEach(ch -> c.add(ch.label()));
yield String.join(", ", c);
}
case MULTI -> {
List<String> c = new ArrayList<>();
o.choices().forEach(ch -> c.add(ch.label()));
yield "any of " + String.join(", ", c);
}
case CHOICE -> choiceList(o);
case MULTI -> "any of " + choiceList(o);
case SEED -> "any text";
case STRING -> "text";
};
}
/** Choice labels, comma-separated; runs of three or more consecutive numbers as "a – b". */
static String choiceList(ModOption o) {
List<String> labels = o.choices().stream().map(ModOption.Choice::label).toList();
List<String> out = new ArrayList<>();
for (int i = 0; i < labels.size(); ) {
int j = i;
while (j + 1 < labels.size() && consecutive(labels.get(j), labels.get(j + 1))) {
j++;
}
if (j - i >= 2) {
out.add(labels.get(i) + " – " + labels.get(j));
} else {
out.addAll(labels.subList(i, j + 1));
}
i = j + 1;
}
return String.join(", ", out);
}
private static boolean consecutive(String a, String b) {
return a.matches("-?\\d{1,9}") && b.matches("-?\\d{1,9}") && Integer.parseInt(b) == Integer.parseInt(a) + 1;
}
private static String defaultText(ModOption o) {
return switch (o.type()) {
case BOOL -> "true".equals(o.defaultValue()) ? "on" : "off";
@@ -1,7 +1,5 @@
package com.eliasmueller.llamamod.editor.ui;
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;
@@ -13,11 +11,8 @@ import java.awt.Font;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.swing.BorderFactory;
import javax.swing.BoxLayout;
import javax.swing.ButtonGroup;
@@ -53,7 +48,7 @@ final class NewLevelDialog extends JDialog {
}
}
JSpinner chapter = new JSpinner(new SpinnerListModel(TestLevelChapters.all()));
chapter.setValue(nextFreeChapter(w));
chapter.setValue(LevelProjects.nextFreeChapter(w.env().levelsDir()));
PicturePanel pic = new PicturePanel(300, 170);
Runnable updatePic = () -> {
OriginalLevels.Level l = (OriginalLevels.Level) base.getSelectedItem();
@@ -164,22 +159,6 @@ final class NewLevelDialog extends JDialog {
return row + 1;
}
/** 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())) {
try {
ProjectInfo i = ProjectIO.info(Files.readAllBytes(p));
if (i.isNewLevel()) {
used.add(i.targetChapter());
}
} catch (Exception e) {
// skip broken files
}
}
return TestLevelChapters.pick(TestLevelChapters.FIRST, used);
}
Result result() {
return result;
}
@@ -554,7 +554,7 @@ public final class ModManager {
}
}
/** original/hd/groove.exe with the HKCU patch, or null if there is no original exe (tests). */
/** original/hd/groove.exe with the base patch ({@link ExePatcher#changes}), or null if there is no original exe (tests). */
private ExeImage baseExe(ApplyLog log) throws IOException {
Path orig = Files.isDirectory(paths.originalHd()) ? Integrity.find(paths.originalHd(), "groove.exe") : null;
if (orig == null || !Files.isRegularFile(orig)) {
@@ -563,8 +563,8 @@ public final class ModManager {
byte[] b = Files.readAllBytes(orig);
ExeImage img = ExeImage.parse(b);
img.setOwner(BASE_EXE_OWNER);
for (int off : ExePatcher.patchHkcu(b).patchedOffsets()) {
img.patch(img.va(off), new byte[] {2}, new byte[] {1}, "registry HKLM -> HKCU");
for (ExePatcher.Change c : ExePatcher.changes(b)) {
img.patch(img.va(c.offset()), c.original(), c.replacement(), c.label());
}
logExe(log, img, BASE_EXE_OWNER);
return img;
@@ -83,6 +83,10 @@ public final class EditorCli {
need(rest, 1, "--editor-new <project.llamalevel> [base level]");
String base = rest.size() > 1 ? rest.get(1) : Templates.DEFAULT_BASE;
Project p = Project.create(cd, "new", base);
p.scene()
.settings()
.prop("targetChapter")
.setRaw(p.scene().settings(), LevelProjects.nextFreeChapter(LevelProjects.dir(paths)));
if (name != null) {
p.scene().settings().prop("name").setRaw(p.scene().settings(), name);
}
@@ -6,6 +6,7 @@ import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModRegistry;
import com.eliasmueller.llamamod.platform.AppPaths;
import com.eliasmueller.llamamod.platform.Config;
import com.eliasmueller.llamamod.save.TestLevelChapters;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -13,8 +14,10 @@ import java.nio.file.StandardCopyOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Stream;
/**
@@ -138,6 +141,25 @@ public final class LevelProjects {
return new Removed(out, cfg.activeMods.contains(mod.id()));
}
/**
* First usable TestLevel chapter from 32 that no new-level project in {@code dir} uses (chapter 1
* belongs to the Test World mod).
*/
public static int nextFreeChapter(Path dir) {
Set<Integer> used = new HashSet<>();
for (Path p : list(dir)) {
try {
ProjectInfo i = ProjectIO.info(Files.readAllBytes(p));
if (i.isNewLevel()) {
used.add(i.targetChapter());
}
} catch (IOException | RuntimeException e) {
// skip broken files
}
}
return TestLevelChapters.pick(TestLevelChapters.FIRST, used);
}
/** Project files in the folder, sorted by name. */
public static List<Path> list(Path dir) {
List<Path> out = new ArrayList<>();
@@ -1,5 +1,6 @@
package com.eliasmueller.llamamod.platform;
import com.eliasmueller.llamamod.exe.ExeImage;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
@@ -16,17 +17,107 @@ import java.util.List;
* (HKLM to HKCU) in those five instructions. Located by pattern rather than fixed offsets so other
* language versions work too; German CD: file offsets 0x579b, 0x57d0, 0x5810, 0x5859, 0x5880
* (the byte after 68).
*
* <p>Second part ({@link #relocateLevelRecords}): the per-chapter coin counters (u32 0x584F1C) and
* flags (u8 0x584EC8), indexed world·6 + chapter, only have rows for worlds 0–8; TestLevel (world 9,
* chapters up to 49) wrote over progress, language and options. Every reference is moved to a
* 128-entry copy in the unused tail of .data's last page (notes/testlevel-chapters.md).
*/
public final class ExePatcher {
private ExePatcher() {}
/** Revision of the base patch; part of the mod cache key, bump when the patch changes. */
public static final int REVISION = 1;
public static final int REVISION = 2;
public static final String SUBKEY = "Software\\Disney Interactive\\Emperors New Groove\\1.0";
public record Result(byte[] data, List<Integer> patchedOffsets, int alreadyPatched) {}
/** One changed byte run of the base patch (file offset). */
public record Change(int offset, byte[] original, byte[] replacement, String label) {}
static final long COINS = 0x584F1C;
static final long FLAGS = 0x584EC8;
static final int ROWS = 54; // worlds 0-8 x 6
static final int SLOTS = 128; // world 9 chapter 49 = index 103
/** The complete base patch: HKCU registry plus the level record relocation. */
public static byte[] patch(byte[] exe) throws IOException {
byte[] out = patchHkcu(exe).data();
for (Change c : relocateLevelRecords(out)) {
System.arraycopy(c.replacement(), 0, out, c.offset(), c.replacement().length);
}
return out;
}
/** All byte changes of {@link #patch} relative to {@code exe}, in file order. */
public static List<Change> changes(byte[] exe) throws IOException {
List<Change> l = new ArrayList<>();
for (int off : patchHkcu(exe).patchedOffsets()) {
l.add(new Change(off, new byte[] {2}, new byte[] {1}, "registry HKLM -> HKCU"));
}
l.addAll(relocateLevelRecords(exe));
return l;
}
/**
* Moves the coin and flag arrays to a buffer in the slack after .data's VirtualSize (zeroed and
* mapped read/write by the loader as part of the section's last page). Every absolute address of
* both arrays in .text is rewritten: bases 0x584F1C / 0x584EC8, the world-1 flag base 0x584ECE
* (selection map, gold count), the flag loop end 0x584EFE and the coin loop end 0x584FF4 (KSV
* load/save; matched as {@code cmp eax, imm32; jl}, the address is also a separate counter).
* Returns nothing if the exe is already relocated or the references differ from the known build.
*/
public static List<Change> relocateLevelRecords(byte[] exe) throws IOException {
ExeImage img = ExeImage.parse(exe);
ExeImage.Section data;
try {
data = img.section(".data");
} catch (IllegalArgumentException e) {
return List.of();
}
long end = data.va() + data.virtualSize();
long coins = (end + 15) & ~15L;
long flags = coins + 4L * SLOTS;
if (data.virtualSize() == 0 || flags + SLOTS > ((end + 0xfff) & ~0xfffL)) {
return List.of();
}
List<Change> l = new ArrayList<>();
boolean ok = retarget(img, l, "%s", COINS, coins, 11, "coins")
&& retarget(img, l, "%s", FLAGS, flags, 9, "flags")
&& retarget(img, l, "%s", FLAGS + 6, flags + 6, 5, "flags from world 1")
&& retarget(img, l, "%s", FLAGS + ROWS, flags + ROWS, 3, "flags end")
&& retarget(img, l, "3D %s 7C", COINS + 4 * ROWS, coins + 4 * ROWS, 2, "coins end");
if (!ok) {
return List.of();
}
l.sort((x, y) -> Integer.compare(x.offset(), y.offset()));
return l;
}
/** {@code pattern}: hex with {@code %s} = the address operand; all hits must be {@code expected}. */
private static boolean retarget(
ExeImage img, List<Change> out, String pattern, long from, long to, int expected, String label) {
List<Long> hits = img.find(".text", pattern.formatted(hex32(from)));
if (hits.size() != expected) {
return false;
}
int skip = pattern.indexOf("%s") / 3;
for (long va : hits) {
out.add(new Change(img.fileOffset(va + skip), le32(from), le32(to), "level records: " + label));
}
return true;
}
private static String hex32(long v) {
byte[] b = le32(v);
return String.format("%02X %02X %02X %02X", b[0], b[1], b[2], b[3]);
}
private static byte[] le32(long v) {
return new byte[] {(byte) v, (byte) (v >> 8), (byte) (v >> 16), (byte) (v >> 24)};
}
public static Result patchHkcu(byte[] exe) throws IOException {
byte[] out = exe.clone();
long va = stringVa(exe, SUBKEY);
@@ -194,7 +194,7 @@ public final class Installer {
}
} else if (name.equals("groove.exe")) {
byte[] orig = Files.readAllBytes(f);
FileOps.writeAtomic(t, ExePatcher.patchHkcu(orig).data());
FileOps.writeAtomic(t, ExePatcher.patch(orig));
cfg.exeSha256 = FileOps.sha256(orig);
} else {
Files.createDirectories(t.getParent());
@@ -20,7 +20,10 @@ import java.util.List;
* 30..49 coin slots of world 0 (never read).
* </pre>
*
* Safe: chapter 1 (Test World) and 32–49.
* Safe: chapter 1 (Test World) and 32–49. Since base patch revision 2 ({@code
* ExePatcher.relocateLevelRecords}) LlamaMod's groove.exe keeps both arrays in a buffer big enough
* for world 9, so the other chapters (map, Boot to level) are harmless too; mod levels still stay on
* these chapters.
*/
public final class TestLevelChapters {
private TestLevelChapters() {}