diff --git a/CHANGELOG.md b/CHANGELOG.md index a2df3f2..817a903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.2.4 – unreleased +- **New mod "Mountain 3 crash fix"** (Gameplay): the rolling boulder spawners in Mountain chapter 3 + deleted themselves too early and their boulders later wrote into the freed memory (use after free), + which can crash the game in `FreeLocalMem`. One word of level data keeps the spawners alive. - **New mod "Developer mode"** (Cheats): turns on the game's hidden developer switch. Hold A+S to warp to the developers' warp points, F1 to fly, F2 for a heart and 5 fruit, F3/F11 to count idols and coins up; Space on the river log makes it invincible and fast. diff --git a/docs/manual/18-mods.md b/docs/manual/18-mods.md index e8d6449..cfd2cb1 100644 --- a/docs/manual/18-mods.md +++ b/docs/manual/18-mods.md @@ -185,6 +185,12 @@ Plays every level mirrored left to right, like the mirror mode of racing games. | Readable textures | on / off | off | Flips writing and pictures back so they read normally. Some textures (water edges, paths) then show seams. Off: a true mirror image. | | Mirror object models | on / off | off | Also mirrors the shape of objects such as huts and crates (characters stay as they are). Off: objects are only placed mirrored. | +#### Mountain 3 crash fix + +Fixes a bug in Mountain chapter 3 that can crash the game: the rolling boulder spawners delete themselves too early and their boulders later write into the freed memory. With the fix the spawners stay alive; the boulders roll as before. + +*Id* `mountain3-fix` + #### Practice tools Practise hard jumps: press F5 to remember where the llama stands and F8 to jump back there (in the same level; the keys can be changed below). Also adds extra checkpoints along the coin trail of levels that have checkpoints. diff --git a/notes/mountain3-crash.md b/notes/mountain3-crash.md index f15f135..a238a47 100644 --- a/notes/mountain3-crash.md +++ b/notes/mountain3-crash.md @@ -142,3 +142,9 @@ boot-to-level + mtn3-diag on the German exe). This is only for the German exe; n > without an error message, and "every so often" around boulders. The likely triggers are the > one-shot boulders near the start/top of the slope and the two red-X push boulders. We'll > probably ship the fix in LlamaMod. + +## In LlamaMod + +The fix is the mod `mountain3-fix` (`mods/fixes/Mountain3FixMod`, Gameplay): it finds the unique pattern in +`t1l5m003.wad` and replaces `pushShort 0` with `pushShort 0.5`; if the pattern is missing (other game +version, file already changed) it leaves the file unchanged and says so in the apply log. diff --git a/src/main/java/com/eliasmueller/llamamod/mods/ModRegistry.java b/src/main/java/com/eliasmueller/llamamod/mods/ModRegistry.java index c794ea1..41ef7e4 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/ModRegistry.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/ModRegistry.java @@ -26,6 +26,7 @@ public final class ModRegistry { "com.eliasmueller.llamamod.mods.arena.ArenaMod", "com.eliasmueller.llamamod.mods.coaster.CoasterMod", "com.eliasmueller.llamamod.mods.devmode.DevModeMod", + "com.eliasmueller.llamamod.mods.fixes.Mountain3FixMod", }; /** {@link ModSource}s loaded by class name (dynamic mods, e.g. level projects). */ diff --git a/src/main/java/com/eliasmueller/llamamod/mods/fixes/Mountain3FixMod.java b/src/main/java/com/eliasmueller/llamamod/mods/fixes/Mountain3FixMod.java new file mode 100644 index 0000000..f562dfc --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/mods/fixes/Mountain3FixMod.java @@ -0,0 +1,84 @@ +package com.eliasmueller.llamamod.mods.fixes; + +import com.eliasmueller.llamamod.mods.LevelWads; +import com.eliasmueller.llamamod.mods.Mod; +import com.eliasmueller.llamamod.mods.ModContext; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.List; +import java.util.regex.Pattern; + +/** + * Fixes the boulder use-after-free in Mountain 3 (t1l5m003): RollingBoulderMountain ends right after + * spawning because it waits for its never-incremented counter v13 to be 0, while its boulders later + * still write {@code parent.v13 -= 1} into the freed spawner. Comparing with 0.5 instead keeps the + * spawner alive (frozen off camera). See notes/mountain3-crash.md. + */ +public final class Mountain3FixMod implements Mod { + static final Pattern LEVEL = Pattern.compile("(?i)t1l5m003\\.wad"); + + /** freezeOffCamera 1; push v13; pushShort 0; eq; jzYield -4; end. */ + static final int[] PATTERN = {0x1002f, 0xd0004, 0x44, 0x4e, 0xfffc0012, 0xb4}; + + /** pushShort 0.5 (20.12) replacing pushShort 0 (third word of the pattern). */ + static final int FIXED = 0x08000044; + + @Override + public String id() { + return "mountain3-fix"; + } + + @Override + public String name() { + return "Mountain 3 crash fix"; + } + + @Override + public String description() { + return "Fixes a bug in Mountain chapter 3 that can crash the game: the rolling boulder spawners delete" + + " themselves too early and their boulders later write into the freed memory. With the fix the" + + " spawners stay alive; the boulders roll as before."; + } + + @Override + public String category() { + return GAMEPLAY; + } + + @Override + public void apply(ModContext ctx) throws IOException { + List files = LevelWads.list(ctx, LEVEL); + for (String f : files) { + byte[] wad = ctx.read(f); + int at = find(wad); + if (at < 0) { + ctx.progress(1, "Mountain 3 fix: pattern not found in " + LevelWads.fileName(f) + " – left unchanged"); + continue; + } + byte[] out = wad.clone(); + ByteBuffer.wrap(out).order(ByteOrder.LITTLE_ENDIAN).putInt(at + 8, FIXED); + ctx.write(f, out); + ctx.progress(1, "Mountain 3 fix applied"); + } + } + + /** Offset of the unique pattern, or -1 if it is missing or not unique. */ + static int find(byte[] wad) { + ByteBuffer b = ByteBuffer.wrap(wad).order(ByteOrder.LITTLE_ENDIAN); + int found = -1; + outer: + for (int i = 0; i + PATTERN.length * 4 <= wad.length; i++) { + for (int k = 0; k < PATTERN.length; k++) { + if (b.getInt(i + 4 * k) != PATTERN[k]) { + continue outer; + } + } + if (found >= 0) { + return -1; + } + found = i; + } + return found; + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectPaletteTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectPaletteTest.java index 5081bdb..22e9328 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectPaletteTest.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectPaletteTest.java @@ -3,12 +3,14 @@ package com.eliasmueller.llamamod.editor.tools.objects; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeFalse; import com.eliasmueller.llamamod.editor.io.Bases; import com.eliasmueller.llamamod.editor.io.Project; import com.eliasmueller.llamamod.editor.model.Scene; import com.eliasmueller.llamamod.editor.ui.EditorContext; import com.eliasmueller.llamamod.editor.ui.EditorPrefs; +import java.awt.GraphicsEnvironment; import java.awt.Rectangle; import java.awt.event.InputEvent; import java.awt.event.MouseEvent; @@ -23,6 +25,7 @@ import org.junit.jupiter.api.Test; class ObjectPaletteTest { @Test void rightClickDoesNotPickTheType() throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display (Swing popup)"); Scene s = Project.create(Bases.fromCd(ObjectToolsTest.cd()), "new", "t1l1m001") .scene(); List tools = new ArrayList<>(); diff --git a/src/test/java/com/eliasmueller/llamamod/mods/fixes/Mountain3FixModTest.java b/src/test/java/com/eliasmueller/llamamod/mods/fixes/Mountain3FixModTest.java new file mode 100644 index 0000000..ad38570 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/mods/fixes/Mountain3FixModTest.java @@ -0,0 +1,43 @@ +package com.eliasmueller.llamamod.mods.fixes; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eliasmueller.llamamod.mods.GameData; +import com.eliasmueller.llamamod.mods.TestModContext; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class Mountain3FixModTest { + @Test + void changesExactlyOneWordInMountain3() throws IOException { + Path cd = GameData.cd(); + Mountain3FixMod mod = new Mountain3FixMod(); + TestModContext ctx = new TestModContext(cd, mod, Map.of(), null); + mod.apply(ctx); + assertEquals(1, ctx.written.size(), "only t1l5m003 is written: " + ctx.written.keySet()); + String f = ctx.written.keySet().iterator().next(); + byte[] o = Files.readAllBytes(cd.resolve(f)); + byte[] n = ctx.written.get(f); + int at = Mountain3FixMod.find(o); + assertTrue(at >= 0); + assertEquals(o.length, n.length); + int diff = 0; + for (int i = 0; i < o.length; i++) { + if (o[i] != n[i]) { + diff++; + assertTrue(i >= at + 8 && i < at + 12, "change outside the fixed word at " + i); + } + } + assertTrue(diff > 0 && diff <= 4); + assertEquals( + Mountain3FixMod.FIXED, + ByteBuffer.wrap(n).order(ByteOrder.LITTLE_ENDIAN).getInt(at + 8)); + assertEquals(-1, Mountain3FixMod.find(n), "pattern is gone after the fix (applying twice is a no-op)"); + } +}