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

@@ -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