launcher: interrupted apply recovers cd-active.new (completion marker) or cd-active.old, config.json merged field by field on save across windows, wine version check with warning below 5.0, mirror worlds as check boxes (new multi option type, old values still load), copy progress in mb in the wizard, saves table fits 1280x720

This commit is contained in:
MineTec committed 2026-11-07 11:57:22 +01:00
1 parent 526f6072f6
commit a6708e5fff
28 files changed
+843 -112

No files matched your search

+1 -1
View File
@@ -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. |
+6 -7
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+24 -3
View File
@@ -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/<lang>/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=<our prefix>`), 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
`<data>/.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.<id>.<key>`, only
user-set values and generated seeds); invalid stored values fall back to the default. The UI shows
+3 -3
View File
@@ -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)
@@ -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();
@@ -342,6 +342,11 @@ public final class ManualContent {
o.choices().forEach(ch -> c.add(ch.label()));
yield String.join(", ", c);
}
case MULTI -> {
List<String> c = new ArrayList<>();
o.choices().forEach(ch -> c.add(ch.label()));
yield "any of " + String.join(", ", c);
}
case SEED -> "any text";
case STRING -> "text";
};
@@ -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<FileOps.LinkKind, Integer> kinds = buildActive(ov, p.sub(0.92, 1));
Map<FileOps.LinkKind, Integer> 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<FileOps.LinkKind, Integer> 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<FileOps.LinkKind, Integer> buildActive(Overlay ov, String key, Progress p) throws IOException {
Map<FileOps.LinkKind, Integer> 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;
@@ -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;
*
* <p>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).
*
* <p>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<String> 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<String> 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<String> 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<String> 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()) {
@@ -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<ModOption> 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<ModOption> 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<Integer> worlds(String spec) {
ModOption o = OPTIONS.getFirst();
Set<Integer> 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;
}
@@ -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<String, Object> 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<String, Object> toMap() {
Map<String, Object> 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<String, Object> snapshot() {
return (Map<String, Object>) 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<String, Object> merged = merge(base, snapshot(), readMap(file));
read(merged);
FileOps.writeAtomic(file, Json.write(merged).getBytes(StandardCharsets.UTF_8));
base = snapshot();
}
}
}
private static Map<String, Object> readMap(Path file) {
try {
if (Files.isRegularFile(file)
&& Json.parse(Files.readString(file, StandardCharsets.UTF_8)) instanceof Map<?, ?> m) {
Map<String, Object> 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<String, Object> merge(Map<String, Object> base, Map<String, Object> mine, Map<String, Object> disk) {
if (base == null || disk == null) {
return mine;
}
Map<String, Object> out = new LinkedHashMap<>(disk);
for (Map.Entry<String, Object> 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<String, Object> mergeOptions(Map<?, ?> base, Map<?, ?> mine, Map<?, ?> disk) {
Map<String, Map<String, Object>> out = new TreeMap<>();
disk.forEach((mod, vals) -> {
if (vals instanceof Map<?, ?> vm) {
Map<String, Object> copy = new TreeMap<>();
vm.forEach((k, v) -> copy.put(k.toString(), v));
out.put(mod.toString(), copy);
}
});
Set<Object> 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<Object> 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<String, Object> 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) {
@@ -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<String, Optional<String>> 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)) {
@@ -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();
}
@@ -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() {
@@ -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));
}
@@ -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<String> on = o.selected(value);
List<JCheckBox> 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<String> 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"));
@@ -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<String> world, JSpinner chapter) {
@@ -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;
@@ -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}.<br><br>Next: tick the mods you like on the <b>Mods</b> tab, click <b>Apply</b>, then <b>Play</b>. The <b>Save games</b> 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
@@ -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));
}
}
@@ -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<String, String> 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<String, String> 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());
}
}
@@ -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
@@ -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);
}
}
@@ -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"));
}
}
@@ -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}));
}
}