diff --git a/docs/manual/18-mods.md b/docs/manual/18-mods.md index f14b8aa..d3f0a12 100644 --- a/docs/manual/18-mods.md +++ b/docs/manual/18-mods.md @@ -119,7 +119,7 @@ Plays every level mirrored left to right, like the mirror mode of racing games. | Option | Values | Default | Description | |---|---|---|---| -| Worlds | text | all | "all", or the numbers of the worlds to mirror, e.g. "1,3" (1 = Village, 9 = TestLevel). | +| Worlds | any of 1 Village, 2 Jungle at Night, 3 River, 4 Jungle by Day, 5 Mountain, 6 City, 7 Catacombs, 8 Lab, 9 TestLevel | all | The worlds whose levels are mirrored. | | 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. | diff --git a/notes/bug-review-launcher.md b/notes/bug-review-launcher.md index dbf8f07..2264eb1 100644 --- a/notes/bug-review-launcher.md +++ b/notes/bug-review-launcher.md @@ -82,10 +82,9 @@ Test World with all mods; boot-to-level + all exe mods (River loading card); pra NoClassDefFoundError. * **OneDrive / redirected Documents** (decided: no warning) as data folder: hard links inside a synced folder and on-demand files are fragile; the wizard could warn when the chosen folder is under `%OneDrive%`. -* **Interrupted apply** (app killed between the two renames of `buildActive`): `cd-active` is - missing until the next apply; the GUI then restores the original (dropping the mods), CLI - `--launch` says "CD folder missing". `cd-active.new` is complete at that point and could be moved - in instead. -* A `config.json` saved by a stale window after another instance applied still overwrites - activeMods/gameExeSha256 (L3 only covers the check); a merge-on-save would be needed. -* Old Wine versions are not detected (no version check); missing Wine gives a clear message. +* ~~**Interrupted apply**~~ – fixed 2026-11-07: `cd-active.new.complete` marker + + `ModManager.recoverInterruptedApply()` (GUI start, Play, `--launch`), see llamamod-app.md. +* ~~A `config.json` saved by a stale window overwrites activeMods/gameExeSha256~~ – fixed + 2026-11-07: field-level merge-on-save under `.config.lock` (`Config.save`). +* ~~Old Wine versions are not detected~~ – 2026-11-07: `wine --version` in Settings, warning below + Wine 5.0 (Settings in red, once before Play); missing Wine gives a clear message. diff --git a/notes/img/app/mirror-worlds-ingame.jpg b/notes/img/app/mirror-worlds-ingame.jpg new file mode 100644 index 0000000..2ff77a5 Binary files /dev/null and b/notes/img/app/mirror-worlds-ingame.jpg differ diff --git a/notes/img/app/mirror-worlds-option.jpg b/notes/img/app/mirror-worlds-option.jpg new file mode 100644 index 0000000..92401e0 Binary files /dev/null and b/notes/img/app/mirror-worlds-option.jpg differ diff --git a/notes/img/app/saves-1280x720.jpg b/notes/img/app/saves-1280x720.jpg new file mode 100644 index 0000000..bf7bdff Binary files /dev/null and b/notes/img/app/saves-1280x720.jpg differ diff --git a/notes/img/app/wizard-copy-mb.jpg b/notes/img/app/wizard-copy-mb.jpg new file mode 100644 index 0000000..9a443b9 Binary files /dev/null and b/notes/img/app/wizard-copy-mb.jpg differ diff --git a/notes/llamamod-app.md b/notes/llamamod-app.md index 56e8659..29fd728 100644 --- a/notes/llamamod-app.md +++ b/notes/llamamod-app.md @@ -56,6 +56,7 @@ different folder chosen in the wizard is remembered via `java.util.prefs`. | Path | Content | |---|---| | `config.json` | settings, enabled/active mods, mod option values (`modOptions`), CD fingerprint | +| `.config.lock`, `.apply.lock` | file locks: config merge-on-save, apply/restore | | `original/hd`, `original/cd` | pristine copy of `Setup//Hd` and `…/Cd` from the ISO | | `game/` | copy of the HD part the game runs from: **patched** `groove.exe`, DLLs, music, save games | | `cd-active/` | what the game sees as its CD: hard links to originals + mod outputs | @@ -66,6 +67,13 @@ different folder chosen in the wizard is remembered via `java.util.prefs`. | `wine/` | own Wine prefix (Linux) | | `logs/` | game.log, wineboot.log, registry.log | +**Config merge-on-save**: `Config.save` takes a file lock on `.config.lock` (plus a JVM-wide +monitor), re-reads config.json and three-way merges field by field against the values this +instance last loaded/saved: fields it did not change keep what another window/CLI/editor wrote +meanwhile (mod options merge per mod and key; unknown keys of newer versions are kept); a field +changed on both sides = last writer wins. The object then holds the merged values. A `new +Config()` that never touched a file overwrites everything (installer, tests). + ## First start The wizard takes an ISO file, an extracted folder or a mounted CD. ISO 9660 is read in pure Java @@ -125,7 +133,10 @@ Windows the launcher refuses other paths with a message (choose a folder like `C Wine cannot create win32 prefixes; system.reg only appears after `wineserver -w`). `WINEDLLOVERRIDES=winemenubuilder.exe=d;mscoree=d;mshtml=d` (no menu entries, no Mono/Gecko prompts). Optional virtual desktop: `wine explorer /desktop=LlamaMod,WxH Z:\…\groove.exe`. - Wine missing → clear error message. Running instances are detected via `/proc/*/environ` + Wine missing → clear error message. `wine --version` (cached per command) is shown in Settings; + below `GameLauncher.MIN_WINE` = 5.0 the line turns red and Play warns once per session ("Play + anyway"); unparsable output never warns or blocks. Only Wine 6.0.3 is actually tested; 5.0 is a + guess at the oldest usable version. Running instances are detected via `/proc/*/environ` (`WINEPREFIX=`), so two games never share the prefix. ## Mods @@ -140,7 +151,13 @@ SHA-256(CD fingerprint (names and sizes) | app version+build id | `ExePatcher.RE skips the mod, and an app update re-runs every mod once (a changed mod without a `version()` bump never serves stale output). Manifests store `"app"`; after each apply, cache entries of other app builds (including all of 0.1.0, which have no `"app"`) are deleted. Then `cd-active` is rebuilt in -`cd-active.new` and swapped in (hard link → symlink (not Windows) → copy). Applying (and restoring) +`cd-active.new` and swapped in (hard link → symlink (not Windows) → copy). Once `cd-active.new` is +complete, `cd-active.new.complete` (content: the activeKey of that build) is written; it is removed +after the swap. If the app is killed between the two renames, `ModManager.recoverInterruptedApply()` +(GUI start, Play, CLI `--launch`) moves `cd-active.new` in when its marker matches config.json's +activeKey (exe and config are only written after the swap, so they still describe the previous +selection), otherwise moves `cd-active.old` back; without either nothing is touched (Play then +restores the original as before). Applying (and restoring) is refused while the game runs (`GameLauncher.isRunning()`) and holds an exclusive lock on `/.apply.lock` (a second window/CLI/editor gets "Another LlamaMod window or command is applying mods"); every run is logged to `logs/apply.log`. @@ -163,7 +180,11 @@ above, collapsible headers with "n of m on") and sorts them by name inside a gro **Options** (`mods/ModOption`, record `(key, label, description, type, defaultValue, min, max, choices)`): factories `ModOption.bool(key, label, desc, def)`, `integer(key, label, desc, def, min, max)`, `string(…, def)`, `choice(key, label, desc, def, ModOption.of(value, label)…)`, `seed(key, -label, desc, def)` (empty default = a random seed is chosen on first use and saved). In `apply`: +label, desc, def)` (empty default = a random seed is chosen on first use and saved), `multi(key, label, +desc, def, ModOption.of(value, label)…)` (any subset; value `all`, `none` or the chosen values +comma-separated in choice order, e.g. `1,3`; empty reads as `all`, so a former STRING option holding +"all"/"1,3" keeps its value and cache key; `o.selected(value)` → list of chosen values; UI: a grid of +check boxes; first user: mirror `worlds`). In `apply`: `ctx.option(key)` (normalized string), `ctx.optionBool`, `ctx.optionInt`, `ctx.optionSeed` (long: numeric text as is, other text hashed). Values live in config.json (`modOptions..`, only user-set values and generated seeds); invalid stored values fall back to the default. The UI shows diff --git a/notes/ux-review-launcher.md b/notes/ux-review-launcher.md index 1ed50cf..09b7f28 100644 --- a/notes/ux-review-launcher.md +++ b/notes/ux-review-launcher.md @@ -75,9 +75,9 @@ Severity: **H** = blocks or seriously misleads; **M** = friction every session; | 22 | L | Mods intro | Fixed 640 px wrap; long technical sentence. | **Fixed**: "Tick the mods you want, then click Apply and Play…" | | 23 | L | Wizard done | "Choose mods on the Mods tab, edit save games…". | **Fixed**: "Next: tick the mods you like on the Mods tab, click Apply, then Play. The Save games tab can unlock worlds." | | 24 | L | Launcher edition | No help/manual at all (the manual window lives in the editor plugin and needs an editor context). | Open – a Help button would need `ManualWindow` without `EditorContext` | -| 25 | L | Mirror mode | *Worlds* is free text ("all" / "1,3"); a list of checkboxes would be friendlier. | Open – now explained inline; changing the option type would change saved configs | -| 26 | L | Wizard | The copy page shows the file name, not "123 of 568 MB" or a time. | Open (copy takes seconds from an ISO) | -| 27 | L | Save games | The chapter table shows 4–5 rows at 720 px height. | Partly – the lower header gives it 46 px more | +| 25 | L | Mirror mode | *Worlds* is free text ("all" / "1,3"); a list of checkboxes would be friendlier. | **Fixed** 2026-11-07: check boxes per world (new `ModOption.Type.MULTI`; stored "all"/"1,3" still load) | +| 26 | L | Wizard | The copy page shows the file name, not "123 of 568 MB" or a time. | **Fixed** 2026-11-07: "Copying 123 of 568 MB (file)" | +| 27 | L | Save games | The chapter table shows 4–5 rows at 720 px height. | **Fixed** 2026-11-07: lives moved into the world-map section (one header less), narrower number/check columns, centered coins – 6 rows at 1280×720 and 840×720 (was 4.5) | ## Changes (files) diff --git a/src/main/java/com/eliasmueller/llamamod/Main.java b/src/main/java/com/eliasmueller/llamamod/Main.java index de57455..0776f4a 100644 --- a/src/main/java/com/eliasmueller/llamamod/Main.java +++ b/src/main/java/com/eliasmueller/llamamod/Main.java @@ -183,7 +183,11 @@ public final class Main { case "--launch" -> { requireInstalled(cfg); new SaveStore(paths).migrateNullSaves().forEach(System.out::println); - checkExe(new ModManager(paths, cfg), paths); + ModManager mm = new ModManager(paths, cfg); + if (mm.recoverInterruptedApply()) { + System.out.println("Repaired the CD folder of an interrupted apply."); + } + checkExe(mm, paths); GameLauncher gl = new GameLauncher(paths, cfg); Process p; try { @@ -262,7 +266,7 @@ public final class Main { String range = switch (o.type()) { case INT -> " " + o.min() + ".." + o.max(); - case CHOICE -> " " + case CHOICE, MULTI -> (o.type() == ModOption.Type.MULTI ? " all, none or list of " : " ") + o.choices().stream() .map(ModOption.Choice::value) .toList(); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/help/ManualContent.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/help/ManualContent.java index 9843c8f..231fc84 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/help/ManualContent.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/help/ManualContent.java @@ -342,6 +342,11 @@ public final class ManualContent { o.choices().forEach(ch -> c.add(ch.label())); yield String.join(", ", c); } + case MULTI -> { + List c = new ArrayList<>(); + o.choices().forEach(ch -> c.add(ch.label())); + yield "any of " + String.join(", ", c); + } case SEED -> "any text"; case STRING -> "text"; }; diff --git a/src/main/java/com/eliasmueller/llamamod/mods/ModManager.java b/src/main/java/com/eliasmueller/llamamod/mods/ModManager.java index c8155e2..1c8751d 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/ModManager.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/ModManager.java @@ -437,7 +437,7 @@ public final class ModManager { // than writing the exe, and a new exe must not run against the old CD folder p.update(0.92, "Building CD folder …"); long t0 = System.nanoTime(); - Map kinds = buildActive(ov, p.sub(0.92, 1)); + Map kinds = buildActive(ov, order.isEmpty() ? "" : key, p.sub(0.92, 1)); log.line(String.format(Locale.ROOT, "CD folder: %s in %.1f s", kinds, (System.nanoTime() - t0) / 1e9)); writeExe(exe, log, notes); if (exe != null) { @@ -673,11 +673,17 @@ public final class ModManager { return notes; } - private Map buildActive(Overlay ov, Progress p) throws IOException { + /** + * {@code key}: the activeKey of this build, written to {@code cd-active.new.complete} once the + * folder is complete so {@link #recoverInterruptedApply()} can tell which state it holds. + */ + private Map buildActive(Overlay ov, String key, Progress p) throws IOException { Map kinds = new EnumMap<>(FileOps.LinkKind.class); Path active = paths.cdActive(); Path next = active.resolveSibling("cd-active.new"); Path old = active.resolveSibling("cd-active.old"); + Path marker = completeMarker(); + Files.deleteIfExists(marker); FileOps.deleteTree(next); FileOps.deleteTree(old); Files.createDirectories(next); @@ -691,14 +697,65 @@ public final class ModManager { p.update((double) n / all.size(), "Building CD folder (" + n + "/" + all.size() + ") …"); } } + Files.writeString(marker, key, StandardCharsets.UTF_8); if (Files.exists(active)) { FileOps.move(active, old); } FileOps.move(next, active); + Files.deleteIfExists(marker); FileOps.deleteTree(old); return kinds; } + private Path completeMarker() { + return paths.cdActive().resolveSibling("cd-active.new.complete"); + } + + /** + * Repairs a {@code cd-active} that is missing because an apply was killed between its two + * renames: {@code cd-active.new} is moved in if it is complete and built for the selection + * config.json records (activeKey, which matches game/groove.exe because both are written only + * after the swap); otherwise {@code cd-active.old}, the folder of that recorded selection, is + * moved back. Returns false if nothing was repaired (no interruption, another apply running). + */ + public boolean recoverInterruptedApply() { + Path active = paths.cdActive(); + if (Files.exists(active)) { + return false; + } + Path next = active.resolveSibling("cd-active.new"); + Path old = active.resolveSibling("cd-active.old"); + if (!Files.isDirectory(next) && !Files.isDirectory(old)) { + return false; + } + // no ApplyLog: it would truncate apply.log, which tells what the interrupted apply did + try (DataLock lock = DataLock.acquire(paths)) { + if (Files.exists(active)) { + return false; + } + Path marker = completeMarker(); + String built = Files.isRegularFile(marker) ? Files.readString(marker, StandardCharsets.UTF_8) : null; + String recorded = Config.load(paths.config()).activeKey; + Path use; + if (built != null && built.equals(recorded) && Files.isDirectory(next)) { + use = next; + } else if (Files.isDirectory(old)) { + use = old; + } else { + System.err.println("interrupted apply: cd-active missing, cd-active.new " + + (built == null ? "incomplete" : "built for another selection") + " – not recovered"); + return false; + } + FileOps.move(use, active); + Files.deleteIfExists(marker); + FileOps.deleteTree(use == next ? old : next); + System.err.println("interrupted apply: recovered cd-active from " + use.getFileName()); + return true; + } catch (IOException e) { + return false; + } + } + public long cacheSize() throws IOException { if (!Files.isDirectory(paths.cache())) { return 0; diff --git a/src/main/java/com/eliasmueller/llamamod/mods/ModOption.java b/src/main/java/com/eliasmueller/llamamod/mods/ModOption.java index 1a6de4c..d1ac432 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/ModOption.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/ModOption.java @@ -1,8 +1,11 @@ package com.eliasmueller.llamamod.mods; +import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Objects; +import java.util.Set; /** * A user-settable option of a mod. Values are stored as canonical strings (see {@link @@ -10,6 +13,10 @@ import java.util.Objects; * *

SEED options hold any text; {@link ModContext#optionSeed(String)} turns it into a number. An * empty SEED default means "pick a random seed when the mod is first used" (then persisted). + * + *

MULTI options pick any subset of their choices; the value is {@code "all"}, {@code "none"} or + * the chosen values comma-separated in choice order (an empty value reads as "all", so a former + * STRING option holding "all" or "1,3" keeps its meaning and cache key). */ public record ModOption( String key, @@ -26,9 +33,13 @@ public record ModOption( INT, STRING, CHOICE, - SEED + SEED, + MULTI } + public static final String ALL = "all"; + public static final String NONE = "none"; + /** A CHOICE value and its UI label. */ public record Choice(String value, String label) {} @@ -42,8 +53,15 @@ public record ModOption( description = description == null ? "" : description; choices = choices == null ? List.of() : List.copyOf(choices); defaultValue = defaultValue == null ? "" : defaultValue; - if (type == Type.CHOICE && choices.isEmpty()) { - throw new IllegalArgumentException("CHOICE option without choices: " + key); + if ((type == Type.CHOICE || type == Type.MULTI) && choices.isEmpty()) { + throw new IllegalArgumentException(type + " option without choices: " + key); + } + if (type == Type.MULTI + && choices.stream() + .anyMatch(c -> c.value().equalsIgnoreCase(ALL) + || c.value().equalsIgnoreCase(NONE) + || !c.value().matches("[^,;\\s]+"))) { + throw new IllegalArgumentException("MULTI choice values must be single words other than all/none: " + key); } if (type != Type.SEED || !defaultValue.isEmpty()) { defaultValue = normalize(key, type, min, max, choices, defaultValue); @@ -66,6 +84,25 @@ public record ModOption( return new ModOption(key, label, description, Type.CHOICE, def, 0, 0, List.of(choices)); } + /** Any subset of {@code choices}; {@code def} e.g. "all", "none" or "1,3". */ + public static ModOption multi(String key, String label, String description, String def, Choice... choices) { + return new ModOption(key, label, description, Type.MULTI, def, 0, 0, List.of(choices)); + } + + /** The choice values a normalized MULTI value stands for, in choice order. */ + public List selected(String value) { + String v = normalize(value); + if (v.equals(ALL)) { + return choices.stream().map(Choice::value).toList(); + } + return v.equals(NONE) ? List.of() : List.of(v.split(",")); + } + + /** Normalized MULTI value for the chosen values. */ + public String multiValue(Collection chosen) { + return chosen.isEmpty() ? NONE : normalize(String.join(",", chosen)); + } + /** {@code def} empty = random seed on first use. */ public static ModOption seed(String key, String label, String description, String def) { return new ModOption(key, label, description, Type.SEED, def, 0, 0, List.of()); @@ -111,6 +148,37 @@ public record ModOption( raw, "expected one of " + choices.stream().map(Choice::value).toList()); } + case MULTI -> { + String lower = v.toLowerCase(Locale.ROOT); + if (lower.isEmpty() || lower.equals(ALL)) { + yield ALL; + } + if (lower.equals(NONE)) { + yield NONE; + } + Set picked = new HashSet<>(); + for (String part : lower.split("[,;\\s]+")) { + if (part.isEmpty()) { + continue; + } + Choice c = choices.stream() + .filter(x -> x.value().equalsIgnoreCase(part)) + .findFirst() + .orElseThrow(() -> invalid( + key, + raw, + "expected all, none or a list of " + + choices.stream() + .map(Choice::value) + .toList())); + picked.add(c.value()); + } + List inOrder = choices.stream() + .map(Choice::value) + .filter(picked::contains) + .toList(); + yield inOrder.size() == choices.size() ? ALL : inOrder.isEmpty() ? NONE : String.join(",", inOrder); + } case STRING -> v; case SEED -> { if (v.isEmpty()) { diff --git a/src/main/java/com/eliasmueller/llamamod/mods/mirror/MirrorMod.java b/src/main/java/com/eliasmueller/llamamod/mods/mirror/MirrorMod.java index a22490e..c4167df 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/mirror/MirrorMod.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/mirror/MirrorMod.java @@ -6,13 +6,14 @@ import com.eliasmueller.llamamod.mods.LevelWads; import com.eliasmueller.llamamod.mods.Mod; import com.eliasmueller.llamamod.mods.ModContext; import com.eliasmueller.llamamod.mods.ModOption; +import com.eliasmueller.llamamod.save.Worlds; import java.io.IOException; import java.util.HashSet; import java.util.List; -import java.util.Locale; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.IntStream; /** Mirror mode: every level is mirrored left-right (see {@link Mirror}). */ public final class MirrorMod implements Mod { @@ -42,55 +43,40 @@ public final class MirrorMod implements Mod { @Override public List options() { - return List.of( - ModOption.string( - "worlds", - "Worlds", - "\"all\", or the numbers of the worlds to mirror, e.g. \"1,3\" (1 = Village, 9 = TestLevel).", - "all"), - ModOption.bool( - "readableTextures", - "Readable textures", - "Flips writing and pictures back so they read normally. Some textures (water edges, paths)" - + " then show seams. Off: a true mirror image.", - false), - ModOption.bool( - "objects", - "Mirror object models", - "Also mirrors the shape of objects such as huts and crates (characters stay as they are)." - + " Off: objects are only placed mirrored.", - false)); + return OPTIONS; } + private static final ModOption.Choice[] WORLDS = IntStream.rangeClosed(1, Worlds.COUNT) + .mapToObj(w -> ModOption.of(Integer.toString(w), w + " " + Worlds.name(w))) + .toArray(ModOption.Choice[]::new); + + private static final List OPTIONS = List.of( + ModOption.multi("worlds", "Worlds", "The worlds whose levels are mirrored.", ModOption.ALL, WORLDS), + ModOption.bool( + "readableTextures", + "Readable textures", + "Flips writing and pictures back so they read normally. Some textures (water edges, paths)" + + " then show seams. Off: a true mirror image.", + false), + ModOption.bool( + "objects", + "Mirror object models", + "Also mirrors the shape of objects such as huts and crates (characters stay as they are)." + + " Off: objects are only placed mirrored.", + false)); + /** After the test world (50) so it is mirrored too; before the debug wireframe (100) so the overlay matches. */ @Override public int order() { return 75; } + /** World numbers of a "worlds" value ("all", "none", "1,3"; formerly free text, still accepted). */ static Set worlds(String spec) { + ModOption o = OPTIONS.getFirst(); Set out = new HashSet<>(); - String s = spec.trim().toLowerCase(Locale.ROOT); - if (s.isEmpty() || s.equals("all")) { - for (int i = 1; i <= 9; i++) { - out.add(i); - } - return out; - } - for (String p : s.split("[,; ]+")) { - if (p.isEmpty()) { - continue; - } - int w; - try { - w = Integer.parseInt(p); - } catch (NumberFormatException e) { - throw new IllegalArgumentException("Mirror mode: invalid world \"" + p + "\""); - } - if (w < 1 || w > 9) { - throw new IllegalArgumentException("Mirror mode: world " + w + " is not 1-9"); - } - out.add(w); + for (String w : o.selected(spec)) { + out.add(Integer.parseInt(w)); } return out; } diff --git a/src/main/java/com/eliasmueller/llamamod/platform/Config.java b/src/main/java/com/eliasmueller/llamamod/platform/Config.java index ea174b9..f4e58a3 100644 --- a/src/main/java/com/eliasmueller/llamamod/platform/Config.java +++ b/src/main/java/com/eliasmueller/llamamod/platform/Config.java @@ -1,13 +1,19 @@ package com.eliasmueller.llamamod.platform; import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.TreeMap; public final class Config { @@ -42,38 +48,49 @@ public final class Config { return layout >= LAYOUT; } + /** + * Values as last loaded or saved by this instance ({@link #snapshot()} form); null for a + * Config that never touched a file. {@link #save} merges against it. + */ + private Map base; + public static Config load(Path file) { Config c = new Config(); if (!Files.isRegularFile(file)) { + c.base = c.snapshot(); return c; } try { - Object o = Json.parse(Files.readString(file, StandardCharsets.UTF_8)); - if (o instanceof Map m) { - c.layout = num(m.get("layout"), 0); - c.source = str(m.get("source"), ""); - c.exeSha256 = str(m.get("exeSha256"), ""); - c.cdFingerprint = str(m.get("cdFingerprint"), ""); - c.enabledMods = list(m.get("enabledMods")); - c.modOptions = options(m.get("modOptions")); - c.activeKey = str(m.get("activeKey"), ""); - c.activeMods = list(m.get("activeMods")); - c.screenW = num(m.get("screenW"), 0); - c.screenH = num(m.get("screenH"), 0); - c.virtualDesktop = !Boolean.FALSE.equals(m.get("virtualDesktop")); - c.wineCommand = str(m.get("wineCommand"), ""); - c.theme = str(m.get("theme"), "light"); - c.gameExeSha256 = str(m.get("gameExeSha256"), ""); - c.exeMods = list(m.get("exeMods")); - c.antivirusNoticeDismissed = Boolean.TRUE.equals(m.get("antivirusNoticeDismissed")); + if (Json.parse(Files.readString(file, StandardCharsets.UTF_8)) instanceof Map m) { + c.read(m); } } catch (IOException | RuntimeException e) { System.err.println("config.json unreadable, using defaults: " + e.getMessage()); recover(file, c); } + c.base = c.snapshot(); return c; } + private void read(Map m) { + layout = num(m.get("layout"), 0); + source = str(m.get("source"), ""); + exeSha256 = str(m.get("exeSha256"), ""); + cdFingerprint = str(m.get("cdFingerprint"), ""); + enabledMods = list(m.get("enabledMods")); + modOptions = options(m.get("modOptions")); + activeKey = str(m.get("activeKey"), ""); + activeMods = list(m.get("activeMods")); + screenW = num(m.get("screenW"), 0); + screenH = num(m.get("screenH"), 0); + virtualDesktop = !Boolean.FALSE.equals(m.get("virtualDesktop")); + wineCommand = str(m.get("wineCommand"), ""); + theme = str(m.get("theme"), "light"); + gameExeSha256 = str(m.get("gameExeSha256"), ""); + exeMods = list(m.get("exeMods")); + antivirusNoticeDismissed = Boolean.TRUE.equals(m.get("antivirusNoticeDismissed")); + } + /** * Damaged config.json (e.g. cut off by a crash): keeps a copy as config.json.broken (the next * save overwrites the file) and, if the data folder holds an installation, keeps it usable @@ -96,7 +113,7 @@ public final class Config { } } - public void save(Path file) throws IOException { + private Map toMap() { Map m = new LinkedHashMap<>(); m.put("layout", layout); m.put("source", source); @@ -114,7 +131,98 @@ public final class Config { m.put("gameExeSha256", gameExeSha256); m.put("exeMods", exeMods); m.put("antivirusNoticeDismissed", antivirusNoticeDismissed); - FileOps.writeAtomic(file, Json.write(m).getBytes(StandardCharsets.UTF_8)); + return m; + } + + /** Deep copy in the types {@link Json#parse} returns, so it compares equal to re-read files. */ + @SuppressWarnings("unchecked") + private Map snapshot() { + return (Map) Json.parse(Json.write(toMap())); + } + + /** + * Saves under a lock file and merges with what is on disk: fields this instance did not change + * since it loaded or last saved the file keep the value another window or command wrote + * meanwhile (mod options per mod and key). Afterwards this object holds the merged values. + */ + public void save(Path file) throws IOException { + synchronized (Config.class) { // FileLock is per process; threads of this JVM wait here + Path dir = file.toAbsolutePath().getParent(); + Files.createDirectories(dir); + try (FileChannel ch = FileChannel.open( + dir.resolve(".config.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE); + FileLock lock = ch.lock()) { + Map merged = merge(base, snapshot(), readMap(file)); + read(merged); + FileOps.writeAtomic(file, Json.write(merged).getBytes(StandardCharsets.UTF_8)); + base = snapshot(); + } + } + } + + private static Map readMap(Path file) { + try { + if (Files.isRegularFile(file) + && Json.parse(Files.readString(file, StandardCharsets.UTF_8)) instanceof Map m) { + Map out = new LinkedHashMap<>(); + m.forEach((k, v) -> out.put(k.toString(), v)); + return out; + } + } catch (IOException | RuntimeException e) { + // unreadable: overwritten with this instance's values + } + return null; + } + + /** Field-level three-way merge; keys only on disk (written by a newer version) are kept. */ + static Map merge(Map base, Map mine, Map disk) { + if (base == null || disk == null) { + return mine; + } + Map out = new LinkedHashMap<>(disk); + for (Map.Entry e : mine.entrySet()) { + String k = e.getKey(); + Object v = e.getValue(); + if (k.equals("modOptions") && v instanceof Map mo && base.get(k) instanceof Map bo) { + out.put(k, mergeOptions(bo, mo, disk.get(k) instanceof Map d ? d : Map.of())); + } else if (!Objects.equals(v, base.get(k)) || !disk.containsKey(k)) { + out.put(k, v); + } + } + return out; + } + + private static Map mergeOptions(Map base, Map mine, Map disk) { + Map> out = new TreeMap<>(); + disk.forEach((mod, vals) -> { + if (vals instanceof Map vm) { + Map copy = new TreeMap<>(); + vm.forEach((k, v) -> copy.put(k.toString(), v)); + out.put(mod.toString(), copy); + } + }); + Set mods = new HashSet<>(base.keySet()); + mods.addAll(mine.keySet()); + for (Object mod : mods) { + Map b = base.get(mod) instanceof Map x ? x : Map.of(); + Map m = mine.get(mod) instanceof Map x ? x : Map.of(); + Set keys = new HashSet<>(b.keySet()); + keys.addAll(m.keySet()); + for (Object key : keys) { + Object mv = m.get(key); + if (Objects.equals(mv, b.get(key))) { + continue; + } + Map target = out.computeIfAbsent(mod.toString(), x -> new TreeMap<>()); + if (mv == null) { + target.remove(key.toString()); + } else { + target.put(key.toString(), mv); + } + } + } + out.values().removeIf(Map::isEmpty); + return new TreeMap<>(out); } private static int num(Object o, int def) { diff --git a/src/main/java/com/eliasmueller/llamamod/platform/GameLauncher.java b/src/main/java/com/eliasmueller/llamamod/platform/GameLauncher.java index ab22dd0..3789bad 100644 --- a/src/main/java/com/eliasmueller/llamamod/platform/GameLauncher.java +++ b/src/main/java/com/eliasmueller/llamamod/platform/GameLauncher.java @@ -17,7 +17,11 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Stream; /** @@ -253,6 +257,70 @@ public final class GameLauncher { return null; } + /** + * Oldest Wine we expect to run the game. Only Wine 6.0.3 was actually tested; older builds lack + * fixes the JDK and DirectX 6/7 games rely on, so below this the UI warns (it never blocks). + */ + public static final int[] MIN_WINE = {5, 0}; + + private static final Map> WINE_VERSIONS = new ConcurrentHashMap<>(); + + /** {major, minor} from {@code wine --version} output ("wine-6.0.3 (Ubuntu …)"), null if unknown. */ + public static int[] parseWineVersion(String out) { + if (out == null) { + return null; + } + Matcher m = Pattern.compile("(?i)\\bwine[- ]?(\\d+)\\.(\\d+)").matcher(out); + if (!m.find()) { + return null; + } + try { + return new int[] {Integer.parseInt(m.group(1)), Integer.parseInt(m.group(2))}; + } catch (NumberFormatException e) { + return null; + } + } + + public static boolean wineTooOld(int[] v) { + return v != null && (v[0] < MIN_WINE[0] || v[0] == MIN_WINE[0] && v[1] < MIN_WINE[1]); + } + + /** First line of {@code wine --version} (cached per command), null if it cannot be run. */ + public String wineVersion() { + String wine = wineCommand(); + if (wine == null || Os.isWindows()) { + return null; + } + return WINE_VERSIONS + .computeIfAbsent(wine, w -> { + try { + Process p = new ProcessBuilder(w, "--version") + .redirectErrorStream(true) + .start(); + p.getOutputStream().close(); + if (!p.waitFor(5, TimeUnit.SECONDS)) { + p.destroyForcibly(); + return Optional.empty(); + } + String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip(); + int nl = out.indexOf('\n'); + return Optional.of(nl < 0 ? out : out.substring(0, nl)); + } catch (IOException e) { + return Optional.empty(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return Optional.empty(); + } + }) + .orElse(null); + } + + /** The detected Wine version if it is older than {@link #MIN_WINE}, else null (also if unknown). */ + public String tooOldWine() { + String v = wineVersion(); + return wineTooOld(parseWineVersion(v)) ? v : null; + } + /** True if {@code cmd} is an executable file (a path) or found on the PATH (a plain name). */ public static boolean isCommand(String cmd) { if (cmd.contains("/") || cmd.contains(File.separator)) { diff --git a/src/main/java/com/eliasmueller/llamamod/platform/Installer.java b/src/main/java/com/eliasmueller/llamamod/platform/Installer.java index 0b7678c..ed51fa7 100644 --- a/src/main/java/com/eliasmueller/llamamod/platform/Installer.java +++ b/src/main/java/com/eliasmueller/llamamod/platform/Installer.java @@ -131,15 +131,32 @@ public final class Installer { continue; // already copied (resume after cancel) } double all = Math.max(1, total); - String msg = "Copying " + f.path(); + long before = done[0]; + Progress fileP = new Progress() { + @Override + public void update(double fraction, String message) { + long bytes = before + Math.round(f.size() * Math.max(0, Math.min(1, fraction))); + p.update(bytes / all, copyMessage(bytes, total, f.path())); + } + + @Override + public boolean cancelled() { + return p.cancelled(); + } + }; try (InputStream in = src.open(f)) { - FileOps.copy(in, t, f.size(), p.sub(done[0] / all, (done[0] + f.size()) / all), msg); + FileOps.copy(in, t, f.size(), fileP, ""); } done[0] += f.size(); - p.update(done[0] / all, msg); + p.update(done[0] / all, copyMessage(done[0], total, f.path())); } } + /** "Copying 123 of 568 MB (wads/t1l1m001.wad)". */ + static String copyMessage(long done, long total, String file) { + return String.format(Locale.ROOT, "Copying %d of %d MB (%s)", done >> 20, total >> 20, file); + } + private static Path target(Path dest, GameSource.SrcFile f) { return dest.resolve(f.path().replace('/', File.separatorChar)).normalize(); } diff --git a/src/main/java/com/eliasmueller/llamamod/ui/AppState.java b/src/main/java/com/eliasmueller/llamamod/ui/AppState.java index 45ab0f4..931ba0b 100644 --- a/src/main/java/com/eliasmueller/llamamod/ui/AppState.java +++ b/src/main/java/com/eliasmueller/llamamod/ui/AppState.java @@ -31,6 +31,9 @@ final class AppState { launcher = new GameLauncher(p, cfg); saves = new SaveStore(p); modManager = new ModManager(p, cfg); + if (cfg.installed()) { + modManager.recoverInterruptedApply(); + } } void reloadMods() { diff --git a/src/main/java/com/eliasmueller/llamamod/ui/MainFrame.java b/src/main/java/com/eliasmueller/llamamod/ui/MainFrame.java index 2f914ed..f782a73 100644 --- a/src/main/java/com/eliasmueller/llamamod/ui/MainFrame.java +++ b/src/main/java/com/eliasmueller/llamamod/ui/MainFrame.java @@ -59,6 +59,7 @@ final class MainFrame extends JFrame { private volatile boolean cancelRequested; private boolean gameRunning; private boolean crashReported; + private boolean oldWineAccepted; MainFrame(AppState state) { super("LlamaMod"); @@ -458,6 +459,26 @@ final class MainFrame extends JFrame { return false; } + /** Warns once per session before playing with a Wine older than GameLauncher.MIN_WINE. */ + private boolean confirmOldWine() { + String v = oldWineAccepted ? null : state.launcher.tooOldWine(); + if (v == null) { + return true; + } + Object[] options = {Strings.t("play.wineTooOld.play"), Strings.t("cancel")}; + int r = JOptionPane.showOptionDialog( + this, + Strings.t("play.wineTooOld", v, GameLauncher.MIN_WINE[0] + "." + GameLauncher.MIN_WINE[1]), + "LlamaMod", + JOptionPane.DEFAULT_OPTION, + JOptionPane.WARNING_MESSAGE, + null, + options, + options[1]); + oldWineAccepted = r == 0; + return oldWineAccepted; + } + /** Re-applies the active mods, which writes game/groove.exe again. */ void rebuildExe(Runnable after) { var active = state.activeMods(); @@ -470,13 +491,14 @@ final class MainFrame extends JFrame { } private void launchGame() { - if (!checkGameExe(this::launchGame)) { + if (!checkGameExe(this::launchGame) || !confirmOldWine()) { return; } runTask( Strings.t("task.launch"), p -> { state.saves.migrateNullSaves(); + state.modManager.recoverInterruptedApply(); if (!Files.isDirectory(state.paths.cdActive())) { state.modManager.restoreOriginal(p.sub(0, 0.3)); } diff --git a/src/main/java/com/eliasmueller/llamamod/ui/ModsPanel.java b/src/main/java/com/eliasmueller/llamamod/ui/ModsPanel.java index 4814dc2..3a5b6b7 100644 --- a/src/main/java/com/eliasmueller/llamamod/ui/ModsPanel.java +++ b/src/main/java/com/eliasmueller/llamamod/ui/ModsPanel.java @@ -15,6 +15,7 @@ import java.awt.FlowLayout; import java.awt.Font; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; +import java.awt.GridLayout; import java.awt.Insets; import java.awt.Point; import java.awt.Rectangle; @@ -499,6 +500,10 @@ final class ModsPanel extends JPanel { l.setToolTipText(tip); l.setLabelFor(input); g.gridx = 0; + if (o.type() == ModOption.Type.MULTI) { + g.anchor = GridBagConstraints.NORTHWEST; + g.insets = new Insets(6, 0, 2, 12); + } p.add(l, g); g = (GridBagConstraints) g.clone(); g.gridx = 1; @@ -512,8 +517,8 @@ final class ModsPanel extends JPanel { h.gridy = row++; h.weightx = 1; h.fill = GridBagConstraints.HORIZONTAL; - h.anchor = GridBagConstraints.WEST; - h.insets = new Insets(2, 16, 2, 0); + h.anchor = o.type() == ModOption.Type.MULTI ? GridBagConstraints.NORTHWEST : GridBagConstraints.WEST; + h.insets = new Insets(o.type() == ModOption.Type.MULTI ? 6 : 2, 16, 2, 0); p.add(tip == null ? Box.createHorizontalGlue() : hintText(tip), h); } return p; @@ -572,6 +577,31 @@ final class ModsPanel extends JPanel { optionInputs.add(c); return c; } + case MULTI -> { + // a grid of check boxes, three per row when there are many choices + JPanel p = new JPanel(new GridLayout( + 0, o.choices().size() > 4 ? 3 : o.choices().size(), 12, 0)); + p.setOpaque(false); + List on = o.selected(value); + List boxes = new ArrayList<>(); + for (ModOption.Choice c : o.choices()) { + JCheckBox b = new JCheckBox(c.label(), on.contains(c.value())); + b.setOpaque(false); + boxes.add(b); + b.addActionListener(e -> { + List chosen = new ArrayList<>(); + for (int i = 0; i < boxes.size(); i++) { + if (boxes.get(i).isSelected()) { + chosen.add(o.choices().get(i).value()); + } + } + setOption(m, o, o.multiValue(chosen), p); + }); + optionInputs.add(b); + p.add(b); + } + return p; + } case SEED -> { JTextField t = textInput(m, o, value, 12); JButton random = new JButton(Strings.t("mods.option.random")); diff --git a/src/main/java/com/eliasmueller/llamamod/ui/SavesPanel.java b/src/main/java/com/eliasmueller/llamamod/ui/SavesPanel.java index 57cfa94..6681c1e 100644 --- a/src/main/java/com/eliasmueller/llamamod/ui/SavesPanel.java +++ b/src/main/java/com/eliasmueller/llamamod/ui/SavesPanel.java @@ -29,9 +29,11 @@ import javax.swing.JSpinner; import javax.swing.JTable; import javax.swing.JToggleButton; import javax.swing.SpinnerNumberModel; +import javax.swing.SwingConstants; import javax.swing.UIManager; import javax.swing.filechooser.FileNameExtensionFilter; import javax.swing.table.AbstractTableModel; +import javax.swing.table.DefaultTableCellRenderer; final class SavesPanel extends JPanel { private final MainFrame frame; @@ -86,7 +88,7 @@ final class SavesPanel extends JPanel { GridBagConstraints g = new GridBagConstraints(); g.gridx = 0; g.gridy = 0; - g.gridwidth = 4; + g.gridwidth = 7; g.anchor = GridBagConstraints.WEST; g.fill = GridBagConstraints.HORIZONTAL; slotTitle.setFont(slotTitle.getFont().deriveFont(Font.BOLD, 17f)); @@ -112,16 +114,15 @@ final class SavesPanel extends JPanel { GridBagConstraints q = new GridBagConstraints(); q.gridx = 1; q.gridy = g.gridy; - q.gridwidth = 3; + q.gridwidth = 6; q.anchor = GridBagConstraints.WEST; q.insets = new Insets(2, 0, 6, 0); ed.add(quick, q); g.gridy++; - ed.add(section(Strings.t("saves.map")), g); + // map and lives share a section: at 720 px window height every row counts for the table + ed.add(section(Strings.t("saves.mapAndLlama")), g); row(ed, ++g.gridy, Strings.t("saves.cursor"), cursorWorld, Strings.t("saves.chapter"), cursorChapter); - g.gridy++; - ed.add(section(Strings.t("saves.llama")), g); - row(ed, ++g.gridy, Strings.t("saves.lives"), lives, null, null); + row(ed, ++g.gridy, Strings.t("saves.lives"), lives); g.gridy++; ed.add(section(Strings.t("saves.chapters")), g); g.gridy++; @@ -131,7 +132,14 @@ final class SavesPanel extends JPanel { table.setRowHeight(24); table.setFillsViewportHeight(true); table.getTableHeader().setReorderingAllowed(false); - table.getColumnModel().getColumn(0).setPreferredWidth(170); + // the level name gets the room; numbers and check boxes need little + table.getColumnModel().getColumn(0).setPreferredWidth(260); + for (int c = 1; c < 4; c++) { + table.getColumnModel().getColumn(c).setPreferredWidth(90); + } + DefaultTableCellRenderer centered = new DefaultTableCellRenderer(); + centered.setHorizontalAlignment(SwingConstants.CENTER); + table.getColumnModel().getColumn(1).setCellRenderer(centered); JScrollPane sp = new JScrollPane(table); sp.setPreferredSize(new Dimension(420, 180)); ed.add(sp, g); @@ -192,24 +200,25 @@ final class SavesPanel extends JPanel { return l; } - private static void row(JPanel p, int y, String l1, JComponent c1, String l2, JComponent c2) { + /** Label/input pairs (at most three) in one row; one row must fit the 820 px minimum window width. */ + private static void row(JPanel p, int y, Object... labelsAndInputs) { GridBagConstraints g = new GridBagConstraints(); g.gridy = y; - g.insets = new Insets(3, 0, 3, 8); g.anchor = GridBagConstraints.WEST; - g.gridx = 0; - p.add(new JLabel(l1), g); - g.gridx = 1; - c1.setPreferredSize(new Dimension(c1 instanceof JSpinner ? 70 : 200, c1.getPreferredSize().height)); - p.add(c1, g); - if (l2 != null) { - g.gridx = 2; - p.add(new JLabel(l2), g); - g.gridx = 3; - g.weightx = 1; - c2.setPreferredSize(new Dimension(70, c2.getPreferredSize().height)); - p.add(c2, g); + for (int i = 0; i < labelsAndInputs.length; i += 2) { + g.insets = new Insets(3, i == 0 ? 0 : 8, 3, 8); + g.gridx = i; + p.add(new JLabel((String) labelsAndInputs[i]), g); + JComponent c = (JComponent) labelsAndInputs[i + 1]; + c.setPreferredSize(new Dimension(c instanceof JSpinner ? 70 : 200, c.getPreferredSize().height)); + g.insets = new Insets(3, 0, 3, 8); + g.gridx = i + 1; + p.add(c, g); } + // the rest of the width goes to an empty last column, so the pairs stay together + g.gridx = 6; + g.weightx = 1; + p.add(Box.createHorizontalGlue(), g); } private void limitChapter(JComboBox world, JSpinner chapter) { diff --git a/src/main/java/com/eliasmueller/llamamod/ui/SettingsPanel.java b/src/main/java/com/eliasmueller/llamamod/ui/SettingsPanel.java index ddd2f64..1efecff 100644 --- a/src/main/java/com/eliasmueller/llamamod/ui/SettingsPanel.java +++ b/src/main/java/com/eliasmueller/llamamod/ui/SettingsPanel.java @@ -219,11 +219,20 @@ final class SettingsPanel extends JPanel { if (!Os.isWindows()) { String w = frame.state.launcher.wineCommand(); boolean ok = w != null && GameLauncher.isCommand(w); + String version = ok ? frame.state.launcher.wineVersion() : null; + boolean tooOld = GameLauncher.wineTooOld(GameLauncher.parseWineVersion(version)); + String min = GameLauncher.MIN_WINE[0] + "." + GameLauncher.MIN_WINE[1]; wineInfo.setText( w == null ? Strings.t("settings.wineMissing") - : ok ? Strings.t("settings.wineFound", w) : Strings.t("settings.wineNotFound", w)); - wineInfo.setForeground(ok ? UIManager.getColor("Label.foreground") : App.ERROR); + : !ok + ? Strings.t("settings.wineNotFound", w) + : tooOld + ? Strings.t("settings.wineTooOld", w, version, min) + : version != null + ? Strings.t("settings.wineFoundVersion", w, version) + : Strings.t("settings.wineFound", w)); + wineInfo.setForeground(ok && !tooOld ? UIManager.getColor("Label.foreground") : App.ERROR); } updateCache(); loading = false; diff --git a/src/main/resources/com/eliasmueller/llamamod/ui/messages.properties b/src/main/resources/com/eliasmueller/llamamod/ui/messages.properties index e82aaa4..04f0d00 100644 --- a/src/main/resources/com/eliasmueller/llamamod/ui/messages.properties +++ b/src/main/resources/com/eliasmueller/llamamod/ui/messages.properties @@ -102,9 +102,8 @@ saves.unlockedUpTo=Unlocked up to saves.chapter=Chapter saves.unlockAll=Unlock all worlds saves.unlockTest=Unlock TestLevel (world 9) -saves.map=World map saves.cursor=Opens at -saves.llama=Llama +saves.mapAndLlama=World map and llama saves.lives=Lives saves.chapters=Chapters saves.col.level=Level @@ -140,6 +139,8 @@ settings.wine=Wine settings.wineCommand=Wine command settings.wineHint=Leave empty to use the installed Wine. Only fill in to use a different Wine program. settings.wineFound=Found: {0} +settings.wineFoundVersion=Found: {0} ({1}) +settings.wineTooOld=Found: {0} ({1}) – too old, the game may not run. Please install Wine {2} or newer. settings.wineMissing=Wine not found – please install Wine to play. settings.wineNotFound=Not found: {0} – correct the command or leave the field empty. settings.data=Data @@ -177,3 +178,5 @@ wizard.copying=Copying and checking game data… wizard.doneTitle=All set! wizard.done=The game data is in {0}.

Next: tick the mods you like on the Mods tab, click Apply, then Play. The Save games tab can unlock worlds. wizard.finish=Finish +play.wineTooOld=Your Wine is too old ({0}).\nThe game may not start or may crash. Please install Wine {1} or newer. +play.wineTooOld.play=Play anyway diff --git a/src/test/java/com/eliasmueller/llamamod/mods/InterruptedApplyTest.java b/src/test/java/com/eliasmueller/llamamod/mods/InterruptedApplyTest.java new file mode 100644 index 0000000..b58c1bc --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/mods/InterruptedApplyTest.java @@ -0,0 +1,123 @@ +package com.eliasmueller.llamamod.mods; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eliasmueller.llamamod.platform.AppPaths; +import com.eliasmueller.llamamod.platform.Config; +import com.eliasmueller.llamamod.platform.Progress; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** The app killed between the two renames of ModManager.buildActive. */ +class InterruptedApplyTest { + @TempDir + Path tmp; + + private static final class Two implements Mod { + @Override + public String id() { + return "two"; + } + + @Override + public String name() { + return "Two"; + } + + @Override + public String description() { + return ""; + } + + @Override + public void apply(ModContext ctx) throws IOException { + ctx.write("wads/t1l1m001.wad", new byte[] {2}); + } + } + + private AppPaths paths; + private Path next; + private Path old; + private Path marker; + + private ModManager install() throws IOException { + paths = new AppPaths(tmp); + Files.createDirectories(paths.originalCd().resolve("wads")); + Files.write(paths.originalCd().resolve("wads/t1l1m001.wad"), new byte[] {1}); + next = tmp.resolve("cd-active.new"); + old = tmp.resolve("cd-active.old"); + marker = tmp.resolve("cd-active.new.complete"); + Config cfg = new Config(); + cfg.layout = Config.LAYOUT; + cfg.save(paths.config()); + return new ModManager(paths, Config.load(paths.config()), () -> false); + } + + private byte wad() throws IOException { + return Files.readAllBytes(paths.cdActive().resolve("wads/t1l1m001.wad"))[0]; + } + + private void folder(Path dir, int value) throws IOException { + Files.createDirectories(dir.resolve("wads")); + Files.write(dir.resolve("wads/t1l1m001.wad"), new byte[] {(byte) value}); + } + + @Test + void completeNewFolderOfTheRecordedSelectionIsMovedIn() throws Exception { + ModManager mm = install(); + mm.apply(List.of(new Two()), Progress.NONE); + String key = Config.load(paths.config()).activeKey; + assertFalse(Files.exists(marker)); + // same selection applied again, killed after cd-active -> cd-active.old + Files.move(paths.cdActive(), next); + Files.writeString(marker, key); + folder(old, 9); + assertTrue(mm.recoverInterruptedApply()); + assertEquals(2, wad()); + assertFalse(Files.exists(next)); + assertFalse(Files.exists(old)); + assertFalse(Files.exists(marker)); + assertFalse(mm.recoverInterruptedApply(), "nothing left to repair"); + } + + @Test + void newFolderOfAnotherSelectionFallsBackToTheOldOne() throws Exception { + ModManager mm = install(); + mm.restoreOriginal(Progress.NONE); + // applying "two" was killed between the renames: config and exe still describe the original + Files.move(paths.cdActive(), old); + folder(next, 2); + Files.writeString(marker, mm.selectionKey(List.of(new Two()))); + assertTrue(mm.recoverInterruptedApply()); + assertEquals(1, wad()); + assertFalse(Files.exists(next)); + assertFalse(Files.exists(marker)); + } + + @Test + void firstApplyKilledWithCompleteFolderIsRecovered() throws Exception { + ModManager mm = install(); + folder(next, 1); + Files.writeString(marker, ""); // restoring the original; config.json activeKey is "" too + assertTrue(mm.recoverInterruptedApply()); + assertEquals(1, wad()); + } + + @Test + void incompleteNewFolderIsNotUsed() throws Exception { + ModManager mm = install(); + folder(next, 2); // killed while building: no marker + assertFalse(mm.recoverInterruptedApply()); + assertFalse(Files.exists(paths.cdActive())); + // the next apply builds everything again + mm.apply(List.of(new Two()), Progress.NONE); + assertEquals(2, wad()); + assertFalse(Files.exists(next)); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/mods/ModOptionMultiTest.java b/src/test/java/com/eliasmueller/llamamod/mods/ModOptionMultiTest.java new file mode 100644 index 0000000..63e9c38 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/mods/ModOptionMultiTest.java @@ -0,0 +1,60 @@ +package com.eliasmueller.llamamod.mods; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.eliasmueller.llamamod.mods.mirror.MirrorMod; +import com.eliasmueller.llamamod.platform.Config; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import org.junit.jupiter.api.Test; + +class ModOptionMultiTest { + private static final ModOption O = ModOption.multi( + "w", "W", "", "all", ModOption.of("1", "One"), ModOption.of("2", "Two"), ModOption.of("3", "Three")); + + @Test + void normalizesToChoiceOrder() { + assertEquals("all", O.normalize("ALL")); + assertEquals("all", O.normalize("")); + assertEquals("all", O.normalize("3,2,1")); + assertEquals("none", O.normalize("none")); + assertEquals("1,3", O.normalize("3, 1")); + assertEquals("1,3", O.normalize("1;3;3")); + assertEquals("2", O.normalize(" 2 ")); + assertThrows(IllegalArgumentException.class, () -> O.normalize("1,4")); + assertEquals(List.of("1", "2", "3"), O.selected("all")); + assertEquals(List.of(), O.selected("none")); + assertEquals(List.of("1", "3"), O.selected("3,1")); + assertEquals("none", O.multiValue(List.of())); + assertEquals("all", O.multiValue(List.of("1", "2", "3"))); + assertEquals("2,3", O.multiValue(List.of("3", "2"))); + assertThrows( + IllegalArgumentException.class, () -> ModOption.multi("x", "X", "", "all", ModOption.of("all", "All"))); + } + + @Test + void mirrorWorldsFromOldFreeTextConfigs() { + MirrorMod mirror = new MirrorMod(); + ModOption worlds = ModOptions.find(mirror, "worlds"); + assertEquals(ModOption.Type.MULTI, worlds.type()); + // values the former STRING option stored keep their meaning (and their cache key) + Map expected = Map.of( + "all", "all", + "1,3", "1,3", + " 1, 3", "1,3", + "", "all", + "9", "9", + "3,1", "1,3", + "1,2,3,4,5,6,7,8,9", "all", + "1,x", "all" /* invalid: default */); + for (Map.Entry e : expected.entrySet()) { + Config cfg = new Config(); + cfg.modOptions.put("mirror", new TreeMap<>(Map.of("worlds", e.getKey()))); + assertEquals(e.getValue(), ModOptions.resolve(mirror, cfg).get("worlds"), "stored " + e.getKey()); + } + assertEquals("1 Village", worlds.choices().getFirst().label()); + assertEquals("9 TestLevel", worlds.choices().getLast().label()); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/mods/mirror/MirrorTest.java b/src/test/java/com/eliasmueller/llamamod/mods/mirror/MirrorTest.java index 516a8d8..cc04457 100644 --- a/src/test/java/com/eliasmueller/llamamod/mods/mirror/MirrorTest.java +++ b/src/test/java/com/eliasmueller/llamamod/mods/mirror/MirrorTest.java @@ -22,6 +22,7 @@ class MirrorTest { void worldsOption() { assertEquals(9, MirrorMod.worlds("all").size()); assertEquals(Set.of(1, 3), MirrorMod.worlds(" 1, 3")); + assertEquals(Set.of(), MirrorMod.worlds("none")); } @Test diff --git a/src/test/java/com/eliasmueller/llamamod/platform/ConfigTest.java b/src/test/java/com/eliasmueller/llamamod/platform/ConfigTest.java index 3844301..feae2b3 100644 --- a/src/test/java/com/eliasmueller/llamamod/platform/ConfigTest.java +++ b/src/test/java/com/eliasmueller/llamamod/platform/ConfigTest.java @@ -42,4 +42,89 @@ class ConfigTest { assertTrue(w.enabledMods.isEmpty()); assertEquals(800, w.screenW); } + + @Test + void staleInstanceKeepsWhatAnotherInstanceSaved() throws Exception { + AppPaths p = new AppPaths(tmp); + Config first = new Config(); + first.layout = Config.LAYOUT; + first.modOptions.put("fog", new java.util.TreeMap<>(java.util.Map.of("strength", "2", "color", "red"))); + first.save(p.config()); + + Config window = Config.load(p.config()); + Config cli = Config.load(p.config()); + // the CLI applies mods and changes an option + cli.activeMods = new java.util.ArrayList<>(java.util.List.of("fog")); + cli.activeKey = "k1"; + cli.gameExeSha256 = "abc"; + cli.modOptions.get("fog").put("strength", "3"); + cli.save(p.config()); + // the stale window only changes the theme and another option of the same mod + window.theme = "dark"; + window.modOptions.get("fog").put("color", "blue"); + window.save(p.config()); + + Config r = Config.load(p.config()); + assertEquals("dark", r.theme); + assertEquals(java.util.List.of("fog"), r.activeMods); + assertEquals("k1", r.activeKey); + assertEquals("abc", r.gameExeSha256); + assertEquals(java.util.Map.of("strength", "3", "color", "blue"), r.modOptions.get("fog")); + // the stale window now holds the merged values + assertEquals("abc", window.gameExeSha256); + assertEquals(java.util.List.of("fog"), window.activeMods); + + // a field both changed: the last writer wins + window.screenW = 800; + window.save(p.config()); + cli.screenW = 1024; + cli.save(p.config()); + assertEquals(1024, Config.load(p.config()).screenW); + assertEquals("dark", cli.theme); + // removing an option value (reset to default) is kept too + window.modOptions.get("fog").remove("color"); + window.save(p.config()); + assertEquals( + java.util.Map.of("strength", "3"), + Config.load(p.config()).modOptions.get("fog")); + } + + @Test + void unknownFieldsOfANewerVersionSurviveASave() throws Exception { + AppPaths p = new AppPaths(tmp); + Files.writeString(p.config(), "{\"layout\": 1, \"futureField\": [1, 2], \"theme\": \"dark\"}"); + Config c = Config.load(p.config()); + c.screenW = 800; + c.save(p.config()); + String s = Files.readString(p.config()); + assertTrue(s.contains("futureField"), s); + assertEquals("dark", Config.load(p.config()).theme); + } + + @Test + void concurrentSavesFromThreadsDoNotFail() throws Exception { + AppPaths p = new AppPaths(tmp); + new Config().save(p.config()); + Config a = Config.load(p.config()); + Config b = Config.load(p.config()); + Thread t = new Thread(() -> { + for (int i = 0; i < 50; i++) { + a.screenW = i; + try { + a.save(p.config()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + }); + t.start(); + for (int i = 0; i < 50; i++) { + b.screenH = i; + b.save(p.config()); + } + t.join(); + Config r = Config.load(p.config()); + assertEquals(49, r.screenW); + assertEquals(49, r.screenH); + } } diff --git a/src/test/java/com/eliasmueller/llamamod/platform/InstallerProgressTest.java b/src/test/java/com/eliasmueller/llamamod/platform/InstallerProgressTest.java new file mode 100644 index 0000000..4165dfa --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/platform/InstallerProgressTest.java @@ -0,0 +1,15 @@ +package com.eliasmueller.llamamod.platform; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class InstallerProgressTest { + @Test + void copyMessageShowsMegabytes() { + assertEquals( + "Copying 123 of 568 MB (wads/t1l1m001.wad)", + Installer.copyMessage(123L << 20, 568L << 20, "wads/t1l1m001.wad")); + assertEquals("Copying 0 of 1 MB (a)", Installer.copyMessage(1000, (1L << 20) + 5, "a")); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/platform/WineVersionTest.java b/src/test/java/com/eliasmueller/llamamod/platform/WineVersionTest.java new file mode 100644 index 0000000..e85d227 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/platform/WineVersionTest.java @@ -0,0 +1,38 @@ +package com.eliasmueller.llamamod.platform; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class WineVersionTest { + @Test + void parsesCommonVersionStrings() { + assertArrayEquals(new int[] {6, 0}, GameLauncher.parseWineVersion("wine-6.0.3 (Ubuntu 6.0.3~repack-1)")); + assertArrayEquals(new int[] {9, 0}, GameLauncher.parseWineVersion("wine-9.0")); + assertArrayEquals(new int[] {10, 12}, GameLauncher.parseWineVersion("wine-10.12 (Staging)")); + assertArrayEquals(new int[] {8, 0}, GameLauncher.parseWineVersion("wine-8.0-rc3")); + assertArrayEquals(new int[] {1, 6}, GameLauncher.parseWineVersion("wine-1.6.2")); + assertArrayEquals(new int[] {7, 22}, GameLauncher.parseWineVersion("Wine 7.22")); + } + + @Test + void unknownOutputIsNotAWarning() { + assertNull(GameLauncher.parseWineVersion(null)); + assertNull(GameLauncher.parseWineVersion("")); + assertNull(GameLauncher.parseWineVersion("CrossOver 23")); + assertNull(GameLauncher.parseWineVersion("wine-99999999999.0")); + assertFalse(GameLauncher.wineTooOld(null)); + } + + @Test + void tooOldBelowTheMinimum() { + assertTrue(GameLauncher.wineTooOld(new int[] {4, 21})); + assertTrue(GameLauncher.wineTooOld(new int[] {1, 6})); + assertFalse(GameLauncher.wineTooOld(new int[] {5, 0})); + assertFalse(GameLauncher.wineTooOld(new int[] {6, 0})); + assertFalse(GameLauncher.wineTooOld(new int[] {10, 0})); + } +}