mountain 3 crash fix mod (keeps the boulder spawners alive, one word of t1l5m003), object palette test skips without a display

This commit is contained in:
MineTec committed 2026-11-08 18:28:02 +01:00
1 parent ec2876d9c8
commit 526059c70a
7 files changed
+146

No files matched your search

+3
View File
@@ -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.
+6
View File
@@ -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.
+6
View File
@@ -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.
@@ -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). */
@@ -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<String> 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;
}
}
@@ -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<String> tools = new ArrayList<>();
@@ -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)");
}
}