hotkeys moved to mods and checked across all enabled mods (apply refuses two hotkey options on the same key), agent briefs in notes/dev

This commit is contained in:
MineTec committed 2026-11-07 10:38:10 +01:00
1 parent 1c8b766454
commit 86fe26d786
10 files changed
+203 -41

No files matched your search

+54
View File
@@ -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/<agent>/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/<yourpkg>/, src/test/java/.../mods/<yourpkg>/, 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-<name>.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 <copy> <copy>.base` (hard links, cheap) –
actually use `rsync -a <copy>/ <copy>.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.
+21
View File
@@ -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/<agent>/llamamod, a pristine baseline copy (<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).
+2
View File
@@ -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). 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. * A cutscene camera set via 0x57f8bc in the render function still wins over the free camera.
* No mouse look. * 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.
+2
View File
@@ -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. 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 * 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. 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.
@@ -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<String, Integer> 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<String> conflicts(List<Mod> mods, Config cfg) {
Map<String, List<String>> users = new LinkedHashMap<>();
for (Mod m : ModManager.sorted(mods)) {
Map<String, String> 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<String> 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;
}
}
@@ -347,6 +347,11 @@ public final class ModManager {
if (gameRunning.getAsBoolean()) { if (gameRunning.getAsBoolean()) {
throw new IOException("The game is running. Please quit it before applying or restoring mods."); throw new IOException("The game is running. Please quit it before applying or restoring mods.");
} }
List<String> 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); try (DataLock lock = DataLock.acquire(paths);
ApplyLog log = new ApplyLog(paths.logs().resolve("apply.log"))) { ApplyLog log = new ApplyLog(paths.logs().resolve("apply.log"))) {
log.line("mods: " + sorted(mods).stream().map(Mod::id).toList()); log.line("mods: " + sorted(mods).stream().map(Mod::id).toList());
@@ -1,9 +1,9 @@
package com.eliasmueller.llamamod.mods.freecam; package com.eliasmueller.llamamod.mods.freecam;
import com.eliasmueller.llamamod.mods.Hotkeys;
import com.eliasmueller.llamamod.mods.Mod; import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModContext; import com.eliasmueller.llamamod.mods.ModContext;
import com.eliasmueller.llamamod.mods.ModOption; import com.eliasmueller.llamamod.mods.ModOption;
import com.eliasmueller.llamamod.mods.practice.Hotkeys;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
@@ -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<String, Integer> 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;
}
}
@@ -2,6 +2,7 @@ package com.eliasmueller.llamamod.mods.practice;
import static com.eliasmueller.llamamod.mods.LevelWads.fileName; 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.LevelWads;
import com.eliasmueller.llamamod.mods.Mod; import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModContext; import com.eliasmueller.llamamod.mods.ModContext;
@@ -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<String> 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));
}
}