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() {}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.eliasmueller.llamamod.editor.tools.geometry;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
|
||||
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 java.awt.image.BufferedImage;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Swatch and name caches follow texture and page edits of an open scene. */
|
||||
class GeoWidgetsCacheTest {
|
||||
@Test
|
||||
void textureAndPageEditsRefreshTheSwatch() {
|
||||
Scene s = new Scene();
|
||||
TexturePage p = (TexturePage) s.create(NodeKind.TEXTURE_PAGE);
|
||||
p.prop("width").setRaw(p, 4);
|
||||
p.prop("height").setRaw(p, 4);
|
||||
p.prop("pixels").setRaw(p, new int[16]);
|
||||
s.add(p, -1);
|
||||
TextureEntry t = (TextureEntry) s.create(NodeKind.TEXTURE);
|
||||
t.prop("u1").setRaw(t, 3);
|
||||
t.prop("v1").setRaw(t, 3);
|
||||
s.add(t, -1);
|
||||
|
||||
BufferedImage a = GeoWidgets.image(s, "#0");
|
||||
assertSame(a, GeoWidgets.image(s, "#0"));
|
||||
int before = a.getRGB(0, 0);
|
||||
|
||||
int[] px = new int[16];
|
||||
java.util.Arrays.fill(px, 0x801f); // opaque red in ABGR1555
|
||||
s.set(p, "pixels", px);
|
||||
BufferedImage b = GeoWidgets.image(s, "#0");
|
||||
assertNotSame(a, b);
|
||||
assertEquals(false, before == b.getRGB(0, 0));
|
||||
|
||||
s.set(t, "u1", 1);
|
||||
BufferedImage c = GeoWidgets.image(s, "#0");
|
||||
assertNotSame(b, c);
|
||||
assertEquals(2, c.getWidth());
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,21 @@ class ManualTest {
|
||||
assertEquals("Ctrl+[", ManualContent.keyText(javax.swing.KeyStroke.getKeyStroke("ctrl OPEN_BRACKET")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void consecutiveNumericChoicesBecomeRanges() {
|
||||
var o = com.eliasmueller.llamamod.mods.ModOption.choice(
|
||||
"c",
|
||||
"C",
|
||||
"",
|
||||
"1",
|
||||
Stream.of("1", "32", "33", "34", "36", "37", "x")
|
||||
.map(v -> com.eliasmueller.llamamod.mods.ModOption.of(v, v))
|
||||
.toArray(com.eliasmueller.llamamod.mods.ModOption.Choice[]::new));
|
||||
assertEquals("1, 32 – 34, 36, 37, x", ManualContent.choiceList(o));
|
||||
String mods = manual.chapter("18-mods.md").markdown();
|
||||
assertTrue(mods.contains("| TestLevel chapter | 32 – 49 | 49 |"), "arena chapter range");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchFindsSections() {
|
||||
List<Manual.Hit> h = manual.search("exit floor");
|
||||
|
||||
@@ -128,10 +128,10 @@ class ExeImageTest {
|
||||
@Test
|
||||
void withoutCaveTheLayoutIsUnchanged() throws Exception {
|
||||
ExeImage img = image();
|
||||
for (int off : ExePatcher.patchHkcu(orig).patchedOffsets()) {
|
||||
img.patch(img.va(off), new byte[] {2}, new byte[] {1}, "hkcu");
|
||||
for (ExePatcher.Change c : ExePatcher.changes(orig)) {
|
||||
img.patch(img.va(c.offset()), c.original(), c.replacement(), c.label());
|
||||
}
|
||||
assertArrayEquals(ExePatcher.patchHkcu(orig).data(), img.build());
|
||||
assertArrayEquals(ExePatcher.patch(orig), img.build());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -68,8 +68,7 @@ class ExeModCombinationsTest {
|
||||
assumeTrue(m != null, "mod " + id + " not built");
|
||||
exeMods.add(m);
|
||||
}
|
||||
byte[] base = ExePatcher.patchHkcu(Files.readAllBytes(p.originalHd().resolve("groove.exe")))
|
||||
.data();
|
||||
byte[] base = ExePatcher.patch(Files.readAllBytes(p.originalHd().resolve("groove.exe")));
|
||||
Path game = p.game().resolve("groove.exe");
|
||||
List<String> failures = new ArrayList<>();
|
||||
for (boolean allOn : new boolean[] {false, true}) {
|
||||
|
||||
@@ -200,7 +200,7 @@ class ModOptionsAndExeTest {
|
||||
Config cfg = new Config();
|
||||
ModManager mm = new ModManager(p, cfg, () -> false);
|
||||
byte[] orig = Files.readAllBytes(p.originalHd().resolve("groove.exe"));
|
||||
byte[] base = ExePatcher.patchHkcu(orig).data();
|
||||
byte[] base = ExePatcher.patch(orig);
|
||||
Path game = p.game().resolve("groove.exe");
|
||||
|
||||
mm.restoreOriginal(Progress.NONE);
|
||||
|
||||
@@ -27,6 +27,17 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class LevelProjectModTest {
|
||||
|
||||
@Test
|
||||
void newProjectsStartOnAFreeChapterFrom32(@TempDir Path dir) throws IOException {
|
||||
Bases cd = Bases.fromCd(GameData.cd());
|
||||
Project a = Project.create(cd, "new", "t1l1m001");
|
||||
assertEquals(32, a.scene().settings().targetChapter(), "template default, not the Test World's 1");
|
||||
assertEquals(32, LevelProjects.nextFreeChapter(dir));
|
||||
ProjectIO.save(a, dir.resolve("a.llamalevel"), cd);
|
||||
Files.writeString(dir.resolve("broken.llamalevel"), "no zip");
|
||||
assertEquals(33, LevelProjects.nextFreeChapter(dir));
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectsBecomeMods(@TempDir Path dir) throws IOException {
|
||||
Path cdDir = GameData.cd();
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
package com.eliasmueller.llamamod.platform;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
import com.eliasmueller.llamamod.exe.ExeImage;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ExePatcherTest {
|
||||
@Test
|
||||
void patchesTheFiveRegistryWrappers() throws Exception {
|
||||
private static byte[] original() throws Exception {
|
||||
Path exe = Path.of(System.getProperty("llamamod.cd", "/nonexistent"))
|
||||
.resolveSibling("Hd")
|
||||
.resolve("groove.exe");
|
||||
assumeTrue(Files.isRegularFile(exe), "groove.exe not available");
|
||||
byte[] orig = Files.readAllBytes(exe);
|
||||
return Files.readAllBytes(exe);
|
||||
}
|
||||
|
||||
@Test
|
||||
void patchesTheFiveRegistryWrappers() throws Exception {
|
||||
byte[] orig = original();
|
||||
ExePatcher.Result r = ExePatcher.patchHkcu(orig);
|
||||
assertEquals(List.of(0x579b, 0x57d0, 0x5810, 0x5859, 0x5880), r.patchedOffsets());
|
||||
for (int o : r.patchedOffsets()) {
|
||||
@@ -25,4 +34,58 @@ class ExePatcherTest {
|
||||
ExePatcher.Result again = ExePatcher.patchHkcu(r.data());
|
||||
assertEquals(5, again.alreadyPatched());
|
||||
}
|
||||
|
||||
@Test
|
||||
void relocatesEveryLevelRecordReference() throws Exception {
|
||||
byte[] orig = original();
|
||||
List<ExePatcher.Change> l = ExePatcher.relocateLevelRecords(orig);
|
||||
// instruction VA of the address operand -> new address
|
||||
Map<Long, Long> got = new TreeMap<>();
|
||||
ExeImage img = ExeImage.parse(orig);
|
||||
for (ExePatcher.Change c : l) {
|
||||
long to = (c.replacement()[0] & 0xffL)
|
||||
| (c.replacement()[1] & 0xffL) << 8
|
||||
| (c.replacement()[2] & 0xffL) << 16
|
||||
| (c.replacement()[3] & 0xffL) << 24;
|
||||
got.put(img.va(c.offset()), to);
|
||||
}
|
||||
long coins = 0x6db940;
|
||||
long flags = coins + 4 * 128;
|
||||
Map<Long, Long> want = new TreeMap<>();
|
||||
for (long va : new long[] {
|
||||
0x40de67, 0x41c72a, 0x41c8fb, 0x41ca6b, 0x41cbcc, 0x426ac2, 0x426d43, 0x426e08, 0x54d6ba, 0x550bc5, 0x55223e
|
||||
}) {
|
||||
want.put(va, coins);
|
||||
}
|
||||
want.put(0x41c74bL, coins + 216);
|
||||
want.put(0x41c91aL, coins + 216);
|
||||
for (long va :
|
||||
new long[] {0x40dea1, 0x40dec9, 0x41c65c, 0x41c832, 0x41ca64, 0x42706b, 0x54d6e3, 0x54d708, 0x54d70f}) {
|
||||
want.put(va, flags);
|
||||
}
|
||||
for (long va : new long[] {0x41b724, 0x41b79c, 0x41b7ee, 0x41cd7b, 0x54a75e}) {
|
||||
want.put(va, flags + 6);
|
||||
}
|
||||
for (long va : new long[] {0x41c6d6, 0x41c8af, 0x41cd9b}) {
|
||||
want.put(va, flags + 54);
|
||||
}
|
||||
assertEquals(want, got);
|
||||
// the buffer lies in the zero-filled tail of .data's last page, past every variable
|
||||
ExeImage.Section data = img.section(".data");
|
||||
assertTrue(coins >= data.va() + data.virtualSize());
|
||||
assertTrue(flags + 128 <= 0x6dc000 && 0x6dc000 <= img.section(".idata").va());
|
||||
|
||||
byte[] full = ExePatcher.patch(orig);
|
||||
assertEquals(List.of(), ExePatcher.relocateLevelRecords(full));
|
||||
assertArrayEquals(full, ExePatcher.patch(full));
|
||||
assertEquals(5 + want.size(), ExePatcher.changes(orig).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownBuildIsLeftAlone() throws Exception {
|
||||
byte[] orig = original().clone();
|
||||
int off = ExeImage.parse(orig).fileOffset(0x426d43);
|
||||
orig[off] = 0x20; // one reference fewer: no relocation at all
|
||||
assertEquals(List.of(), ExePatcher.relocateLevelRecords(orig));
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ class IsoVariantsTest {
|
||||
Path exe = paths.game().resolve("groove.exe");
|
||||
assertTrue(Files.isRegularFile(exe), image + ": game/groove.exe missing");
|
||||
byte[] orig = Files.readAllBytes(Integrity.find(paths.originalHd(), "groove.exe"));
|
||||
assertArrayEquals(ExePatcher.patchHkcu(orig).data(), Files.readAllBytes(exe), image + ": exe");
|
||||
assertArrayEquals(ExePatcher.patch(orig), Files.readAllBytes(exe), image + ": exe");
|
||||
assertEquals(ModManager.ExeStatus.OK, mm.checkGameExe());
|
||||
assertTrue(new SaveStore(paths).load(1).used(), image + ": save game not found by the save editor");
|
||||
Path movies = Integrity.find(paths.cdActive(), "movies");
|
||||
|
||||
Reference in new issue
Block a user