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:
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() {}
|
||||
|
||||
Reference in new issue
Block a user