diff --git a/notes/dev/agent-brief.md b/notes/dev/agent-brief.md new file mode 100644 index 0000000..8101bf8 --- /dev/null +++ b/notes/dev/agent-brief.md @@ -0,0 +1,54 @@ +# Common rules for LlamaMod mod agents (read fully) + +Project: ~/projects/llamamod — Java 25 Swing mod manager "LlamaMod" for the PC game "Ein Königreich für ein Lama" +(Emperor's New Groove, 2000, Argonaut/Croc-2 engine, 32-bit DirectX 6/7, German version). Git repo: DO NOT commit. + +WORK IN YOUR OWN COPY (4 agents work in parallel; a half-written file in the shared tree would break the others' builds): + rsync -a --exclude build --exclude .gradle --exclude re/proj --exclude out ~/projects/llamamod/ $CLAUDE_JOB_DIR/tmp//llamamod/ + (re/groove_decomp.c and re/groove.exe come along; Ghidra project: copy re/proj too if you need headless Ghidra: + ~/opt/ghidra_12.2_PUBLIC, JAVA_HOME=~/opt/jdk-25.0.5+8; never run it on ~/projects/llamamod/re/proj directly.) +Build/test there: ./gradlew spotlessApply build fatJar. When DONE, copy back ONLY your own new package directories +(src/main/java/.../mods//, src/test/java/.../mods//, resources under your package) plus your notes +file into ~/projects/llamamod, then run `./gradlew spotlessApply build` in ~/projects/llamamod once (if it fails because of +ANOTHER agent's in-progress package, re-run later; never edit other agents' packages). Your mod classes are ALREADY +registered in mods/ModRegistry.java (reflection, skipped if absent) – do not edit ModRegistry. Avoid editing shared +framework files (Mod, ModContext, ModManager, ModOption(s), exe/*, ui/*, platform/*). If you truly need a framework change, +keep it minimal and backwards compatible, and list it explicitly in your report (it must then also be copied back; tell me). + +READ: notes/llamamod-app.md (framework: options API, categories, ExeImage/code caves/X86 emitter, "Testing mods in game" +with tools/apptest.py), notes/geometry.md, notes/objects.md, notes/strat_types_table.md, notes/levelbau.md, notes/debugviz.md. +Format API: package com.eliasmueller.llamamod.format (Wad, Level, MapChunk, Trak/Model/Vertex/Face, TextChunk, +Collision, Geometry, Stpc, Strats, Lin3, PyMath). Examples of mods: mods/debug/DebugWireframeMod, mods/testworld/TestWorldMod. +Decompile of groove.exe: re/groove_decomp.c (grep, don't read whole). Save format: save/Ksv.java. + +LANGUAGE: everything English (identifiers, comments, UI labels, messages). Only necessary comments (offsets, patch bytes, +non-obvious engine facts). Each mod: id, name, description, category (Mod.LEVELS/GAMEPLAY/VISUAL/CHEATS), options(). + +IN-GAME TESTING is mandatory: tools/apptest.py from YOUR copy (it uses your copy's fat jar), with YOUR display number +(given below) and session names prefixed with your agent name. It runs a private PulseAudio (silent) – never start the +game any other way. Never use display :0, never touch ~/.wine-lama or the user's saves, never `pkill -f` patterns +(they match your own shell) – use PIDs / apptest stop. Stop your sessions when done. Look at screenshots with Read +(use montage/--small to save context). Disk is limited (~20 GB free): delete your copy's build dirs and session dirs at the end +(keep the session template _llamamod-template). + +REPORT BACK: what the mod does (options), how it works (offsets/patches/data changes), what was verified in game (which +levels, screenshots paths – put 2–4 phone-friendly JPGs ≤1280px into ~/projects/llamamod/notes/img/mods/), known limits, +any framework changes. Also write notes/mod-.md (English, concise). + +## Extra rules for EDITOR phase-3 agents (several agents edit the editor in parallel) +- Right after rsync, make a pristine baseline of your start state: `cp -al .base` (hard links, cheap) – + actually use `rsync -a / .base/` so later edits in the copy don't change it. +- Put your work in your OWN plugin package (class name ending in `EditorPlugin`, see notes/editor-ui.md) – no shell edits needed. +- Shared files (editor.model, editor.io, editor.render, editor.ui shell, format, levelbuild, framework): only minimal, + backwards-compatible additions. At copy-back, for every shared file you changed: if the main tree's version differs from + your baseline (another agent changed it meanwhile), DO NOT overwrite – re-apply your diff (`diff -u base copy | patch`) + onto the main tree's version and resolve conflicts by hand. New files of your own: copy directly. +- After copy-back: `./gradlew spotlessApply build` in ~/projects/llamamod must pass (retry later if another agent's + half-merged change breaks it; never edit their packages). +- Launch the editor: `llamamod --editor [file|t1l1m001]` (fat jar) on your UI display; screenshots via `import -display :N`. + +## SIMPLIFY agents (overnight review waves) +- Goal: make your assigned area simpler and cleaner WITHOUT changing behaviour: remove duplication (extract shared helpers), dead/unused code, needless indirection and over-engineering, overly long methods (split where it clarifies), inconsistent naming, unnecessary comments (keep only non-obvious ones), stray debug code; tidy APIs used only internally (make private/package-private). +- Do NOT: rewrite architectures, change file formats or byte output, change UI behaviour/labels, change public APIs used by other areas (if needed, list it instead), touch areas assigned to other agents. +- Every golden/byte-exact test and all 300+ tests must stay green; add tests if you touch untested logic. Commit nothing. +- Report: what was simplified (with rough LOC before/after for your area), anything risky you deliberately left alone, and suggestions outside your area. diff --git a/notes/dev/wave-2026-11-07.md b/notes/dev/wave-2026-11-07.md new file mode 100644 index 0000000..1398b6c --- /dev/null +++ b/notes/dev/wave-2026-11-07.md @@ -0,0 +1,21 @@ +# Coordinator rules for the 2026-11-07 open-points waves (read together with agent-brief.md) + +- You are one of several agents working in parallel on ~/projects/llamamod. Read notes/dev/agent-brief.md fully, + INCLUDING the "EDITOR phase-3" merge rules – they apply to every agent in this wave, not only editor agents: + own copy under $CLAUDE_JOB_DIR/tmp//llamamod, a pristine baseline copy (.base via rsync), and at + copy-back every shared file you changed is merged with `diff -u base copy | patch` onto the main tree's + current version (never blindly overwrite a file another agent may have changed meanwhile). +- Do NOT commit (the coordinator commits after each package). Do not touch git in the main tree. +- Build in your own copy: `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 ./gradlew spotlessApply build fatJar`. + After copy-back run `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 ./gradlew spotlessApply build` in the main tree + once; if it fails only because of another agent's half-merged change, wait a few minutes and retry. +- Every behaviour change needs a test (unit test where possible) and, for UI, a screenshot check on YOUR display; + for game-side changes an in-game check with tools/apptest.py (your display, session names prefixed with your name). +- Keep user-facing texts English, short and plain. Update the relevant notes/*.md and, if users see the change, + the manual (docs/manual) briefly. +- Out of scope for everyone: Windows hardware test, texture packs, German translation of the UI, a launcher + manual/help window, a OneDrive warning, git remotes. +- At the end delete your copy's build/, .gradle/, the .base copy and your apptest session dirs (disk is tight). +- REPORT (final message, English, concise): what you changed (files), how verified (tests, screenshots paths), + shared/framework files you touched, and a list "NEW ISSUES / NOT DONE" with anything you found but did not fix + (the coordinator turns those into follow-up tasks). diff --git a/notes/mod-freecam.md b/notes/mod-freecam.md index ba17770..8ea2c0a 100644 --- a/notes/mod-freecam.md +++ b/notes/mod-freecam.md @@ -111,3 +111,5 @@ swallowed while the free camera is on. region's objects, or the backdrop only where the level has no geometry (open sea around islands). * A cutscene camera set via 0x57f8bc in the render function still wins over the free camera. * No mouse look. + +Hotkeys are checked together with the other mods' hotkeys (`mods/Hotkeys.conflicts`): applying fails with a clear message if two enabled hotkey options share a key. diff --git a/notes/mod-practice.md b/notes/mod-practice.md index 0a59494..b1bb95c 100644 --- a/notes/mod-practice.md +++ b/notes/mod-practice.md @@ -88,3 +88,5 @@ to the real exe (no overlapping patches) and writes `build/tmp/practice-freecam. CheckPointPost, so it is expected to behave like the original ones. * Posts are placed at coins with floor below; a coin on a moving platform or behind a one-way section can give a post the llama only reaches from one side. + +Hotkeys are checked together with the other mods' hotkeys (`mods/Hotkeys.conflicts`): applying fails with a clear message if two enabled hotkey options share a key. diff --git a/src/main/java/com/eliasmueller/llamamod/mods/Hotkeys.java b/src/main/java/com/eliasmueller/llamamod/mods/Hotkeys.java new file mode 100644 index 0000000..9de8de2 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/mods/Hotkeys.java @@ -0,0 +1,72 @@ +package com.eliasmueller.llamamod.mods; + +import com.eliasmueller.llamamod.platform.Config; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Hotkey choices for cave code that polls GetAsyncKeyState. F6 (screenshot in the game's window + * procedure) and F10 (system key) are left out; the game's own controls are DirectInput keys. + */ +public final class Hotkeys { + private Hotkeys() {} + + /** Option value → Windows virtual-key code. */ + public static final Map VK = new LinkedHashMap<>(); + + static { + int[] f = {1, 2, 3, 4, 5, 7, 8, 9, 11, 12}; + for (int n : f) { + VK.put("F" + n, 0x6F + n); + } + VK.put("Insert", 0x2D); + VK.put("Delete", 0x2E); + VK.put("Home", 0x24); + VK.put("End", 0x23); + } + + public static ModOption option(String key, String label, String description, String def) { + ModOption.Choice[] c = VK.keySet().stream().map(k -> ModOption.of(k, k)).toArray(ModOption.Choice[]::new); + return ModOption.choice(key, label, description, def, c); + } + + /** True for options created by {@link #option}. */ + public static boolean isHotkey(ModOption o) { + return o.type() == ModOption.Type.CHOICE + && o.choices().stream().map(ModOption.Choice::value).toList().equals(List.copyOf(VK.keySet())); + } + + /** + * One message per key that more than one hotkey option of {@code mods} uses (within one mod or + * across mods); empty if all hotkeys are distinct. + */ + public static List conflicts(List mods, Config cfg) { + Map> users = new LinkedHashMap<>(); + for (Mod m : ModManager.sorted(mods)) { + Map values = ModOptions.resolve(m, cfg); + for (ModOption o : m.options()) { + if (isHotkey(o)) { + users.computeIfAbsent(values.get(o.key()), k -> new ArrayList<>()) + .add("\"" + o.label() + "\" (" + m.name() + ")"); + } + } + } + List out = new ArrayList<>(); + users.forEach((key, who) -> { + if (who.size() > 1) { + out.add(key + " is used by " + String.join(" and ", who)); + } + }); + return out; + } + + public static int vk(String name) { + Integer v = VK.get(name); + if (v == null) { + throw new IllegalArgumentException("unknown hotkey " + name); + } + return v; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/mods/ModManager.java b/src/main/java/com/eliasmueller/llamamod/mods/ModManager.java index fb65a87..c8155e2 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/ModManager.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/ModManager.java @@ -347,6 +347,11 @@ public final class ModManager { if (gameRunning.getAsBoolean()) { throw new IOException("The game is running. Please quit it before applying or restoring mods."); } + List clashes = Hotkeys.conflicts(mods, cfg); + if (!clashes.isEmpty()) { + throw new IOException("Two hotkeys share a key: " + String.join("; ", clashes) + + ". Please choose different keys in the mod options."); + } try (DataLock lock = DataLock.acquire(paths); ApplyLog log = new ApplyLog(paths.logs().resolve("apply.log"))) { log.line("mods: " + sorted(mods).stream().map(Mod::id).toList()); diff --git a/src/main/java/com/eliasmueller/llamamod/mods/freecam/FreeCamMod.java b/src/main/java/com/eliasmueller/llamamod/mods/freecam/FreeCamMod.java index 2c85999..7b96b03 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/freecam/FreeCamMod.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/freecam/FreeCamMod.java @@ -1,9 +1,9 @@ package com.eliasmueller.llamamod.mods.freecam; +import com.eliasmueller.llamamod.mods.Hotkeys; import com.eliasmueller.llamamod.mods.Mod; import com.eliasmueller.llamamod.mods.ModContext; import com.eliasmueller.llamamod.mods.ModOption; -import com.eliasmueller.llamamod.mods.practice.Hotkeys; import java.io.IOException; import java.util.List; diff --git a/src/main/java/com/eliasmueller/llamamod/mods/practice/Hotkeys.java b/src/main/java/com/eliasmueller/llamamod/mods/practice/Hotkeys.java deleted file mode 100644 index 0b9a636..0000000 --- a/src/main/java/com/eliasmueller/llamamod/mods/practice/Hotkeys.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.eliasmueller.llamamod.mods.practice; - -import com.eliasmueller.llamamod.mods.ModOption; -import java.util.LinkedHashMap; -import java.util.Map; - -/** - * Hotkey choices for cave code that polls GetAsyncKeyState. F6 (screenshot in the game's window - * procedure) and F10 (system key) are left out; the game's own controls are DirectInput keys. - */ -public final class Hotkeys { - private Hotkeys() {} - - /** Option value → Windows virtual-key code. */ - public static final Map VK = new LinkedHashMap<>(); - - static { - int[] f = {1, 2, 3, 4, 5, 7, 8, 9, 11, 12}; - for (int n : f) { - VK.put("F" + n, 0x6F + n); - } - VK.put("Insert", 0x2D); - VK.put("Delete", 0x2E); - VK.put("Home", 0x24); - VK.put("End", 0x23); - } - - public static ModOption option(String key, String label, String description, String def) { - ModOption.Choice[] c = VK.keySet().stream().map(k -> ModOption.of(k, k)).toArray(ModOption.Choice[]::new); - return ModOption.choice(key, label, description, def, c); - } - - public static int vk(String name) { - Integer v = VK.get(name); - if (v == null) { - throw new IllegalArgumentException("unknown hotkey " + name); - } - return v; - } -} diff --git a/src/main/java/com/eliasmueller/llamamod/mods/practice/PracticeMod.java b/src/main/java/com/eliasmueller/llamamod/mods/practice/PracticeMod.java index eb4e822..0c01ae7 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/practice/PracticeMod.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/practice/PracticeMod.java @@ -2,6 +2,7 @@ package com.eliasmueller.llamamod.mods.practice; import static com.eliasmueller.llamamod.mods.LevelWads.fileName; +import com.eliasmueller.llamamod.mods.Hotkeys; import com.eliasmueller.llamamod.mods.LevelWads; import com.eliasmueller.llamamod.mods.Mod; import com.eliasmueller.llamamod.mods.ModContext; diff --git a/src/test/java/com/eliasmueller/llamamod/mods/HotkeysTest.java b/src/test/java/com/eliasmueller/llamamod/mods/HotkeysTest.java new file mode 100644 index 0000000..ccd00dd --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/mods/HotkeysTest.java @@ -0,0 +1,45 @@ +package com.eliasmueller.llamamod.mods; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eliasmueller.llamamod.mods.freecam.FreeCamMod; +import com.eliasmueller.llamamod.mods.practice.PracticeMod; +import com.eliasmueller.llamamod.platform.Config; +import java.util.List; +import org.junit.jupiter.api.Test; + +class HotkeysTest { + private final Mod practice = new PracticeMod(); + private final Mod freecam = new FreeCamMod(); + + @Test + void defaultsDoNotClash() { + assertTrue(Hotkeys.conflicts(List.of(practice, freecam), new Config()).isEmpty()); + } + + @Test + void sameKeyAcrossModsIsReported() { + Config cfg = new Config(); + ModOptions.set(cfg, freecam, "camKey", "F5"); + List c = Hotkeys.conflicts(List.of(practice, freecam), cfg); + assertEquals(1, c.size()); + assertTrue(c.get(0).startsWith("F5 is used by"), c.get(0)); + assertTrue(Hotkeys.conflicts(List.of(freecam), cfg).isEmpty()); + } + + @Test + void sameKeyWithinOneModIsReported() { + Config cfg = new Config(); + ModOptions.set(cfg, practice, "loadKey", "F5"); + assertEquals(1, Hotkeys.conflicts(List.of(practice), cfg).size()); + } + + @Test + void onlyHotkeyOptionsCount() { + assertTrue(practice.options().stream().anyMatch(Hotkeys::isHotkey)); + assertTrue(practice.options().stream() + .filter(o -> !o.key().endsWith("Key")) + .noneMatch(Hotkeys::isHotkey)); + } +}